RabbitMQ 消息积压排查:从指标定位到消费者扩容

RabbitMQ 消息积压排查:从指标定位到消费者扩容

比赛高峰时,判题结果从几秒变成几分钟,RabbitMQ 控制台里的消息数持续上涨。此时最直觉的动作是增加消费者,但如果瓶颈在数据库、容器启动、外部 API 或宿主机 CPU,扩容消费者只会让更多任务同时压向下游,最终把一个队列积压演变成数据库雪崩。

消息积压不是单一故障,它表示"进入速率在一段时间内大于有效完成速率"。排查必须先确认消息处于 Ready 还是 Unacked,再把每条消息的处理阶段拆开,建立容量模型,最后决定扩容、限流、降级还是修复毒消息。本文以 Spring Boot + RabbitMQ 的异步判题任务为例,给出一套可执行的定位与恢复方法;文中的公式用于估算,实际结论必须由本系统指标和压测验证。

一、先判断是正常削峰还是持续性积压

队列本来就用于吸收短暂峰值。峰值结束后,消费速率高于生产速率,消息数能够在业务允许时间内归零,这是正常削峰。真正需要告警的是:

  • messages_ready 持续增长,且生产速率长期高于 ACK 速率;
  • messages_unacknowledged 长期处于高位,消费者已取走消息但完成很慢;
  • 消费者数量下降、频繁重连或不断重启;
  • 最老消息年龄超过业务 SLA,即使队列总量暂时不大;
  • 积压下降速度不足以在下一次峰值前清空;
  • 大量消息在主队列、重试队列与死信队列之间循环。

不要只看"队列有十万条"就判断严重程度。每条任务耗时 5 毫秒与 30 秒,含义完全不同;消息体 200 字节与 2 MB,对磁盘和网络的影响也不同。最老消息年龄通常比单纯队列深度更接近用户体验。

事故开始时先冻结关键现场:队列名、vhost、时间窗口、Ready、Unacked、publish/deliver/ack rate、消费者数、最老消息年龄、节点磁盘与内存告警、应用发布记录和下游健康状态。不要一边随意改 prefetch,一边失去基线。

二、读懂 Ready、Unacked 与速率组合

RabbitMQ 中常用的三个数量是:

  • Ready:消息在队列中等待投递,尚未分配给消费者;
  • Unacked:已经投递给消费者,但 Broker 尚未收到确认;
  • Total:通常可理解为 Ready 与 Unacked 的合计视图。

结合速率才能定位:

现象 常见原因 下一步
Ready 上升,Unacked 很低 无消费者、消费能力不足、消费者被限流 看消费者数、连接、日志和 deliver rate
Ready 上升,Unacked 也高 消费慢且 prefetch 占满 拆处理耗时、看下游资源
Ready 很低,Unacked 很高 消费者拿走大量消息后阻塞 检查 prefetch、线程池、ACK 与卡死
publish 突增,ack 随后追上 正常短峰 关注最老消息年龄与清空时间
deliver 与 redeliver 都高 消费失败、连接断开或拒绝重回队列 查异常分类和毒消息
消费者为零 部署、连接、权限、监听器启动失败 优先恢复消费者,不谈扩容

ACK rate 才接近有效完成速率。Deliver rate 高不代表处理快,可能只是把 Ready 搬到了 Unacked。若使用自动确认或错误确认策略,还要核对 ACK 是否真的发生在业务事务完成之后;过早 ACK 会让队列看起来健康,却把失败消息丢失。

管理页面的瞬时速率会抖动,建议看至少数分钟的窗口和趋势。发布端批量发送也可能造成锯齿,不能用一个点做容量结论。

三、先排除 Broker 与拓扑层问题

应用耗时不是唯一原因。RabbitMQ 节点若触发内存或磁盘水位,会对发布连接实施流控;集群节点网络异常、队列 leader 所在节点资源紧张,也会影响吞吐。排查时检查:

  • 节点是否出现 memory alarm、disk alarm 或连接 blocked;
  • 文件描述符、Erlang 进程数、磁盘 IOPS 与网络是否耗尽;
  • 队列类型、leader 分布、镜像或 quorum 副本是否符合预期;
  • 是否有大消息、过多队列、频繁自动删除或拓扑反复声明;
  • 发布确认延迟是否同时上升;
  • 是否在近期修改 TTL、死信路由、优先级或最大长度。

