写点什么

配运基础数据缓存瘦身实践

  • 2023-03-08
    四川
  • 本文字数:2566 字

    阅读完需:约 8 分钟

配运基础数据缓存瘦身实践

作者:京东物流 张仲良

一、背景:

在现代物流的实际作业流程中,会有大量关系到运营相关信息的数据产生,如商家,车队,站点,分拣中心,客户等等相关的信息数据,这些数据直接支撑齐了物流的整个业务流转,具有十分重要的地位,那么对于这一类数据我们需要提供基本的增删改查存的能力,目前京东物流的基础数据是由中台配运组来整体负责。


在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。

二、方案:

这次优化我们挑选了商家基础资料和 C 后台 2 个系统进行了缓存数据的优化试点,从结果看取得了非常显著的成果,节省了大量的硬件资源成本,下面的数据是优化前后的缓存使用情况对比:


商家基础资料 Redis 数据量由 45G 降为 8G;


C 后台 Redis 数据量由 132G 降为 7G;


从结果看这个优化的力度太大了,相信大家对如何实现的更加好奇了,那接下来就让我们一步步来看是如何做到的吧!


首先目前的商家基础资料使用@Caceh注解组件作为缓存方式,它会将从 db 中查出的值放入本地缓存及 jimdb 中,由于该组件早期的版本没有 jimdb 的默认过期时间且使用注解时也未显式声明,造成早期大量的 key 没有过期时间,从而形成了大量的僵尸 key。


所以如果我们可以找到这些僵尸 key 并进行优化,那么就可以将缓存进行一个整体的瘦身,那首先要怎么找出这些 key 呢?

2.1 keys 命令

可能很多同学会想到简单粗暴的 keys 命令,遍历出所有的 key 依次判断是否有过期时间,但 Redis 是单线程执行,keys 命令会以阻塞的方式执行,遍历方式实现的复杂度是 O(n),库中的 key 越多,阻塞的时间会越长,通常我们的数据量都会在几十 G 以上,显然这种方式是无法接受的。

2.2 scan 命令

redis 在 2.8 版本提供了 scan 命令,相较于 keys 命令的优势:


  • scan 命令的时间复杂度虽然也是 O(N),但它是分次进行的,不会阻塞线程。

  • scan 命令提供了类似 sql 中 limit 参数,可以控制每次返回结果的最大条数。


当然也有缺点:


  • 返回的数据有可能会重复,至于原因可以看文章最后的扩展部分。

  • scan 命令只保证在命令开始执行前所有存在的 key 都会被遍历,在执行期间新增或删除的数据,是不确定的即可能返回,也可能不返回。

2.3 基本语法

目前看来这是个不错的选择,让我们来看下命令的基本语法:


SCAN cursor [MATCH pattern] [COUNT count]


  • cursor:游标

  • pattern:匹配的模式

  • count:指定从数据集里返回多少元素,默认值为 10

2.4 实践

首先感觉上就是根据游标进行增量式迭代,让我们实际操作下:



看来我们只需要设置好匹配的 key 的前缀,循环遍历删除 key 即可。


可以通过 Controller 或者调用 jsf 接口来触发,使用云 redis-API,demo 如下:



好的,大功告成.在管理端执行 randomkey 命令查看.发现依然存在大量的无用 key,貌似还有不少漏网之鱼,这里又是怎么回事呢?


下面又到了喜闻乐见的踩坑环节。

2.5 避坑指南

通过增加日发现,返回的结果集为空,但游标并未结束!


其实不难发现 scan 命令跟我们在数据库中按条件分页查询是有别的,mysql 是根据条件查询出数据,scan 命令是按字典槽数依次遍历,从结果中再匹配出符合条件的数据返回给客户端,那么很有可能在多次的迭代扫描时没有符合条件的数据。


我们修改代码使用 scanResult.isFinished()方法判断是否已经迭代完成。



至此程序运行正常,之后通过传入不同的匹配字符,达到清楚缓存的目的。

三、课后扩展

这里我们探讨重复数据的问题:为什么遍历出的数据可能会重复?

3.1 重复的数据

