🚨 Redis Stream Pending 堆积引发主从切换失败事故复盘报告


事故背景

系统架构

  • Redis 版本:6.x / 7.x(支持 Stream)
  • 部署模式:1 主 + 2 从 + 3 哨兵(Sentinel)
  • 哨兵关键配置
    sentinel down-after-milliseconds mymaster 5000  # 5秒无响应即主观下线
    
  • 业务使用:通过 Redis Stream 实现微信消息队列,消费组名为 stlm

故障现象

  • 时间:2026-01-16 08:37:59
  • 告警Redis-Server 6379 is stop
  • 异常行为
    • 原主节点 10.191.2.123 被哨兵标记为 ODOWN
    • 哨兵选举 10.191.2.121 为新主
    • 6 秒后,新主也被标记为 +sdown
    • 主从反复切换,服务不可用

❓ 表面矛盾:进程未挂、网络正常,为何 Redis “失联”?


Redis Stream 与 Pending 机制详解(前置知识)

什么是 Pending?

  • 定义:Pending Entries List (PEL) 是 Redis 为每个消费组维护的内存数据结构,记录:
    • 已被 XREADGROUP 读取但未被 XACK 确认的消息
    • 所属消费者名称
    • 首次投递时间戳(用于 XCLAIM 超时接管)
  • 存储位置:独立于 Stream 消息本体,即使消息被 XTRIM 删除,PEL 仍存在
  • 内存开销:每条 pending 消息 ≈ 100~200 字节

消费流程(正常 vs 异常)

graph LR
    A[XADD msg] --> B[Stream 存储]
    B --> C{XREADGROUP?}
    C -->|是| D[加入 PEL → pending]
    D --> E[返回给消费者]
    E --> F{处理成功?}
    F -->|是| G[XACK → 从 PEL 移除]
    F -->|否| H[消息留在 PEL,可被 XCLAIM 接管]
    C -->|否| I[XRANGE/XREAD → 无 pending]

关键误区澄清

误区 真相
XLEN 小就安全” XLEN 只反映消息本体数量,pending 可能巨大
“重启 Redis 能清 pending” ❌ pending 是持久化状态,重启后仍在
XTRIM 能清理 pending” XTRIM 只删消息本体,不影响 PEL
DEL 能快速删 Stream” DEL 需遍历所有 PEL,pending 越多越慢

完整排查

确认 Redis 进程状态

# 登录疑似“宕机”节点(如 10.191.2.121)
ps -ef | grep redis-server        # 进程存在
netstat -tuln | grep 6379         # 端口监听正常

✅ 结论:非进程崩溃,而是假死(阻塞)


分析哨兵日志(定位切换时间点)

grep -E "sdown|switch-master|odown" /var/log/redis/sentinel.log

关键日志片段

08:38:00.974 # +switch-master mymaster 10.191.2.123 6379 10.191.2.121 6379
08:38:06.033 # +sdown master mymaster 10.191.2.121 6379   ← 仅6秒后新主下线!

✅ 推断:新主在 6 秒内无法响应哨兵 PING → 主线程阻塞


检查慢查询日志(找到元凶命令)

redis-cli -h 10.191.2.121 -a <password> SLOWLOG GET 5

输出

1) 1) (integer) 2352
   2) (integer) 1768526239      # 时间戳
   3) (integer) 9632634         # 耗时微秒 ≈ 9.6秒
   4) 1) "DEL"
      2) "wxMsg"
   5) "10.191.3.65:50840"       # 客户端 IP

✅ 结论:DEL wxMsg 阻塞主线程 9.6 秒 > 哨兵超时 5 秒 → 被判下线


深入分析 wxMsg Stream

# 1. 确认类型
127.0.0.1:6379> TYPE wxMsg
"stream"

# 2. 查看消息本体数量(迷惑性指标!)
127.0.0.1:6379> XLEN wxMsg
(integer) 241

# 3. 【关键】查看消费组状态
127.0.0.1:6379> XINFO GROUPS wxMsg
1) 1) "name"
   2) "stlm"
   3) "consumers"
   4) (integer) 4
   5) "pending"
   6) (integer) 83734874   # ⚠️ 8373 万条 pending!

# 4. 查看具体 pending 消息
127.0.0.1:6379> XPENDING wxMsg stlm - + 3
1) 1) "1768520000000-0"
   2) "worker-1"
   3) (integer) 86400000   # idle 24小时
   4) (integer) 1
...

✅ 根因锁定:stlm 消费组存在 8373 万条未 ACK 消息


关联业务日志(确认触发源)

  • xxl-job 日志
    Caused by: org.springframework.dao.QueryTimeoutException: 
    Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException:
    Command timed out after 2 second
    
  • 操作上下文:运维执行了 DEL wxMsg 清理历史数据

✅ 完整链路:

业务代码 bug(未 XACK) 
→ pending 日积月累至 8373 万 
→ 运维执行 DEL wxMsg 
→ Redis 遍历 PEL 耗时 9.6 秒 
→ 哨兵超时判定 Redis 下线 
→ 主从切换失败

根因深度分析:为什么 DEL 会卡死?

Redis 内部删除逻辑

当执行 DEL stream_key 时,Redis 需完成:

  1. 删除 Stream 消息链表(O(N),N=241 → 快)
  2. 遍历每个消费组的 PEL,逐条释放内存(O(M log M),M=8373 万 → 极慢)

🔬 PEL 数据结构:红黑树(Red-Black Tree),按消息 ID 排序
8373 万条删除复杂度 ≈ 8373 万 × log₂(8373 万) ≈ 15 亿次内存操作

