一个服务的 Redis 偶发"几百毫秒无响应",监控上看"CPU 不高、连接数正常"。查了两周,最后发现是"某个后台任务在跑 KEYS user:*"——Redis 是单线程执行命令,这个"排队"的性质很关键:Redis 不会"只卡那一个客户端"——于是"一个后台脚本"能让"整个集群"的 P99 变差,而"根因"在另一条连接上。
一条慢命令会让所有请求排队。
Redis 的"卡"几乎都来自"单线程 + 慢命令"。而"慢"有两类:O(n) 的遍历,以及阻塞式的等待。第二类更隐蔽:它"不耗 CPU",所以监控上看不出来。
1. 三类"会让 Redis 卡"的命令
| 类 | 例子 |
|---|---|
| O(n) 遍历 | KEYS、HGETALL(大 hash)、SMEMBERS(大 set) |
| 阻塞式 | BLPOP、WAIT、BRPOPLPUSH |
| 大规模写 | DEL 大 key、FLUSHALL |
第二类"不耗 CPU 却占着线程":**BLPOP 在没有元素时会"挂住连接"**第二类的"正确理解"是:Redis 用事件驱动"挂起"这些客户端,不占 CPU、不挡别人——真正出问题的是"业务线程":BLPOP 阻塞了调用它的那个线程。
——它不影响别人(因为 Redis 用"事件驱动"处理阻塞命令),但"配错超时"会"挂住业务线程"。
第三项的"量化":DEL 一个 100 万元素的 hash 要"逐个释放",在"内存分配器"下可能几十到几百毫秒——而这段时间里,所有命令排队。
第三项是"单条命令耗时长":DEL 一个百万元素的 key 会阻塞几百毫秒——而它看起来只是"删一个 key"。
2. "O(n)"的三种替代
| 原命令 | 替代 |
|---|---|
KEYS pattern |
SCAN cursor MATCH pattern COUNT 500 |
HGETALL(大 hash) |
HSCAN 分批 |
SMEMBERS(大 set) |
SSCAN 分批 |
SCAN 的语义是"游标遍历":它不保证"一次拿全",但保证"不漏"SCAN 的"保证"有个前提:遍历期间"不增删"——如果在遍历中"不断新增 key",可能"漏掉"也可能"重复";所以"边界"要接受"弱一致"。
(在"遍历期间没有增删"的前提下)——于是"业务上要接受'分页'"。
// 两种"找 key"的方式
KEYS user:* → 一次遍历全库,阻塞(禁用)
SCAN 0 MATCH user:* COUNT 500 → 每次 500 个,多次往返(可用)
// 代价:客户端要"循环到游标为 0",且"总耗时更长"(但每次很短)
3. "大 key"的三种操作代价
| 操作 | 代价 |
|---|---|
DEL big |
一次释放全部(阻塞) |
UNLINK big |
后台释放(4.0+,推荐) |
HSCAN + 逐个 HDEL |
分批删(最慢但最稳) |
第二项的"代价"是"内存不会立刻降":UNLINK 之后 used_memory 是"慢慢降"——于是"看着内存没降"会让人以为"没删掉"。
第二项是"标准做法":UNLINK 把"释放内存"放到后台线程——于是"删除"变成"O(1)"。
"Lua 脚本"也算"单条命令",于是"长脚本"与 KEYS 同类:
// 两类"脚本"的差别
短脚本(< 1ms):判断 + 修改,安全(如库存预扣)
长脚本(循环 10 万次):阻塞整个实例,与 KEYS 同级
// 判据:脚本里的循环次数"是否随业务数据量增长"
4. "阻塞命令"的正确用法
| 命令 | 注意 |
|---|---|
BLPOP key 5 |
超时要设(否则"永久挂") |
WAIT 1 100 |
会"阻塞等待副本确认" |
BRPOPLPUSH |
同上 + 顺序风险 |
第一项在"队列"场景常用:超时设 1–5 秒第一项的"设计"要配"退出信号":消费线程"超时返回 → 检查停止标志 → 再阻塞"——于是"停机"不必等"队列空"。
,于是"没有任务时"客户端能"定期回来检查状态"(如"是否该退出")。
"慢在哪"要用"两个日志"分开看:慢命令日志与客户端列表。
// 两条命令(一次排查,30 秒)
redis-cli SLOWLOG GET 10 # 最近的慢命令(含参数摘要)
redis-cli CLIENT LIST | awk '{print $2, $3}' | sort | uniq -c | sort -rn | head
// 判据:SLOWLOG 有"KEYS/HGETALL" → O(n);CLIENT LIST 有大量 blpop → 阻塞/队列
5. "CPU 不高但卡"的三种可能
| 可能 | 判据 |
|---|---|
| 慢命令(O(n)) | SLOWLOG 里有记录 |
| 阻塞命令挂着 | CLIENT LIST 里 cmd=blpop |
| 网络/带宽打满 | 吞吐正常但延迟上升 |
第一项的"默认值"值得记住:slowlog-log-slower-than 默认 10000 微秒(10ms),而"只记'执行时间'、不含'排队时间'"——于是"卡 500ms 但单条都快"时,它帮不上。
第一项是最直接的:SLOWLOG GET 10 会列出"超过阈值的命令 + 耗时"——而"阈值默认 10ms",多数团队从没看过它。
"大 key"要能"提前发现",否则只能"出事再查":
// 两种"扫大 key"的方式(按成本)
redis-cli --bigkeys # 采样统计(快,但可能漏)
redis-cli --memkeys # 按内存排序(较慢,更准)
// 判据:单个 key > 10MB 或"元素数 > 10 万" → 列入"改造清单"
6. 排错:五种"Redis 卡顿"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 偶发几百毫秒无响应 | KEYS/大 key 操作 |
改 SCAN/UNLINK |
| 删除后卡一下 | DEL 大 key |
改 UNLINK |
| 队列消费者"卡住" | BLPOP 超时太长 |
缩短超时 |
| 主从延迟导致读旧值 | 写入量大 + WAIT |
关键读走主 |
| 吞吐正常但 P99 高 | 单条慢命令 | 看 SLOWLOG |
"分批"的"批大小"要选,太小"往返多"、太大"单次卡":
// 一条"够用"的批量规则
SCAN COUNT 500:单次 ≈ 1–3ms(500 个 key 的匹配)
SCAN COUNT 5000:单次 ≈ 10–30ms(可能进 SLOWLOG)
// 判据:单次耗时 < 5ms(P99 预算的 1/10),条数对应"可观测的往返成本"
7. 边界:什么情况"O(n)"可以接受
| 场景 | 为什么 | 做法 |
|---|---|---|
| key 数量极少(< 1000) | KEYS 只扫千级 |
可接受 |
| 只在"运维脚本"里跑 | 无并发请求 | 可接受(但避开高峰) |
| 数据结构"天然小"(如配置 hash) | HGETALL 只有几十字段 |
可接受 |
| 已有"独立实例"承载批任务 | 与在线隔离 | 可接受 |
判断口径一句话:"这条命令的最坏耗时,会不会超过'你的 P99 预算'"。最后一条与"上线流程"有关的边界:"能在生产上跑 O(n) 命令"这件事必须"被禁止在流程里"——比如"禁止在应用代码里出现 KEYS"(可用静态扫描),否则"这次修了、下次又有人写"。
会(KEYS、大 DEL、大 HGETALL),必须改;不会(小结构、低峰脚本),它只是"理论风险"。
补一个"集群"视角:SCAN 在集群模式下"只扫当前节点"——要"扫全库"必须"对每个主节点各扫一遍"。而很多人以为"一条 SCAN 就遍历全局",于是"只找到了 1/3 的 key",还以为"数据丢了"。
还有一条"运行方式"的建议:把"运维脚本"与"在线实例"分开——KEYS 只在"从库/独立实例"上跑。因为"运维方便"与"在线延迟"本来就该两条路,强行共用一套实例,早晚会出现"为了一次排查,全站卡一秒"。
而"分开"的成本,只是一条连接串。
最后一句:"卡"这个问题上,Redis 从不会"自己变慢",它总是"被某条命令卡住"。
8. 自测
- 三类"会让 Redis 卡"的命令分别是什么?为什么"阻塞式"在监控上看不出来?
DEL与UNLINK的差别是什么?为什么"删一个 key"会阻塞几百毫秒?SCAN的语义是什么?它"不保证"什么?业务上要接受什么代价?BLPOP的超时为什么要设?"永久挂"会挂住谁?- "CPU 不高但卡"的三种可能怎么区分?
SLOWLOG的默认阈值是多少? - 判断"O(n) 能否接受"的那个问题是什么?