Redis 实用小技巧——线上不让用 KEYS 命令,怎么搞?

从慢查询说起

最近在优化 Redis 的时候,顺便看了下最近的 Redis 慢查询。不看不要紧,一看居然还有意外收获——发现了一堆 Redis 的慢查询命令。

慢查询的命令虽然不少,但是仔细看了看,主要分为两类:

一类是用了KEYS命令,每次调用阻塞数据库 2000 多毫秒,调用语句如下:

KEYS cacche_*

另一类是SCAN命令,虽然每次阻塞数据库 20 多毫秒,但是调用次数很多,调用语句如下:

...
SCAN 0 MATCH *foo* COUNT 10000
...

SCAN 这个命令看着像 Redis 桌面客户端工具调用的结果,暂且放一放。我需要重点查一下 KEYS 这个命令的操作,看看是谁这么「大胆」。

WHO DID IT?

根据 KEYS 命令调用的客户端 IP,很快我便锁定了调用的项目范围,于是,我便让负责相关业务的同事帮忙排查。

一排查,发现这是老项目里清除系统缓存时触发的逻辑。后台的缓存放在了 cache_前缀的 key 中,当后台用户需要清除缓存时,会触发删除所有 cache_ 前缀 key 的逻辑。代码并不复杂,逻辑大致如下:

//KEYS 命令获取所有 key
$cache = Redis::keys('cache_*');
foreach ($cache as $key) {
    //遍历删除 key
    Redis::del($key);
}

汗——

同事看到 KEYS 命令,先是一愣,然后作了个困惑的表情:


「不应该啊,以前这里都很快的啊,系统现在有多少 KEY 了?」

「我看过了,差不多一千多万吧。」

「我的天,怎么会这么多,这不正常吧?」

「是的,有大量未设置过期的 key 。」

「那是不是把 key 的数量降下来就行了?」

「别闹,哪天数量一上来,这里还是一个不定时炸弹。」

「那怎么办呢?禁掉删除缓存功能?」

「改变用户操作行为,也是不可取的。让我想想怎么搞……」


不让用 KEYS 命令,怎么搞?

先不用考虑 key 数量的多少,就算没有异常数据,无论何时,我们都应该把 key 数量的影响考虑进来。

话说回来,像这种 「全量 key 中删除指定前缀的 key」 的场景,有哪些替代方案呢?

方案一:使用 SCAN 命令替代

Redis 提供了一种 KEYS 命令的替代方案,可以使用 SCAN 命令遍历全量的 KEY ,并且 SCAN 命令支持按规则匹配和每次迭代的数量。

相比于 KEYS 命令,SCAN 命令确实能缩短单次命令执行的时间。但是,在 key 基数很大的情况下,SCAN 命令同样存在问题。

直接影响 SCAN 命令执行时间的因素主要有两个:

一个是 匹配规则。如果我们为了减小检索范围而加上匹配规则的话,这会使 SCAN 命令执行的时间变长。

另一个影响因素是 迭代数量。当迭代数量变大时,也会使 SCAN 命令变慢。

受制于以上两个因素,我们一般会在使用 SCAN 命令时去掉匹配规则,同时减小迭代数量。但问题又来了,虽然每次执行命令快了,但调用 SCAN 命令的次数也变多了。

举个例子,同样是遍历 1000 万元素,原来迭代数量是 1 万,每次执行时间是 20 毫秒,需要迭代 1000 次。现在迭代数量是1000,每次执行时间是 2 毫秒,但需要执行 10000 次。对于用户来讲,可能感觉相差无几。但是对于 Redis 来说,也算是一种优化,毕竟不是慢查询了。

开头提到的两种慢查询,第二种就是这种 SCAN 命令。从命令的格式和请求数量上看,这应该是有人在本地使用了 Redis 桌面客户端工具。某些 Redis 客户端工具支持按列表展示 key 和检索 key 的功能,其底层应该就是使用了 SCAN 命令。虽然好过使用 KEYS 命令,但我们也看到了,这同样也造成了慢查询。

所以,我真心不建议本地使用桌面客户端工具直接连接线上的 Redis 进行操作。

不过,比起 SCAN 这种全表检索的方式,我更倾向使用「方案二」。

方案二:使用 LIST 或 SET 命令存储目标 KEY

在生成某固定前缀 key 的时候,如果考虑到后续可能有删除这些 key 的操作,或者对这些 key 集中管理,我会在生成 key 的同时,将 key 名存到一个固定的 LIST 或者 SET 里。

用 LIST 和 SET 的区别不是很大,两者都支持 POP 操作,如果考虑去重的话,可以使用 SET。

当需要删除该固定前缀的 key 时,我们可以使用如下遍历方案进行删除:

//从队列中取出 key 名删除 key
while($key = Redis::lpop('cache_list')){
    Redis::del($key);
}
...
//从集合中取出键名删除 key
while($key = Redis::spop('cache_set')){
    Redis::del($key);
}

