• 背景

    最近我们组里有个服务接口,特点是读写量比较大,同时每次写入的内容一分钟后会过期,同时还要统计热门的频度。现在新增的需求是,查询返回的结果要按过期时间排序,这对我们已有的服务是个小小的挑战:因为读写更新逻辑都已经写死到业务逻辑中!

    这个服务的情况是:每个用户都有一个唯一的 userid,并会频繁请求服务端的文件,每次的 userid 请求,服务端会记录 userid 并设置一个过期时间,同时统计被请求文件的频次做热门统计。还有就是需要提供一个主动删除 userid 的接口。

    解藕

    因为刚接手这个服务,乍一看有过期时间,数据可以 KV 存储,貌似用 redis 非常合适,而且 redis 中也支持 top N 这种频次统计,唯一的问题在于如何获取按过期时间排序的结果。经过查阅资料,可以用sort by命令,依据未过期的数据进行排序,具体思路如下:

    LPUSH list userid        # 将 userid 放入列表中
    SET expired_userid 60    # 将 userid 加前缀 `expired_` 作为 key,设置其过期时间为 60s
    
    SORT list BY expired_* DESC  # 安装未过期的 userid 作为标准对 list 进行排序
    

    但这种方法引入了一个问题:从列表中删除 userid 变得很复杂!而且使用的 redis 第三方库竟然不支持 sort,好吧,思路可以换,先把这些数据更新读写的操作从业务逻辑中拿出来,组个新的库,根据业务的需要,确定调用的接口,这样服务逻辑就不用改了,剩下的就仅仅是修改这个库了。

    简而言之,第一次修改就是解藕,同时思考如何构建合适的结构来操作这些数据。

    梳理

    解藕之后,优化的点就在如果处理数据上了,一般而言数据的操作可以概括为“增删改查”。那么结合我们具体的业务,可以对这些操作做一个梳理:

    • :新增的 userid 和 文件 id
    • :删除指定的 userid
    • :文件 id 根据访问增加频次,userid 每次访问修改过期时间
    • :top5 文件 id,访问相同文件并按过期时间排序的 userid

    如果使用 redis 的话,可以使用zincrbyzrevrange获取 top5;

    而对于 userid 的相关操作,可以使用zadd,以文件 id 作为 key,过期时间作为 score,userid 作为 value,这样的话,删除可以用 zrem,获取可以使用zrevrangebyscore。但是,这样做的话,就出现无法删除过期 usid 的问题。并且做测试时,发现使用的第三方库也有问题,当并发量比较大时,就开始频繁出现connect timeout错误,猜测在数据量比较大时,这些操作开始变得耗时引起的,这个还有待进一步验证,也许是第三方库的问题。

    可以看下benchmark

    BenchmarkSet-4	         300	   4953393 ns/op	   29560 B/op	     277 allocs/op
    BenchmarkDel-4   	     200	   7536812 ns/op	   48901 B/op	     485 allocs/op
    BenchmarkSort-4   	     200	   6097776 ns/op	   69760 B/op	     902 allocs/op
    

    毕竟实现了需求,可以先用着,后续继续优化。

    优化

    通过与同事的沟通,了解到数据量其实并不太大,使用 redis 有些过重,而且因为还没添加删除过期 userid 的功能,导致 redis 占用大量内存!而且业务对实时性要求比较高,redis 的建连也会有耗时,于是开始思考设计什么样的数据结构,可以直接在内存中比较高效的完成这些操作?

    先放出优化后的benchmark,可以看到提升还是非常明显的:

    BenchmarkSet-4	         500	   2441997 ns/op	   18758 B/op	     184 allocs/op
    BenchmarkDelete-4   	 300	   4559653 ns/op	   37815 B/op	     356 allocs/op
    BenchmarkSort-4   	     500	   2537748 ns/op	   28263 B/op	     307 allocs/op
    

    优化的点:

    • top5 文件直接用 map[string]int64 进行计数统计,新建一个 goroutine,每 5s 进行一次 top5 的更新;这就把每次访问都要进行的排序操作,放到了后台执行,牺牲了一些准确度,换取更快的获取数据速度。
    • 使用 map[string][]string 存储文件访问的数据,其中 keyexpire_time + fid 组成,[]string 为 userid 列表,这样牺牲了一些空间,可以把动态的过期时间变成静态单一的数字,每次写入一个 key 时,把这个 key 也写入一个执行删除操作的 channel;而返回给用户的 userid 列表,则可以直接按过期时间取,这样就不需要排序操作了,毕竟排序是比较耗时的操作。
    • 对于删除操作,一种是过期自动删除,这个利用了上面的 channel,使用 goroutinechannel 中取数据,从 key 中的 expire_time 判断是否过期,如果过期就直接删掉,没过期就 sleep 至过期再执行删除操作,因为进入 channelkey 是按时间序来的;对于另一种主动删除,则需要一个 map[string]bool 来维护各个 userid 的状态。

    以上就是基本的优化点,配合 goroutine 使用,访问速度上的提升还是比较明显的。

    这些优化可能并不是最优的,如果读者朋友有好的想法,可以一起讨论~优化之路漫漫且修远,我且慢慢探索中。