前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >配运基础数据缓存瘦身实践

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

作者头像
京东技术
发布2021-07-16 11:27:43
3630
发布2021-07-16 11:27:43
举报
文章被收录于专栏:京东技术

导读

通过redis scan命令实现对字典数据的遍历,从而对得到的数据进行处理;介绍了redis字典的几种状态:扩容后,缩容后,rehashing;探究scan命令的底层原理,如何保证字典状态变化时遍历数据的完整性。

01

问题背景

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

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

02

问题方案

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

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

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

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

首先目前的商家基础资料使用@Cache注解组件作为缓存方式,它会将从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命令的优劣势如下:

优势

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

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

劣势

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

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

2.3

基本语法

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

SCAN cursor [MATCH pattern] [COUNT count]

cursor:游标

pattern:匹配的模式

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

2.4

实践

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

代码语言:javascript
复制
127.0.0.1:6379> scan 0 match * count 5
1) "14"
2) 1) "key6"
   2) "key8"
   3) "key9"
   4) "key3"
   5) "key5"
127.0.0.1:6379> scan 14 match * count 5
1) "15"
2) 1) "key4"
   2) "key2"
   3) "key1"
   4) "key7"
   5) "key10"
127.0.0.1:6379> scan 15 match * count 5
1) "0"
2) (empty list or set)

根据游标进行增量式迭代的命令

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

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

代码语言:javascript
复制
//参数设置
ScanOptions options = ScanOptions.scanOptions().match(String.format("%s*", keyPrefix)).count(10000).build();
KeyScanResult<String> scanResult = jimClient.scan(null, options);
//挖坑处?
while (CollectionUtils.isNotEmpty(scanResult.getResult())) {
        //遍历结果
        for (String key : scanResult.getResult()) {
            try {
                //判断是否设置过期时间
                Long ttl = jimClient.ttl(key);
                if (ttl < 0) {
                    jimClient.del(key);
                    logger.info("清理无用redis,keyPrefix={},key={},ttl={},删除条数={}", keyPrefix, key, ttl, succ++);
                }
            } catch (Exception e) {
                logger.error("清理redis异常:keyPrefix={},key={},失败次数={}", keyPrefix, key, fail++, e);
            }
        }
 //重新获取游标
 scanResult = jimClient.scan(scanResult.getCursor(), options);
}

根据key前缀匹配及删除数据的源码

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

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

2.5

避坑指南

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

其实不难发现scan命令跟我们在数据库中按条件分页查询是有别的:

  • mysql是根据条件查询出数据;
  • scan命令是按字典槽数依次遍历,从结果中再匹配出符合条件的数据返回给客户端,那么很有可能在多次的迭代扫描时没有符合条件的数据。

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

代码语言:javascript
复制
//参数设置
ScanOptions options = ScanOptions.scanOptions().match(String.format("%s*", keyPrefix)).count(10000).build();
KeyScanResult<String> scanResult = jimClient.scan(null, options);
while (!scanResult.isFinished()) {
    if (CollectionUtils.isNotEmpty(scanResult.getResult())) {
        //扫描结束
        for (String key : scanResult.getResult()) {
            //过期时间
            try {
                Long ttl = jimClient.ttl(key);
                //没有过期时间
                if (ttl < 0) {
                    jimClient.del(key);
                    logger.info("清理无用redis,keyPrefix={},key={},ttl={},删除条数={}", keyPrefix, key, ttl, succ++);
                }
            } catch (Exception e) {
                logger.error("清理redis异常:keyPrefix={},key={},失败次数={}", keyPrefix, key, fail++, e);
            }
        }
    }
    //重新获取游标
    scanResult = jimClient.scan(scanResult.getCursor(), options);
}

更改迭代是否完成的判断条件

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

03

课后扩展

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

3.1

重复的数据

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

代码语言:javascript
复制
127.0.0.1:6379> keys *
1) "thirdKey"
2) "secondKey"
3) "firstKey"
127.0.0.1:6379> scan 0 match * count 1
1) "2"
2) 1) "thirdKey"
127.0.0.1:6379> scan 2 match * count 1
1) "1"
2) 1) "secondKey"
127.0.0.1:6379> scan 1 match * count 1
1) "0"
2) 1) "firstKey"
127.0.0.1:6379>

查看scan命令的遍历顺序

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

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

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

(1)字典表没有扩容:

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

(2)字典表已扩容完成:

图1 字典表已扩容完成

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

(3)字典表已缩容完成

图2 字典表已缩容完成

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

(4)字典表正在Rehashing

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

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

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

图3 Redis扫描的游标与顺序扫描的游标转换成二进制对比

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

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

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

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

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

图4 高位顺序访问中scan的扫描方式

这时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号桶都没有了,要怎么处理呢?我们继续在源码中找答案:

代码语言:javascript
复制
 if (!dictIsRehashing(d)) {
        t0 = &(d->ht[0]);
        m0 = t0->sizemask;

        /* Emit entries at cursor */
        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
        de = t0->table[v & m0];
        while (de) {
            next = de->next;
            fn(privdata, de);
            de = next;
        }

        /* Set unmasked bits so incrementing the reversed cursor
         * operates on the masked bits */
        v |= ~m0;
        
        /* Increment the reverse cursor */
        v = rev(v);
        v++;
        v = rev(v);
    }

redis扫描计算目标桶的源码

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

3.2

结论

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

04

小结

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-07-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 京东技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档