指引网

当前位置: 主页 > 编程开发 > C >

Redis缓存之Set使用及redis遇到的一些问题

来源:网络 作者:佚名 点击: 时间:2017-07-19 23:09
[摘要]  Redis是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。本文我们主要讲讲redis的set的使用及问题总结。

Redis缓存Set使用

在Redis中,我们可以将Set类型看作为没有排序的字符集合,和List类型一样,我们也可以在该类型的数据值上执行添加、删除或判断某一元素是否存在等操作。需要说明的是,这些操作的时间复杂度为O(1),即常量时间内完成次操作。Set可包含的最大元素数量是4294967295。

和List类型不同的是,Set集合中不允许出现重复的元素,这一点和C++标准库中的set容器是完全相同的。换句话说,如果多次添加相同元素,Set中将仅保留该元素的一份拷贝。和List类型相比,Set类型在功能上还存在着一个非常重要的特性,即在服务器端完成多个Sets之间的聚合计算操作,如unions、intersections和differences。由于这些操作均在服务端完成,因此效率极高,而且也节省了大量的网络IO开销。

Redis做缓存Set可能到的的比较多

打开redis服务器:
'''


打开redis客户端:


这就是一个set集合!

至于redisset的命令小伙伴们可以参考(http://redisdoc.com)

下面分享redis在.net中的使用方法

1,获得集合

// 获取sortset表中setId中的所有keys,倒序获取
public List<string> GetAllItemsFromSortedSetDesc(string setId)
{
    List<string> result = ExecuteCommand<List<string>>(client =>
    {
        return client.GetAllItemsFromSortedSetDesc(setId);
    });
    return result;
}
public List<string> GetAllItemsFromSortedSet(string setId)
{
    List<string> result = ExecuteCommand<List<string>>(client =>
    {
        return client.GetAllItemsFromSortedSet(setId);
    });
    return result;
}
// 获取sortset表中setId中的所有keys,values
public IDictionary<string, double> GetAllWithScoresFromSortedSet(string setId)
{
    IDictionary<string, double> result = ExecuteCommand<IDictionary<string, double>>(client =>
    {
        return client.GetAllWithScoresFromSortedSet(setId);
        //return client.GetFromHash<Dictionary<string, string>>(hashID);
    });
    return result;
}

2,删除某个set

// 删除某个KEY的值,成功返回TRUE
public bool RemoveKey(string key)
{
    bool result = false;
    result = ExecuteCommand<bool>(client =>
         {
             return client.Remove(key);
         });
    return result;
}
 
// 删除Set数据中的某个为item的值
public bool RemoveItemFromSet(string setId, string item)
{
    byte[] bvalue = System.Text.Encoding.UTF8.GetBytes(item);
    bool result = ExecuteCommand<bool>(client =>
    {
        var rc = client as RedisClient;
        if (rc != null)
        {
            return rc.SRem(setId, bvalue) == 1;
        }
        return false;
    });
    return result;
}


3,搜索

//搜索key
public List<string> SearchKeys(string pattern)
{
    List<string> result = ExecuteCommand<List<string>>(client =>
    {
        return client.SearchKeys(pattern);
    });
    return result;
}


4,增加某个元素到set

public bool AddItemToSet(string setId, string item)
 {
     byte[] bvalue = System.Text.Encoding.UTF8.GetBytes(item);
     bool result = ExecuteCommand<bool>(client =>
     {
         var rc = client as RedisClient;
         if (rc != null)
         {
             return rc.SAdd(setId, bvalue) == 1;
         }
         return false;
     });
     return result;
 
 }


这里只分享几个方法,其实还有很多关于set的操作方法。

利用Redis提供的Sets数据结构,可以存储一些集合性的数据,比如在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis还为集合提供了求交集、并集、差集等操作,可以非常方便的实现如共同关注、共同喜好、二度好友等功能,对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存集到一个新的集合中。 


在redis使用过程遇到的一些问题的总结


tpn(taobao push notification)在使用redis计算消息未读数的过程中,遇到了一系列的问题,下面把这个过程整理了一下,也让大家了解这个纠结的过程,供大家以后使用redis或者做类似的功能时进行参考

redis在 tpn里面主要是用于计算移动千牛(Android、IOS)上的消息未读数。tpn的未读消息数是基于bizId维度的,即同一个bizId(每条消息的业务id,如果商品id、订单id等),即使有多条消息,未读数也只能算1。因此在接收消息,计算移动千牛未读数的过程中,就需要对bizId去重,这个去重的功能就是通过redis来实现的。随着消息量的不断上涨,这个基于redis的去重方案也不断变化。

一、基于redis Set结构的未读数计算

前面说到的tpn未读数计算的最大特点就是基于bizId去重,在java里面,我们很容易想到利用HashMap或者HashSet来判重,因此最初tpn就是利用redis的Set结构来进行判重。主要利用了redis set结构的这两个命令:SADD和SCARD

SADD key member [member....]:将一个或多个 member 元素加入到集合 key 当中,已经存在于集合的 member 元素将被忽略。假如 key 不存在,则创建一个只包含 member 元素作成员的集合。 如果member元素不在集合里面,则返回1;如果member元素已经存在于集合当中,则返回0。

SCARD key:返回集合 key 中元素的数量。

有了这两个命令,计算未读数的步骤就是这样的:


tpn会为用户保留7天内的消息,也就是说保存到redis set结构中的bizId失效时间是7天,同时用户在查看消息后,就会把其对应的redis set清空(即如果一个用户连续几天都不查看千牛的消息,那么其对应的redis set集合里面就会保存大量的bizid)。tpn总共有6台redis机器,每台机器上部署5个redis实例,每个实例的maxmemory设为1G,总共30G的内存用于存放消息bizId。在tpn的早期,由于用户量不多,消息量也不大,redis的内存完全可以存放7天内的所有消息bizId,因此这个方案work的很好。但随着全网大多数活跃卖家开始使用千牛,tpn的消息量也随之暴涨,越来越多的消息bizId给redis带来了极大的压力,在消息高峰期,tpn的日志里会有大量的redis timeout异常(tpn使用jedis,配置的timeout是300ms),经过分析,主要是由下面原因造成的:

缓存失效造成的超时:前面我们提到了,tpn的每个redis实例的maxmemory设置的是1G,因为bizId越来越多,因此很快每个redis 实例的内存就超过了maxmemory。而redis在处理客户端请求时,如果发现当前内存的使用量已经大于等于maxmemory,就会去失效部分过期的缓存,直到内存使用量小于maxmemory。很明显这个失效缓存释放内存的操作会影响redis的rt。在消息高峰期,redis实例的内存使用量一直再maxmemory附加徘徊,造成redis在应对大量请求的同时,还要不停地失效缓存释放内存,造成频繁超时。

因为bizId太多,而redis内存不够,所以造成redis请求大量超时,最简单地办法就是加机器,部署更多的redis实例来存储越来越多的消息bizId。初步估计了一下,要完全把7天内的所有消息bizId都保存到内存中,需要高达上百G的内存:交易消息和商品消息是tpn最主要的两类消息,因为目前全网大多数活跃卖家都使用了千牛,为了去重,tpn需要把全网7天内所有新增的交易id和商品id都保存到redis内存中,换句话来说,也就是要用内存来保存7天内tc和ic新增的所有id。tpn基本不可能申请到这么多的redis机器,就算有这么多的redis机器,部署维护成本也是巨大的。就算不用redis,使用tair的rdb,这个陈本仍然是不能接受的。

在移动千牛客户端,推送没有正常到达的情况下(比如长连接断开的时候),是依赖客户端在发现长连接断开以后调用messagecount.get接口来获取到消息未读数,然后促使用户手动获取最新的消息。当redis的内存使用量接近极限时,调用redis的sadd、scard命令很容易就timeout了,因此不能正确地计算出消息未读数,就会造成用户不能及时获取到最新的消息。

总的来说,redis的内存容量不足以容纳越来越多的业务消息bizId,造成大量redis请求超时,不能正确地计算消息未读数。因此需要对上述方案进行优化。

二、redis用于消息去重判断,tair存放未读数消息数的方案

根据上面的分析,当redis内存使用量达到了上限时,很容易发送timeout,同时redis内存使用量会之所以会很快地达到上限,主要是因为不活跃用户的set结构里面保存了大量的bizId。在不能快速增加redis机器的前提下,最简单地方法就是在夜间重启redis。重启redis会带来一下影响:

所有用户保存在set里面的消息bizId全部被清空了,就会造成误判:即对同一个bizId的消息重复提醒用户有新消息。但这个并不会对用户造成太大的影响:因为活跃用户会及时地来查看消息,所以活跃的set结构基本都是空的;而非活跃用户的redis set结构虽然有很多消息bizId,但是因为其是不活跃的,就算被清空,很快又会有新的bizId存放进去,但认为是不活跃用户,对这种情况基本无感知。

因为set结构被清空,所以所有用户的消息未读数也被清空(通过scard命令来计算未读数)。根据前面的分析,在消息推送不能正常达到的情况下,正确的未读数会促使用户主动地来获取最新消息,所以基本不能接受重启redis的时候,清空用户的消息未读数

因为不能接受随意清空用户的消息未读数,所以我们不能定期重启redis来释放内存。但是如果我们把消息去重和计算未读数分开,即redis的set结构只用于判断一条消息是否是新消息,是否需要增加未读数,而把未读数保存在其他的地方,如果tair之类的,那我们是不是就可以定期重启redis了呢?因此我们得到了下面的方案:

继续是用redis的set结构来判断一条消息是不是新消息,是不是需要增加消息未读数

不再使用redis的scard命令计算消息未读数,而是采用基于tair的计数器来计算消息未读数,即如果通过redis的set结构判断出是新消息,则对保存在tair里面的未读数计数器执行incr unReadCountKey 1。


这样一来,redis就只用于对消息bizId去重,而不再用于计算消息未读数,消息未读数单独保存在基于tair的计数器当中。因此我们就大胆地定期在夜间重启redis了。这个方案成功work了一段时间,但过了一段时间后,应用在请求redis的时候又开始是不是抛出大量的timeout exception。分析了一下,问题还是处在redis内存上:

虽然可以通过定期重启redis来释放内存,但是redis内存的增加的速度是不可预期的,我们并不能每次都能在内存使用达到极限前重启redis

有时候虽然redis的整体内存使用量还没有达到极限,但是如果一个用户的set结构里面的bizId太多了,scard命令仍然会timeout

所以这个方案还不是一个最佳的方案,仍然需要通过更好的办法来降低redis的内存使用量

三、基于redis的bloomfilter的消息去重方案

从方案一到方案二,我们一直想解决的就是如何用最小的内存来判断一个消息bizId是不是新的bizId,即一个消息bizId是不是已经存在了。以最小的内存来实现判断操作,很容易就联想到bloomfilter。但是在这个场景,我们不能简单地使用bloomfilter,先来计算一下“最直接”地使用bloomfilter需要多大的内存:bloomfilter的所占用的内存由bitSize决定,而根据公式:

bitSize = (int) Math.ceil(maxKey * (Math.log(errorRate) / Math.log(0.6185)));

我们为每个用户的每个消息类型创建一个bloomfilter,以500万用户,每个用户订阅了10个消息类型,那么这个用于去重的bloomfilter所占用的内存总量是:

totalMemory(G) = 5000000*10*Math.ceil(maxKey * (Math.log(errorRate) / Math.log(0.6185)))

这个totalMemory的大小就取决于maxKey和errorRate,保证errorRate不变的前提下,bloomfilter 的maxKey越大,bloomfilter所需要的内存也就越大。那我们估算一下使用bloomfilter,需要多少内存。

以商品消息和交易小为例,不同的卖家,7天内的消息数从几个到几万个不等。最小的是7天只有几条消息,最多的7天内有7万多条。就算取个1000的评价值,这5000w个bloomfilter的内存消耗也在上百G,这明显行不通。

但是,tpn的消息未读数还有一个业务特点就是,当一个用户的某个消息类型的未读数已经超99了,就不再显示具体的数字,而是显示成99+,同时一个用户的消息未读数超过了99,那么其实他自己对消息未读数的敏感性也不高了,即就算有一条消息不是新消息,但是仍然给未读数+1了,用户也察觉不出来。

因此,在上面的公式里,我们可以把每个bloomfilter的maxKey设为100,那这样一来,所占用的内存就是一个十分能够接受的数字了:设 errorRate=0.0001,maxKey=100,那么上面的5000w个bloomfilter只需要11G的内存,很明显,这不是一个完全可以接受的内存消耗。

这样一来,我们就得出下面这个基于redis bloomfilter去重方案:

通过redis的setbit命令来实现一个远端的bloomfilter,具体可以参见这个例子:https://github.com/olylakers/RedisBloomFilter/blob/master/src/main/java/org/olylakers/bloomfilter/BloomFilter.java

每次来一条新消息,通过redis的bloomfilter来判断这是不是一条新消息

如果是,则对tair中的未读数计数器+1

用户每次读取消息后,则清空对应的bloomfilter


这样一来,终于我们可以通过能接受的内存来实现未读数的计算,不再要每天担心redis是不是内存不够用了,应用又频繁抛timeout exception了

四、诡异的connection broken pipe

在方案三上线以后,我认为这些redis应该会消停了,redis运行一段时间后,的确再也没用timeout exception了,但是在运行一段时间后,tpn在向redis执行请求时,往redis写入命令时会报这个异常:

java.net.SocketException: Broken pipe。我们知道,如果一个socket连接已经被远端给close掉了,但是客户端没有察觉,仍然通过这个连接读写数据,那么就会产生Broken pipe异常。因为tpn使用jedis,通过common pool来实现jedis的connection pool,我第一反应就是tpn没用正确使用jedis的connection pool,没有销毁掉broken的redis connection,而是已经重新把归还给了connection pool,或者是jedis的connection pool有bug,造成了connection泄露,导致ton在往一条已经往一条已经被close的连接写入数据。但是仔细检查了一遍tpn的代码和 jedis connection pool的代码,发现没用什么问题,那就说明有些redis是真的被redis服务端给关闭了,但是jedis 的connection pool没有发现。

因为客户端的jedis pool没有问题,那么基本上可以确定的确是redis server端关闭了一些连接。首先怀疑的就是tpn的redis 配置出错了,错误地配置了redis.conf里的timeout 配置项:

首先怀疑的是不是tpn的redis配置不多,造成因此就去查看redis的相关代码。redis的配置文件redis.config里面有timeou这个配置项:

# Close the connection after a client is idle for N seconds (0 to disable) timeout 0

检查了下tpn 6台redis上的所有配置文件,发现都没有配置这个选择,但是tpn部署了两个版本的redis,redis-2.6.14和redis-2.4,结果在redis-2.4里面,如果没有配置这个值,redis就会使用默认的值,5*60(s),而redis-2.6.14的默认值是0,即disable timeout,同时又去查看了下jedis common pool的设置,发现minEvictableIdleTimeMillis=1000L * 60L * 60L * 5L(ms),即一个redis连接的空闲时间超过5个小时才会被connection pool给回收。很明显,就是因为客户端和服务端的connection idle time设置不一样,造成了connection被一端关闭了,但是另一端没有感知,所有造成了broken pipe。解决办法就是把redid-2.4升级到redid-2.6.14。

五、总结

从方案一到方案三,我最大的感触就是,在解决问题,优化方案的时候,不能仅仅执拗于技术本身,而是要联系业务思考。这个redis的bloomfilter的想法我很早就有了,但是我之前一直没有想到tpn未读数消息数只显示99+这个业务逻辑,而是一直想如何通过降低消息bizId的长度来尽可能地去节省内存,结果越想越复杂,然后就没有然后了。。。。


------分隔线----------------------------