Redis 为什么突然变慢:从事件循环到 SLOWLOG、LATENCY 和客户端
「Redis 是内存数据库,慢不了」这句话忽略了大 key、集中过期、fork、输出缓冲区和网络往返。诊断要先建立基线,再按证据一层层排除;SLOWLOG 只是其中一个视角。
做一个实验:一个探测客户端每毫秒发一次 GET,记录每次请求比计划时刻晚了多少;同时依次制造几种常见的「变慢」,再看 Redis 自己的 SLOWLOG 和 LATENCY 记录下了什么。
最值得注意的是中间几行:50 万个 key 同时过期,让探测请求最多推迟了 26.6ms,而 SLOWLOG 里一条记录都没有;只查 SLOWLOG 的人会得出「Redis 没有慢命令」的结论。本文用 Redis 8.10.1 的实测说明延迟从哪里来、每种来源在哪里能看到、怎样止损。
一、先把「单线程」说准确
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)。把阈值调到 1ms 也只是多记录几条命令,看不到命令之外的阻塞:过期删除、fork、写回响应都不属于某条命令。SLOWLOG 没有记录,不代表不是 Redis 的问题。
以 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。
之后每次异常,都和基线对比:是所有请求一起变慢(排队),还是某类命令变慢(命令本身),还是个别客户端变慢(网络、客户端)。
四、六类常见的抖动
4.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 数据结构与编码。
有序集合分段读的另一个坑:同分成员。 用 ZRANGE ... BYSCORE LIMIT 分页时,常见的写法是记住上一页最后一个成员的 score,下一页从它之后取。Redis 里同分成员按成员名的字典序排列,只记 score 的游标在同分成员处会出错。14 个成员、每页 4 个、其中 6 个同为 80 分:
游标只记 score,下一页取 (score:得到 9 个,遗漏 ['p02', 'p03', 'p04', 'p11', 'p12'],重复 0 个
游标只记 score,下一页取 [score 并去掉第一个:……同分成员多于一页时原地打转
游标记 (score, member),同分时按成员名继续:得到 14 个,遗漏 [],重复 0 个;与全量顺序一致:True
按排名分页:第 1 页 ['p01', 'p07', 'p06', 'p05'];新增一个 95 分的成员后第 2 页 ['p05', 'p04', 'p03', 'p02'],与第 1 页重复 ['p05']游标要同时记 score 和成员名,同分时跳过已返回的成员;或者另建一个 score 全为 0 的有序集合,把定宽编码的分数和成员名拼成成员,用 ZRANGE ... BYLEX 做排他游标。按排名(ZRANGE key start stop)分页在翻页期间有新成员加入时会重复或遗漏。这和跨分片分页里单列游标的问题相同:排序键不唯一,游标就必须带上决胜键。
4.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 内存满了会怎样。
4.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 持久化与恢复。
4.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 故障切换。
4.5 内存压力、swap 与大页
这一类在容器实验里没有复现,只给检查项:INFO memory 中 RSS 远大于 used_memory 说明碎片;RSS 小于 used_memory 或 /proc/<pid>/smaps 中有 Swap 说明被换出,延迟会升到毫秒到秒级;Linux 的透明大页(THP)会让 fork 后的写时复制以 2MB 为单位进行,官方建议关闭。
4.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 的应用契约 |
七、排查的三个问题
排查 Redis 延迟,先分清三件事:调用方感受到的延迟、命令的执行时间、命令之外的阻塞。SLOWLOG 回答第二个问题;打开 LATENCY 监控才能回答第三个;两者都要和调用方的时间窗口对齐。大部分止损手段都是把工作挪走或摊开,而不是让它消失,所以每一项修复都要写清楚副作用。
配套实验
- codesphere-labs/cache/redis-latency-diagnostics:每毫秒一次的探测请求与大 key 删除、集中过期、长 Lua、KEYS、自动 BGSAVE、慢订阅者的对照,SLOWLOG 与 LATENCY 原始输出,pipeline 批次对比(验证记录)
- codesphere-labs/cache/zset-score-cursor:Redis 8.10.1、redis-py 8.1.0:同分成员的顺序、只记 score 的排他与包含游标、(score, member) 游标、字典序二级索引、按排名分页时新增成员(验证记录)
参考资料