Skip to content

Redis 经典故障:BigKey 导致堵塞

提出问题

Redis 处理请求是单线程模型,任何耗时操作都会让后面所有请求排队等待。BigKey(大 key)就是最常见的"慢命令"元凶——一个 DEL 操作可能阻塞主线程 10-30 秒,导致线上大量请求超时、连接池打满,甚至引发全链路雪崩。

面试官问 BigKey 故障,不只是考你知不知道概念,而是想听:你见过什么故障?怎么定位的?怎么修的? 这题是区分"背过教程"和"真上过线"的试金石。下面我会把一套完整的故障排查链路、治理方案和面试追问都拆开讲。

分析问题

BigKey 的典型场景

BigKey 不是一夜之间长出来的,是代码写了一个"无上限"的集合,日积月累膨胀到百万级。

java
// 危险代码:无限制追加到 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 万成员> 5000DEL 100 万元素 ≈ 8-15 秒
Set> 1 万成员> 5000DEL 100 万元素 ≈ 10-18 秒
Hash> 1 万 field> 5000DEL 100 万 field ≈ 12-20 秒
Sorted Set> 1 万成员> 5000DEL 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 自身问题

bash
# 确认 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 秒,明显异常)

第二斧:看慢查询,定位具体是哪个命令

bash
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 间隔避免抖动)

bash
# -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 OBJECTDUMP 那个大 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 线程

治理三板斧:预防 → 发现 → 治理

第一斧:预防——在写入时设置上限

java
// 安全做法:限制 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));

更彻底的预防:在代码层面做校验

java
// 在写入前就判断集合大小,超过阈值抛异常或截断
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);
}

第二斧:发现——监控大盘 + 定期扫描

bash
# 定时任务扫描,输出到日志用于告警(凌晨 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(推荐方案)

bash
# 保留最近 1000 条,删除前面的
redis-cli LTRIM user:actions:uid12345 -1000 -1
# LTRIM 是 O(N),N 是删除的元素数。如果 List 有 300 万,LTRIM 删除 299.9 万,仍然会阻塞!
# 正确做法:分批裁剪,每次只删一小批

正确分批步骤:

python
# 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(异步删除,不阻塞主线程)

bash
# 直接异步删除整个 key
redis-cli UNLINK user:actions:uid12345
# 返回 (integer) 1,表示成功标记待删除
# 后台线程会在 1-3 秒内真正回收内存

UNLINK 的原理: UNLINK 不会遍历并释放所有元素,而是把 key 从全局 dict 中摘除(O(1)),然后丢给后台 bio 线程做真正的内存回收。所以 UNLINK 本身只阻塞几十微秒。

方案三:分批删除大 Set

bash
# 方法:SRANDMEMBER 取出 + SREM 删除,每次 100 个
redis-cli SRANDMEMBER big:set 100
redis-cli SREM big:set member1 member2 ... member100
# 循环执行,直到 SCARD 为 0

# 或者用 SSCAN + SREM 组合

不同删除策略的真实耗时对比

方案100 万元素耗时主线程阻塞时间内存回收方式推荐场景
DEL8-15 秒全部阻塞同步回收不推荐,除非确定 key 很小
UNLINK50-100 微秒几乎不阻塞后台线程异步首选方案,Redis ≥ 4.0
分批 LTRIM30-50 秒(总耗时)每次 200-500ms同步回收,分批不能整体删除时(保留部分数据)
分批 SREM60-120 秒(总耗时)每次 100-300ms同步回收,分批大 Set 保留部分数据
lazyfree-lazy-user-del yes50-100 微秒几乎不阻塞后台线程异步全量 opensource,但需要验证版本

一个容易被忽略的问题:内存碎片化

BigKey 删除后,你看到 used_memory 下去了,但 used_memory_rss 可能并没有降下来,因为 jemalloc 不会把内存立即归还给 OS。这就是内存碎片化

bash
# 看内存碎片率
redis-cli INFO memory
# used_memory: 2147483648
# used_memory_rss: 3758096384
# mem_fragmentation_ratio: 1.75  ← 碎片率 1.75,意味着有 1.6 GB 是碎片
# mem_fragmentation_bytes: 1610612736

BigKey 删除后的碎片影响:

  • 释放 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 而触发淘汰。

bash
# 检查各节点内存分布
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 而是 DUMPDUMP 同样需要序列化整个数据结构,时间复杂度 O(N)。很多人只知道 BigKey 对 DEL 有影响,不知道 DUMPMIGRATESORTZRANGE 等命令在 BigKey 上同样致命。

修复:

  1. UNLINK 删除这个 BigKey(耗时 80 微秒)
  2. 业务改为「每个用户一个 Set,上限 1000 首」,不再用全局歌单 Set
  3. 监控新增 --bigkeys 每周扫描,阈值 > 5000 成员就告警

面试追问:你遇到过哪些"不是 DEL 但也会阻塞"的命令?

面试官可能追问:"除了 DEL,还有哪些命令在 BigKey 上会有问题?"

至少说 5 个,每个带什么场景会出事:

命令复杂度BigKey 上的风险实际案例
DUMPO(N)序列化整个数据结构,占用内存双倍QQ 音乐 45 秒阻塞
MIGRATEO(N)序列化 + 网络传输 + 反序列化数据迁移时卡死两个节点
SORTO(N+M) + O(M log M)排序本身 + 创建临时列表百万级 Set 排序卡 10 秒
ZRANGE (with scores)O(log N + M)有 WITHSCORES 时全量拷贝看大 ZSet 的前 N 条没问题,但全量取会炸
DELO(N)遍历释放所有元素最常见的故障
LREMO(N)遍历查找 + 删除删除大 List 中某个元素
KEYSO(N)全量遍历所有 key这个是另一个故事了(KEYS 本身就不该用)

回答要点:

  • 后台 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 生产事故里出现频率最高的几类之一,面试官问这个题,其实在考察三个层面:

  • 发现能力:知道 --bigkeysSLOWLOGlatency latestMEMORY USAGE 这些工具,能快速定位问题
  • 治理能力:预防(写入时限制)、发现(监控告警)、治理(UNLINK / 分批裁剪 / Lazy Free)三板斧
  • 架构意识:不是只堵一个坑,而是把 lazy-free 配置、BigKey 监控、限流降级做成基础设施;还要知道 BigKey 在 Cluster 模式下会导致数据倾斜,删除后还有内存碎片化的问题

面试可以这样收尾:"BigKey 不是 Bug,是设计缺陷。写代码时不限制集合大小,相当于把 MySQL 表不设索引——迟早出事。治理的重点不是出了问题怎么修,而是怎么让代码在写出 BigKey 之前就发现它。另外,不只是 DEL 危险,DUMPMIGRATESORT 在 BigKey 上同样致命,很多团队只堵了 DEL 这个坑,没堵 DUMP 的坑。最后,删除 BigKey 后别忘了关注内存碎片率,别删了 BigKey 但内存没降下来,那就是虚假的快乐。"

参考:Redis 官方文档 - redis-cli --bigkeysSLOWLOGUNLINKMEMORY USAGE、Lazy Free 机制

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。