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 指定 key
,KEYS 匹配数量为0
,KEYS 匹配数量为10
,KEYS 匹配数量为100
和 KEYS 匹配数量为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 协议》,转载必须注明作者和本文链接
通过 tag 来处理呢,不同场景使用不同的缓存前缀,场景下的所有key,都属于这这个tag,如果要清理,直接把这个这个场景的tag 删了就行