不要在事故中直接 purge 队列。消息可能是仍需处理的业务事实,删除属于破坏性操作,必须有业务授权和备份/重放方案。若确认某类消息无效,也应通过受控脚本按条件迁移或标记,而不是清空整个队列。

可通过管理 API、监控系统或运维命令采集状态。例如命令行只作为示意,生产凭证与 vhost 应遵循权限管理:

bash 复制代码
rabbitmqctl list_queues -p app   name messages_ready messages_unacknowledged consumers   message_bytes_ready message_bytes_unacknowledged
rabbitmqctl list_connections name state channels send_pend

命令输出是快照,仍需与时序指标结合。若只有一个队列异常而节点整体健康,优先查消费者和消息内容;若所有队列、发布确认和连接都变慢,则先处理 Broker 或基础设施。

四、用容量模型判断"需要多少消费者"

设平均生产速率为 λ 条/秒,单个有效消费单元完成速率为 μ 条/秒,并行消费单元数为 c,则稳定条件近似为:

text 复制代码
c × μ > λ

这里的"消费单元"可能是一个进程、一个 listener 并发线程,也可能受 CPU 核、容器槽位和数据库连接限制,不能简单等同于实例数。若单任务平均耗时为 S 秒且能完全并行,理论 μ 约为 1 / S;实际还要考虑长尾、I/O 等待、失败重试和共享资源竞争。

已有积压 B 条,峰值后生产仍为 λ,当前总完成速率为 C,则理想清空时间约为:

text 复制代码
drain_time = B / (C - λ)    其中 C 必须大于 λ

若 C 小于等于 λ,队列永远清不完。计算要用 ACK 速率的稳定窗口,而不是配置线程数推测。任务耗时分布长尾明显时,平均数会乐观,需结合 P95/P99 和不同任务类型分别建模。

Little 定律可帮助检查一致性:稳定状态下,系统内平均任务数 L 约等于到达率 λ 乘平均停留时间 W。若消息年龄快速上升而吞吐不变,说明队列等待主导了用户延迟。

容量评估还要为节点故障、发布波动和长任务留余量。不能把系统长期运行在 100% CPU、100% 连接池占用的理论极限,否则任何重试都会触发排队雪崩。

五、把消费者处理链路拆成阶段计时

判题任务可能包含:反序列化、拉取代码、创建沙箱、编译、运行测试、上传日志、写数据库和发送结果事件。只记录 listener 总耗时,无法知道慢在哪里。为每个阶段创建指标或 Trace Span:

java 复制代码
@RabbitListener(queues = "judge.task")
public void consume(
        JudgeTask task,
        Channel channel,
        @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {

    Timer.Sample total = Timer.start(registry);
    try {
        SourceBundle source = timed("source.fetch",
                () -> sourceService.fetch(task.submissionId()));
        Sandbox sandbox = timed("sandbox.create",
                () -> sandboxService.create(task.language()));
        CompileResult compiled = timed("compile",
                () -> compiler.compile(sandbox, source));
        RunResult result = timed("execute",
                () -> runner.run(sandbox, compiled, task.limits()));
        timed("result.persist",
                () -> resultService.finish(task, result));
    } catch (Exception ex) {
        // route 必须先把消息可靠送入延迟重试或隔离流程。
        // route 失败时异常上抛,连接关闭后原消息会重新投递。
        failureHandler.route(task, ex);
        channel.basicNack(deliveryTag, false, false);
        return;
    } finally {
        total.stop(registry.timer(
                "judge.consume.total",
                "language", safeLanguage(task.language())));
    }

    // 业务结果和幂等记录均已持久化后再确认消息。
    // ACK 自身失败时不另发重试消息,由 Broker 重投原消息。
    channel.basicAck(deliveryTag, false);
}

标签使用有限枚举,不能把 submissionId 放入指标造成高基数。请求 ID 放 Trace 或日志。

这里与后文的 acknowledge-mode: manual 配套:成功路径显式 basicAck,失败路径只有在错误已经可靠进入延迟重试或隔离流程后才 basicNack(requeue=false)。不能只记录异常后吞掉,也不能对所有失败立即 requeue=true,否则毒消息会形成高速循环。若项目不需要逐条控制确认,应改用 AUTO,让容器在监听方法正常返回后确认,而不是保留 MANUAL 配置却不调用 ACK。

阶段耗时与资源指标交叉观察:sandbox.create 变慢同时宿主机磁盘 IOPS 饱和,优先处理镜像与磁盘;result.persist 变慢同时数据库连接等待增加,扩容消费者会更糟;只有应用 CPU 有余量、下游稳定且 Ready 增长时,增加并发才更合理。

还要看任务大小分布。少量超长用例占住消费者,会造成队头阻塞。可以按语言、预估用例数或资源等级拆队列,让轻任务不被超长任务拖住;但分队列后要重新规划公平性与总容量,不能无限细分。

六、Prefetch 与消费者并发要配套

Prefetch 控制 Broker 允许消费者持有多少条未确认消息。过大时,一个消费者会预取大量任务,其他新实例即使启动也拿不到消息;进程崩溃后大量 Unacked 重新入队,恢复抖动明显。过小时,消费者每完成一条都等待网络投递,可能无法充分利用 I/O 并发。

Spring AMQP 配置示意:

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual
        concurrency: 4
        max-concurrency: 8
        prefetch: 8
        default-requeue-rejected: false

配置值不能照抄。CPU 密集任务通常 prefetch 接近每个消费线程的在途能力;I/O 密集且能并行时可以稍高。要确认 prefetch 是按 consumer 还是 channel 生效,以及容器实际创建多少 consumer。

一个实用检查是比较 Unacked / consumer_count。若远高于每个实例可并行处理数,说明消息被提前占用。调整后观察吞吐、公平性、Unacked、内存和重投恢复时间,不能只看 Ready 是否下降。

手动 ACK 时,只有业务事务和必要的幂等记录成功后才确认。不要把异步任务提交到本地线程池后立即 ACK,除非任务已经可靠落入另一个持久化队列;否则进程崩溃会丢任务。

七、消费者扩容前先检查下游容量

扩容决策至少满足三个条件:Ready 持续上升;现有消费者单元接近自身容量;数据库、缓存、第三方 API 和宿主机仍有余量。否则应该先修瓶颈或降低入口速率。

例如每个判题 Worker 同时运行四个容器,宿主机安全上限为二十个容器,那么单机最多约五个 Worker 单元;再增加 listener 并发只会争抢 CPU 和内存。数据库连接池也要算:每个实例上限 20,扩到 30 个实例可能理论占用 600 个连接,数据库未必承受。

扩容时分批增加,观察每批后的 ACK rate 与下游 P95。如果消费者数量翻倍但 ACK rate 几乎不变,瓶颈不在消费者数量;如果错误率和数据库延迟同步上升,应立即停止扩容并回退。

自动扩缩容不要只绑定队列长度。更稳妥的信号是最老消息年龄、净积压增长率、每实例吞吐、CPU/内存和下游保护状态。设置冷却时间,避免短峰触发实例反复启动;本地模型、沙箱或大镜像启动还要计入预热时间。

八、从生产端控制进入速率与任务优先级

当有效消费能力短期无法提升,必须控制生产。网关可对非关键请求限流,批量任务暂停或延后,重复任务用业务幂等合并。发布端在 Broker 流控或确认延迟升高时应退避,不能无限把消息缓存在 JVM 内存。

比赛提交可以按用户和题目限速,防止脚本反复提交占满资源。系统任务与在线判题分开队列或保留容量,避免后台重算影响用户请求。高优先级队列只适合明确的少量紧急流量;所有消息都标高优先级等于没有优先级,还增加 Broker 开销。

拆分队列时使用业务可解释维度,如在线/离线、轻/重任务、不同资源池。不要按每个租户创建大量动态队列,队列与 consumer 本身也消耗 Broker 资源。公平调度如果要求复杂,可以在业务调度层先排队,再向有限 RabbitMQ 队列发布。

生产端要使用 publisher confirm 识别是否被 Broker 接收,并为失败发布保留 Outbox 或重试记录。没有确认就无限重发会制造重复消息,消费者仍需幂等。

九、重试、死信与毒消息治理

一个永远失败的消息若立即 requeue,会形成高速循环,占满消费者和日志。错误必须分类:

类型 示例 动作
短暂依赖故障 网络抖动、下游 5xx 指数退避后有限重试
容量限制 429、资源池已满 延迟重试并限速
永久业务错误 参数非法、资源不存在 不重试,记录失败或死信
代码缺陷 反序列化异常、空指针 隔离消息并告警
结果未知 外部执行超时 先按幂等键查询结果

退避不能用 listener 线程 sleep 数分钟,也不要立即 requeue。可以使用带 TTL 的重试队列、延迟机制或调度服务,重试消息携带 attempt、首次时间和稳定 messageId。达到上限进入死信队列,由人工或自动修复流程处理。

死信队列不是垃圾桶。监控进入速率、消息年龄和原因,提供脱敏查看、修复、单条重放与审计。重放前确认代码已修复且消费者幂等,否则一次批量回放可能制造第二次事故。

十、积压恢复要防止二次冲击

根因修复后,直接把消费者开到最大并不安全。积压中的老任务可能已被用户取消、超时或由其他流程补偿;先做业务有效性检查,可以丢弃已终态任务,但这个"丢弃"要有可审计状态而不是静默 ACK。

恢复计划通常分阶段:

  1. 保持入口限流,确认新消息不再高速增长;
  2. 修复毒消息与下游故障,验证单实例正确完成;
  3. 小批增加消费者,观察 ACK rate、错误率和下游 P95;
  4. 对积压按业务优先级清理,限制总并发;
  5. 估算清空时间,定期更新状态;
  6. 队列接近正常水位后逐步解除入口限制;
  7. 保留故障后监控窗口并完成对账。

恢复过程中重试流量与正常流量分开计数。若大量历史失败同时到期,可能产生"重试风暴"。为重试设置全局速率和随机抖动,让它只占一部分容量。

用户可见任务要提供状态查询和超时语义。超过有效期的判题即使最终执行,也可能没有价值;业务层应定义截止时间,消费者在执行昂贵步骤前检查。这样减少无效工作,也让清空时间更可预测。

十一、监控与告警应覆盖原因和影响

建议至少采集以下指标:

  • Ready、Unacked、总消息、最老消息年龄;
  • publish、deliver、ack、redeliver 与 reject rate;
  • consumer 数量、连接/Channel 状态和重启次数;
  • 每个处理阶段的 P50/P95/P99 与错误分类;
  • Prefetch、线程池活跃数、队列长度、拒绝数;
  • 数据库连接等待、慢 SQL、缓存与外部 API 延迟;
  • 重试队列、死信队列深度和最老年龄;
  • Publisher confirm 延迟、失败与应用本地待发送数;
  • Broker 内存、磁盘、文件描述符、网络与 alarm。

告警不要只设置固定队列长度。结合"最老年龄超过 SLA""净增长持续 N 分钟""消费者为零""redeliver 比例突升"等条件更准确。告警消息附带 vhost、队列、当前速率、消费者数、最近发布版本和 Runbook 链接,值班人员才能快速行动。

日志用 messageId、业务 ID、attempt 和 TraceId 串联,载荷脱敏。单条毒消息反复失败时使用采样或聚合,避免日志洪水占满磁盘。

十二、测试与性能验证方法

容量测试使用与生产相近的消息大小和任务耗时分布,不能全是固定 10 毫秒的空任务。分别构造稳定流量、阶梯增压、短峰、长任务混入和下游变慢,采集进入率、ACK rate、最老年龄、P95 处理时间与资源利用率。

故障注入包括:关闭一个消费者实例、让数据库延迟上升、让容器创建失败、阻断 ACK 连接、产生毒消息、触发重试队列同时回流。验证消息没有丢失,重复投递被幂等处理,死信可定位,恢复阶段不会冲垮下游。

Prefetch 实验固定消费者与任务分布,逐档调整,比较吞吐、Unacked、公平性和崩溃重投时间。扩容实验每次增加少量实例,绘制消费者数与有效 ACK rate 的关系;曲线进入平台区说明共享瓶颈出现。

没有实测环境时,报告上述方法和需要采集的指标,不编造"扩容后提升几倍"。真实报告应注明队列类型、RabbitMQ 版本、节点规格、消息大小、持久化、publisher confirm、消费者并发、prefetch、下游配置和预热过程。

十三、常见误区与延伸

常见误区包括:只看 Ready 不看 Unacked;把 deliver rate 当完成率;一积压就无限加消费者;prefetch 越大吞吐越高;业务提交前 ACK;失败立即 requeue;死信只进不管;恢复时一次放开全部流量;用队列深度一个指标驱动自动扩容。

面试回答可以按"现象、定位、容量、恢复"展开。先看 Ready/Unacked、生产与 ACK rate、消费者数和最老消息年龄;再拆消费者阶段耗时并检查 Broker 与下游;用 λ、μ、并发和清空时间估算容量;确认下游余量后分批扩容,同时治理 prefetch、重试和毒消息;恢复期限流,防止二次冲击。

常见追问有:Ready 低但 Unacked 高怎么办?消费者预取后卡住,检查 prefetch、线程与下游;消费者翻倍吞吐不变说明什么?共享瓶颈或资源上限;如何避免重复消费?稳定 messageId、Inbox/业务唯一键,事务完成后 ACK;怎样估算多久清空?使用当前有效 ACK rate 减去持续生产 rate 计算净清理速率,并考虑长尾和失败。

十四、总结

RabbitMQ 消息积压的本质是进入速率长期超过有效完成速率。排查从 Ready、Unacked、publish 和 ACK rate 开始,再检查消费者、Broker、处理阶段和下游资源。容量模型帮助判断系统是否稳定以及理论清空时间,但最终决策必须由真实 ACK rate、任务长尾和资源指标验证。

扩容只是工具之一。正确的方案还包括合理 prefetch、业务幂等、生产限流、任务分级、有限退避重试、死信治理和分阶段恢复。能解释每一条消息在哪里等待、为什么变慢、增加并发会压到谁,并通过故障注入证明恢复路径,才算真正把队列从"黑盒缓冲区"变成可治理的异步系统。

事故复盘时还应把"触发积压的第一项变化"和"放大积压的后续因素"分开。一次发布造成单任务耗时上升可能是根因,而立即重试、过大 prefetch 与盲目扩容则是放大器。把时间线、配置变化和指标拐点对齐,才能制定真正有效的预防措施,而不是只给集群永久加机器。

相关推荐
栩栩云生1 天前
被抹去的 27 秒:你的代码,其实并不活在真实的时间里
分布式·unix
风样滴男人哟1 天前
基于 .NET 开源、功能齐全的分布式作业调度系统
分布式·开源·.net
梅头脑2 天前
交易跨10个库、日均千万订单——选错一次分布式事务方案,加班三个月重写
java·分布式
霸道流氓气质2 天前
分布式系统中接口时序不确定性处理
java·开发语言·分布式
YOU OU2 天前
Redis分布式锁
数据库·redis·分布式
爱浦路 IPLOOK2 天前
5GC实验室建设方案:面向5G核心网教学、研发与测试的标准化实验平台
分布式·科技·5g·架构·信息与通信
小罗水2 天前
第9章 RabbitMQ 消息队列与索引任务
分布式·rabbitmq
naierfengdian2 天前
分布式风力发电机机抗风扰、稳发电结构设计技术解析
分布式·能源
wWYy.2 天前
基于Raft分布式Kv存储:sendRequestVote
分布式