温馨提示:
可能有些心急的小伙伴会想,这里直接 LRANGE cache_list 0 -1 或者 SMEMBERS cache_set把元素一次性取出来不就行了么?千万别这么干。去看看这两个命令的时间复杂度,一个 O(S+N),一个 O(N),Redis 命令中,但凡遇到时间复杂度是这种跟 key 基数(N)有关的,都要慎重使用。别说小题大作,现在遇不到之类的话,真等到遇见的时候,这泼天的「富贵」,你可能真接不住。

KEYS 真的有那么慢吗?

为了验证 key 基数对 KEYS 命令的影响,我特地做了一个小测试。

测试环境是在我的笔记本的 docker 环境,先交代一下我的测试基准(benchmark):

  • 笔记本型号:MacBook Pro 15-inch, 2018
  • 处理器:2.2 GHz 六核Intel Core i7
  • 内存:16 GB 2400 MHz DDR4
  • 本地操作系统及版本:14.2 (23C64)
  • docker Redis 版本:5.0.3

我的测试思路是这样的,分别设定不同的 key 基数,然后在每种基数下,分别测试 GET 指定 keyKEYS 匹配数量为0KEYS 匹配数量为10KEYS 匹配数量为100KEYS 匹配数量为1000 的命令执行时间。

这里限定了 KEYS 匹配元素的最高数量为 1000,没别的原因,就怕小本扛不住压力挂了。

为了模拟数据的准确性,我将每种操作分别执行 100 次,然后求平均值。这样得到的数值在一定程度上减小了测试随机性的影响。

测试结果如下表所示:

key 基数 GET 指定 key 执行时间 KEYS 匹配数量为0 执行时间 KEYS 匹配数量为10 执行时间 KEYS 匹配数量为100 执行时间 KEYS 匹配数量为1000 执行时间
1万 55.13微秒 419.18微秒 532.72微秒 592.37微秒 1,029.95微秒
10万 65.5微秒 5,120.26微秒 10,926.73微秒 11,602.46微秒 11,820.14微秒
100万 64.66微秒 128,053.22微秒 270,312.4微秒 271,815.35微秒 273,763.29微秒
1000万 57.51微秒 2,210,037.53微秒(2.2秒) 4,397,228.49微秒(4.4秒) 4,307,594.28微秒(4.3秒) 4,319,986.43微秒(4.3秒)

通过折线图来看的话,变化趋势更明显:

从折线图可以看出随着 key 基数和匹配元素数量的增加,命令执行时间越来越长,特别是 key 的基数超过百万级别以后,变化尤为明显。

当 key 的基数达到 1000 万时,使用 KEYS 匹配数量为1000 的命令执行时间为 4.3 秒。

可能对见多了 MySQL 慢查询的小伙伴来说,会不以为然:「才 4 秒而已,我一条慢 SQL 都要几十秒,慌啥?」

我们需要清楚一件事,之所以选择使用 Redis 作为缓存,正是因为 Redis 比数据库更快。同样,Redis 查询可以接受的时响应间,跟普通数据库相比,也不是一个级别的。

默认情况下,Redis 慢查询日志设定的时间是 10毫秒。对于并发不高,使用缓存不多的业务场景下,Redis 几秒的慢查询可能对业务的影响不大。但是对于并发很高,且缓存使用广泛的场景,超过几百毫秒的响应,可能就会引起 Redis 服务宕机,进而引起数据库压力骤增,甚至服务挂掉。

这绝不是危言耸听。

最后唠叨几句

现在来总结一下,为什么说 KEYS 命令是个危险的命令呢?

首先,取决于命令的时间复杂度。学习 Redis 命令,不光要会用,还要用好。用好 Redis 命令很关键的一点,就是根据命令的时间复杂度合理使用命令。一般时间复杂度是 O(N) 的命令,都需要慎重使用。因为随着 key 基数的变大,时间复杂度为 O(N) 的命令,执行时间也会越来越长,最终造成阻塞。

对,KEYS 命令最致命的「威胁」就是阻塞。因为 Redis 是基于内存的单线程操作,所以执行速度很快。单线程意味着同一时间点只能有一个命令执行,所以当出现有命令阻塞线程时,其他命令都处于等待状态。Redis 阻塞严重情况下,会造成 Redis 服务宕机,甚至会使数据库瞬间压力过大,间接造成数据库压力过大甚至宕机。

另外,KEYS 命令执行期间会将匹配的 key 存储到内存中,如果键数量过多或者键本身过大,会消耗大量的内存,这可能会导致 Redis 内存占用过高,甚至触发 Redis 内存淘汰机制,从而导致 key 被删除这种不可控行为。

由此可见,KEYS 命令除了操作简单点,浑身上下都是「致命毒刺」,翩翩君子,应当敬而远之。

最好的预防手段,就是线上直接禁用掉 KEYS 这种 O(N) 时间复杂度的命令。


「别说什么局势尽在掌控,将来的事,谁又说得准呢?」

感谢大家的持续关注~

本作品采用《CC 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 3

通过 tag 来处理呢,不同场景使用不同的缓存前缀,场景下的所有key,都属于这这个tag,如果要清理,直接把这个这个场景的tag 删了就行

1周前 评论
快乐的皮拉夫 (楼主) 1周前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
文章
38
粉丝
110
喜欢
650
收藏
719
排名:275
访问:3.5 万
私信
所有博文
社区赞助商