主题
延迟排查案例集:从 P99 抖动到雪崩复盘
本文是 Redis 系统学习系列的 L3 实战篇。前置:31. 事件循环与线程模型。 学完可以配合面试题食用:15-big-key-blocking-fault-analysis、18-cache-avalanche-production-incident
排查框架:先定层,再定因
Redis 延迟问题的排查有个固定套路:先定位在哪一层,再在那一层查具体原因。
三层划分:
- 客户端层:业务代码绕路、连接池耗尽、重试逻辑爆炸
- 网络层:带宽打满、TCP 重传、丢包
- 服务端层:事件循环阻塞、系统资源争抢、数据规模超限
服务端层再往下分三类:
- 事件循环阻塞:bigkey 操作(
HGETALL、KEYS、DEL)、慢查询、AOF fsync 同步写 - 系统因素:fork 停顿、内存 swap、CPU 偷取(虚拟机邻居抢资源)
- 数据规模:单 key 太大、过期 key 太多、连接数打满
排查顺序:先看 slowlog(最快锁定服务端),再查网络指标,最后看客户端。下面四个案例覆盖了最常见的三类场景。
案例 1:HGETALL 大 Hash 导致 P99 抖动
现象:某服务每天 10:00-11:00 响应 P99 从 5ms 飙升到 200ms,持续约 20 分钟后恢复。CPU 和内存都正常。
排查:
bash
# 第一步:看 slowlog(取最近 100 条慢查询,超过 10ms 的)
redis-cli SLOWLOG GET 100
# 输出关键行:
# 1) 1) (integer) 1423 # 唯一 ID
# 2) (integer) 1735300000123 # 时间戳
# 3) (integer) 187234 # 执行耗时(微秒),187ms!
# 4) 1) "HGETALL" # 命令
# 2) "product:tags:9872" # key
# 5) "127.0.0.1:54321"
# 第二步:查这个 key 有多大
redis-cli MEMORY USAGE "product:tags:9872"
# (integer) 47123456 # 约 45MB,36 万个 field
# 第三步:看这个 key 的 field 数
redis-cli HLEN "product:tags:9872"
# (integer) 362000250ms 的 HGETALL 在单线程模型下直接阻塞了所有其他请求。为什么是 10:00?因为这个 key 是早上定时任务批量写入的,每天 10:00 线上业务开始大量读取。
修复:SSCAN/SCAN 分批读取,不用一次性 HGETALL。
bash
# 改造方案:用 HSCAN 分批取
redis-cli HSCAN "product:tags:9872" 0 COUNT 1000
# 输出每批 1000 个 field,游标非零继续
# 1) "1843" # 下次游标
# 2) 1) "tag:electronics"
# 2) "1"
# ...
# 应用层也改,用 pipeline 并发读多个分桶代码改造:
java
// 之前:一次性取全部
// Map<Object, Object> all = redisTemplate.opsForHash().entries("product:tags:9872");
// 之后:HSCAN 分批
public void processTags(String key) {
RedisConnection conn = redisTemplate.getConnectionFactory().getConnection();
ScanOptions options = ScanOptions.scanOptions().count(500).build();
Cursor<Map.Entry<byte[], byte[]>> cursor = conn.hScan(key.getBytes(), options);
int batchSize = 0;
while (cursor.hasNext()) {
Map.Entry<byte[], byte[]> entry = cursor.next();
String field = new String(entry.getKey());
String value = new String(entry.getValue());
// 逐条处理,不阻塞事件循环
batchSize++;
if (batchSize % 100 == 0) {
Thread.sleep(1); // 主动让出 CPU,给其他请求留时间窗口
}
}
cursor.close();
}效果:HSCAN 每次只读 500 个 field,单次耗时从 187ms 降到 0.5ms,P99 恢复到 8ms。
案例 2:批量过期导致周期性延迟尖刺
现象:每小时的 05 分、15 分、25 分……(每隔 10 分钟)P99 出现规律性尖刺,持续 3-5 秒。
排查:
bash
# 第一步:看 latency history(Redis 内置延迟监控)
redis-cli LATENCY HISTORY command
# 输出(每 10 分钟一次,持续可见)
# 1) 1) (integer) 1735300000000
# 2) (integer) 345 # 345ms 延迟
# 2) 1) (integer) 1735300006000
# 2) (integer) 287
# 3) 1) (integer) 1735300012000
# 2) (integer) 312
# 第二步:看最近延迟事件类型
redis-cli LATENCY LATEST
# 1) 1) "expire-cycle" # 关键:延迟来自过期循环
# 2) (integer) 1735300000000
# 3) (integer) 312
# 4) (integer) 312
# 第三步:查过期 key 数量
redis-cli INFO stats | grep expired_keys
# expired_keys:29876543
# 对比上次采样间隔,每分钟约 12 万个 key 过期原因:定时任务设置了大量 key 过期时间相同(如 set key value EX 600,所有 key 都在整 10 分钟时刻过期)。Redis 的过期清理是惰性 + 周期性采样,大量 key 同时过期会导致 expire cycle 单次耗时激增。
修复:过期时间加随机偏移。
bash
# 之前:所有 key 过期时间一样
# EX 600
# 之后:600 秒基础 + 随机偏移 0-300 秒
# EX 600 + random(0, 300)代码改造:
java
// 给过期时间加随机抖动,打散过期时刻
private static final int BASE_TTL = 600;
private static final int JITTER_WINDOW = 300;
private static final Random RANDOM = new Random();
public void setWithJitter(String key, String value) {
int ttl = BASE_TTL + RANDOM.nextInt(JITTER_WINDOW);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
}效果:过期 key 从每分钟 12 万降到每秒 1-2 千,EXPIRE 循环延迟从 300ms 降到 5ms 以下。
案例 3:fork 停顿叠加 AOF 重写
现象:每天凌晨 2:00 左右,Redis 节点响应延迟突然飙升到 500ms+,持续 5-10 秒。和业务低峰期重合,但影响凌晨的同步任务。
排查:
bash
# 第一步:看 fork 耗时
redis-cli INFO stats | grep latest_fork_usec
# latest_fork_usec:482345 # 482ms!fork 本身卡了将近半秒
# 第二步:看 AOF 重写状态
redis-cli INFO persistence | grep aof
# aof_enabled:1
# aof_rewrite_in_progress:1 # 正在重写
# aof_last_bgrewrite_status:ok
# aof_current_size:21474836480 # AOF 文件 20GB
# 第三步:看实例内存
redis-cli INFO memory | grep used_memory_human
# used_memory_human:18.5G # 实例 18.5GB,fork 自然慢原因:两件事在同一时间发生——cron 定时触发了 BGREWRITEAOF,而 Redis 实例内存已接近 20GB。fork 子进程时操作系统需要复制页表,18.5GB 的进程 fork 用了 480ms。这 480ms 内 Redis 完全阻塞,不处理任何请求。雪上加霜的是,AOF 重写时如果有大量写入,Redis 还会在缓冲区积累差异,重写结束后再一次性写入,进一步拉长阻塞窗口。
修复:降规格 + 错峰,两个方向同时做。
bash
# 方案一:控制 AOF 重写触发时机(redis.conf)
# 调大触发阈值,避开低峰期的定时任务
auto-aof-rewrite-percentage 200 # 默认 100 -> 200(增长 200% 才触发)
auto-aof-rewrite-min-size 64gb # 默认 64mb -> 64gb(大实例适当调大)
# 方案二:拆实例,每个实例控制在 10GB 以内
# 方案三:手动安排 AOF 重写时间,避开 cron 任务高峰
# 在 crontab 里设置:
# 0 3 * * * redis-cli BGREWRITEAOF # 从 2:00 改到 3:00效果:实例内存降到 8GB 后 fork 耗时降到 50ms,延迟恢复正常。AOF 重写调整到 3:00 后不再与同步任务冲突。
案例 4:缓存雪崩全链路复盘
现象:某天下午 14:23,用户订单查询接口超时率从 0.1% 飙到 100%,持续 12 分钟。Redis Cluster 12 个节点全部高负载,CPU 100%。
排查:
bash
# 第一步:看 redis 端
redis-cli INFO stats | grep -E "expired_keys|evicted_keys"
# expired_keys:987654321
# evicted_keys:12345678 # key 被驱逐了大量
# 第二步:看慢查询
redis-cli SLOWLOG GET 10 | grep -E "GET|MGET"
# 几乎全是 MGET 命令,单个耗时 50-200ms
# 第三步:看 key 分布
redis-cli --bigkeys
# 大量 key 的 TTL 集中在 0-5 秒内原因复盘:三个因素叠加触发雪崩:
- 业务方设置缓存过期时间用的都是固定值(
EX 3600),大量 key 在同一时间过期 - 雪崩发生后,请求全部穿透到 DB,DB 连接池被打满,慢查询堆积
- 主从切换时,新的主节点刚上线,缓存全空,雪崩进入第二波
修复:三级措施,按紧急程度分级。
bash
# 第一级:熔断降级(紧急止损)
# 在网关层配置限流规则,量大的请求直接返回降级数据
# 第二级:多级缓存(中期改造)
# 本地缓存 + Redis + 数据库,每层解决不同量级的请求java
// 多级缓存实现
public class MultiLevelCache {
// L1:本地缓存(Caffeine),毫秒级,扛 90% 读请求
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.SECONDS) // 短过期,一致性窗口短
.build();
// L2:Redis,扛剩余 10% 中的 90%
private final StringRedisTemplate redis;
// L3:数据库,只扛余量
private final OrderDao orderDao;
public Order getOrder(String orderId) {
// 1. 查本地缓存
Order order = (Order) localCache.getIfPresent(orderId);
if (order != null) return order;
// 2. 查 Redis
String json = redis.opsForValue().get("order:" + orderId);
if (json != null) {
order = JSON.parseObject(json, Order.class);
localCache.put(orderId, order); // 回填本地缓存
return order;
}
// 3. 查数据库(加分布式锁防止缓存击穿)
String lockKey = "lock:order:" + orderId;
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
order = orderDao.getById(orderId);
if (order != null) {
// 写入缓存,TTL 加随机偏移
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.opsForValue().set("order:" + orderId,
JSON.toJSONString(order), ttl, TimeUnit.SECONDS);
localCache.put(orderId, order);
}
} finally {
redis.delete(lockKey);
}
}
return order;
}
}效果:多级缓存上线后,同场景下雪崩未再次发生。本地缓存挡住 90% 请求,Redis 回源压力降到原来的 1/10。
延迟问题排查 SOP
以上四个案例提炼出一套排查流程,一屏可看完:
[延迟发现]
├─ 客户端感知:P99 超阈值 / 超时率上升
└─ 监控告警触发
[第 1 步:定位层]
├─ SLOWLOG GET 100
│ ├─ 有慢查询 → 服务端问题,跳第 2 步
│ └─ 无慢查询 → 网络或客户端,跳第 3 步
├─ LATENCY LATEST 看事件类型
└─ INFO 看连接数/网络 IO
[第 2 步:服务端分类]
├─ 事件循环阻塞:bigkey 操作(HGETALL/KEYS/DEL)→ 分批改造
├─ 系统因素:fork 耗时 > 100ms → 降实例内存 / 错峰
├─ 数据因素:过期 key 大量同时到期 → 加 TTL 随机偏移
└─ AOF 重写阻塞 → 调整触发阈值 / 低峰重写
[第 3 步:网络层]
├─ 带宽跑满:netstat -s 看 TCP 重传率
└─ 连接数耗尽:connected_clients vs maxclients
[第 4 步:客户端层]
├─ 连接池配置不合理(maxTotal 太小)
├─ 重试逻辑指数退避没做
└─ 管道化使用不当
[修复确认]
└─ 24 小时观察 P99 是否恢复到基线常见误区与小结
- 误区:延迟高就加机器。先定位层再动刀。如果是 bigkey 导致的,加机器也没用——单线程模型下,一台机器里有 1 个 CPU 干活,其余在等。
- 误区:slowlog 返回 0 条就说明 Redis 没问题。slowlog 默认只记录超过 10ms 的命令,批量过期、fork 阻塞这类事件不体现在 slowlog 里。要用
LATENCY LATEST补齐。 - 误区:AOF 重写不会影响在线服务。fork 子进程时父进程会阻塞,内存越大阻塞越久。10GB 以上实例要规划好 AOF 重写时间窗口。
- 误区:缓存雪崩只在"同时过期"时发生。主从切换、实例重启、AOF 重写异常结束后,都可能触发二次雪崩。多级缓存是唯一能扛住全部场景的解法。
- 误区:排查一遍完事。好的运维团队会把每次排查过程写成 SOP,固化到监控告警的响应流程里。下次同类问题出现,5 分钟内就能定位到根因。
小结:Redis 延迟问题绕不开事件循环的单线程本质——所有阻塞最终都会落到"某个操作霸占了 CPU,其他请求排队等"。排查的通用思路是"先定层,再定因":slowlog 看服务端,latency metrics 看系统因素,客户端指标找调用方问题。四个案例覆盖了 80% 的生产场景,对应的 SOP 可以贴在团队 Wiki 里,下次遇到不用从头查。
参考
参考:Redis 官方文档 Latency Monitoring / Redis latency problems troubleshooting / SLOWLOG 命令