生产事故:RabbitMQ 内存告警导致整个集群不可用——"流量控制"的连环陷阱
引言
周三下午,订单系统突然全线报错。监控大屏上 RabbitMQ 集群三个节点的内存指示灯齐刷刷变红,业务侧表现却是"冰火两重天":
- 上游:订单服务发消息全部卡住,接口超时——生产者被 MQ 拒绝接收;
- 下游:通知服务、积分服务的消费者日志一片寂静——消费者"拿不到"消息;
- 队列里:明明积压了几十万条消息,消费速率却是 0。
消息既进不去、也出不来,整个异步业务链路在十分钟内彻底断开。复盘后发现,触发点小到令人发指:两天前一次消费者重构,把 ACK 模式改成了手动确认,但代码里异常分支既不 ack 也不 nack——消息被消费者"领走"后永远没有"交差",unacked 计数爬到上限后消费者不再被投递,队列只进不出,两天的积压最终撞穿内存水位线,触发了 RabbitMQ 最让人头疼的保护机制:Flow Control(流量控制)。
这篇文章把事故完整复盘:RabbitMQ 的内存水位与流控机制是怎么一环扣一环地锁死集群的、手动 ACK 为什么会变成"消息黑洞"、修复方案和监控体系怎么建——重点不是骂代码,而是讲清楚这个连环陷阱的每一步,让它不会在你身上重演。
一、事故时间线:15 分钟里发生了什么
| 时间 | 事件 | 当时的现象 |
|---|---|---|
| 周一 10:00 | 通知服务消费者重构上线:acknowledge-mode 改为 manual | 灰度流量正常,无人察觉异常 |
| 周一 14:00 | 消费者日志开始出现零星异常(下游短信网关超时),异常分支吞掉异常后直接 return | 无 ACK,但消息量小,未告警 |
| 周二全天 | messages_unacknowledged 缓慢爬升,prefetch 槽位被逐步占满;新消息不再投递到这些消费者 | 消费速率缓慢下降,值班同学误以为是流量低谷 |
| 周三 14:02 | 队列积压监控告警:order.notify.queue 消息数 > 5 万 | 群里有人开始看 |
| 周三 14:05 | MQ 节点内存使用率突破 0.4 水位线,内存告警(memory alarm)触发 | 管理 UI 出现红色 Memory 告警条 |
| 周三 14:06 | Flow Control 启动:所有发布连接进入 blocked 状态 | 订单服务发送线程全部阻塞,接口超时 |
| 周三 14:08 | 业务雪崩:订单创建接口大面积超时,上游重试加剧积压 | P0 事故成立 |
| 周三 14:12 | 排查发现 unacked 消息 48 万条,消费者代码异常分支无 ACK | 锁定根因 |
| 周三 14:15 | 重启异常消费者实例:连接断开,unacked 消息重新入队(ready),内存压力骤降,告警解除 | 链路恢复 |
| 周三 14:25 | 消费追平积压,业务恢复正常 | —— |
最反直觉的一点:内存告警后,消费端并没有被 RabbitMQ 主动掐断——它在内存告警期间依然允许消费。但我们的消费者早已"自己把自己锁死"了:prefetch 槽位被永不确认的消息占满,RabbitMQ 认为它们"手里还有 250 条没处理完",不会再投递新消息。于是生产者被流控堵住、消费者被自己堵死,队列变成只进不出的堰塞湖。
二、RabbitMQ 内存与流控机制:保护机制如何变成连环陷阱
2.1 内存水位线:0.4 阈值是什么
RabbitMQ 有个核心配置 vm_memory_high_watermark,默认值是 0.4:当节点内存使用量达到系统可用内存的 40% 时,触发内存告警(memory alarm)。
# 查看当前水位与内存使用
rabbitmqctl status | grep -A 8 memory
# {total,{34359738368}}, ← 总内存 32GB
# {watermark,0.4}, ← 水位线 40%
# ...
# memory: 14.2GB / 32GB ← 使用接近 0.44,告警已触发
设计初衷完全是善意的:Erlang 虚拟机之外,操作系统还需要内存(page cache、文件句柄缓冲区等)。如果 MQ 把内存吃到 90% 以上,机器会触发 swap(交换分区),Erlang 进程遇到磁盘级 swap 会慢 100 倍,节点直接卡死——所以 RabbitMQ 选择在 40% 就主动"踩刹车",给系统留足缓冲。
2.2 Flow Control:刹车是怎么踩的
内存告警触发后的连锁反应(这是事故的核心机制):
内存使用 > 0.4 水位线
│
▼
① 节点触发 memory alarm(管理 UI 顶部出现红色告警条)
│
▼
② 所有"发布消息"的连接被强制进入 blocked 状态
├─ RabbitMQ 停止从这些 TCP 连接读取数据
├─ 客户端发送缓冲区写满 → 发送调用阻塞
└─ rabbitmqctl list_connections 可见 state = blocked
│
▼
③ 生产者侧表现:channel.basicPublish() 卡住不返回
├─ 用 Confirm 模式:confirm 永远等不到(后续超时)
├─ 用事务模式:txCommit 阻塞
└─ 业务线程池被发送线程占满 → 接口超时雪崩
│
▼
④ 消费侧:投递不受内存告警直接阻止(可以继续消费帮 MQ 减压)
└─ 但如果消费侧本身有问题(本案:prefetch 槽位被占满),就彻底死锁
关键点:Flow Control 阻塞的是"写",不直接阻塞"读"。RabbitMQ 的设计逻辑是"内存满了别再往里灌,但欢迎往外掏"。这也解释了为什么正常情况下内存告警能自愈——消费者把队列吃下去,内存回落,告警自动解除。但当消费侧也失效时,这个自救通道就断了——善意的保护机制变成了死锁的闭环。
管理命令速查(事故时我们一条条敲过):
# 哪些连接被 blocked(生产者卡住的直接证据)
rabbitmqctl list_connections peer_host peer_port state blocked_by
# 队列消息分布:ready(待投递) vs unacked(已投递未确认)——本案关键
rabbitmqctl list_queues name messages messages_ready messages_unacknowledged memory
# name messages messages_ready messages_unacknowledged memory
# order.notify.queue 482331 2133 480198 6.1GB ← 异常:unacked 占了 48 万
# order.dlx.queue 3201 3201 0 8.2MB
# 内存被什么吃掉了
rabbitmqctl status | grep -A 20 breakdown
messages_unacknowledged 这个数字在本案中就是破案线索:正常情况下它应该等于"消费者正在处理中的消息数"(量级 ≈ 消费者数 × prefetch),稳定在一个小范围波动。它高达 48 万且只增不减,说明消息被领走后再也没回来确认。
2.3 内存到底被什么占了:unacked 消息的成本
很多人以为"消息投递给消费者就从队列里消失了",错。消息投递后只是状态从 ready 变为 unacked——RabbitMQ 依然持有这条消息的完整副本(在内存中,或可分页到磁盘),因为它随时准备在你不确认时重新投递给别人。
经典队列(classic queue)在内存压力下会把部分消息分页到磁盘,但这个机制在本案中没救得了场:分页速度跟不上持续涌入,且 unacked 消息的分页策略远没有 ready 消息激进。48 万条消息、每条附带业务 JSON(短信内容、用户信息,平均 2~5KB),6GB+ 内存就这么没了。
三、根因:手动 ACK 的"消息黑洞"
3.1 出事的代码长什么样
重构后的消费者(简化还原):
/**
* ❌ 事故代码:手动 ACK 模式,但异常分支不 ack 也不 nack
*/
@RabbitListener(queues = "order.notify.queue")
public void onMessage(OrderNotifyMessage msg, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
try {
SmsResult result = smsClient.send(msg.getPhone(), msg.getContent());
if (result.isSuccess()) {
channel.basicAck(tag, false); // ✅ 只有成功分支确认
} else {
log.warn("短信发送失败: {}", result);
// ❌ 失败分支:既不 nack,也不 ack——消息永远停在 unacked
}
} catch (Exception e) {
log.error("处理异常", e);
// ❌ catch 里直接吞掉,同样没有任何确认动作
// (更隐蔽的是:这里 return 之后,这条消息的 tag 就再也没人管了)
}
}
配套配置:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual # ← 重构时从 auto 改成了 manual
prefetch: 250 # ← 每个消费者最多"领走"250 条未确认消息
concurrent-consumers: 8 # ← 8 个消费者线程
# retry 未配置
3.2 为什么两天才爆:prefetch 的"温水煮青蛙"
事故代码不是一上线就炸的,机制是这样的:
单个消费者线程的视角:
① RabbitMQ 给它投递消息(最多 prefetch=250 条未确认)
② 正常处理 → ack,槽位释放,可以领新消息
③ 异常分支 → 无 ack/nack,这条消息永久占用 1 个槽位
④ 槽位从 250 → 249 → 248 → ... 逐步被"死消息"占满
⑤ 250 个槽位全满后,RabbitMQ 认为该消费者"手头工作堆积"
→ 不再投递新消息 → 这个线程事实上停止消费
单实例 8 线程 × 250 = 2000 个槽位;6 个实例 = 12000 个槽位被死消息占满
→ 消费者全部"假死",消费速率归零
→ 队列只进不出,ready 消息开始堆积
→ 48 万条消息(含 unacked 副本)逐步吃掉内存
→ 两天后撞穿 0.4 水位线 → Flow Control → 生产者也被堵死
prefetch 在这里扮演了放大器角色:它本意是"让消费者批量领消息减少网络往返",但在 ACK 逻辑有缺陷时,它变成了"每个消费者能安全吞掉多少条消息不被发现"的上限。prefetch 越大,黑洞容量越大,问题暴露越晚。
3.3 为什么是"集群不可用"而不是"一个队列慢"
复盘时被问最多的问题:一个通知队列出事,为什么整个集群挂了?
| 层级 | 原因 |
|---|---|
| 内存告警是节点级 | 内存超水位的节点上,所有 vhost、所有队列的生产者连接都被 block,不分业务 |
| 队列分布 | 集群 3 节点承载了全部业务队列,通知队列恰好在节点 A,节点 A 先告警,该节点上所有业务的生产者被堵 |
| 连接漂移 | 客户端重连会漂移到其他节点,但其他节点内存也在高位(消息共享/镜像副本),14:08 三个节点全部告警 |
| 业务链路依赖 | 订单创建 → 发 MQ 是同步动作(Confirm 模式等待),发送阻塞直接拖垮订单接口 |
一个队列的消费缺陷,通过"内存"这个共享资源,升级成了整个节点乃至集群的事件——这是本事故最值得记住的教训:MQ 的资源隔离是软隔离,内存和连接是全局共享的。
四、修复方案:四层加固
4.1 第一层:ACK 模式纠偏(根因修复)
先立选型原则,再改代码:
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 通知类、日志类、允许偶发丢失或可重放 | AUTO(Spring 容器确认) | 监听器正常返回自动 ack,抛异常自动 nack/reject,无"忘记确认"可能 |
| 业务处理必须成功、失败要重试 | AUTO + 容器重试 + 死信队列 | 见下方配置,可靠性不输给手写 manual |
| 需要在 ack 前做复杂事务控制(如批量 ack、跨消息聚合) | MANUAL,但必须保证每条路径都有确认 | try/finally 兜底 |
我们的通知业务属于第二类,最终配置:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto # ✅ 容器托管 ACK
prefetch: 30 # ✅ 从 250 降到 30(见 4.3)
concurrent-consumers: 4
max-concurrent-consumers: 16 # 允许弹性扩容消费者
retry:
enabled: true # ✅ 容器内重试:异常时自动重试 3 次
max-attempts: 3
initial-interval: 1000ms
multiplier: 2.0
default-requeue-rejected: false # ✅ 重试耗尽不重新入队(避免毒消息无限循环),进 DLX
/**
* ✅ 修复后:AUTO 模式下业务代码只关心业务,ACK 由容器保证
* 抛出异常 → 容器 nack;重试耗尽 → 拒绝并路由到死信队列
*/
@RabbitListener(queues = "order.notify.queue")
public void onMessage(OrderNotifyMessage msg) {
SmsResult result = smsClient.send(msg.getPhone(), msg.getContent());
if (!result.isSuccess()) {
// 业务失败抛异常即可:容器负责重试 → DLX,消息永远不会"消失"
throw new BizException("SMS_SEND_FAIL", result.getErrorMsg());
}
}
如果确实需要保留 MANUAL(个别批量确认场景),强制规范写法:
/**
* ✅ MANUAL 模式的安全模板:try/catch/finally 三路径全覆盖
*/
@RabbitListener(queues = "xxx")
public void onMessage(Message message, Channel channel) throws IOException {
long tag = message.getMessageProperties().getDeliveryTag();
boolean success = false;
try {
success = handle(message);
} catch (Exception e) {
log.error("处理异常, tag={}", tag, e);
} finally {
// 无论成功失败,必须给出结论:requeue=false 让失败消息走 DLX,不允许"悬着"
if (success) {
channel.basicAck(tag, false);
} else {
channel.basicNack(tag, false, false);
}
}
}
铁律:手动 ACK 模式下,消息的终态只有 ack 和 nack 两种,不允许存在"什么都不做"的代码路径。Code Review 时把"catch 块里有没有确认动作"列为 MQ 消费代码的必查项。
4.2 第二层:队列 TTL + 死信队列(兜底过期清理)
即使消费再出问题,也要让积压消息有"过期释放"的出路,避免无限吃内存:
/**
* 队列声明:消息 TTL 30 分钟 + 死信路由
* 超时未消费的消息自动进入 DLX,主队列内存/磁盘压力有上限
*/
@Bean
public Queue notifyQueue() {
return QueueBuilder.durable("order.notify.queue")
.withArgument("x-message-ttl", 1800000) // 消息最长存活 30 分钟
.withArgument("x-dead-letter-exchange", "order.dlx") // 过期/拒绝 → 死信交换机
.withArgument("x-dead-letter-routing-key", "notify.dead")
// 可选硬上限:队列长度封顶,防止任何情况下无限堆积
.withArgument("x-max-length", 200000)
.withArgument("x-overflow", "reject-publish") // 超限时拒绝新消息(生产者可感知)
.build();
}
设计要点:
| 参数 | 取值依据 |
|---|---|
| x-message-ttl | 按业务时效定:短信通知 30 分钟后再发已无意义,比业务最大容忍延迟略长 |
| x-max-length | 按消费能力 × 故障容忍时间估算(如峰值 5000 条/分钟 × 30 分钟 ≈ 15 万,留冗余设 20 万) |
| x-overflow | reject-publish 让生产者在队列满时收到失败(配合 Confirm 可降级),优于静默丢头的 drop-head |
| DLX 队列 | 死信消息进 DLX 后有独立消费者做人工/自动补偿,TTL 丢弃的是时效,不是数据 |
4.3 第三层:prefetch 与消费者并发调优
prefetch 的本质是"每个消费者手上允许同时有几条在途消息",调优原则:
prefetch 太小(如 1):每处理一条都要等网络往返拿新消息,消费者大量时间在等
prefetch 太大(如 250/1000):
① ACK 缺陷时黑洞容量巨大(本案)
② 单消费者囤积消息,集群负载倾斜(其他消费者闲着)
③ 消息在单节点内存里集中
经验值:
消息处理 10~50ms 的快任务:prefetch = 20~50
消息处理 100ms~1s 的慢任务(调外部接口):prefetch = 10~20
并发靠 concurrent-consumers / max-concurrent-consumers 弹性扩,不靠调大 prefetch
我们把 prefetch 从 250 降到 30、消费者并发弹性 4~16,吞吐不降反升(负载更均匀),且任何消费缺陷影响面的"静默上限"从 12000 条降到 480 条,监控几分钟内就能发现异常。
4.4 第四层:内存水位与堆积监控告警
最后补上"早发现"的能力。RabbitMQ 自带 Prometheus 插件(3.8+):
# 启用
rabbitmq-plugins enable rabbitmq_prometheus
# 默认在 15692 端口暴露 /metrics
Prometheus 关键告警规则(按我们的真实配置整理):
groups:
- name: rabbitmq
rules:
# ① 内存水位:超过 0.7 × 水位线就预警(0.4 水位线 → 0.28 内存使用率),不要等到 blocked
- alert: RabbitMQMemoryHigh
expr: rabbitmq_process_resident_memory_bytes
/ rabbitmq_resident_memory_limit_bytes > 0.7
for: 2m
labels: { severity: P1 }
annotations:
summary: "RabbitMQ {{instance}} 内存使用超水位线 70%"
# ② 内存/磁盘告警已触发(流控可能正在发生)
- alert: RabbitMQAlarmActive
expr: rabbitmq_alarms_memory_alarm == 1 or rabbitmq_alarms_disk_space_alarm == 1
for: 0m
labels: { severity: P0 }
annotations:
summary: "RabbitMQ {{instance}} 触发内存/磁盘告警,生产者可能已被阻塞"
# ③ unacked 异常:unacked 数远超"消费者×prefetch"理论上限,或只增不减
- alert: RabbitMqUnackedGrowth
expr: sum(rabbitmq_queue_messages_unacked) by (queue) > 1000
for: 10m
labels: { severity: P1 }
annotations:
summary: "队列 {{queue}} unacked 消息持续 >1000,疑似 ACK 逻辑异常"
# ④ 队列积压:ready 消息增长速率 > 消费速率
- alert: RabbitMqQueueBacklog
expr: rate(rabbitmq_queue_messages_ready[5m]) > 0
and rabbitmq_queue_consumers > 0
for: 5m
labels: { severity: P1 }
annotations:
summary: "队列 {{queue}} 有消费者但消息持续积压"
# ⑤ 生产者连接被 blocked(事故进行中)
- alert: RabbitMqConnectionsBlocked
expr: rabbitmq_connections_state{state="blocked"} > 0
for: 1m
labels: { severity: P0 }
告警设计的核心思路:告警要发在"水位线之前"和"死锁形成之前"。内存到 0.4 才告警等于事故已经发生,所以我们设了 0.7 × 水位线的预警;unacked 的异常增长(规则③)更是能在事故前两天就暴露问题——48 万条消息的黑洞,在 1000 条时就该被看见。
五、排查方法论:遇到 MQ 卡死按这个顺序查
把事故后的排查路径固化成 SOP:
第一步:看状态
rabbitmqctl list_connections state → blocked 连接有多少?
管理 UI 顶部有没有红色 memory/disk alarm?
→ 有 blocked + memory alarm = 流控进行中,优先释放内存
第二步:看内存被谁占
rabbitmqctl list_queues name messages messages_ready \
messages_unacknowledged memory | sort -k5 -n
→ 重点看 memory 列最大、unacked 异常高的队列
第三步:判断堆积类型
├─ ready 高 + unacked 正常 + consumers=0 → 消费者挂了/没部署
├─ ready 高 + unacked 正常 + consumers>0 + 无消费速率 → 消费者处理慢/卡住
└─ ready 高 + unacked 异常高(本案)→ ACK 逻辑黑洞,查消费代码异常路径
第四步:止血
├─ unacked 黑洞:重启问题消费者(连接断开 → unacked 自动 requeue 回 ready)
├─ 单纯积压:临时扩消费者实例 / 调大 max-concurrent-consumers
└─ 内存告警:止血后消费追平,内存自然回落,alarm 自动解除
第五步:防复发
ACK 模式审计 + TTL/DLX 补齐 + 4.4 的五条告警上线
六、常见问题
6.1 AUTO 确认模式会丢消息吗?
不会因为"忘记确认"丢消息。Spring 的 AUTO 模式语义是:监听器方法正常返回 → ack;抛出异常 → nack(配合 retry 重试,耗尽后 reject)。它丢失消息的唯一场景是"业务成功了但 ack 发送时连接断开"——这个窗口在 MANUAL 模式下同样存在(你手动 ack 的那一刻网络也可能断),不是 AUTO 特有的风险。真正需要 at-least-once 强保证的业务,靠的是消费幂等 + 死信补偿,而不是把 ack 写在业务代码里。
6.2 手动 ACK 什么时候才真正需要?
三类场景:① 批量处理后统一 ack(basicAck(tag, true) 批量确认多条);② 消息处理结果需要跨事务/跨资源协调(如先写库再 ack,且要求顺序严格);③ 需要自定义 requeue 目标(requeue=true 重新入队重试)。这些场景下务必套用 4.1 的 try/finally 模板,并配 Code Review checklist。
6.3 prefetch 设成多少合适?有公式吗?
没有精确公式,但有可靠的推导:prefetch ≈ 单条处理耗时(秒) × 期望单消费者吞吐(条/秒) 向上取整后 × 2 的冗余。例:单条处理 50ms、目标单消费者 200 条/秒 → 0.05 × 200 = 10,取 20~30。慢任务(调外部接口 500ms)反而要调小(10 左右),避免大量消息在一个消费者上超时堆积。总吞吐不够时加消费者实例,不要加 prefetch。
6.4 为什么不把内存水位线调高(比如 0.8)来"避免"流控?
这是事故后被提过的馊主意。水位线是根据 Erlang VM + 操作系统缓冲的安全比例定的,调高到 0.8 意味着内存吃满时没有缓冲,一旦分页机制跟不上就是节点 swap 卡死——那时不是"生产者 blocked 几分钟",而是节点整个无响应、集群脑裂风险,恢复只能重启。流控是保险丝,不是故障本身。内存紧张的正确解法是加内存、减少积压、迁移队列,而不是拆保险丝。
6.5 消息持久化了,unacked 也占内存吗?
占。持久化(deliveryMode=2)保证的是节点重启后消息不丢(磁盘副本),不代表消息平时只在磁盘上。经典队列在内存宽裕时消息常驻内存以保证性能,内存压力下才分页。且消息的元数据、索引无论如何都在内存中。不要以为"消息落盘了就不占内存",这是我们复盘时纠正的一个普遍误解。队列内存大时优先排查消息体积和积压量。
6.6 Lazy Queue(惰性队列)能解决这个问题吗?
能缓解"ready 消息吃内存"(惰性队列让消息尽量驻留磁盘),但解决不了本案的 unacked 黑洞——已投递未确认的消息管理逻辑不同,且惰性队列吞吐更低、延迟更高,不适合在线业务队列。它的定位是"海量消息、允许较慢消费"的场景(如日志、审计)。根因(ACK 缺陷)不解决,换队列类型只是让黑洞填得更慢。
七、总结
事故链条速查卡
代码层:手动ACK异常分支无确认
↓ 每条异常消息永久占用 1 个 prefetch 槽位
消费者层:槽位占满 → RabbitMQ 停止投递 → 消费者"假死"
↓ 消费速率归零
队列层:只进不出,ready + unacked 双份消息持续积压
↓ 内存爬升(48万条 ≈ 6GB)
节点层:内存超 0.4 水位线 → memory alarm → Flow Control
↓ 所有发布连接 blocked
集群层:生产者发送阻塞 → 订单接口超时 → 业务雪崩
↓
重启消费者 → unacked requeue → 内存回落 → alarm 解除(止血)
根因修复:AUTO确认 + TTL/DLX + prefetch=30 + 水位/堆积告警
四层修复对照表
| 层级 | 措施 | 防住的风险 |
|---|---|---|
| 代码 | acknowledge-mode=auto;manual 必须 try/finally 双路径确认 | ACK 黑洞(根因) |
| 队列 | x-message-ttl + DLX + x-max-length + reject-publish | 无限积压、内存无上限 |
| 消费端 | prefetch 30 + 消费者并发弹性 4~16 | 黑洞放大、负载倾斜 |
| 监控 | 内存 0.7 水位预警、alarm P0、unacked 增长、积压速率、blocked 连接 | 故障提前两天可见 |
关键数据
- 事故影响:订单创建接口超时 17 分钟,P0
- 根因消息量:unacked 48 万条、占用内存 6.1GB,两天缓慢形成
- 止血耗时:重启消费者后 3 分钟内存告警解除
- 修复后:同故障场景下 unacked 上限从 12000 条降至 480 条,监控 10 分钟内必告警;队列 TTL 保证积压 30 分钟自动释放
一句话
RabbitMQ 的 Flow Control 是保险丝不是故障——内存撞 0.4 水位线时,它宁可阻塞生产者也要保住节点不 swap 卡死;真正让集群死锁的是"写被堵、读也自己堵死自己"的闭环,而闭环的源头永远在消费侧。手动 ACK 的每一条消息都必须有终态(ack 或 nack),prefetch 不是越大越好,队列必须有 TTL 和死信兜底,监控要建在水位线之前——这四条做到,内存告警永远只是一条预警短信,而不是一次 P0。
给团队的建议
| 阶段 | 建议 |
|---|---|
| 所有 MQ 消费代码 | 审计 ACK 模式:能用 AUTO 不用 MANUAL;MANUAL 强制 try/finally 模板 |
| 所有队列 | 补 x-message-ttl + DLX;无 TTL 的队列逐个给出业务理由 |
| 本周可做 | 执行 list_queues messages_unacknowledged,unacked 长期 > consumers×prefetch 的队列立刻排查 |
| 监控 | 上线 4.4 的五条告警,重点是内存水位预警和 unacked 增长告警 |
| Code Review | MQ 消费者代码 checklist:异常路径有无确认、prefetch 多大、失败消息去哪 |
互动话题:你们踩过 RabbitMQ Flow Control 的坑吗?unacked 消息数你们平时会监控吗?手动 ACK 的代码在 Code Review 时会专门检查异常分支吗?评论区聊聊。
标题:生产事故:RabbitMQ 内存告警导致整个集群不可用——"流量控制"的连环陷阱
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/04/1788585536408.html
公众号:服务端技术精选
- 引言
- 一、事故时间线:15 分钟里发生了什么
- 二、RabbitMQ 内存与流控机制:保护机制如何变成连环陷阱
- 2.1 内存水位线:0.4 阈值是什么
- 2.2 Flow Control:刹车是怎么踩的
- 2.3 内存到底被什么占了:unacked 消息的成本
- 三、根因:手动 ACK 的"消息黑洞"
- 3.1 出事的代码长什么样
- 3.2 为什么两天才爆:prefetch 的"温水煮青蛙"
- 3.3 为什么是"集群不可用"而不是"一个队列慢"
- 四、修复方案:四层加固
- 4.1 第一层:ACK 模式纠偏(根因修复)
- 4.2 第二层:队列 TTL + 死信队列(兜底过期清理)
- 4.3 第三层:prefetch 与消费者并发调优
- 4.4 第四层:内存水位与堆积监控告警
- 五、排查方法论:遇到 MQ 卡死按这个顺序查
- 六、常见问题
- 6.1 AUTO 确认模式会丢消息吗?
- 6.2 手动 ACK 什么时候才真正需要?
- 6.3 prefetch 设成多少合适?有公式吗?
- 6.4 为什么不把内存水位线调高(比如 0.8)来"避免"流控?
- 6.5 消息持久化了,unacked 也占内存吗?
- 6.6 Lazy Queue(惰性队列)能解决这个问题吗?
- 七、总结
- 事故链条速查卡
- 四层修复对照表
- 关键数据
- 一句话
- 给团队的建议
评论