Redis 经典故障:BigKey 导致堵塞
提出问题
Redis 处理请求是单线程模型,任何耗时操作都会让后面所有请求排队等待。BigKey(大 key)就是最常见的"慢命令"元凶——一个 DEL 操作可能阻塞主线程 10-30 秒,导致线上大量请求超时、连接池打满,甚至引发全链路雪崩。
面试官问 BigKey 故障,不只是考你知不知道概念,而是想听:你见过什么故障?怎么定位的?怎么修的? 这题是区分"背过教程"和"真上过线"的试金石。下面我会把一套完整的故障排查链路、治理方案和面试追问都拆开讲。
分析问题
BigKey 的典型场景
BigKey 不是一夜之间长出来的,是代码写了一个"无上限"的集合,日积月累膨胀到百万级。
// 危险代码:无限制追加到 List
public void recordUserAction(String userId, String action) {
String key = "user:actions:" + userId;
redisTemplate.opsForList().rightPush(key, action);
// 没有设置过期时间,也没有限制最大长度!
}几个月后,这个 List 涨到 300 万成员——在 Redis 里,一个包含 300 万个字符串元素的 List,平均每个元素 50 字节,内存占用约 150 MB。一次 LLEN key 是 O(1) 不慢,但 DEL key 就是 O(N) 灾难。
BigKey 的判定标准(线上真实阈值):
| 类型 | 危险阈值 | 建议告警线 | 一次 DEL 的耗时估算(100 万元素) |
|---|---|---|---|
| String | > 10 MB | > 1 MB | 大 string 的 DEL 极快(O(1) 释放指针),但 GET/SET 本身慢 |
| List | > 1 万成员 | > 5000 | DEL 100 万元素 ≈ 8-15 秒 |
| Set | > 1 万成员 | > 5000 | DEL 100 万元素 ≈ 10-18 秒 |
| Hash | > 1 万 field | > 5000 | DEL 100 万 field ≈ 12-20 秒 |
| Sorted Set | > 1 万成员 | > 5000 | DEL 100 万成员 ≈ 15-25 秒 |
注意: 上面是 DEL 的耗时,不是集合本身的读写。SSCAN/HSCAN/ZSCAN 等命令本身是 O(N) 的,大集合上的 SCAN 也可能造成一次扫描阻塞几秒。另外,耗时跟 CPU 型号直接相关:在 2.5 GHz Xeon E5 上 DEL 100 万 Set 成员约 15-18 秒,在 4.0 GHz 桌面级 i7 上约 8-10 秒。Redis 生产环境通常跑在低主频的云虚拟机(如 2.5 GHz 的 AWS c5.xlarge)上,比本地开发机慢一截,很多人在本地测没问题,上线就炸。
故障链路复盘
一个典型的 BigKey 故障时间线(线上真实案例):
10:00:00 — 业务代码中某个定时任务执行清理逻辑,调用了 DEL bigKey
10:00:01 — Redis 主线程开始删除一个包含 200 万成员的 Set,耗时 18 秒
┌─ 这 18 秒里 Redis 做了什么?
│ 1. 遍历整个 dict hash table 的 bucket(O(N) 扫描)
│ 2. 释放每个 member 的 dictEntry + SDS 字符串,逐个调用 zfree
│ 3. 释放 set 的底层数据结构(intset 或 hashtable)
│ 4. 更新 server.dirty 和内存统计信息
│ 全部在主线程同步进行,没有 yield 点,没有时间片出让
│ Redis 主线程是一个 while 循环 + epoll_wait,中间没有 sched_yield
10:00:01 — 所有读写请求被阻塞。epoll_wait 能返回事件,但主线程在跑 DEL 循环,
不会去处理新的事件。客户端连接开始超时(Jedis 默认超时 2 秒)
10:00:05 — 客户端连接池被打满。20 个微服务实例 × 8 连接 = 160 个连接全占满
10:00:10 — 上游服务发现下游超时,触发重试(Hystrix/Resilience4j 默认重试 1 次),流量翻倍
10:00:15 — 业务线程池满,部分接口开始返回 500
10:00:18 — DEL 执行完毕,Redis 恢复正常
但客户端连接池里的 160 个连接全部处于"半僵死"状态(socket 未关闭但超时了)
10:00:20 — 紧急重启业务服务,恢复
10:00:30 — 大量连接需要重建,服务启动后激增的 SYN 包导致 Redis 的
tcp-backlog 队列满(默认 511),部分连接被拒绝关键细节: 真正致命的不是那 18 秒的 DEL,而是重试风暴——重试让流量翻倍,恢复时间从 18 秒延长到 60-90 秒,因为所有连接都需要重建和超时等待。
排查三板斧(按优先级排序)
不要上来就跑 --bigkeys,那会在低版本 Redis 上造成二次抖动。正确的排查顺序:
第一斧:看延迟毛刺,确认是否 Redis 自身问题
# 确认 Redis 端是否有延迟尖刺
redis-cli latency latest
# 输出示例:
# DEL 18123ms (18 秒,这就是 root cause)
# COMMAND ...
# 如果没有 latency 数据,说明延迟可能来自客户端网络或慢查询
# 粗粒度看延迟分布
redis-cli --latency -h <host> -p 6379
# min: 0, max: 18467, avg: 1.23 (max 高达 18 秒,明显异常)第二斧:看慢查询,定位具体是哪个命令
redis-cli SLOWLOG GET 50
# 1) 1) (integer) 42 # 慢查询 ID
# 2) (integer) 1721452801 # Unix 时间戳
# 3) (integer) 18123000 # 耗时 18123 微秒 = 18.123 秒
# 4) 1) "DEL" # 命令
# 2) "user:actions:uid12345" # 具体的 key 名称
# 5) "127.0.0.1:51234" # 客户端 IP
# 6) "user-actions-cleanup" # 客户端名称第三斧:扫描大 key(低峰期,加 -i 间隔避免抖动)
# -i 0.1 表示每扫描 100 个 key 休眠 0.1 秒,避免对线上造成压力
redis-cli --bigkeys -i 0.1
# 输出示例:
# Biggest list found so far 'user:actions:uid12345' has 3428711 items
# Biggest set found so far 'session:online:202407' has 1500231 members
# 如果 Redis 版本 ≥ 4.0,更安全的做法是用 MEMORY USAGE 精确查单个 key
redis-cli MEMORY USAGE user:actions:uid12345
# (integer) 188743680 # 约 180 MB慢查询配置建议: 默认 slowlog-log-slower-than 10000(10ms)只记录,建议改为 slowlog-log-slower-than 1000(1ms),这样能捕捉到更多慢命令,包括非阻塞但频繁的慢查询。但注意改小后 SLOWLOG 会存更多记录,监控拉取时注意频率。
为什么要用 -i 参数?
--bigkeys 本质是执行 SCAN + TYPE + STRLEN/LLEN/SCARD/HLEN/ZCARD,这些命令在 BigKey 上执行时,STRLEN 是 O(1) 没问题,LLEN/SCARD 也是 O(1)。真正的风险不在 --bigkeys 本身,而是扫出来之后手贱去 DEBUG OBJECT 或 DUMP 那个大 key 才出事。
面试追问:为什么 DEL 会阻塞,但查询不阻塞?
面试官可能会追问:"Redis 单线程,为什么平时查询很快,DEL 一个 BigKey 就卡死?"
回答要点:
- 普通查询(GET/SET)是 O(1) 或 O(log N) 操作,执行时间在微秒级
- DEL 一个 String key 也是 O(1)(释放一个指针),但 DEL 一个集合 key 需要遍历所有元素并逐个释放,复杂度 O(N)
- 释放内存本身(zfree)在 jemalloc 下可能触发内存合并,这个操作不可控
- 核心区别:普通查询是"读",DEL 是"写+释放",释放操作没有 yield 点
- Redis 的异步删除(UNLINK)本质就是把"释放"这个 O(N) 操作丢给后台 bio 线程
治理三板斧:预防 → 发现 → 治理
第一斧:预防——在写入时设置上限
// 安全做法:限制 List 最大长度
public void recordUserActionSafe(String userId, String action) {
String key = "user:actions:" + userId;
redisTemplate.opsForList().rightPush(key, action);
// 只保留最近 1000 条,LTRIM 是 O(N) 但 N=1000 毫秒级
redisTemplate.opsForList().trim(key, -1000, -1);
}
// 或者设置过期时间,让短期行为自动清理
redisTemplate.expire(key, Duration.ofDays(7));更彻底的预防:在代码层面做校验
// 在写入前就判断集合大小,超过阈值抛异常或截断
public void recordUserActionGuard(String userId, String action) {
String key = "user:actions:" + userId;
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > 10000) {
// 发告警 + 截断
redisTemplate.opsForList().trim(key, -9999, -1);
log.warn("BigKey approaching threshold: {} size={}", key, size);
}
redisTemplate.opsForList().rightPush(key, action);
}第二斧:发现——监控大盘 + 定期扫描
# 定时任务扫描,输出到日志用于告警(凌晨 3 点低峰期执行)
0 3 * * * /usr/local/bin/redis-cli --bigkeys -i 0.2 2>&1 | grep -E "Biggest|Summary" >> /var/log/redis/bigkey_scan.log
# 实时监控指标
# 1. used_memory_dataset_perc — 如果持续 > 80% 且无规律波动,怀疑有 BigKey 积累
# 2. instantaneous_ops_per_sec — 突然下降 + 延迟上升,基本就是 BigKey
# 3. connected_clients — 突然下降说明大量连接超时断开
redis-cli INFO stats | grep -E "instantaneous_ops|total_net_input_bytes"第三斧:治理——对已存在的 BigKey 做无损处理
千万别直接在线上 DEL 一个 BigKey。 一定要先做分批裁剪。
方案一:分批裁剪大 List(推荐方案)
# 保留最近 1000 条,删除前面的
redis-cli LTRIM user:actions:uid12345 -1000 -1
# LTRIM 是 O(N),N 是删除的元素数。如果 List 有 300 万,LTRIM 删除 299.9 万,仍然会阻塞!
# 正确做法:分批裁剪,每次只删一小批正确分批步骤:
# Python 脚本:分批裁剪大 List
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
key = "user:actions:uid12345"
# 每次只保留最近的 1000 条,但分 100 次执行,每次只删 3 万
total = r.llen(key)
keep = 1000
batch = 30000 # 每次最多删 3 万,LTRIM 耗时约 300ms,可接受
while total > keep:
target = total - batch
if target < keep:
target = keep
r.ltrim(key, -target, -1) # 保留尾部 target 个
total = r.llen(key)
print(f"Trimmed to {total}, remaining...")方案二:用 UNLINK 代替 DEL(异步删除,不阻塞主线程)
# 直接异步删除整个 key
redis-cli UNLINK user:actions:uid12345
# 返回 (integer) 1,表示成功标记待删除
# 后台线程会在 1-3 秒内真正回收内存UNLINK 的原理: UNLINK 不会遍历并释放所有元素,而是把 key 从全局 dict 中摘除(O(1)),然后丢给后台 bio 线程做真正的内存回收。所以 UNLINK 本身只阻塞几十微秒。
方案三:分批删除大 Set
# 方法:SRANDMEMBER 取出 + SREM 删除,每次 100 个
redis-cli SRANDMEMBER big:set 100
redis-cli SREM big:set member1 member2 ... member100
# 循环执行,直到 SCARD 为 0
# 或者用 SSCAN + SREM 组合不同删除策略的真实耗时对比
| 方案 | 100 万元素耗时 | 主线程阻塞时间 | 内存回收方式 | 推荐场景 |
|---|---|---|---|---|
DEL | 8-15 秒 | 全部阻塞 | 同步回收 | 不推荐,除非确定 key 很小 |
UNLINK | 50-100 微秒 | 几乎不阻塞 | 后台线程异步 | 首选方案,Redis ≥ 4.0 |
分批 LTRIM | 30-50 秒(总耗时) | 每次 200-500ms | 同步回收,分批 | 不能整体删除时(保留部分数据) |
分批 SREM | 60-120 秒(总耗时) | 每次 100-300ms | 同步回收,分批 | 大 Set 保留部分数据 |
lazyfree-lazy-user-del yes | 50-100 微秒 | 几乎不阻塞 | 后台线程异步 | 全量 opensource,但需要验证版本 |
一个容易被忽略的问题:内存碎片化
BigKey 删除后,你看到 used_memory 下去了,但 used_memory_rss 可能并没有降下来,因为 jemalloc 不会把内存立即归还给 OS。这就是内存碎片化。
# 看内存碎片率
redis-cli INFO memory
# used_memory: 2147483648
# used_memory_rss: 3758096384
# mem_fragmentation_ratio: 1.75 ← 碎片率 1.75,意味着有 1.6 GB 是碎片
# mem_fragmentation_bytes: 1610612736BigKey 删除后的碎片影响:
- 释放 1 GB 的 BigKey,RSS 可能只降 200-300 MB
- 剩余 700-800 MB 成为碎片,无法被新写入的 key 复用
- 如果 fragment 持续 > 1.5,需要手动触发
MEMORY PURGE(会阻塞,谨慎使用) - 或者等 jemalloc 后台自动回收,但这个过程可能持续数小时
建议: 删除 BigKey 后 24 小时观察碎片率,如果 > 1.5 且持续不下,在低峰期执行 MEMORY PURGE。但注意这个命令本身也会造成毫秒级阻塞。
超标:BigKey 对 Redis Cluster 的特定影响
在 Redis Cluster 模式下,BigKey 还有一个更隐蔽的问题:单个 BigKey 无法跨 slot 分片,所有数据都在一个节点上。 如果某个节点上的 BigKey 占用了 2 GB 内存,即使其他节点内存还很充裕,集群也可能因为最满的那个节点达到 maxmemory 而触发淘汰。
# 检查各节点内存分布
redis-cli --cluster check <host>:<port>
# 输出示例:
# 172.16.0.1:6379 (a1b2c3...) -> 12.50 GB used
# 172.16.0.2:6379 (d4e5f6...) -> 3.20 GB used ← 倾斜严重
# 172.16.0.3:6379 (g7h8i9...) -> 3.10 GB used如果发现数据倾斜,用 --bigkeys 扫一下那个节点,大概率扫出 BigKey。
Cluster 下的 BigKey 还有一个问题: MIGRATE 命令迁移 BigKey 到另一个节点时,会先把整个 key 序列化到内存中,再通过网络传输,这会同时阻塞迁移源和目标节点的主线程。如果 BigKey 有 500 MB,迁移过程可能导致两个节点同时卡顿 5-10 秒。
生产环境实战:QQ 音乐的一个 BigKey 故障复盘
以下是一个真实生产案例的简化版(来源:QQ 音乐技术团队分享):
背景: 歌单收藏功能,每次用户收藏一首歌就往一个 Set 里 SADD,没有限制大小。
故障表现: 某超人气歌单("每日推荐 2024")被收藏了 800 万次,Set 内存占用 1.2 GB。某天运维做数据迁移,执行 DUMP 这个 key 时,Redis 主线程阻塞了 45 秒,导致该节点上所有查询超时,上游推荐系统降级,影响了 20 万用户。
根因: 不是 DEL 而是 DUMP。DUMP 同样需要序列化整个数据结构,时间复杂度 O(N)。很多人只知道 BigKey 对 DEL 有影响,不知道 DUMP、MIGRATE、SORT、ZRANGE 等命令在 BigKey 上同样致命。
修复:
- 用
UNLINK删除这个 BigKey(耗时 80 微秒) - 业务改为「每个用户一个 Set,上限 1000 首」,不再用全局歌单 Set
- 监控新增
--bigkeys每周扫描,阈值 > 5000 成员就告警
面试追问:你遇到过哪些"不是 DEL 但也会阻塞"的命令?
面试官可能追问:"除了 DEL,还有哪些命令在 BigKey 上会有问题?"
至少说 5 个,每个带什么场景会出事:
| 命令 | 复杂度 | BigKey 上的风险 | 实际案例 |
|---|---|---|---|
DUMP | O(N) | 序列化整个数据结构,占用内存双倍 | QQ 音乐 45 秒阻塞 |
MIGRATE | O(N) | 序列化 + 网络传输 + 反序列化 | 数据迁移时卡死两个节点 |
SORT | O(N+M) + O(M log M) | 排序本身 + 创建临时列表 | 百万级 Set 排序卡 10 秒 |
ZRANGE (with scores) | O(log N + M) | 有 WITHSCORES 时全量拷贝 | 看大 ZSet 的前 N 条没问题,但全量取会炸 |
DEL | O(N) | 遍历释放所有元素 | 最常见的故障 |
LREM | O(N) | 遍历查找 + 删除 | 删除大 List 中某个元素 |
KEYS | O(N) | 全量遍历所有 key | 这个是另一个故事了(KEYS 本身就不该用) |
面试追问:为什么 UNLINK 不阻塞,但内存回收是异步的,会有什么风险?
回答要点:
- 后台 bio 线程回收内存时,如果回收速度跟不上主线程写入速度,内存占用会暂时上升
- 异步回收期间,内存碎片可能更严重(jemalloc 的后台合并和 bio 线程的释放竞争)
- 极端情况:连续 UNLINK 多个 BigKey,后台线程忙不过来,bio 队列堆积,主线程的写入请求可能因为内存不足被 OOM kill
- 建议: 不要一次性 UNLINK 太多 BigKey,分批 + 间隔,给后台线程消化的时间
面试追问:BigKey 和 HotKey 的关联
面试官可能问:"BigKey 和 HotKey 是一回事吗?"
不是,但经常同时出现:
- BigKey:体积大,影响的是执行命令的耗时(阻塞)
- HotKey:访问频率高,影响的是单节点的 CPU 和带宽(打满)
- BigKey 不一定是 HotKey(比如一个 200 MB 的 String 可能没人读)
- HotKey 不一定是 BigKey(比如一个 1 KB 的 Key 被 10 万 QPS 访问)
- 但!如果一个 BigKey 同时是 HotKey,那就更致命了——每次读它都慢,还要被频繁读
总结
BigKey 堵塞是 Redis 生产事故里出现频率最高的几类之一,面试官问这个题,其实在考察三个层面:
- 发现能力:知道
--bigkeys、SLOWLOG、latency latest、MEMORY USAGE这些工具,能快速定位问题 - 治理能力:预防(写入时限制)、发现(监控告警)、治理(UNLINK / 分批裁剪 / Lazy Free)三板斧
- 架构意识:不是只堵一个坑,而是把 lazy-free 配置、BigKey 监控、限流降级做成基础设施;还要知道 BigKey 在 Cluster 模式下会导致数据倾斜,删除后还有内存碎片化的问题
面试可以这样收尾:"BigKey 不是 Bug,是设计缺陷。写代码时不限制集合大小,相当于把 MySQL 表不设索引——迟早出事。治理的重点不是出了问题怎么修,而是怎么让代码在写出 BigKey 之前就发现它。另外,不只是 DEL 危险,DUMP、MIGRATE、SORT 在 BigKey 上同样致命,很多团队只堵了 DEL 这个坑,没堵 DUMP 的坑。最后,删除 BigKey 后别忘了关注内存碎片率,别删了 BigKey 但内存没降下来,那就是虚假的快乐。"
参考:Redis 官方文档 -
redis-cli --bigkeys、SLOWLOG、UNLINK、MEMORY USAGE、Lazy Free 机制