执行个 DEL 竟然也会阻塞 Redis?深挖一下果然不简单
hello,大家好,我是张张,「架构精进之路」公号作者。
前两天有同事在执行 DEL 进行 Redis 数据删除的时候,阻塞 Redis 造成服务报警。
相信很多人对此不能理解:
DEL 操作不就是从 Redis DB 字典中删除过期 key 么?
它的时间复杂度不就是 O(1) 么?
为什么会对 Redis 造成阻塞呢?
这篇文章,我就来和你盘点一下,使用 Redis DEL 会踩到「坑」,以及深挖下其中的原理。
1. DEL 命令的时间复杂度
删除一个 key,你肯定会用 DEL 命令,它的时间复杂度是多少呢?
是 O(1) 么?
其实不一定。
如果你有认真阅读 Redis 的官方文档,就会发现:删除一个 key 的耗时,与这个 key 的类型有关。
Redis 官方文档在介绍 DEL 命令时,是这样描述的:
key 是 String 类型,DEL 时间复杂度是 O(1)
key 是 List/Hash/Set/ZSet 类型,DEL 时间复杂度是 O(M),M 为元素数量
也就是说,如果你要删除的是一个非 String 类型的 key,这个 key 的元素越多,那么在执行 DEL 时耗时就越久!
为什么会是这样呢?
原因就在于:删除这种 key 时,Redis 需要依次释放每个元素的内存,元素越多,这个过程就会越耗时。
而这么长的操作耗时,势必会阻塞整个 Redis 实例,影响 Redis 的性能。
接下来,我们再来分析下,删除一个 String 类型的 key 会不会有这种问题?
前面不是提到过,Redis 官方文档的描述,删除 String 类型的 key,时间复杂度是 O(1) 么?这不会导致 Redis 阻塞吧?
其实,这也 不 一 定!
2. Redis 的删除策略
Redis 主要提供两种删除策略:
请求时删除:执行命令操作时,先检查下 key 是否已经过期,若已过期则删除,反之,继续操作。
定期清理:请求时删除的补充,按一定的请求频率清理已过期的 key。
其中,针对定期清理 redis 提供了两种触发机制:
一种是低频次,频率由周期性 server.hz 决定。
另一种是高频次,频率与主事件循环有关,由 beforeSleep 方法触发。
其实不管哪种方式触发,都需要考虑这个问题:不能对服务造成长时间的阻塞。
现实中总会有各种各样难以解决的问题:
假如我们一次性过期很多 key,要一次性清理该怎么办?
假如有个大 key,要花很长时间才能删除掉,又该怎么办?
你考虑一下,如果这个 key 占用的内存非常大呢?
例如,这个 key 存储了 800MB 的数据(很明显,它是一个 bigkey),那在执行 DEL 时,耗时依旧会变长!
这是因为,Redis 释放这么大的内存给操作系统,也是需要时间的,所以操作耗时也会变长。
所以,对于 String 类型来说,你最好也不要存储过大的数据,否则在删除它时,也会有性能问题。
3. 如何稳妥的进行 Redis 删除
当我们在删除 List/Hash/Set/ZSet 类型的 key 时,一定要格外注意,不能无脑执行 DEL,而是应该用以下方式删除:
查询元素数量:执行 LLEN/HLEN/SCARD/ZCARD 命令
判断元素数量:如果元素数量较少,可直接执行 DEL 删除,否则分批删除
分批删除:执行 LRANGE/HSCAN/SSCAN/ZSCAN + LPOP/RPOP/HDEL/SREM/ZREM 删除
主要采用以下两种原则来应对:
少量、高频次 进行过期数据清理
大 key 采用异步惰性删除策略,避免主线程阻塞
从触发删除时机来看,redis 主要有两种触发场景:
请求时触发:对所有请求命令执行之前都检查下 key 是否过期。
定期触发:定期的检查下是否有 key 过期,有的话就尝试删除。
关于请求时触发,本质上这是一种 惰性 的思想,你主动发起请求了,我就顺便帮你检查下当前状态,如果没有请求,那么我就不做任何处理。
站在系统的角度,它需要客户端触发,然后系统被动的接受指令。
此时,你可能会想:Redis 4.0 不是推出了 lazy-free 机制么?
打开这个机制,释放内存的操作会放到后台线程中执行,那是不是就不会阻塞主线程了?
那真的会是这样吗?
4. 什么是 lazy-free?
在应用之前,我们首先要搞清楚,什么是 lazy-free?
lazy-free 是 4.0 新增的功能,但是默认是关闭的,需要手动开启。这里的 lazy-free,将慢操作异步化,这也是在事件处理上向多线程迈进了一步。
惰性删除的意思是:将释放内存的操作交给后台线程异步的进行处理,也就意味着一个 key 真正意义上的删除,具有一定的延迟。
惰性,就体现在删除操作并没有真正执行,而是交给后台线程异步处理,可能比实际响应结果要晚一些才真正完成。
从 DB 中删除过期 key 时,只是从 DB 字典中将关系删除,内存没有真正释放,而是交给后台异步线程去删除。
主线程将待删除的 key 扔到 lazy_free 队列,并唤醒对应的后台线程,此时 bio_lazy_free 后台线程就从队列中取出对应的 key 进行内存清理。这也是一个典型的 生产者 - 消费者 模型。
5. DEL bigkey 会阻塞
手动开启 lazy-free 时,有 4 个选项可以控制:
lazyfree-lazy-expire:key 在过期删除时尝试异步释放内存
lazyfree-lazy-eviction:内存达到 maxmemory 并设置了淘汰策略时尝试异步释放内存
lazyfree-lazy-server-del:执行 RENAME/MOVE 等命令或需要覆盖一个 key 时,删除旧 key 尝试异步释放内存
replica-lazy-flush:主从全量同步,从库清空数据库时异步释放内存
除了 replica-lazy-flush 之外,其他情况都只是可能去异步释放 key 的内存,并不是每次必定异步释放内存的。
开启 lazy-free 后,Redis 在释放一个 key 的内存时,首先会评估代价,如果释放内存的代价很小,那么就直接在主线程中操作了,没必要放到异步线程中执行(不同线程传递数据也会有性能消耗)。
那什么情况才会真正异步释放内存?
这和 key 的类型、编码方式、元素数量都有关系:
当 Hash/Set 底层采用哈希表存储(非 ziplist/int 编码存储)时,并且元素数量超过 64 个
当 ZSet 底层采用跳表存储(非 ziplist 编码存储)时,并且元素数量超过 64 个
当 List 链表节点数量超过 64 个(注意,不是元素数量,而是链表节点的数量,List 的实现是在每个节点包含了若干个元素的数据,这些元素采用 ziplist 存储)
再加一个条件就是 refcount=1 就是没有人在引用这个 key 的时候
只有以上这些情况,在删除 key 释放内存时,才会真正放到异步线程中执行,其他情况一律还是在主线程操作。
也就是说 String(不管内存占用多大)、List(少量元素)、Set(int 编码存储)、Hash/ZSet(ziplist 编码存储)这些情况下的 key 在释放内存时,依旧在主线程中操作。
由此可见,即使开启了 lazy-free,String 类型的 bigkey,在删除一个 String 类型的 bigkey 时,它仍旧是在主线程中处理,而不是放到后台线程中执行,依旧有阻塞 Redis 的风险!
6. 总结
其实,接触任何一个新领域,都会经历陌生、熟悉、踩坑、吸收经验、游刃有余这几个阶段。
踩坑不可怕,根据问题再深挖一下,一定能收获到你期望的答案。
本文通过对 Redis DEL 命令的一系列分析,总结来看,Redis 有自己的一套代价评估方法,认为可以清理代价较大,才会交给后台线程惰性处理;反之,顺手就给清理了。
最后忠告各位开发同学,即便 Redis 提供了 lazy-free,我建议还是尽量不要在 Redis 中存储 bigkey。
希望今天的讲解对大家有所帮助,谢谢!
Thanks for reading!
作者:张张,十年研发风雨路,大厂架构师,「架构精进之路」专注架构技术沉淀学习及分享,职业与认知升级,坚持分享接地气儿的干货文章,期待与你一起成长。
关注并私信我回复“01”,送你一份程序员成长进阶大礼包,欢迎勾搭。
版权声明: 本文为 InfoQ 作者【架构精进之路】的原创文章。
原文链接:【http://xie.infoq.cn/article/6dab34df709d3a0e29685a2d6】。文章转载请联系作者。
评论