首先我们看下 scan 命令的遍历顺序:



Redis 中有 3 个 key,我们用 scan 命令查看发现遍历顺为 0->2->1->3,是不是感到奇怪,为什么不是按 0->1->2->3 的顺序?


我们都知道 HashMap 中由于存在 hash 冲突,当负载因子超过某个阈值时,出于对链表性能的考虑会进行 Resize 操作.Redis 也一样,底层的字典表会有动态变换,这种扫描顺序也是为了应对这些复杂的场景。


3.1.1 字典表的几种状态及使用顺序扫描会出现的问题


  • 字典表没有扩容

  • 字段 tablesize 保持不变,顺序扫描没有问题

  • 字典表已扩容完成



假设字典 tablesize 从 8 变为 16,之前已经访问过 3 号桶,现在 0~3 号桶的数据已经 rehash 到 8~11 号桶,若果按顺序继续访问 4~15 号桶,那么这些元素就重复遍历了。


  • 字典表已缩容完成



假设字典 tablesize 从 16 缩小到 8,同样已经访问过 3 号桶,这时 8~11 号桶的元素被 rehash 到 0 号桶,若按顺序访问,则遍历会停止在 7 号桶,则这些数据就遗漏掉了。


  • 字典表正在 Rehashing

  • Rehashing 的状态则会出现以上两种问题即要么重复扫描,要么遗漏数据。


3.1.2 反向二进制迭代器算法思想


我们将 Redis 扫描的游标与顺序扫描的游标转换成二进制作对比:



高位顺序访问是按照字典 sizemask(掩码),在有效位上高位加 1。


举个例子,我们看下 Scan 的扫描方式:


1.字典 tablesize 为 8,游标从 0 开始扫描;


2.返回客户端的游标为 6 后,字典 tablesize 扩容到之前的 2 倍,并且完成 Rehash;


3.客户端发送命令 scan 6;



这时 scan 命令会将 6 号桶中链表全部取出返回客户端,并且将当前游标的二进制高位加一计算出下次迭代的起始游标.通过上图我们可以发现扩容后 8,12,10 号槽位的数据是从之前 0,4,2 号槽位迁移过去的,这些槽位的数据已经遍历过,所以这种遍历顺序就避免了重复扫描。


字典扩容的情况类似,但重复数据的出现正是在这种情况下:


还以上图为例,再看下缩容时 Scan 的扫描方式:


1.字典 tablesize 的初始大小为 16,游标从 0 开始扫描;


2.返回客户端的游标为 14 后,字典 tablesize 缩容到之前的 1/2,并完成 Rehash;


3.客户端发送命令 scan 14;


这时字典表已完成缩容,之前 6 和 14 号桶的数据已经 Rehash 到新表的 6 号桶中,那 14 号桶都没有了,要怎么处理呢?我们继续在源码中找答案:



即在找目标桶时总是用当前 hashtaba 的 sizemask(掩码)来计算,v=14 即二进制 000 1110,当前字典表的掩码从 15 变成了 7 即二进制 0000 0111,v&m0 的值为 6,也就是说在新表上还要扫一遍 6 号桶.但是缩容后旧表 6 和 14 号桶的数据都已迁移到了新表的 6 号桶中,所以这时扫描的结果就出现了重复数据,重复的部分为上次未缩容前已扫描过的 6 号桶的数据。


结论:


当字典缩容时,高位桶中的数据会合并进低位桶中(6,14)->6,scan 命令要保证不遗漏数据,所以要得到缩容前 14 号桶中的数据,要重新扫描 6 号桶,所以出现了重复数据.Redis 也挺难的,毕竟鱼和熊掌不可兼得。

总结

通过本次 Redis 瘦身实践,虽然是个很小的工具,但确实带来的显著的效果,节约资源降低成本,并且在排查问题中又学习到了命令底层巧妙的设计思想,收货颇丰,最后欢迎感兴趣的小伙伴一起交流进步。

发布于: 刚刚阅读数: 2
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
配运基础数据缓存瘦身实践_数据库_京东科技开发者_InfoQ写作社区