Redis Stream Pending 堆积引发主从切换失败事故复盘报告
🚨 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 需完成:
- 删除 Stream 消息链表(O(N),N=241 → 快)
- 遍历每个消费组的 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 group → UNLINK 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?
- ☐ 是否有重试或死信机制?
运维自查
- ☐ 是否禁止直接
DELStream? - ☐ 是否有定期清理废弃消费组的流程?
- ☐ 是否对大 Key 操作有审批机制?
监控自查
- ☐ 是否监控 pending 消息数量?
- ☐ 是否监控消费者 idle 时间?
- ☐ 是否有慢查询告警?
八、总结
- Pending 是隐形杀手:消息本体小 ≠ 安全,pending 才是内存炸弹。
- XACK 是生命线:不 ACK = 消息永远 pending = 系统迟早崩溃。
- DEL 是高危操作:对含 pending 的 Stream 使用
DEL= 自杀。 - 监控要深入:必须监控 pending,而非仅看 CPU/内存。
- 历史配置要清理:定期审计无用消费组,避免“僵尸 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
- 感谢你赐予我前进的力量
赞赏者名单
因为你们的支持让我意识到写文章的价值🙏
本文是原创文章,采用 CC BY-NC-ND 4.0 协议,完整转载请注明来自 龙羽

