消息队列这件事,我在好几个 .NET 项目里都落地过,从最早手搓 RabbitMQ 客户端,到后来在 .NET 6 项目里做统一封装,踩过的坑能写满一页纸。这篇不聊虚的,就围绕.NET 6 整合 RabbitMQ这条主线,把"什么场景该用哪种消息模式"讲透——因为很多人卡住的地方根本不是连不上 Broker,而是不知道该用简单队列、工作队列、发布订阅还是路由模式,选错了模式,后面代码怎么写都别扭。
先说清楚这篇适合谁看:如果你已经能在 .NET 6 里跑起一个 Web API,知道依赖注入怎么用,但对 RabbitMQ 的印象还停留在"装个包、发条消息"的阶段,那这篇正好。我会把每种应用场景对应的消息模式拆开讲,配上可直接抄的代码结构,再补上那些文档里不会写的实操心得——比如连接为什么要做成单例、手动 Ack 到底什么时候开、消息积压了先看哪里。全文基于 .NET 6 的IHostedService、IConnection复用、BasicQos限流这些真实可用的机制展开,不涉及任何平台相关的敏感内容,纯粹是技术落地经验。
1. 先想清楚:你的业务到底该不该上消息队列
我见过太多项目,一上来就说"我们要解耦,上 MQ",结果业务量一天几百条,最后维护成本比收益还高。所以在写第一行ConnectionFactory代码之前,得先判断你的场景是不是真的需要 RabbitMQ。这一步想不明白,后面模式选得再对也是白搭。
1.1 三个典型信号:出现这些再考虑引入
判断标准其实很朴素,我一般看三个信号。第一是同步调用链太长:比如用户下单这个动作,要扣库存、发短信、写积分、生成物流单,全塞在一个 HTTP 请求里,接口响应时间飙到两三秒,任何一个下游抖动都拖垮主流程。这种"一个动作触发多个后续处理"的场景,就是消息队列最经典的用武之地——主流程只负责把"下单成功"这个事实扔进队列,剩下的谁关心谁去消费。
第二是流量有明显的尖峰:比如整点秒杀、定时批量导入、月底对账。这类场景的特点是平时很闲,峰值瞬间打满。RabbitMQ 在这里扮演的是"蓄水池"角色,请求先落队列,消费者按自己的能力慢慢取,避免把数据库打穿。第三是跨系统、跨进程的异步通知:比如订单系统要通知仓储系统发货,两个系统可能部署在不同机器、甚至不同团队维护,用 HTTP 直连耦合太紧,一方挂了另一方跟着报错,换成消息投递就松耦合多了。
反过来说,如果你的场景是"我要立刻拿到结果",比如查询用户余额、校验验证码,这种强同步、要求实时返回的,就别硬套消息队列了,徒增复杂度。我踩过一次坑:早期把一个需要同步返回结果的校验逻辑做成了发消息等回调,结果超时处理、回调丢失、幂等重试全得自己兜,代码量翻了三倍,最后老老实实改回同步调用。
1.2 引入 MQ 之后你必须额外承担的代价
很多人只看到解耦的好处,没算清楚账。上了 RabbitMQ,你至少要多操心这几件事:消息可靠性(生产者发出去的消息会不会丢、Broker 宕机消息还在不在、消费者处理到一半挂了怎么办)、幂等性(同一条消息可能被投递多次,你的消费逻辑必须能重复执行而不出错)、消息顺序(有些业务要求严格有序,但多消费者并发消费天然破坏顺序)、监控与告警(队列积压了多少、有没有死信、消费者是不是卡住了)。
这些代价是实打实的。我的经验是:业务量没到一定规模、团队没有运维消息中间件的能力时,优先考虑用数据库表 + 定时任务这种"土办法"过渡,等真正扛不住了再上 RabbitMQ。一旦决定上,就要把上面四件事当成一等公民来设计,而不是等出了问题再补。这篇后面讲的所有模式,都会围绕"怎么把可靠性、幂等性这些代价控制住"来展开。
1.3 一张对照表:什么场景配什么模式
在动手之前,我习惯先画一张场景到模式的映射表,这样团队里谁看代码都知道为什么这么设计。下面这张表是我在多个项目里沉淀下来的,直接拿去用:
| 业务场景 | 推荐模式 | 关键特征 | 典型例子 |
|---|---|---|---|
| 单一后台任务异步化 | 简单队列 | 一个生产者一个消费者 | 发送邮件、生成报表 |
| 耗时任务分摊到多实例 | 工作队列 | 多消费者竞争消费,需限流 | 图片压缩、视频转码 |
| 一个事件通知多方 | 发布订阅(Fanout) | 广播给所有绑定队列 | 配置变更通知、缓存刷新 |
| 按条件精准投递 | 路由模式(Direct) | 按 routingKey 精确匹配 | 按订单类型分发处理 |
| 多维度组合订阅 | 主题模式(Topic) | 通配符匹配 routingKey | 按区域+业务类型订阅 |
这张表的价值在于:它把"业务语言"翻译成了"RabbitMQ 语言"。产品经理说"下单后要通知好几个系统",你脑子里立刻能映射到 Fanout;说"只有 VIP 订单才走特殊处理",那就是 Direct 按 routingKey 分流。模式选对了,Exchange 类型、队列绑定关系这些配置就是水到渠成的事。
2. 环境搭建里那些容易翻车的细节
环境这块看似简单,装个 RabbitMQ、引个 NuGet 包就完事,但我见过太多人卡在连接字符串、端口、权限这些地方。这一节把 .NET 6 项目接入 RabbitMQ 的准备工作讲细,尤其是那些"文档一笔带过、实际一踩一个准"的点。
2.1 Broker 侧:队列、Exchange 的声明策略
RabbitMQ 有个特性:队列和 Exchange 如果不存在,生产者发消息会直接失败(除非你开了特定的容错配置)。所以我的做法是在应用启动时统一声明所有需要的 Exchange、Queue 和绑定关系,而不是等到发消息时才临时创建。这样做的好处是启动即校验,配置错了立刻暴露,而不是等到线上第一条消息发不出去才发现。
声明的时候有几个参数必须想清楚。durable(持久化):Exchange 和 Queue 都建议设为true,这样 Broker 重启后元数据还在。autoDelete:一般设false,避免最后一个消费者断开后队列被自动删除导致消息丢失。消息本身的持久化:发消息时要把BasicProperties的Persistent设为true,注意这是消息级别的,和队列持久化是两码事——队列持久化只保证队列本身不丢,消息要单独标记持久化才能落盘。这个区别我当初搞混过,以为队列 durable 了消息就安全了,结果 Broker 重启丢了一批消息,排查半天才反应过来。
2.2 .NET 6 侧:连接与通道的生命周期管理
这是最容易出问题的地方。RabbitMQ 的IConnection是重量级对象,创建成本高,必须做成单例全局复用;而IModel(通道)是轻量级的,但不是线程安全的,多个线程共用一个通道会出各种诡异问题。所以正确的做法是:连接单例,通道按需创建或者用对象池管理。
在 .NET 6 里,我通常把IConnection注册成单例,通过IHostedService在应用启动时建立连接、应用停止时优雅关闭。下面是一个精简的连接管理骨架:
public class RabbitConnection : IDisposable { private readonly IConnection _connection; public IConnection Connection => _connection; public RabbitConnection(IConfiguration config) { var factory = new ConnectionFactory { HostName = config["Rabbit:Host"], Port = int.Parse(config["Rabbit:Port"]), UserName = config["Rabbit:User"], Password = config["Rabbit:Password"], VirtualHost = config["Rabbit:VHost"] ?? "/", AutomaticRecoveryEnabled = true, // 断线自动重连 NetworkRecoveryInterval = TimeSpan.FromSeconds(10), DispatchConsumersAsync = true // 异步消费者必须开 }; _connection = factory.CreateConnection(); } public void Dispose() => _connection?.Dispose(); }这里有两个参数值得单独说。AutomaticRecoveryEnabled一定要开,网络抖动、Broker 重启时客户端会自动重连并恢复通道和消费者,不开的话你得自己写重连逻辑,非常麻烦。DispatchConsumersAsync在 .NET 6 里做异步消费时必须设为true,否则异步消费者会以同步方式被调用,async/await直接失效,这个坑我踩过一次,消费方法里await之后的代码行为完全不对,查了好久才发现是这个开关没开。
2.3 配置项集中管理,别散落在代码里
连接信息、队列名、Exchange 名这些,我强烈建议全部放进appsettings.json,用强类型配置类绑定,而不是硬编码在代码里。原因很实际:不同环境(开发、测试、生产)的 Broker 地址和虚拟主机不一样,硬编码意味着每次部署都要改代码重新编译。用配置类还有个好处是启动时能做校验,配置缺失直接抛异常,比运行时才报错友好得多。
public class RabbitOptions { public string Host { get; set; } = "localhost"; public int Port { get; set; } = 5672; public string User { get; set; } = "guest"; public string Password { get; set; } = "guest"; public string VHost { get; set; } = "/"; public Dictionary<string, string> Queues { get; set; } = new(); }注册的时候用services.Configure<RabbitOptions>(config.GetSection("Rabbit")),然后在需要的地方注入IOptions<RabbitOptions>。队列名用字典管理,新增队列只改配置不改代码,这个习惯在队列数量多起来之后能省很多事。
3. 简单队列与工作队列:从单打独斗到团队协作
这两种模式是 RabbitMQ 的地基,理解了它们,后面的发布订阅、路由、主题模式都是在这个基础上加 Exchange 做变体。我把它们放在一起讲,是因为它们的差别本质上就是"消费者数量"这一个变量。
3.1 简单队列:一个生产者一个消费者的最小闭环
简单队列就是最朴素的模型:生产者把消息扔进一个队列,一个消费者从队列里取出来处理。它适合什么场景?后台任务异步化。比如用户注册成功后要发一封欢迎邮件,这个动作不需要用户等待,扔进队列让后台慢慢发就行。代码结构上,生产者只负责BasicPublish,消费者用BasicConsume注册回调。
这里有个实操细节:消费端一定要处理异常。我见过消费方法里直接抛异常没捕获的,结果消息被反复重投,日志刷屏,队列直接堵死。正确的做法是在消费回调里 try-catch,处理失败时根据业务决定是BasicNack重新入队、还是投递到死信队列、还是记录日志后BasicAck丢弃。这三种处理方式对应三种业务语义,不能一刀切。
var consumer = new EventingBasicConsumer(channel); consumer.Received += (model, ea) => { try { var body = ea.Body.ToArray(); var message = Encoding.UTF8.GetString(body); ProcessMessage(message); // 业务处理 channel.BasicAck(ea.DeliveryTag, false); } catch (Exception ex) { // 记录日志,根据重试次数决定 nack 还是进死信 channel.BasicNack(ea.DeliveryTag, false, requeue: false); } }; channel.BasicConsume(queue: "email_queue", autoAck: false, consumer: consumer);注意autoAck: false这个参数,它意味着手动确认。自动确认(autoAck: true)是新手最容易踩的坑:消息一投递给消费者就立刻从队列删除,如果消费者处理到一半崩了,这条消息就永久丢了。手动确认虽然多写一行BasicAck,但换来的是"处理成功才删除"的可靠性,这个账怎么算都划算。
3.2 工作队列:多消费者抢活干,但别忘了限流
工作队列(Work Queue)在简单队列基础上加了多个消费者,它们竞争同一个队列里的消息,谁空闲谁取。这个模式解决的是单消费者处理能力不足的问题,比如图片压缩任务量大,一个消费者压不过来,起三个实例一起压。RabbitMQ 默认是轮询分发(round-robin),消息依次分给各个消费者,不管对方忙不忙。
轮询分发有个问题:如果消息处理耗时差异大,会出现"一个消费者累死、一个闲死"的情况。解决办法是开BasicQos 限流,设置prefetchCount,意思是"每个消费者手上最多同时持有几条未确认的消息"。设成 1 就是处理完一条才拿下一 条,实现"能者多劳"。这个参数要根据实际处理耗时来调,设太小吞吐上不去,设太大又失去限流意义,我一般从 10 开始试,压测后调整。
channel.BasicQos(prefetchSize: 0, prefetchCount: 10, global: false);global: false表示这个限流作用于当前通道上的每个消费者,而不是整个通道共享。这个参数的含义容易搞混,简单记:false 是 per-consumer,true 是 per-channel,绝大多数场景用 false 就够了。
3.3 一个真实教训:消费者扩容后消息顺序乱了
工作队列有个隐含假设:消息之间没有顺序依赖。我早期做过一个订单状态流转的队列,起两个消费者想提高吞吐,结果同一个订单的"创建"和"支付"两条消息被分到不同消费者,支付先于创建被处理,状态机直接错乱。这个坑的根因是:多消费者并发消费,天然不保证顺序。
解决办法有两个方向。一是业务上规避:如果确实需要顺序,就别用多消费者,或者按业务主键做哈希路由到固定队列(后面路由模式会讲)。二是消费端做幂等和状态校验:处理"支付"消息时先查订单当前状态,如果还没创建就拒绝或延迟重试。我最后采用的是第二种,因为订单量上来后单消费者扛不住,只能靠消费端自己保证正确性。这个经验说明:模式选择要和业务约束一起考虑,不能只看吞吐。
4. 发布订阅与路由:让一条消息找到它该去的地方
前面两种模式都是"点对点",消息最终只被一个消费者处理。但现实业务里经常有"一个事件、多方关心"的需求,这就轮到 Exchange 登场了。Exchange 的核心作用是根据规则把消息路由到一个或多个队列,发布订阅、路由、主题三种模式的区别,全在"路由规则"上。
4.1 Fanout:广播给所有关心的人
Fanout 类型的 Exchange 最简单粗暴:它忽略 routingKey,把消息复制给所有绑定到它的队列。典型场景是"配置变更通知"——配置中心改了配置,所有服务实例都要刷新本地缓存,这时候用 Fanout 广播最合适,每个实例一个队列绑上去,改一次全员收到。
这里有个设计要点:每个消费者要有自己独立的队列。如果多个消费者绑同一个队列,那就退化成工作队列了(竞争消费),只有一个能收到。所以 Fanout 模式下,队列名通常带实例标识或者消费者类型,比如cache_refresh_serviceA、cache_refresh_serviceB。声明的时候用QueueDeclare加随机后缀或者服务名,绑定到同一个 Fanout Exchange 上。
channel.ExchangeDeclare("config_fanout", ExchangeType.Fanout, durable: true); var queueName = channel.QueueDeclare().QueueName; // 服务端生成唯一队列名 channel.QueueBind(queueName, "config_fanout", routingKey: "");QueueDeclare()不带参数时会让服务端生成一个随机队列名,配合exclusive: true(默认)实现"连接断开队列自动删除",适合这种临时订阅场景。但要注意,如果消费者需要持久化、断线重连后继续收消息,就不能用这种临时队列,得用固定名字的持久化队列。
4.2 Direct:按 routingKey 精准投递
Direct Exchange 根据 routingKey 精确匹配,消息的 routingKey 和队列绑定的 routingKey 完全一致才会投递。这个模式适合按类型分流的场景。比如订单系统,普通订单、VIP 订单、跨境订单要走不同的处理流程,生产者发消息时带上order.normal、order.vip、order.crossborder这样的 routingKey,三个队列分别绑定对应的 key,各处理各的。
Direct 模式的一个实用技巧是一个队列可以绑定多个 routingKey。比如"错误日志处理"队列同时绑定log.error和log.fatal,这样两种级别的日志都进同一个队列,省得为每个级别单独建队列。绑定关系是灵活的组合,设计时先想清楚"哪些消息该归到一起处理",再反推绑定关系。
4.3 Topic:通配符带来的组合订阅能力
Topic Exchange 是 Direct 的增强版,routingKey 支持通配符:*匹配一个单词,#匹配零个或多个单词,单词之间用点分隔。这个模式适合多维度订阅。举个日志收集的例子,routingKey 设计成区域.服务.级别的格式,比如cn.order.error、us.payment.info,那么:
- 队列 A 绑定
*.order.*:订阅所有区域的订单服务日志 - 队列 B 绑定
cn.#:订阅中国区域的所有日志 - 队列 C 绑定
#.error:订阅所有服务的错误日志
这种组合能力是 Direct 做不到的。Topic 的设计难点在于routingKey 的命名规范要提前定好,一旦上线再改,所有绑定关系都得跟着改。我的经验是把 routingKey 当成一种"数据契约"来管理,写进文档,团队统一遵守,新增维度时往后追加而不是插在中间。
4.4 四种模式的选择决策树
讲完这几种模式,可能有人还是纠结怎么选。我总结了一个简单的判断顺序:先问"一条消息要被几个消费者处理"——只有一个,就在简单队列和工作队列里选(看要不要多实例分摊);有多个,就上 Exchange。再问"多个消费者是全都收还是按条件收"——全都收用 Fanout,按条件收用 Direct 或 Topic。最后问"条件匹配是精确还是模糊"——精确用 Direct,需要通配符组合用 Topic。
这个决策顺序的好处是从业务语义出发,而不是从技术名词出发。团队新人按这个顺序走一遍,基本不会选错。选错模式的代价很大,因为模式决定了 Exchange 类型和绑定关系,改起来牵一发动全身,所以在设计阶段多花半小时想清楚,比上线后重构划算得多。
5. 可靠性设计:消息不丢、不重、不乱的那些手段
模式选对了只是第一步,真正让 RabbitMQ 在生产环境稳定运行的,是可靠性设计。这一节讲三个核心问题:消息怎么保证不丢、重复投递怎么处理、消费失败怎么兜底。这三件事做好了,消息队列才算真正可用。
5.1 消息不丢的三道防线
消息从生产者到消费者,中间要经过生产者→Broker→消费者三个环节,每个环节都可能丢消息,所以要三道防线。第一道是生产者确认(Publisher Confirms):开启 confirm 模式后,Broker 收到消息会回一个 ack,生产者收到 ack 才认为发送成功,没收到就重发。这个机制能防"消息发出去了但 Broker 没收到"的情况。
第二道是 Broker 持久化:Exchange、Queue 设 durable,消息设 persistent,三者缺一不可。只设队列持久化不设消息持久化,Broker 重启消息照样丢,这个前面提过。第三道是消费者手动 Ack:处理成功才确认,处理失败 nack 重入队或进死信。三道防线全开,消息基本不会丢,代价是性能下降(持久化和确认都有开销),所以要根据业务重要性权衡——金融类业务三道全开,日志类业务可以适当放宽。
5.2 幂等:重复投递是常态,不是异常
必须接受一个事实:RabbitMQ 保证的是"至少一次"投递,不是"恰好一次"。网络抖动导致 ack 丢失、消费者处理完但确认前崩溃,都会触发重投。所以消费逻辑必须幂等。实现幂等的常见手段有三种:数据库唯一约束(比如用消息 ID 做唯一索引,重复插入直接失败)、状态机校验(处理前查当前状态,已经是目标状态就跳过)、Redis 去重(用消息 ID 做 key,处理前 setnx,已存在就跳过)。
我一般优先用数据库唯一约束,因为它最可靠、不依赖额外组件。具体做法是给业务表加一个message_id字段并建唯一索引,消费时先尝试插入,捕获唯一约束冲突就说明重复了,直接 ack 跳过。这个方案的前提是消息里要带全局唯一的 ID,生产者在发送时生成(比如 GUID),放进消息头或者消息体。
5.3 死信队列:给失败消息一个归宿
消费失败的消息如果一直重入队,会形成"毒消息"——反复消费反复失败,堵住整个队列。解决办法是配死信队列(DLX):给队列设置x-dead-letter-exchange参数,消息被 nack 且不重入队、或者超过 TTL、或者队列满了,就会自动转到死信 Exchange,进而进死信队列。死信队列里的消息可以人工排查、定时重试、或者归档分析。
配置死信的代码大概是这样:
var args = new Dictionary<string, object> { { "x-dead-letter-exchange", "dlx_exchange" }, { "x-dead-letter-routing-key", "dlx.order" } }; channel.QueueDeclare("order_queue", durable: true, exclusive: false, autoDelete: false, arguments: args);死信队列不是配了就完事,还得有配套的监控和告警——死信队列积压说明有消息持续处理失败,得有人去看。我的做法是给死信队列单独配一个消费者,只做记录和告警,不自动重试,避免"失败→重试→再失败"的死循环。
5.4 消费端限流与优雅停机
生产环境还有个容易被忽略的点:消费者不能无限拉取消息。如果消费者一次性拉几千条到内存里,处理速度跟不上,内存直接爆。这就是前面提到的BasicQos的作用,通过prefetchCount控制每个消费者手上的未确认消息数,实现背压(backpressure)。
另外,.NET 6 应用停止时,IHostedService的StopAsync要负责优雅停机:先停止接收新消息(BasicCancel),等手上正在处理的消息处理完并 ack,再关闭通道和连接。不做这一步的话,应用重启时正在处理的消息会因为连接断开而重投,虽然幂等能兜住,但白白浪费处理资源。优雅停机的实现要在StopAsync里加一个等待逻辑,给正在处理的消息留出完成时间,通常设个 10 到 30 秒的超时。
6. 在 .NET 6 里把消费者做成后台服务
前面讲的都是原理和模式,这一节落到 .NET 6 的具体实现上。.NET 6 的BackgroundService是做常驻消费者的理想载体,配合依赖注入,能把连接管理、消费逻辑、优雅停机串起来。这块我踩过的坑主要集中在生命周期和异常处理上。
6.1 用 BackgroundService 承载消费者
BackgroundService的ExecuteAsync方法在应用启动后运行,直到应用停止。把消费者逻辑放进去,天然就是一个常驻的后台消费进程。结构上,构造函数注入RabbitConnection单例和业务处理服务,ExecuteAsync里创建通道、声明队列、注册消费者,然后await Task.Delay(Timeout.Infinite, stoppingToken)挂起等待停止信号。
public class OrderConsumerService : BackgroundService { private readonly RabbitConnection _rabbit; private readonly IServiceScopeFactory _scopeFactory; public OrderConsumerService(RabbitConnection rabbit, IServiceScopeFactory scopeFactory) { _rabbit = rabbit; _scopeFactory = scopeFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var channel = _rabbit.Connection.CreateModel(); channel.BasicQos(0, 10, false); var consumer = new AsyncEventingBasicConsumer(channel); consumer.Received += async (s, ea) => { using var scope = _scopeFactory.CreateScope(); var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>(); try { await handler.HandleAsync(ea.Body.ToArray()); channel.BasicAck(ea.DeliveryTag, false); } catch { channel.BasicNack(ea.DeliveryTag, false, false); } }; channel.BasicConsume("order_queue", false, consumer); await Task.Delay(Timeout.Infinite, stoppingToken); } }这里有个关键点:业务处理服务要用IServiceScopeFactory创建作用域。因为BackgroundService是单例,如果直接注入 Scoped 的 DbContext 之类的服务,会报"不能在单例中注入 Scoped 服务"的错误,或者更隐蔽地导致 DbContext 被长期持有、状态错乱。用 scope 包一层,每次消费创建独立作用域,用完释放,这是 .NET 依赖注入的标准做法。
6.2 异步消费者的异常处理陷阱
AsyncEventingBasicConsumer的Received事件是异步的,但有个坑:如果事件处理器内部抛出的异常没有被捕获,会导致整个通道关闭。我遇到过消费方法里一个空引用异常没被 catch 住,结果整个消费者通道挂掉,后续消息全收不到,日志里只有一条不起眼的通道关闭记录。所以Received里必须把所有异常都兜住,业务异常走 nack,系统异常记录后也要 nack 或进死信,绝不能让异常逃逸出去。
另外,DispatchConsumersAsync这个连接参数前面提过,必须设为true,否则AsyncEventingBasicConsumer的异步行为不生效。这个参数和消费者类型要配套使用,用同步消费者配异步开关、或者反过来,都会出问题。
6.3 多队列消费的组织方式
一个应用往往要消费多个队列,比如订单服务既要处理"订单创建"又要处理"订单取消"。有两种组织方式:一个 BackgroundService 消费一个队列,职责清晰但类会变多;一个 BackgroundService 消费多个队列,用多个通道或者一个通道多个消费者。我倾向于前者,一个消费者一个类,出问题好定位,也方便单独控制每个队列的限流参数。类多了可以用基类抽取公共逻辑,但消费逻辑本身保持独立。
7. 上线前必须过的几道检查
代码写完不等于能上线,RabbitMQ 相关的检查项我整理了一份清单,每次上线前过一遍,能挡掉大部分低级问题。
7.1 连接与通道的泄漏检查
IConnection和IModel都是IDisposable,创建了不释放会泄漏。重点检查两处:生产者每次发消息是不是都新建了通道却没关(应该复用通道或者用完即关)、消费者异常退出时通道有没有释放。我一般用using包住临时通道,长期通道在应用停止时统一释放。连接泄漏的典型表现是运行几天后 Broker 上连接数暴涨,最后拒绝新连接。
7.2 队列积压的排查路径
队列积压是生产环境最常见的告警。排查顺序我总结为:先看消费者还在不在(消费者进程挂了、或者连接断了没重连,消息没人消费自然积压)、再看消费速度跟不跟得上(消费者在跑但处理慢,看是不是下游数据库慢、或者单条处理逻辑太重)、最后看是不是毒消息堵住(某条消息反复失败重入队,把后面的堵住,这时候看死信队列或者未确认消息数)。
对应的监控指标:队列的messages_ready(待消费数)、messages_unacknowledged(已投递未确认数)、消费者的数量。这三个指标配上告警阈值,基本能覆盖大部分积压场景。messages_unacknowledged持续很高但messages_ready不降,说明消费者卡在处理环节;两个都高说明消费能力不足,得扩容。
7.3 上线前的配置核对表
| 检查项 | 正确配置 | 常见错误 |
|---|---|---|
| Exchange durable | true | 设 false,重启后 Exchange 消失 |
| Queue durable | true | 设 false,重启后队列和消息全丢 |
| 消息 persistent | true | 只设队列持久化,消息没标记 |
| 消费 autoAck | false | 设 true,处理失败消息丢失 |
| prefetchCount | 按耗时调,通常 10-50 | 不设,消费者内存爆 |
| 死信队列 | 配置并监控 | 不配,毒消息堵队列 |
| 自动重连 | AutomaticRecoveryEnabled=true | 不开,断网后永久失联 |
这张表里的每一项我都见过真实的反例,尤其是"队列持久化但消息没持久化"和"autoAck 设 true"这两个,出问题时往往已经丢了数据才发现。上线前对着表逐项核对,花不了几分钟,能避免大事故。
8. 一些踩坑之后才明白的经验
最后分享几条纯经验性的东西,都是文档里不会写、但实际项目中反复验证过的。
第一条:消息体尽量小,大文件走对象存储。我见过把整个文件二进制塞进消息体的,结果 Broker 内存暴涨、消息传输超时。正确做法是消息体只放元数据和文件地址,真正的文件存对象存储,消费者拿到地址自己去取。RabbitMQ 适合传"指令"和"事件",不适合当文件传输通道。
第二条:routingKey 和队列名当成契约管理。这些字符串散落在生产者和消费者两端,改一处忘一处就是线上事故。我的做法是集中定义成常量类或者配置项,两端引用同一份定义,改的时候编译器能帮你发现遗漏。
第三条:本地开发用 Docker 起 Broker,别连测试环境。本地连共享的测试环境,多个开发者互相干扰,队列里的消息不知道被谁消费了,排查问题极其痛苦。用 Docker 一条命令起个本地实例,数据隔离,随便折腾。这个习惯养成后,本地调试效率提升非常明显。
第四条:消费逻辑要能单独测试。把业务处理从消费回调里抽出来做成独立的 Handler 类,消费回调只负责收消息、调 Handler、ack/nack。这样 Handler 可以脱离 RabbitMQ 单独做单元测试,覆盖各种边界情况,比连着 Broker 调试高效得多。这个重构我做过好几次,每次都觉得早该这么做。
第五条:别在消费回调里做重活。消费回调应该快速返回,重活(比如大批量数据处理、调用多个外部接口)要么异步化,要么拆成多个消息分步处理。回调里耗时太长会占住通道,影响其他消息的消费,也容易触发心跳超时导致连接断开。我一般要求单个消息的处理时间控制在秒级以内,超过的就得考虑拆分。
这些经验没有一条是"高深技术",全是实际踩出来的。消息队列这东西,原理不难,难的是把可靠性、幂等、监控这些"脏活累活"做扎实。模式选对、三道防线开全、消费逻辑幂等、监控告警到位,基本就能稳定跑了。剩下的就是根据业务量调参数、根据告警查问题,属于日常运维的范畴了。