1. 从一次消息堆积事故说起:为什么订阅分发值得单独拎出来讲
去年帮一个做电商中台的朋友排查线上问题,订单服务在促销期间积压了十几万条消息,运维那边看到队列深度报警,第一反应是消费者挂了。结果查下来消费者进程活得好好的,日志也在刷,就是处理速度上不去。最后定位到根因:他们用的是直连交换机加固定路由键,所有订单相关消息全塞进一个队列,消费者只有一个,再怎么加机器也没用,因为队列是单点消费模型。
这个坑其实很典型。很多人用RabbitMQ,入门的时候学的是Hello World那种一个生产者对一个队列对一个消费者的模型,跑通了就觉得够用了。但真实业务里,消息的类型是多样的,消费方的关注点也是不同的。订单创建、订单支付、订单取消、库存扣减、物流通知,这些消息如果全部走一条通道,消费者就得写一大堆if-else去判断消息类型,然后分发给不同的处理逻辑。这种写法能跑,但扩展性极差,加一个新业务就得改消费者代码,风险高,测试成本也大。
订阅分发模式要解决的就是这个问题。它的核心思路是:生产者只管把消息扔到交换机上,带上一个描述性的路由键,比如order.created、order.paid、inventory.deducted;交换机根据路由键的模式匹配规则,把消息复制分发到多个队列;每个队列背后是独立的消费者组,各管各的业务逻辑。生产者不需要知道有多少个消费者,消费者也不需要关心消息是谁发的,双方通过路由键的约定解耦。
RabbitMQ里实现订阅分发的核心是Topic类型的交换机。它支持通配符匹配,路由键用点号分隔单词,*匹配一个单词,#匹配零个或多个单词。这个设计看起来简单,但实际用起来有很多细节值得推敲。比如路由键怎么设计才合理,通配符用多了会不会导致意外的消息投递,队列绑定关系怎么管理,消息重复投递怎么处理,消费者端怎么保证幂等。这些问题在官方文档里往往一笔带过,但在生产环境里每一个都可能变成事故。
这篇文章适合已经用过RabbitMQ基本功能、想搞清楚Topic订阅分发怎么落地的人。我会从设计思路讲到代码实现,再到踩过的坑和排查技巧,尽量把每个决策背后的原因说清楚。代码用Java写,基于官方amqp-client库,不依赖Spring Boot,这样你能看清楚底层到底发生了什么。如果你用的是Spring AMQP或者Spring Boot的starter,原理是一样的,只是封装层次不同。
2. Topic交换机的设计思路与路由键规划
2.1 为什么选Topic而不是Direct或Fanout
RabbitMQ内置了四种交换机类型:Direct、Fanout、Topic、Headers。选哪种取决于你的消息路由需求。
Direct交换机做的是精确匹配,路由键必须完全一致。比如你绑定了order.created,那只有路由键恰好是order.created的消息才会进来。这种模式适合点对点精确投递,但一旦业务需要按类别订阅,比如“所有订单相关消息”,Direct就无能为力了,你得为每种订单消息单独绑定一个队列,维护成本高。
Fanout交换机做的是广播,忽略路由键,把消息复制到所有绑定的队列。这种模式适合通知类场景,比如配置变更通知所有节点。但它没法做选择性订阅,你没法说“我只想要订单创建的消息,不要订单取消的”。
Topic交换机是两者的折中。它支持通配符匹配,既能做精确投递,也能做类别订阅。路由键用点号分隔,*匹配一个单词,#匹配零个或多个单词。比如绑定order.*能收到order.created和order.paid,但收不到order.item.created;绑定order.#能收到order.created、order.item.created、order.item.detail.created等所有以order.开头的路由键。
Headers交换机用的是消息头属性匹配,而不是路由键。它的表达能力更强,可以匹配多个条件,但性能比Topic差,而且用起来更复杂。除非你有非常特殊的匹配需求,否则Topic基本够用了。
实际项目里,Topic是使用频率最高的交换机类型。它的路由键设计本身就是一种契约,生产者按约定发消息,消费者按约定订阅,双方不需要直接通信。这种解耦带来的灵活性,是消息中间件最核心的价值之一。
2.2 路由键设计的三个原则
路由键设计看起来简单,但设计不好会给后期维护埋雷。我总结了三个原则,都是在实际项目里踩过坑之后总结出来的。
第一个原则是层级要稳定。路由键用点号分隔,每一段代表一个维度的信息。比如业务模块.实体.动作这种三段式结构,order.payment.completed、user.profile.updated、inventory.stock.warning。这种结构的好处是,消费者可以用order.#订阅所有订单相关消息,也可以用order.payment.*订阅所有支付相关消息,粒度灵活。但如果你的路由键层级忽长忽短,比如有时候是order.created,有时候是order.item.created.detail,那通配符匹配就会变得难以预测。
第二个原则是单词要有意义且不重复。路由键的每一段应该是一个独立的语义单元,不要出现order.order.created这种冗余。也不要用缩写,除非是团队内约定俗成的,比如msg代表message、svc代表service。缩写多了之后,新人根本看不懂,排查问题的时候还得翻文档。
第三个原则是避免过度使用#。#匹配零个或多个单词,表达能力最强,但也最容易导致意外的消息投递。比如你绑定了#.created,那所有以.created结尾的路由键都会进来,包括user.created、order.created、inventory.created。如果你只想订阅订单创建消息,应该绑定order.created或者order.*.created,而不是用#开头。#应该用在确实需要跨模块订阅的场景,比如审计日志队列,它需要接收所有模块的操作记录。
2.3 队列绑定关系的管理策略
队列和交换机的绑定关系是Topic模式的核心配置。绑定关系决定了消息会被复制到哪些队列。管理绑定关系有两种方式:代码里声明和运维后台配置。
代码里声明的好处是自动化,服务启动的时候自动创建交换机、队列和绑定关系。缺点是如果多个服务共享同一个交换机,每个服务都声明自己的绑定,容易出现冲突或者重复声明。RabbitMQ对重复声明是幂等的,只要参数一致就不会报错,但如果参数不一致,比如队列的持久化属性不同,就会抛异常。
运维后台配置的好处是集中管理,所有绑定关系一目了然。缺点是容易遗漏,特别是新服务上线的时候,忘了配置绑定关系,消息就丢了。而且后台配置没法版本化,改错了很难回滚。
我的建议是:交换机由基础设施团队统一声明,队列和绑定关系由各个业务服务自己声明。交换机是共享资源,统一声明可以避免参数不一致的问题。队列是业务私有的,每个服务知道自己需要什么消息,自己声明绑定关系更灵活。声明的时候加上durable=true,保证RabbitMQ重启后交换机和队列不丢失。绑定关系不需要持久化,因为它是依附于交换机和队列存在的,只要两者都在,重新绑定就行。
还有一点要注意:队列名要全局唯一。RabbitMQ的队列是全局命名的,不同服务如果用了相同的队列名,会绑定到同一个队列上,导致消息被错误的消费者消费。建议队列名加上服务前缀,比如order-service.order-created-queue、inventory-service.stock-warning-queue。这样即使多个服务连到同一个RabbitMQ实例,也不会冲突。
3. Java客户端实操:从连接工厂到消费者分发
3.1 环境准备与依赖引入
先说一下环境。RabbitMQ服务端我用的是3.12版本,管理界面默认在15672端口。Java端用官方amqp-client库,版本5.20.0。Maven依赖如下:
<dependency> <groupId>com.rabbitmq</groupId> <artifactId>amqp-client</artifactId> <version>5.20.0</version> </dependency>如果你用Gradle,对应的是:
implementation 'com.rabbitmq:amqp-client:5.20.0'日志框架建议加上slf4j和logback,amqp-client内部用slf4j输出日志,排查问题的时候很有用。不加的话,连接异常、信道关闭这些信息看不到,出了问题只能干瞪眼。
<dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.9</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.14</version> </dependency>连接工厂的配置有几个关键参数需要说明。host和port不用多说,virtualHost默认是/,如果你在RabbitMQ里创建了独立的vhost,这里要改成对应的名称。username和password用默认的guest/guest就行,但生产环境一定要改,而且不要用guest,guest默认只能从localhost连接。
ConnectionFactory factory = new ConnectionFactory(); factory.setHost("192.168.1.100"); factory.setPort(5672); factory.setVirtualHost("/"); factory.setUsername("admin"); factory.setPassword("your_password"); factory.setConnectionTimeout(10000); factory.setHandshakeTimeout(10000); factory.setRequestedHeartbeat(60); factory.setAutomaticRecoveryEnabled(true); factory.setNetworkRecoveryInterval(5000);connectionTimeout和handshakeTimeout建议设成10秒,默认是60秒,太长了,服务启动的时候如果连不上RabbitMQ,会卡很久。requestedHeartbeat设成60秒,客户端和服务端会在这个间隔内互发心跳包,检测连接是否存活。automaticRecoveryEnabled设成true,网络闪断后客户端会自动重连,不需要手动处理。networkRecoveryInterval是重连间隔,默认5秒,可以保持默认。
注意:自动重连只恢复连接和信道,不恢复消费者的投递状态。如果消费者在处理消息的过程中连接断了,那条消息会被重新投递,所以消费者端必须做幂等处理。
3.2 声明Topic交换机与队列绑定
连接建立之后,下一步是声明交换机和队列。声明操作是幂等的,如果资源已经存在且参数一致,RabbitMQ不会报错。但如果参数不一致,比如交换机类型从topic改成direct,就会抛PRECONDITION_FAILED异常,信道也会被关闭。
try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { // 声明Topic交换机,durable=true表示持久化 channel.exchangeDeclare("order.topic.exchange", BuiltinExchangeType.TOPIC, true); // 声明订单创建队列 channel.queueDeclare("order-service.order-created-queue", true, false, false, null); channel.queueBind("order-service.order-created-queue", "order.topic.exchange", "order.created"); // 声明订单支付队列,订阅所有支付相关消息 channel.queueDeclare("payment-service.payment-queue", true, false, false, null); channel.queueBind("payment-service.payment-queue", "order.topic.exchange", "order.payment.*"); // 声明审计日志队列,订阅所有订单消息 channel.queueDeclare("audit-service.order-audit-queue", true, false, false, null); channel.queueBind("audit-service.order-audit-queue", "order.topic.exchange", "order.#"); }exchangeDeclare的第二个参数是交换机类型,用BuiltinExchangeType.TOPIC枚举比字符串"topic"更安全,编译期就能发现拼写错误。第三个参数durable设成true,RabbitMQ重启后交换机不丢失。注意,交换机的持久化只保证交换机本身不丢,不保证消息不丢,消息的持久化还要看队列和消息本身的属性。
queueDeclare的第二个参数是durable,设成true表示队列持久化。第三个参数exclusive设成false,表示队列可以被多个连接访问。第四个参数autoDelete设成false,表示队列在没有消费者的时候不会自动删除。第五个参数是队列参数,可以用来设置TTL、死信交换机等,这里先不展开。
queueBind把队列绑定到交换机上,第三个参数是绑定键。绑定键支持通配符,order.created是精确匹配,order.payment.*匹配order.payment.completed和order.payment.failed,order.#匹配所有以order.开头的路由键。
提示:绑定键的通配符匹配是单向的,只影响交换机到队列的投递,不影响生产者发送消息时的路由键。生产者发消息的时候,路由键必须是一个具体的字符串,不能带通配符。
3.3 生产者发送消息与路由键构造
生产者发送消息的时候,需要指定交换机名称和路由键。路由键的构造要遵循前面说的设计原则,保持层级稳定、语义清晰。
public class OrderEventProducer { private final ConnectionFactory factory; private final String exchangeName = "order.topic.exchange"; public OrderEventProducer(ConnectionFactory factory) { this.factory = factory; } public void sendOrderCreated(String orderId, String userId, BigDecimal amount) throws Exception { String routingKey = "order.created"; String message = String.format( "{\"orderId\":\"%s\",\"userId\":\"%s\",\"amount\":%s,\"eventTime\":%d}", orderId, userId, amount, System.currentTimeMillis() ); try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .contentType("application/json") .deliveryMode(2) // 2表示持久化 .messageId(UUID.randomUUID().toString()) .timestamp(new Date()) .build(); channel.basicPublish(exchangeName, routingKey, props, message.getBytes(StandardCharsets.UTF_8)); System.out.println("发送消息: routingKey=" + routingKey + ", body=" + message); } } public void sendOrderPaid(String orderId, String paymentId, BigDecimal amount) throws Exception { String routingKey = "order.payment.completed"; String message = String.format( "{\"orderId\":\"%s\",\"paymentId\":\"%s\",\"amount\":%s,\"eventTime\":%d}", orderId, paymentId, amount, System.currentTimeMillis() ); try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .contentType("application/json") .deliveryMode(2) .messageId(UUID.randomUUID().toString()) .timestamp(new Date()) .build(); channel.basicPublish(exchangeName, routingKey, props, message.getBytes(StandardCharsets.UTF_8)); System.out.println("发送消息: routingKey=" + routingKey + ", body=" + message); } } }deliveryMode设成2表示消息持久化,RabbitMQ会把消息写到磁盘。注意,持久化消息的性能比非持久化消息差很多,因为每次都要刷盘。如果业务允许消息丢失,比如日志采集这种场景,可以设成1。但订单、支付这类核心业务,必须设成2。
messageId设成UUID,方便在日志里追踪消息。消费者端可以用这个ID做幂等判断,比如把处理过的messageId存到Redis,重复消息直接丢弃。
每次发送消息都创建新的Connection和Channel,这种写法在低频场景下没问题,但高频场景下性能很差。Connection的创建涉及TCP握手和AMQP协议握手,开销很大。生产环境应该复用Connection,Channel可以每次创建,因为Channel是轻量级的,创建和销毁的成本很低。
// 生产环境推荐:复用Connection,每次发送创建Channel public class OrderEventProducer implements AutoCloseable { private final Connection connection; private final String exchangeName = "order.topic.exchange"; public OrderEventProducer(ConnectionFactory factory) throws Exception { this.connection = factory.newConnection(); } public void send(String routingKey, String message) throws Exception { try (Channel channel = connection.createChannel()) { AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .contentType("application/json") .deliveryMode(2) .messageId(UUID.randomUUID().toString()) .timestamp(new Date()) .build(); channel.basicPublish(exchangeName, routingKey, props, message.getBytes(StandardCharsets.UTF_8)); } } @Override public void close() throws Exception { if (connection != null && connection.isOpen()) { connection.close(); } } }注意:Channel不是线程安全的,多个线程共用一个Channel会导致消息交错、确认混乱。每个线程应该有自己的Channel,或者用ThreadLocal管理。Connection是线程安全的,可以共享。
3.4 消费者端的分发与手动确认
消费者端的核心是basicConsume方法,它注册一个回调,当消息到达时由RabbitMQ推送给消费者。回调里处理业务逻辑,处理完成后手动发送ack确认。
public class OrderCreatedConsumer { private final ConnectionFactory factory; private final String queueName = "order-service.order-created-queue"; public OrderCreatedConsumer(ConnectionFactory factory) { this.factory = factory; } public void start() throws Exception { Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); // 每次只预取1条消息,处理完再取下一條 channel.basicQos(1); DeliverCallback deliverCallback = (consumerTag, delivery) -> { String message = new String(delivery.getBody(), StandardCharsets.UTF_8); String routingKey = delivery.getEnvelope().getRoutingKey(); String messageId = delivery.getProperties().getMessageId(); System.out.println("收到消息: routingKey=" + routingKey + ", messageId=" + messageId); System.out.println("消息内容: " + message); try { // 模拟业务处理 processOrderCreated(message); // 手动确认,multiple=false表示只确认当前这条 channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); System.out.println("消息处理成功,已确认: " + messageId); } catch (Exception e) { System.err.println("消息处理失败: " + e.getMessage()); // 拒绝消息,requeue=true表示重新入队 channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, true); } }; CancelCallback cancelCallback = consumerTag -> { System.out.println("消费者被取消: " + consumerTag); }; channel.basicConsume(queueName, false, deliverCallback, cancelCallback); System.out.println("消费者已启动,等待消息..."); } private void processOrderCreated(String message) throws Exception { // 解析JSON,执行业务逻辑 Thread.sleep(100); // 模拟耗时操作 } }basicQos(1)设置预取数量为1,意思是消费者最多同时持有1条未确认的消息。RabbitMQ会等消费者确认了当前消息,再推送下一条。这个设置对消费速度影响很大。设成1的时候,消费速度最慢,但消息分布最均匀,适合处理耗时的业务。设成10或者20的时候,吞吐量更高,但可能导致消息在消费者之间分布不均,某个消费者积压了很多消息,其他消费者空闲。
basicConsume的第二个参数autoAck设成false,表示手动确认。如果设成true,RabbitMQ推送消息后立即认为消息已消费,不管消费者有没有处理成功。消费者如果处理失败或者进程崩溃,消息就丢了。所以生产环境必须用手动确认。
basicAck的第二个参数multiple设成false,表示只确认当前这条消息。如果设成true,会确认所有小于等于当前deliveryTag的消息。批量确认可以提高吞吐量,但一旦消费者崩溃,未确认的消息会全部重新投递,可能导致大量重复消费。
basicNack的第三个参数requeue设成true,表示消息重新入队。如果设成false,消息会被丢弃或者进入死信队列。重新入队要小心,如果消息本身有问题,比如格式错误导致处理逻辑一直抛异常,重新入队会导致无限循环。建议结合重试次数判断,超过阈值就拒绝并不重新入队,让消息进入死信队列。
提示:消费者处理消息的时候,如果业务逻辑涉及数据库操作,建议先写业务数据,再发ack。如果先ack再写数据,消费者在写数据之前崩溃,消息就丢了。如果先写数据再ack,消费者在ack之前崩溃,消息会重新投递,业务数据可能重复,所以业务逻辑必须幂等。
4. 消息分发机制深度解析与常见问题排查
4.1 通配符匹配的边界情况
Topic交换机的通配符匹配有一些边界情况,不搞清楚的话很容易踩坑。
*匹配一个单词,#匹配零个或多个单词。这里的“单词”是指路由键中用点号分隔的每一段。比如路由键order.payment.completed有三段:order、payment、completed。
绑定键order.*能匹配order.created和order.paid,但不能匹配order.payment.completed,因为*只匹配一段,而order.payment.completed有三段。绑定键order.#能匹配order.created、order.payment.completed、order.payment.detail.completed等所有以order.开头的路由键。
#可以匹配零个单词,这意味着绑定键order.#也能匹配路由键order本身。这个行为有时候会让人意外。比如你绑定了order.#,然后发了一条路由键为order的消息,这条消息也会被投递到队列。如果你不希望这样,绑定键应该写成order.*或者order.#.something。
#出现在绑定键中间的时候,匹配行为更复杂。比如绑定键order.#.completed,它能匹配order.completed(#匹配零个单词)、order.payment.completed(#匹配payment)、order.payment.detail.completed(#匹配payment.detail)。这种写法适合订阅“所有以completed结尾的订单相关消息”,但用多了会让路由关系变得难以理解。
注意:RabbitMQ的通配符匹配是基于单词的,不是基于字符的。
order*这种写法是无效的,*必须作为独立的单词出现,即order.*。同样,ord*也不会匹配order,因为*不参与字符级别的匹配。
4.2 消息重复投递的根因与幂等方案
消息重复投递在RabbitMQ里是常态,不是异常。以下几种情况都会导致消息重复:
第一种是消费者处理成功但ack丢失。消费者处理完消息,发送ack的时候网络断了,RabbitMQ没收到ack,以为消息没被消费,连接恢复后重新投递。这种情况下,消息被处理了两次。
第二种是消费者处理超时。RabbitMQ有一个consumer_timeout参数,默认30分钟。如果消费者30分钟没有发送ack,RabbitMQ会认为消费者挂了,把消息重新投递给其他消费者。如果业务处理确实需要超过30分钟,要么调大这个参数,要么把长任务拆成多个短任务。
第三种是网络分区恢复后。RabbitMQ集群发生网络分区,分区恢复后,部分消息可能会被重新投递。
第四种是消费者主动nack并requeue。消费者处理失败,调用basicNack并设requeue=true,消息重新入队,可能被同一个消费者再次消费。
既然重复投递不可避免,消费者端就必须做幂等。幂等的核心思路是:给每条消息一个唯一标识,处理之前先检查这个标识是否已经处理过。如果处理过,直接ack,不再执行业务逻辑。
唯一标识可以用消息的messageId属性,也可以用业务字段的组合,比如订单ID加事件类型。messageId需要生产者在发送时设置,如果生产者没设置,RabbitMQ不会自动生成,getMessageId()返回null。
幂等存储可以用Redis或者数据库。Redis的性能更好,适合高并发场景。数据库的可靠性更高,适合对一致性要求极高的场景。
public class IdempotentConsumer { private final Jedis jedis; private final String idempotentPrefix = "mq:idempotent:"; public IdempotentConsumer(Jedis jedis) { this.jedis = jedis; } public boolean isProcessed(String messageId) { return jedis.exists(idempotentPrefix + messageId); } public void markProcessed(String messageId, int expireSeconds) { jedis.setex(idempotentPrefix + messageId, expireSeconds, "1"); } public void handleMessage(String messageId, String message) { if (isProcessed(messageId)) { System.out.println("消息已处理过,跳过: " + messageId); return; } try { // 执行业务逻辑 processBusiness(message); // 标记已处理,设置过期时间,避免Redis无限增长 markProcessed(messageId, 86400 * 7); // 保留7天 } catch (Exception e) { // 业务处理失败,不标记已处理,让消息重新投递 throw new RuntimeException("业务处理失败", e); } } private void processBusiness(String message) { // 实际业务逻辑 } }提示:幂等标记的过期时间要大于消息可能重复投递的最大时间窗口。如果消息可能在7天后重复投递,过期时间就要设成大于7天。但也不能太长,否则Redis会积累大量无用key。一般设成3到7天比较合适。
4.3 消费者预取数量与吞吐量的平衡
basicQos的预取数量对消费吞吐量影响很大。预取数量是消费者同时持有的未确认消息的最大数量。RabbitMQ会尽量让每个消费者的未确认消息数量不超过这个值。
预取数量设成1的时候,消费者处理完一条消息并ack之后,RabbitMQ才会推送下一条。这种模式的消息分布最均匀,每个消费者处理的消息数量大致相同。但吞吐量最低,因为消费者在ack和接收下一条消息之间有一段空闲时间。
预取数量设成10的时候,消费者可以同时持有10条未确认消息。RabbitMQ会一次性推送10条,消费者可以连续处理,减少了等待时间。吞吐量比设成1高很多。但如果消费者处理速度慢,这10条消息会一直占着,其他消费者可能空闲。
预取数量设成0的时候,表示不限制,RabbitMQ会把队列里的消息尽可能多地推送给消费者。这种模式吞吐量最高,但消息分布最不均匀,可能导致某个消费者积压大量消息,而其他消费者空闲。
实际项目中,预取数量的选择取决于业务特点。如果消息处理耗时短且均匀,比如简单的数据转换,预取数量可以设大一些,比如20到50。如果消息处理耗时长且不均匀,比如调用外部接口,预取数量设小一些,比如1到5。
我一般会先设成10,然后根据监控数据调整。如果发现消费者CPU利用率不高,但消息积压,说明预取数量太小,可以调大。如果发现某个消费者积压严重,其他消费者空闲,说明预取数量太大,可以调小。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 消息发送后队列里没有 | 路由键不匹配任何绑定 | 在管理界面查看交换机的绑定关系,确认路由键是否匹配 | 检查绑定键的通配符是否正确,生产者路由键是否符合预期 |
| 消费者收不到消息 | 队列没有绑定到交换机 | 查看队列的绑定关系 | 确认队列已绑定到正确的交换机,绑定键匹配生产者的路由键 |
| 消息重复消费 | 消费者ack丢失或超时 | 查看消费者日志,确认是否有重复的messageId | 消费者端实现幂等,用messageId或业务ID去重 |
| 消息积压严重 | 消费者处理速度慢或预取数量太小 | 查看队列深度和消费者数量 | 增加消费者实例,调大预取数量,优化业务处理逻辑 |
| 消费者频繁重连 | 网络不稳定或心跳超时 | 查看客户端日志,确认是否有连接异常 | 调大心跳间隔,启用自动重连,检查网络质量 |
| 消息丢失 | 队列或消息未持久化 | 查看队列和消息的持久化属性 | 队列设durable=true,消息设deliveryMode=2 |
| 交换机声明报错 | 已存在的交换机类型不一致 | 查看异常信息,确认交换机类型 | 删除已存在的交换机重新声明,或改用正确的类型 |
| 消费者处理超时 | 业务处理时间超过consumer_timeout | 查看RabbitMQ日志,确认是否有consumer_timeout告警 | 调大consumer_timeout,或拆分长任务 |
注意:排查消息路由问题的时候,管理界面的“交换机”页面有一个“绑定”区域,可以看到所有绑定到该交换机的队列和绑定键。还可以用“发布消息”功能手动发一条测试消息,指定路由键,看消息是否投递到预期的队列。这个功能在调试路由键的时候非常有用。
5. 生产环境落地经验与扩展思路
5.1 连接和信道的生命周期管理
生产环境里,Connection和Channel的管理直接影响系统的稳定性和性能。我见过不少项目,每次发消息都创建Connection,发完就关,QPS一上来就各种连接超时。也见过项目全局共用一个Channel,多线程并发发送的时候消息交错,ack混乱。
正确的做法是:Connection全局复用,Channel按需创建。Connection是重量级资源,创建涉及TCP握手和AMQP协议协商,开销大,应该在整个应用生命周期内复用。Channel是轻量级资源,创建和销毁的成本很低,每次操作创建一个Channel,用完就关,可以避免线程安全问题。
如果并发量很高,每次创建Channel也有开销,可以用Channel池。Apache Commons Pool可以管理Channel对象,但要注意Channel不是线程安全的,池化的时候要保证每个Channel同时只被一个线程使用。
public class ChannelPool { private final GenericObjectPool<Channel> pool; public ChannelPool(Connection connection) { PooledObjectFactory<Channel> factory = new BasePooledObjectFactory<Channel>() { @Override public Channel create() throws Exception { return connection.createChannel(); } @Override public PooledObject<Channel> wrap(Channel channel) { return new DefaultPooledObject<>(channel); } @Override public void destroyObject(PooledObject<Channel> p) throws Exception { p.getObject().close(); } @Override public boolean validateObject(PooledObject<Channel> p) { return p.getObject().isOpen(); } }; this.pool = new GenericObjectPool<>(factory); this.pool.setMaxTotal(20); this.pool.setMaxIdle(10); this.pool.setMinIdle(2); this.pool.setTestOnBorrow(true); } public Channel borrowChannel() throws Exception { return pool.borrowObject(); } public void returnChannel(Channel channel) { if (channel != null && channel.isOpen()) { pool.returnObject(channel); } } }提示:Channel池的大小要根据实际并发量调整。设得太小,高并发的时候线程会阻塞等待Channel。设得太大,RabbitMQ服务端会消耗更多资源。一般设成最大并发线程数的1.5倍左右比较合适。
5.2 消息确认与死信队列的配合
手动确认模式下,消费者处理失败的消息如果一直requeue,会导致无限循环。正确的做法是:限制重试次数,超过阈值后把消息投递到死信队列。
RabbitMQ的死信队列(Dead Letter Queue,DLQ)是一个普通的队列,通过队列参数x-dead-letter-exchange和x-dead-letter-routing-key指定。当消息被拒绝且requeue=false,或者消息过期,或者队列达到最大长度时,消息会被投递到死信交换机,进而进入死信队列。
// 声明死信交换机 channel.exchangeDeclare("order.dlx.exchange", BuiltinExchangeType.TOPIC, true); // 声明死信队列 channel.queueDeclare("order-service.order-created-dlq", true, false, false, null); channel.queueBind("order-service.order-created-dlq", "order.dlx.exchange", "order.created.dlq"); // 声明业务队列,指定死信交换机 Map<String, Object> args = new HashMap<>(); args.put("x-dead-letter-exchange", "order.dlx.exchange"); args.put("x-dead-letter-routing-key", "order.created.dlq"); args.put("x-message-ttl", 86400000); // 消息24小时过期 args.put("x-max-length", 100000); // 队列最大长度10万 channel.queueDeclare("order-service.order-created-queue", true, false, false, args); channel.queueBind("order-service.order-created-queue", "order.topic.exchange", "order.created");消费者处理失败的时候,根据重试次数决定是requeue还是拒绝。重试次数可以存在消息头里,每次重试加1。
DeliverCallback deliverCallback = (consumerTag, delivery) -> { Map<String, Object> headers = delivery.getProperties().getHeaders(); int retryCount = headers != null && headers.containsKey("x-retry-count") ? (int) headers.get("x-retry-count") : 0; try { processMessage(delivery); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); } catch (Exception e) { if (retryCount < 3) { // 重试次数未超限,重新入队 AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .headers(Map.of("x-retry-count", retryCount + 1)) .build(); channel.basicPublish(delivery.getEnvelope().getExchange(), delivery.getEnvelope().getRoutingKey(), props, delivery.getBody()); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); } else { // 重试次数超限,拒绝并不重新入队,进入死信队列 channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, false); } } };注意:重新入队的消息会排到队列尾部,不会立即被再次消费。如果队列里消息很多,重试的延迟会很大。如果需要精确控制重试延迟,可以用延迟队列插件,或者把重试消息发到专门的延迟队列,设置TTL后再转发回业务队列。
5.3 监控指标与告警配置
生产环境必须监控RabbitMQ的关键指标,否则出了问题只能等用户投诉。管理界面提供了基本的监控数据,但要做告警还得靠Prometheus加Grafana,或者用RabbitMQ的HTTP API自己写脚本。
关键监控指标包括:
- 队列深度:队列里待消费的消息数量。持续增长说明消费者处理速度跟不上生产速度,需要扩容或者优化。
- 消费者数量:每个队列的消费者实例数。如果消费者数量为0,说明消费者挂了,消息会一直积压。
- 消息入队速率和出队速率:入队速率大于出队速率,队列深度会增长。两者应该大致平衡。
- 未确认消息数:消费者已接收但未ack的消息数量。如果这个数字持续等于预取数量,说明消费者处理慢或者卡住了。
- 连接数和信道数:连接数突增可能是客户端异常重连,信道数泄漏可能是Channel没有正确关闭。
- 磁盘使用率:RabbitMQ持久化消息会写磁盘,磁盘满了会导致所有生产者阻塞。
告警阈值根据业务特点设置。队列深度超过1万告警,消费者数量为0告警,未确认消息数持续5分钟等于预取数量告警。磁盘使用率超过80%告警。
# 用RabbitMQ HTTP API获取队列深度 curl -u admin:password http://192.168.1.100:15672/api/queues/%2F/order-service.order-created-queue | jq '.messages' # 获取消费者数量 curl -u admin:password http://192.168.1.100:15672/api/queues/%2F/order-service.order-created-queue | jq '.consumers'提示:RabbitMQ的HTTP API默认端口是15672,和管