Redis 为什么突然变慢:从事件循环到 SLOWLOG、LATENCY 和客户端
「Redis 是内存数据库,慢不了」这句话忽略了大 key、集中过期、fork、输出缓冲区和网络往返。诊断要先建立基线,再按证据一层层排除;SLOWLOG 只是其中一个视角。
做一个实验:一个探测客户端每毫秒发一次 GET,记录每次请求比计划时刻晚了多少;同时依次制造几种常见的「变慢」,再看 Redis 自己的 SLOWLOG 和 LATENCY 记录下了什么。
最值得注意的是中间几行:50 万个 key 同时过期,让探测请求最多推迟了 26.6ms,而 SLOWLOG 里一条记录都没有;只查 SLOWLOG 的人会得出「Redis 没有慢命令」的结论。本文用 Redis 8.10.1 的实测说明延迟从哪里来、每种来源在哪里能看到、怎样止损。
一、先说结论
- 命令在主线程上依次执行,任何一条慢命令都会让后面的请求排队。 实测
DEL一个百万成员的 Set 执行了 38.1ms,同一时刻的探测请求被推迟了 38.3ms;换成UNLINK,SLOWLOG 没有记录,探测请求的往返最多 0.4ms。 - SLOWLOG 只记录命令本身的执行时间。 过期删除、fork、写回响应都不属于某条命令,不会出现在 SLOWLOG 里;集中过期实测让请求最多推迟 26.6ms,只有 LATENCY 的
expire-cycle事件能看到。 - LATENCY 默认是关闭的。
latency-monitor-threshold默认为 0,要主动打开才会记录 fork、expire-cycle、aof-fsync 这类事件。 - pipeline 用吞吐换单批延迟。 实测每批 1、10、100、1000 条
SET的吞吐分别是 2.4 万、16 万、47 万、46 万次/秒,每批的往返 p50 从 43µs 涨到 2.2ms。 - 修复都有副作用:
UNLINK把释放挪到后台线程但不减少 CPU,随机 TTL 摊平删除但不解决回源容量,pipeline 批次太大会占住事件循环。
二、先把「单线程」说准确
Redis 的命令执行以一个主线程的事件循环为核心,但它并不是只有一个线程在干活:
| 执行者 | 做什么 | 对延迟的含义 |
|---|---|---|
| 主线程(事件循环) | 执行命令与 Lua、主动过期、淘汰、调用 fork() | 这里的任何耗时都会让后面的请求排队 |
I/O 线程(io-threads,默认 1 即不启用) | 读写 socket、解析请求 | 缓解网络读写的 CPU 瓶颈,命令仍在主线程执行 |
| 后台线程 | lazy free(UNLINK、异步删除)、AOF fsync、关闭文件 | 把耗时从主线程挪走,但仍消耗 CPU 与 I/O |
| 子进程 | RDB、AOF 重写、全量同步的快照 | fork 本身在主线程上,之后的写时复制消耗内存 |
所以「单线程」准确的说法是:同一时刻只有一条命令在执行。 它避免了锁,但也意味着一条慢命令会阻塞所有客户端。
三、端到端延迟和 SLOWLOG 的边界
调用方看到的一次请求耗时包括:客户端连接池里的排队、网络往返、在 Redis 事件循环里等前面的工作做完、命令执行、响应写回。SLOWLOG 只记录「命令执行」这一段,默认阈值 slowlog-log-slower-than 10000(10ms)。
以 DEL 为例:SLOWLOG 记录了 DEL 执行 38.1ms,而那 38ms 里到达的每一个 GET 在 SLOWLOG 里都是几微秒,它们的等待被算在了 DEL 头上,不会出现在自己的记录里。所以排查时要把调用方的慢请求和 SLOWLOG 按时间窗口对齐,而不是按命令对齐。
LATENCY 监控补上了不属于任何命令的部分。打开它:
redis-cli CONFIG SET latency-monitor-threshold 1 # 单位毫秒,超过就记录
redis-cli LATENCY LATEST # 每类事件最近一次与最大耗时
redis-cli LATENCY HISTORY expire-cycle # 某类事件的时间序列
redis-cli LATENCY DOCTOR # 生成一段可读的分析四、先建立三条基线
没有基线就无法判断「慢」。在同一台机器、同样的数据规模上记录:
- 系统本身的抖动:
redis-cli --intrinsic-latency 5在 Redis 所在的机器上运行,测的是不涉及 Redis 的调度延迟。实测容器内最大 5.1ms:Docker 运行在虚拟机里,调度本身就会带来毫秒级的抖动,这就是这台机器上延迟的下限。 - 网络往返:
redis-cli --latency从客户端所在的机器发起。 - 业务命令的分位数:用真实的命令和数据量,记录 p50、p99 和最大值。实测探测
GET的基线是 p50 49µs、p99 106µs。
之后每次异常,都和基线对比:是所有请求一起变慢(排队),还是某类命令变慢(命令本身),还是个别客户端变慢(网络、客户端)。
五、六类常见的抖动
5.1 大 key 与 O(N) 命令
| 操作 | SLOWLOG 记录的执行时间 | 探测请求最多推迟 |
|---|---|---|
DEL 百万成员的 Set | 38.1ms | 38.3ms |
UNLINK 同样的 Set | 未记录 | 5.3ms(往返最多 0.4ms) |
KEYS nomatch:*(遍历 100 万个 key) | 39.0ms | 38.8ms |
| Lua 空循环 300 万次 | 350.1ms | 349.7ms |
「推迟」是请求比计划发出时刻晚了多久,包含客户端自己的调度抖动;这台机器的 intrinsic latency 最大 5.1ms,所以几毫秒以内的差别不能归因到 Redis。UNLINK 那一行的往返最多只有 0.4ms。
- 看什么:SLOWLOG、
INFO commandstats中各命令的usec_per_call、redis-cli --bigkeys/--memkeys采样。 - 止损:删除大 key 用
UNLINK;遍历用SCAN分批;集合按范围分段读(HSCAN、ZRANGE ... LIMIT);拆分大 key。长脚本超过busy-reply-threshold(默认 5 秒)后,其他客户端会收到BUSY错误,只能SCRIPT KILL(脚本还没写入时)或SHUTDOWN NOSAVE。
数据结构层面的选择见 Redis 数据结构与编码。
5.2 大量 key 同时过期
50 万个 key 设置成在同一时刻前后到期:探测请求 p99 从 106µs 升到 1.16ms,最多推迟 26.6ms,SLOWLOG 没有记录,LATENCY 的 expire-cycle 事件最大 25ms。同样数量的 key 把 TTL 分散到 10 秒里,p99 为 762µs,最多推迟 9.6ms。
- 看什么:
INFO stats的expired_keys增速、expired_stale_perc;LATENCY 的expire-cycle。 - 止损:TTL 加随机偏移;批量写入的缓存不要用同一个过期时间。过期回收的时间序列见 Redis 内存满了会怎样。
5.3 fork 与持久化
由 save 规则自动触发的 BGSAVE 不对应任何客户端命令,SLOWLOG 看不到。实测 160MB 数据 fork 约 1ms,探测请求最多推迟 2.4ms。fork 的耗时和页表大小成正比,数据集到几十 GB 时明显变长,这时 LATENCY 的 fork 事件和 INFO stats 的 latest_fork_usec 是直接证据。AOF 的 fsync 慢时会出现 aof-fsync-always、aof-write-pending-fsync 等事件。
- 止损:控制单实例内存;把持久化放到 replica 上;错开 RDB 与 AOF 重写;磁盘性能不足时
appendfsync everysec比always稳定得多,取舍见 Redis 持久化与恢复。
5.4 客户端输出缓冲区
一个订阅者订阅后不读取,同时发布 3 万条 4KB 的消息:这个客户端的输出缓冲区采样到最高 25.9MB,随后因超过 client-output-buffer-limit(pubsub 默认硬上限 32MB,或持续 60 秒超过 8MB)被服务器断开。期间探测请求最多推迟 9.2ms,SLOWLOG 与 LATENCY 都没有记录。
- 看什么:
INFO clients的client_recent_max_output_buffer、INFO memory的mem_clients_normal、INFO stats的client_output_buffer_limit_disconnections;CLIENT LIST中各连接的omem。 - 止损:限制单次返回量;慢消费者要么跟上,要么被断开;replica 的输出缓冲区太小会导致全量同步反复失败,见 Redis Sentinel 故障切换。
5.5 内存压力、swap 与大页
这一类在容器实验里没有复现,只给检查项:INFO memory 中 RSS 远大于 used_memory 说明碎片;RSS 小于 used_memory 或 /proc/<pid>/smaps 中有 Swap 说明被换出,延迟会升到毫秒到秒级;Linux 的透明大页(THP)会让 fork 后的写时复制以 2MB 为单位进行,官方建议关闭。
5.6 网络往返与 pipeline
| 每批条数 | 吞吐 | 每批往返 p50 / p99 |
|---|---|---|
| 1 | 23,565 次/秒 | 43 / 56µs |
| 10 | 161,846 次/秒 | 63 / 81µs |
| 100 | 474,741 次/秒 | 206 / 249µs |
| 1000 | 461,003 次/秒 | 2,164 / 2,711µs |
单条请求时,吞吐几乎完全由往返次数决定;每批 100 条就接近上限,再加大批次只会让单批变慢,还会让这一批在服务器上连续执行更久。容器内的往返是回环地址,跨机器、跨可用区时,pipeline 的收益会更大。
六、把工具串起来
一次延迟告警的排查顺序:
- 确认现象:调用方的 p99 从什么时候开始变差,所有命令还是某类命令,所有实例还是一个实例。
- 同一时间窗口的 SLOWLOG:有记录就看命令、参数、客户端地址,回到 5.1。
- LATENCY LATEST / HISTORY:有
expire-cycle、fork、aof-*事件,回到 5.2、5.3。 - INFO:
clients(连接数、输出缓冲)、memory(RSS、碎片、mem_clients_normal)、stats(过期、淘汰、断开次数)、persistence(后台任务)、commandstats(各命令的调用次数与平均耗时)。 - 主机与网络:CPU 限额与争抢、swap、网卡带宽、客户端所在机器的负载。
线上执行诊断命令也要有分寸:KEYS、--bigkeys 的全量扫描、DEBUG 系列都可能成为新的延迟来源;--bigkeys 基于 SCAN,但仍然建议在 replica 或低峰期运行。
七、修复的副作用
| 修复 | 解决了什么 | 带来什么 |
|---|---|---|
UNLINK / lazyfree-lazy-* | 大对象释放不再阻塞主线程 | 后台线程仍消耗 CPU,内存要晚一点才回收 |
| TTL 加随机偏移 | 过期删除不再集中 | 不解决缓存失效后的回源容量 |
| pipeline | 减少往返次数 | 批次过大时单批变慢、客户端与服务器缓冲变大 |
| 关闭或放宽持久化 | 去掉 fork 与 fsync 的影响 | 扩大数据丢失窗口 |
打开 io-threads | 网络读写不再占满主线程 | 命令执行仍是单线程;线程多了也有调度开销 |
| 扩容、分片 | 分散单实例的压力 | 路由、跨 slot 操作与迁移的复杂度,见 Redis Cluster 的应用契约 |
八、常见误区
- 「SLOWLOG 没记录就不是 Redis 的问题」:集中过期实测让请求推迟 26.6ms,SLOWLOG 一条没有。
- 「Redis 是单线程的」:命令执行是单线程,但还有 I/O 线程、后台线程和子进程;准确的说法是同一时刻只执行一条命令。
- 「用了
UNLINK就没有代价」:释放工作挪到了后台,CPU 和内存回收的时间都还在。 - 「pipeline 越大越好」:实测每批 100 条后吞吐不再增长,每批 1000 条的往返是 2.2ms。
- 「慢查询阈值调到 1ms 就能看到一切」:阈值只影响记录哪些命令,看不到命令之外的阻塞。
小结
排查 Redis 延迟,先分清三件事:调用方感受到的延迟、命令的执行时间、命令之外的阻塞。SLOWLOG 回答第二个问题;打开 LATENCY 监控才能回答第三个;两者都要和调用方的时间窗口对齐。大部分止损手段都是把工作挪走或摊开,而不是让它消失,所以每一项修复都要写清楚副作用。
配套实验
- codesphere-labs/cache/redis-latency-diagnostics:每毫秒一次的探测请求与大 key 删除、集中过期、长 Lua、KEYS、自动 BGSAVE、慢订阅者的对照,SLOWLOG 与 LATENCY 原始输出,pipeline 批次对比(验证记录)
参考资料