Skip to content

Redis 为什么突然变慢:从事件循环到 SLOWLOG、LATENCY 和客户端 ​

「Redis 是内存数据库,慢不了」这句话忽略了大 key、集中过期、fork、输出缓冲区和网络往返。诊断要先建立基线,再按证据一层层排除;SLOWLOG 只是其中一个视角。

做一个实验:一个探测客户端每毫秒发一次 GET,记录每次请求比计划时刻晚了多少;同时依次制造几种常见的「变慢」,再看 Redis 自己的 SLOWLOG 和 LATENCY 记录下了什么。

SLOWLOGLATENCY基线0.5ms无无DEL 百万成员 Set38.3ms有有UNLINK 同样的 Set5.3ms无无50 万 key 同时过期26.6ms无有过期分散到 10 秒9.6ms无无Lua 循环 300 万次349.7ms有有KEYS 遍历 100 万38.8ms有有自动 BGSAVE(fork)2.4ms无有慢订阅者积压9.2ms无无
图 1 · 大 key 删除、长 Lua、KEYS 会进 SLOWLOG;集中过期、fork、慢消费者不进 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 的边界 ​

客户端排队连接池、pipeline网络RTT事件循环排队等前面的命令、过期、fork命令执行SLOWLOG 记录写回响应输出缓冲网络 + 解析调用方测到的往返时间SLOWLOG 的范围LATENCY(latency-monitor-threshold)还记录 fork、expire-cycle、aof-fsync 等不属于任何命令的事件
图 2 · 调用方看到的延迟 = 客户端排队 + 网络 + 在事件循环里排队 + 执行 + 写回响应;SLOWLOG 只记录「执行」这一段,前面的命令执行得再慢,也只算在它自己头上

调用方看到的一次请求耗时包括:客户端连接池里的排队、网络往返、在 Redis 事件循环里等前面的工作做完、命令执行、响应写回。SLOWLOG 只记录「命令执行」这一段,默认阈值 slowlog-log-slower-than 10000(10ms)。

以 DEL 为例:SLOWLOG 记录了 DEL 执行 38.1ms,而那 38ms 里到达的每一个 GET 在 SLOWLOG 里都是几微秒,它们的等待被算在了 DEL 头上,不会出现在自己的记录里。所以排查时要把调用方的慢请求和 SLOWLOG 按时间窗口对齐,而不是按命令对齐。

LATENCY 监控补上了不属于任何命令的部分。打开它:

bash
redis-cli CONFIG SET latency-monitor-threshold 1   # 单位毫秒,超过就记录
redis-cli LATENCY LATEST                           # 每类事件最近一次与最大耗时
redis-cli LATENCY HISTORY expire-cycle             # 某类事件的时间序列
redis-cli LATENCY DOCTOR                           # 生成一段可读的分析

四、先建立三条基线 ​

没有基线就无法判断「慢」。在同一台机器、同样的数据规模上记录:

  1. 系统本身的抖动:redis-cli --intrinsic-latency 5 在 Redis 所在的机器上运行,测的是不涉及 Redis 的调度延迟。实测容器内最大 5.1ms:Docker 运行在虚拟机里,调度本身就会带来毫秒级的抖动,这就是这台机器上延迟的下限。
  2. 网络往返:redis-cli --latency 从客户端所在的机器发起。
  3. 业务命令的分位数:用真实的命令和数据量,记录 p50、p99 和最大值。实测探测 GET 的基线是 p50 49µs、p99 106µs。

之后每次异常,都和基线对比:是所有请求一起变慢(排队),还是某类命令变慢(命令本身),还是个别客户端变慢(网络、客户端)。

五、六类常见的抖动 ​

5.1 大 key 与 O(N) 命令 ​

操作SLOWLOG 记录的执行时间探测请求最多推迟
DEL 百万成员的 Set38.1ms38.3ms
UNLINK 同样的 Set未记录5.3ms(往返最多 0.4ms)
KEYS nomatch:*(遍历 100 万个 key)39.0ms38.8ms
Lua 空循环 300 万次350.1ms349.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
123,565 次/秒43 / 56µs
10161,846 次/秒63 / 81µs
100474,741 次/秒206 / 249µs
1000461,003 次/秒2,164 / 2,711µs

单条请求时,吞吐几乎完全由往返次数决定;每批 100 条就接近上限,再加大批次只会让单批变慢,还会让这一批在服务器上连续执行更久。容器内的往返是回环地址,跨机器、跨可用区时,pipeline 的收益会更大。

六、把工具串起来 ​

一次延迟告警的排查顺序:

  1. 确认现象:调用方的 p99 从什么时候开始变差,所有命令还是某类命令,所有实例还是一个实例。
  2. 同一时间窗口的 SLOWLOG:有记录就看命令、参数、客户端地址,回到 5.1。
  3. LATENCY LATEST / HISTORY:有 expire-cycle、fork、aof-* 事件,回到 5.2、5.3。
  4. INFO:clients(连接数、输出缓冲)、memory(RSS、碎片、mem_clients_normal)、stats(过期、淘汰、断开次数)、persistence(后台任务)、commandstats(各命令的调用次数与平均耗时)。
  5. 主机与网络:CPU 限额与争抢、swap、网卡带宽、客户端所在机器的负载。
调用方 p99 变差按时间窗口对齐同一窗口 SLOWLOG 有记录?SLOWLOG GET查命令本身大 key、O(N) 命令、长脚本查 LATENCY 事件expire-cycle、fork、aof-fsync查客户端与缓冲区INFO clients、输出缓冲、连接数查网络与主机RTT、CPU 争抢、swap、THP有没有
图 3 · 先看调用方的分位数有没有真的变差,再按「这段时间 SLOWLOG 有没有记录」分叉:有就查命令本身,没有就查 LATENCY 事件、客户端与缓冲区、网络与主机

线上执行诊断命令也要有分寸: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 监控才能回答第三个;两者都要和调用方的时间窗口对齐。大部分止损手段都是把工作挪走或摊开,而不是让它消失,所以每一项修复都要写清楚副作用。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权