使用Redis SortedSet实现增量更新

导读:前段时间有个需求是提供一个接口供客户端增量更新数据,当有数据被删除了以后客户端也需要感知到,并且要支持一定并发;

关键词:高并发,增量更新

前言

何谓增量更新,顾名思义就是只更新变化的部分,这样即经济(尤其对流量敏感型用户)又高效,比如微信朋友圈,微博的消息,头条推荐等等。要实现增量更新,首先要解决三个问题,1.如何识别数据的变化,2.如何识别增量更新的起始位置,3.如何感知数据被删除。

初步分析

首先说说如何识别数据的变化,简单来说就是每条记录都需要有一个版本信息,可能是时间戳或者是一个自增的数字,为了简答起见我选用的是时间戳,但是时间戳在并发极其高的时候可能会重复,从而导致增量更新永远结束不了,这个需要特别注意,总体来说,整个数据集需要有一个全局递增的版本号,有了这个前提才能满足第二点“识别增量更新的起始位置”,这样很容易就能想到只要每次查询的时候让前端把最后一条记录的版本信息带上来作为查询条件就可以满足需求了,至于第三点,其实就是对数据做逻辑删除。画个简答的时序图加深理解。

使用Redis SortedSet实现增量更新

 直抒胸臆

我分析完之后首先想到的就是使用redis的SortedSet来实现,用member存储uid,用score来存储uid的最后更新时间,借助ZRANGEBYSCORE实现增量更新。当用户信息添加或者修改的时候使用ZADD来修改uid的最后更新时间。删除的时候就稍微麻烦一些,不仅要修改最后更新时间,还需要将删除的uid保存起来,使用一个SortedSet实现不了,需要将删除的uid暂存到另一个SortedSet(set更简单,为什么不用?)中,这样在查询数据的时候判断下uid是否已被删除然后打标即可让前端感知到数据被删除。

剥茧抽丝

前面说过我会使用ZADD来修改uid对应的score,也就是最后更新时间,那这个score由谁产生呢?第一版使用的是服务器的当前时间(System.currentTimeMillis),似乎一切都很完美,但是跑了几天以后客户端反馈说有个用户的信息已经删除了,但是客户端却没有感知到,查了服务端的操作日志并没有发现什么问题,uid的score是最新的,那为什么增量接口没有感知到呢?后来在分析客户端请求日志的时候发现了一个细节,客户端携带的version(本质就是个时间戳)居然比有问题uid的score要大,那就好解释为什么查询不到了,我是将version作为ZRANGEBYSCORE的min参数来实现增量更新的,如果version已经是最新的了,就不会返回数据,那version怎么就超前了呢?前面说过我是使用服务器的当前时间作为score,有没有可能程序里取到的时间忽早忽晚呢,说到这儿可能有人不信,但这就是事实,在集群环境下时钟不是强同步的,通过一个表格来还原现场。

使用Redis SortedSet实现增量更新

原因找到了,怎么解决呢?有人会说,搞单台服务器,对不起,我们毕竟是一个追求高可用的系统。用消息队列,单线程消费呢?这个倒是可行,但说实话复杂了,能不能让redis内部消化呢,我们都知道,redis执行指令内部也是排队的,如果这个score让redis生成就可以解决问题,带着问题我查看了redis的api,发现TIME命令可以满足需求,TIME命令返回两个字符串,第一个代表当前的秒,第二个代表当前这一秒已经过去的微秒,做一个简单的计算就就可以得到当前时间对应的微秒,最终的jua脚本如下:

local times=redis.call(‘TIME‘) ; 
local score = times[1]*1000000+times[2] //通过计算得到当前时间的微秒
redis.call(‘zadd‘,KEYS[2],score,ARGV[1])  
接着再来聊下删除操作的细节,删除时有两步操作要完成,为了确保这两步操作的原子性,还是要借助lua来实现,最终lua脚本如下:
local times=redis.call(‘TIME‘) ;
local score = times[1]*1000000+times[2] ;
redis.call(‘zadd‘,KEYS[1],score,ARGV[1]) ;//修改uid最后更新时间
redis.call(‘zadd‘,KEYS[2],score,ARGV[1]) ;//插入删除set

查询时如何给用户打标,首先通过ZRANGEBYSCORE查询出一批uid,然后遍历uid判断是否已删除,如果已删除给uid打标,考虑到性能,这里还是使用lua来实现,最终lua脚本如下:

local signlist = redis.call(‘zrangebyscore‘,KEYS[1],ARGV[1],ARGV[2],‘WITHSCORES‘,‘LIMIT‘,ARGV[3],ARGV[4]) 
local signTable = {} //使用lua脚本判断uid是否删除可以避免多次网络请求(加入将signList返回给服务端,服务端再判断)
for i = 1, #signlist, 2 do 
    local h = {} h[‘uid‘] = signlist[i] h[‘t‘]= signlist[i + 1] h[‘status‘]=1 
    if(redis.call(‘ZRANK‘,KEYS[2],signlist[i])) then 
        h[‘status‘] =0   //使用ZRANK判断uid是否在删除列表中,如果已删除标志位删除状态
    end 
    table.insert(signTable,h) end
                                                               
local result = #signTable>0 and cjson.encode(signTable) or ‘[]‘
return result

最后的一点优化

截止到这,整个方案可以说介绍完了,不知道细心的你有没有发现一个问题,那些逻辑删除的数据终将会成为一颗炸弹,想象一下redis被撑爆的那天,送给自己两句诗,“待到山花烂漫时,她在丛中笑”。为了避免悲剧,还是早日将优化提到日程上来吧(不知道从哪听到的一句话,程序员都抱有侥幸心理),怎么优化呢?归根节点就是要将这部分数据给清除了,但是物理删除客户端就感知不到了,似乎陷入了矛与盾的世界中。搞什么增量更新,真是麻烦啊,要不每次拉全量。每次全量绝对不行,倒是可以偶尔拉一次全量,这个偶尔最终被赋予的含义是“如果客户端两天没在线就拉全量数据,否则拉增量数据”,鉴于此客户端还需要一个最后请求时间(有人会问为什么不用最后一条记录的更新时间呢?毕竟不是一直有数据被更新啊),这样服务端就可以将两天之前逻辑删除的数据做物理删除了,前面有个疑问是“存储删除用户的uid用set不是更简单吗,为什么用SortedSet”,正好在这解答下,因为我可以很方便的找出两天之前被删除的那些uid。

总结

看似一个简单的需求,当你用心去完成的时候一定会带给你意想不到的收获,如果觉得不错,请点个推荐。