单线程模型放大问题

  • Redis 主线程被 DEL 阻塞 9.6 秒
  • 期间无法处理:
    • 客户端请求
    • 哨兵 PING 命令
  • 哨兵连续 5 秒收不到回复 → 触发 sdown

解决方案:安全清理与恢复

⚠️ 绝对禁止直接 DEL wxMsg

场景 1:消费组已废弃(推荐方案)

步骤 1:确认消费组无用

  • 联系开发团队确认 stlm 组是否仍在使用
  • 检查消费者活跃度:
    XINFO CONSUMERS wxMsg stlm
    # 若 idle 时间 > 24h 且 delivery_count=1,基本可判定废弃
    

步骤 2:销毁消费组(清理 PEL)

XGROUP DESTROY wxMsg stlm
  • 瞬时完成(仅删除元数据)
  • ✅ 自动清空该组所有 pending 消息

步骤 3:异步删除 Stream Key

UNLINK wxMsg
  • ✅ 非阻塞操作,内存回收在后台进行
  • ✅ 避免主线程阻塞

验证清理结果

EXISTS wxMsg                    # (integer) 0
XINFO GROUPS wxMsg              # (empty array)

场景 2:消费组仍需使用(谨慎操作)

方案 A:批量 ACK(适用于可重放场景)

# 分批获取 pending ID(每次 1000 条)
XPENDING wxMsg stlm - + 1000

# 对返回的 ID 执行 XACK
XACK wxMsg stlm id1 id2 ... id1000

方案 B:重置消费组游标(丢弃历史 pending)

# 将游标重置到最新($),丢弃所有 pending
XGROUP SETID wxMsg stlm $

# 后续 XREADGROUP 只读新消息

⚠️ 警告:此操作会永久丢失未 ACK 消息!


六、长期加固措施

开发侧:修复消费逻辑

必须包含 XACK 的代码模板(Python)

import redis

def safe_consume(stream, group, consumer):
    r = redis.Redis()
    try:
        # 创建消费组(首次运行)
        r.xgroup_create(stream, group, id='$', mkstream=False)
    except redis.ResponseError as e:
        if "BUSYGROUP" not in str(e):
            raise

    while True:
        try:
            messages = r.xreadgroup(
                group, consumer, {stream: '>'}, 
                count=10, block=5000
            )
            if messages:
                for msg_id, fields in messages[0][1]:
                    try:
                        process_message(fields)
                        r.xack(stream, group, msg_id)  # ✅ 关键!
                    except Exception as e:
                        logger.error(f"Process failed for {msg_id}: {e}")
                        # 可选:记录失败ID到重试队列
        except Exception as e:
            logger.exception("Consumer error")
            time.sleep(1)

关键原则:

  • XACK 必须在 try 块内成功处理后立即调用
  • 异常路径不能静默吞掉错误
  • 考虑实现死信队列(DLQ)

运维侧:操作规范

操作 安全命令 危险命令
删除 Stream XGROUP DESTROY key groupUNLINK key DEL key
清理历史数据 XTRIM key MAXLEN ~ N 手动 DEL
测试消费 用临时消费组 + 及时 XGROUP DESTROY 直接 XREADGROUP 不清理

监控侧:专项指标

必须监控的 4 个指标:

指标 命令 告警阈值 采集方式
Stream pending 总数 XINFO GROUPS key → sum(pending) > 100,000 Prometheus + redis_exporter
消费者 idle 时间 XINFO CONSUMERS key group > 3600s 同上
慢查询数量 SLOWLOG LEN > 0 持续5分钟 Redis 自带
Stream 数量突增 INFO STREAMS 异常增长 自定义脚本

Prometheus 告警规则示例:

- alert: RedisStreamPendingHigh
  expr: redis_stream_groups_pending > 100000
  for: 10m
  labels:
    severity: critical
  annotations:
    summary: "Redis Stream pending too high on {{ $labels.instance }}"
    description: "Stream {{ $labels.stream }} group {{ $labels.group }} has {{ $value }} pending messages."

- alert: RedisSlowlogDetected
  expr: redis_slowlog_length > 0
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Redis slowlog detected on {{ $labels.instance }}"

七、最佳实践自查清单

开发自查

  • 消费代码是否包含 XACK
  • XACK 是否在成功处理后立即调用?
  • 异常路径是否会导致漏 ACK?
  • 是否有重试或死信机制?

运维自查

  • 是否禁止直接 DEL Stream?
  • 是否有定期清理废弃消费组的流程?
  • 是否对大 Key 操作有审批机制?

监控自查

  • 是否监控 pending 消息数量?
  • 是否监控消费者 idle 时间?
  • 是否有慢查询告警?

八、总结

  1. Pending 是隐形杀手:消息本体小 ≠ 安全,pending 才是内存炸弹。
  2. XACK 是生命线:不 ACK = 消息永远 pending = 系统迟早崩溃。
  3. DEL 是高危操作:对含 pending 的 Stream 使用 DEL = 自杀。
  4. 监控要深入:必须监控 pending,而非仅看 CPU/内存。
  5. 历史配置要清理:定期审计无用消费组,避免“僵尸 pending”。

💡 最后忠告
“在 Redis Stream 的世界里,最危险的不是消息本身,而是被遗忘的 Pending。”


附录:常用命令速查表

场景 命令
创建消费组 XGROUP CREATE stream group $
读取消息 XREADGROUP GROUP group consumer STREAMS stream >
确认消息 XACK stream group id
查看 pending XPENDING stream group
接管消息 XCLAIM stream group new_consumer 60000 id
销毁消费组 XGROUP DESTROY stream group
异步删除 UNLINK stream
截断消息 XTRIM stream MAXLEN ~ 1000