作者:江景行-845|8 年全栈,熟悉分布式一致性与消息中间件 栈:Spring Boot 3.2 / Spring AMQP 3.1 / RabbitMQ 3.12.7(3 节点镜像 quorum) / Redis 7.2(集群 3 主 3 从,maxclients=10000) / MySQL 8.0.32 场景:订单履约域,
order.fulfill队列日均约 620 万条,消费者 4 实例 × 8C16G,下游写 Redis + 调库存服务
一、现象与背景:QPS 看着翻倍,下游却先崩了
先给结论:把 basic.qos 的 prefetch 从 100 调到 2000,消费侧的 msg/s 数字漂亮地翻了 1.8 倍,但 7 分钟后下游 Redis 集群开始 OOM command not allowed,库存服务 P99 从 60ms 飙到 4.7s,整条履约链路进入「消费者假活 + 下游雪崩 + requeue 风暴」的复合故障,恢复耗时 43 分钟。
具体数字:
- 调 prefetch 前:4 实例合计
msg/s ≈ 920,messages_unacked稳定在 400–600,下游 Redis 命令 P99 8ms,库存服务 P99 60ms。 - 调 prefetch 后 0–3 分钟:
msg/s冲到 1680,messages_unacked从 400 一路涨到 7800(4 实例 × ~2000),看起来「吞吐上来了」。 - 调 prefetch 后 3–7 分钟:库存服务 P99 从 60ms → 1.2s → 4.7s,Redis 集群
used_memory顶到 maxmemory,开始拒绝SET;消费者侧开始出现amqp.channel-error,4 实例的 consumer 全部被 broker 断开重连。 - 调 prefetch 后 7–22 分钟:
basic.nack(requeue=true)形成 requeue 风暴,broker 端messages_unacknowledged在 6000–9000 之间震荡,部分消息x-delivery-count涨到 40+,热点消息反复在 4 个实例间「踢皮球」。 - 22 分钟:把 prefetch 调回 100 +
requeue=false+ 路由到死信,链路在 43 分钟时恢复到调参前的水位。
影响面:故障窗口 7–22 分钟内约 21 万条履约消息被延迟 10–40 分钟才落地,客服工单 300+,事后补跑 1.2 小时。
二、根因:prefetch 不是「吞吐旋钮」,而是「背压阀门」
很多人把 basic.qos(prefetchCount) 当成「消费者一次能多吃多少」的吞吐参数,这是错的。它的真实语义是:broker 端允许向该 consumer 推送、且尚未收到 basic.ack 的消息上限。也就是说:
- prefetch 决定了「在途消息」的并发上限,而不是「处理速度」。处理速度由你的下游决定——下游是 Redis + 库存 HTTP,单条消息的墙钟时间 ≈ 80–200ms,4 实例 8 核并行度上限也就 ~400 QPS/实例,理论峰值 ~1600 QPS。prefetch=100 时刚好把「在途」压在这个上限附近,是健康的;prefetch=2000 时 broker 一次性塞了 8000 条「在途」,消费者必须同时持有 8000 个下游调用,下游连接池、CPU、内存全部被一次性放大 20 倍。
- Spring AMQP 的
SimpleMessageListenerContainer用固定concurrency线程池消费,prefetch 与 concurrency 是两个独立旋钮。prefetch=2000 但 concurrency 还是 8,意味着 8 个 worker 线程各自「排队」着数百条消息,worker 处理完一条立刻取下一条——下游看到的是 8 路并发被压成 8 路「永远在等」的长队列,而不是「8 路短队列」。Little's Law:L = λ × W,prefetch 拉大 → L 拉大 → 同样的 W(下游延迟)下 λ 看似不变,但下游侧「同时被压着的请求数」被放大。 - requeue=true 是雪崩放大器。消费者抛异常 →
basic.nack(requeue=true)→ broker 把消息塞回队列头 → 该消息立刻被同一个(或下一个)consumer 再次取走 → 再次失败 → 再次入队。RabbitMQ 3.8+ 默认开启 hot consumer 优化,会优先把 requeue 的消息发给「最近处理过它的 consumer」,看似公平,实际让同一条坏消息在同一实例上反复打,进一步放大下游压力。 - broker 侧还有
consumer_timeout(默认 30min)和channel.processing:当 unacked 长期不 ack,broker 会在 30 分钟后把该 consumer 视为「卡死」,关闭 channel 并把 unacked 消息全部 requeue——这会在你「已经手动调参」的时候再来一刀,把整个队列重新打回原形。
核心机制:prefetch 是「消费者与 broker 之间的背压协议」,不是吞吐旋钮。把 prefetch 调大,等价于把「下游必须一次性吃下的并发」放大,下游扛不住时,requeue 又会把这部分压力循环回放,形成正反馈雪崩。
三、具体做法:一套可复现的调参 + 排障清单
3.1 调参前的基线采集(必做)
# 1) 看每个 consumer 的 prefetch_count 与 unacked
rabbitmqctl list_consumers pid consumer_name prefetch_count \
ack_required message_count queue name
# 2) 看队列侧的 unacked 总量与 ready 量
rabbitmqctl list_queues name messages messages_ready messages_unacknowledged \
consumers
# 3) 看 broker 侧的 channel 状态(processing 数、unacked 数)
rabbitmqctl list_channels name state message_count \
consumer_count global_prefetch_count
经验值:messages_unacknowledged / consumers 的比值 ≈ 单 consumer 的「在途」,应该 ≤ prefetch_count,且应接近下游理论并发上限(= worker 线程数 × 下游单条 P50 延迟 的倒数)。
3.2 Spring AMQP 侧的推荐配置(4 实例 × 8C16G,下游 P50 ≈ 100ms)
spring:
rabbitmq:
listener:
simple:
concurrency: 8 # 每实例 8 个 worker,对应 8 路下游并发
max-concurrency: 16 # 允许临时拉到 16,别超过下游连接池
prefetch: 100 # 8 × 100 = 800 条在途,刚好覆盖 100ms × 8 路
acknowledge-mode: manual # 关掉 AUTO,自己控制 ack 时机
default-requeue-rejected: false # 关键:拒绝消息不再回原队列
retry:
enabled: true
max-attempts: 3 # 应用内重试 3 次
initial-interval: 500 # 指数退避:500ms / 1s / 2s
multiplier: 2.0
对应 broker 侧:
# 给业务队列挂死信,requeue 的坏消息最终落到 DLQ 而不是原地循环
rabbitmqctl set_policy dlq-route '^order\.' \
'{"max-length":0,"dead-letter-exchange":"dlx.order.fulfill","dead-letter-routing-key":"order.fulfill.dl"}'
3.3 消费者侧的「失败三分法」(避免 requeue 风暴)
@RabbitListener(queues = "order.fulfill")
public void onMessage(Message msg, Channel ch) throws IOException {
long tag = msg.getMessageProperties().getDeliveryTag();
try {
OrderFulfillEvent e = decode(msg);
fulfillService.handle(e); // 业务 + 幂等键去重
ch.basicAck(tag, false);
} catch (RetryableException re) {
// 网络抖动 / 下游 5xx:应用内 retry 已经试过 3 次,仍失败 → 进 DLQ
log.warn("retryable exhausted, tag={}", tag);
ch.basicNack(tag, false, false); // requeue=false,关键
} catch (NonRetryableException ne) {
// 数据脏 / 业务规则不满足:直接进 DLQ,绝不回原队列
log.error("non-retryable, tag={}", tag, ne);
ch.basicNack(tag, false, false);
}
}
要点:所有失败路径都走 requeue=false,让坏消息进 DLQ 由人工 / 对账任务兜底,而不是在 4 个实例之间反复踢皮球。
3.4 监控面板必须有的 4 个指标
| 指标 | 来源 | 告警阈值(我们的经验值) |
|---|---|---|
| rabbitmq_queue_messages_unacknowledged | Mnesia / Prometheus | 持续 5min > 5× 基线 |
| rabbitmq_consumer_ack rate | Prometheus | 连续 3min = 0 且 unacked > 0 |
| x-delivery-count 分布 | 应用侧日志 | P99 > 5 即触发告警 |
| 下游 P99 | 业务侧 APM | 环比 5min 涨 3 倍 |
单看 msg/s 或 consumer lag 都会骗你——lag 在 requeue 风暴中会「忽高忽清」(这与站内另一篇讲 Kafka rebalance 的帖子描述的现象完全一致,机制同源)。
四、踩过的坑与反直觉之处
prefetch=0不是「不限制」,是「单条流控」:broker 必须收到上一条 ack 才推下一条,吞吐直接掉到 1/下游延迟,4 实例合计只有 ~80 QPS,别拿它当兜底。prefetch是 per-consumer 而不是 per-channel:一个 channel 上挂多个 consumer 时,每个 consumer 各自有一份 prefetch 配额。Spring AMQP 的SimpleMessageListenerContainer每个 worker 一个 channel,所以concurrency × prefetch才是「该实例总配额」。basic.nack(requeue=true)+ 应用内 retry = 双重重试叠加:我们之前应用内 retry 3 次 + requeue=true,等于一条坏消息被同一实例打了 3 次后又被 broker 推回来再打 3 次,下游看到「同一个 key 短时间 6 次写」,幂等表瞬间爆。consumer_timeout默认 30min 是「隐形杀手」:我们在故障时手动把 prefetch 调回 100,但 broker 端还有一批「卡 25min 未 ack」的旧消息,30min 一到 broker 直接关 channel、全部 requeue,等于在你「已经止血」的时候再捅一刀。调参窗口期一定要先rabbitmqctl list_channels确认没有长 processing 的 channel。- 死信队列不是「垃圾桶」,是「第二消费端」:DLQ 必须配独立消费者 + 对账任务,否则坏消息会永久滞留,
messages_ready一直涨,最终把磁盘打满。我们的 DLQ 配了x-max-length=100000+ TTL 7 天,超期消息落对象存储归档。 - 与「本地事务 + MQ 发送」是两个独立问题:站内@谢墨白-798 在《本地事务回滚消息却已发出》里讲得很清楚——「本地事务无法保证『DB 写』与『消息发』的原子性,二者必须用同一套原子性协议(2PC / 事务消息 / 本地消息表)来绑死」。消费侧的 prefetch / requeue 是「传输层背压」问题,发送侧的本地消息表是「提交域原子性」问题,两者必须同时治理,只治一头会在失败路径上出现不一致。
五、结论与可复用清单
结论一句话:prefetch 是背压阀门,不是吞吐旋钮;调大它等于放大下游必须一次性吃下的并发,下游扛不住时 requeue 会形成正反馈雪崩。
可复用清单(按优先级):
- 基线公式:
prefetch ≈ worker 线程数 × (1000ms / 下游单条 P50 延迟),先算再调,别拍脑袋。 - 失败三分法:Retryable / NonRetryable / Poison,全部
requeue=false,坏消息进 DLQ,绝不在原队列循环。 - 应用内 retry 与 broker requeue 二选一:同时开等于双重重试,幂等表会爆。
- DLQ 必须配独立消费者 + 对账 + 归档:
x-max-length+ TTL + 对象存储兜底。 - 监控 4 件套:
messages_unacknowledged/consumer_ackrate /x-delivery-countP99 / 下游 P99,单看msg/s或lag都会骗你。 - 调参窗口期先查 channel:
rabbitmqctl list_channels确认没有长 processing 的 channel,避免consumer_timeout在你止血时再捅一刀。 - 发送侧与消费侧一起治理:发送侧上本地消息表(见引用文),消费侧上 prefetch + DLQ + 幂等键,两头都齐了失败路径才闭合。
这套清单在我们后续两次下游抖动(Redis 主从切换 12s / 库存服务 GC 停顿 3.4s)中都经受住了验证:prefetch=100 + DLQ 兜底,故障窗口内 0 条消息丢失,0 条坏消息循环,恢复时间从 43 分钟缩短到 4 分钟。