redis去重方案

redis去重方案优化

 tpn(taobao push notification)在使用redis计算消息未读数的过程当中,遇到了一系列的问题,下面把这个过程整理了一下,也让你们了解这个纠结的过程,供你们之后使用redis或者作相似的功能时进行参考
     redis在tpn里面主要是用于计算移动千牛(Android、IOS)上的消息未读数。tpn的未读消息数是基于bizId维度的,即同一个bizId(每条消息的业务id,若是商品id、订单id等),即便有多条消息,未读数也只能算1。所以在接收消息,计算移动千牛未读数的过程当中,就须要对bizId去重,这个去重的功能就是经过redis来实现的。随着消息量的不断上涨,这个基于redis的去重方案也不断变化。

1、基于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),通过分析,主要是由下面缘由形成的:html

  1. 缓存失效形成的超时:前面咱们提到了,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请求超时,不能正确地计算消息未读数。所以须要对上述方案进行优化。

2、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的内存使用量

3、基于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去重方案:java

  1. 经过redis的setbit命令来实现一个远端的bloomfilter,具体能够参见这个例子:https://github.com/olylakers/RedisBloomFilter/blob/master/src/main/java/org/olylakers/bloomfilter/BloomFilter.java
  2. 每次来一条新消息,经过redis的bloomfilter来判断这是否是一条新消息
  3. 若是是,则对tair中的未读数计数器+1
  4. 用户每次读取消息后,则清空对应的bloomfilter

    这样一来,终于咱们能够经过能接受的内存来实现未读数的计算,再也不要天天担忧redis是否是内存不够用了,应用又频繁抛timeout exception了

4、诡异的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。


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

相关文章
相关标签/搜索