ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RabbitMQ入门实战:从消息队列原理到Spring Boot收发消息与排查

RabbitMQ入门实战:从消息队列原理到Spring Boot收发消息与排查

先说我写这篇东西的背景。之前帮一个正在学后端的同学搭消息队列环境,他在RabbitMQ上卡了两天,问题倒不难,但是网上资料要么讲得太深根本看不下去,要么直接从Spring Cloud那一套开始,跳过了最关键的“消息到底是怎么从生产者走到消费者的”这条主线。我后来把整个链路按他的理解方式重新讲了一遍,效果很好。这篇就是按那个思路整理出来的,目标是让一个完全没接触过消息队列的人,看完能自己把RabbitMQ装起来、写代码收发消息、出了问题知道去哪排查。

1. 先搞明白RabbitMQ到底在解决什么问题

1.1 没有消息队列时,系统是怎么被拖垮的

很多刚开始接触后端的人会觉得,消息队列这东西是“大厂才用得上的”。如果你只是写个几百人用的管理系统,确实用不上。但如果你负责的业务是那种典型的电商订单流程,我举个例子你就懂了。

假设用户在浏览器里点了“提交订单”,后端要干这么几件事:写入订单表、扣减库存、通知仓储系统、给用户发短信、调用积分系统加积分,有时候还要推送给风控系统做校验。如果全部用同步调用串在一起,用户点一次按钮,要等这些接口全部执行完才能看到“下单成功”的提示。哪怕每个接口只要200毫秒,五个接口串下来就是一秒多,用户感知已经很明显了。更危险的是,某一个下游系统突然变慢,比如短信服务超时了五秒,那整条链路都会被卡住,订单接口跟着超时,大量请求堆积在Tomcat线程池里,最后整个服务雪崩。

还有另一种场景:平时每秒只有几百个请求,系统很轻松。但某天突然做秒杀活动,瞬时流量冲到每秒上万,数据库连接池直接被打满,SQL查询排队,整个系统卡死。这不是某段代码写得不好,而是架构上缺少一层缓冲。

1.2 消息队列提供的三种核心能力

RabbitMQ就是一个消息代理,它干的事情,本质上是在两个系统之间或者同一个系统的两个模块之间,充当一个可靠的“中转站”。它带来的核心价值就三个:

  • 异步解耦。订单系统把“订单创建成功”这个事件写进RabbitMQ,然后就立刻返回“下单成功”给用户。库存系统、短信服务、积分系统各自订阅这个事件,自己去处理,谁慢谁快互不影响。下游系统挂了也不会拖死上游。
  • 削峰填谷。高峰期的请求先全部怼进队列,后端服务按照自己最大能承受的速率慢慢消费。消息队列就像一个大坝,把洪峰挡住,让水慢慢流。
  • 数据可靠性。消息可以持久化到磁盘,即使Broker重启了,消息也不会丢;消费端处理完消息才回执,没处理完就是一个“未确认”状态,重启后还能重新投递。

1.3 小白最容易搞混的三个角色

RabbitMQ里有三个最基本的角色:生产者、消费者、Broker。很多教程上来就讲这个词,不讲清楚就让人看代码,能不晕吗。

生产者就是发送消息的那一方,它不关心消息最后被谁处理。消费者是接收消息并处理的那一方,它不关心消息是谁发的。Broker就是RabbitMQ服务器本身,你把消息交给它,它负责路由存储、最终投递给你指定的队列。

我平时跟别人打比方是这么打的:生产者是寄快递的人,消费者是收快递的人,RabbitMQ是快递柜。寄件人把包裹放进快递柜,就不用等着收件人当面签收;收件人什么时候方便什么时候去取。中间包裹放在快递柜里,由快递柜系统统一管理。当然现实里一个人既可以寄件也可以收件,系统里一个服务也就可能既是生产者又是消费者。

这个类比能帮你理解异步,但还要记住一个区别:快递柜是“包裹在某个格子里等收件人按地址来取”,RabbitMQ里消息不是直接进队列的,它必须先经过交换机,由交换机按照规则路由到对应的队列里。这一步是很多人后面看代码犯迷糊的第一道坎。

2. 核心概念拆解:交换机、绑定、队列、路由键

2.1 消息从生产到消费的完整路径

RabbitMQ最核心的消息流转路径是:生产者先把消息发给Exchange(交换机),交换机根据消息携带的Routing Key(路由键),以及自己的类型规则,找到所有匹配的Binding(绑定),把消息放入一个或多个Queue(队列),消费者再从队列里取走消息。

注意,生产者从来不直接往队列里塞消息。哪怕你只想把消息发到一个固定的队列,也必须走“交换机 -> 绑定 -> 队列”这条链路。这是RabbitMQ和Redis做简单列表消费最大的不同,也是它的灵活之处。

我见过有人一开始RocketMQ或者Kafka,再到RabbitMQ,会不习惯这个“交换机”概念。因为Kafka是直接指定topic写入的。RabbitMQ多这一层交换机的抽象,好处是你可以在生产者和队列中间做很复杂的路由策略,而不需要改任何生产者的代码。比如同一个订单消息,既进订单队列又进统计队列,只是换一下绑定关系而已。

2.2 四种交换机类型怎么选

RabbitMQ的交换机有四种类型:direct、fanout、topic、headers。前三种在生产中占绝大多数,headers很少用,我直接说前三种就够了。

  • direct:完全匹配。消息的Routing Key和队列绑定的Routing Key一模一样,才会路由进去。适合精确的点对点场景,比如订单创建的消息专门发往订单队列。
  • fanout:广播。不管你Routing Key写什么,消息会被复制一份,发送到所有绑定在这个交换机上的队列。适合广播通知场景,比如用户上下线事件,多个系统都要知道。
  • topic:按通配符匹配。Routing Key支持通配符,代表一个单词,#代表零个或多个单词。比如绑定键写成order.,那order.create、order.delete都能进来;写成order.#,order.create.success也能进来。实际业务中这是最灵活的。
  • headers:不看Routing Key,看消息头的键值对匹配。复杂且性能一般,我基本没用过,知道有这个东西就够了。

给一个简单的选型建议:确认点对点的就用direct;要让所有消费者都能收到就用fanout;要做模糊匹配或按层级分类就用topic。不要一上来就问“哪个最好”,没有最好,只有最合适。

2.3 绑定和路由键的关系

绑定是一个声明,它告诉交换机:“凡是符合某个规则的Routing Key,请把消息塞到我的队列里来。”绑定键就是那个规则本身。

举个例子。你现在有一个交换机叫order_exchange,一个队列叫order_queue,你给这个队列设置的绑定键是order.create。那么当生产者发送一条Routing Key为order.create的消息时,交换机一看匹配,就把消息放入order_queue。如果发送的是order.update,那么这条消息就不会进入order_queue,如果没有任何队列匹配这个路由键,消息会被直接丢弃(不设置备份交换机的情况下)。

这里有一个很重要的点:RabbitMQ允许一个队列绑定多个键,也允许一个交换机绑定多个队列。一条消息可以同时进入多个队列,也可以一个都进不去。这个特性在后面设计复杂业务路由时非常有用。

3. 从零环境搭建:用Docker一分钟拉起RabbitMQ

3.1 为什么推荐用Docker部署

安装RabbitMQ最劝退新手的点是:它依赖Erlang虚拟机,而且RabbitMQ和Erlang之间存在版本匹配关系。你直接去官网找安装包,还要手动配置ERLANG_HOME环境变量,官网的安装文档也不够直观。一旦你操作系统的版本和官方文档里测过的不一致,各种编译错误、依赖缺失问题会直接把你劝退。

用Docker是直接绕开这些问题的做法。一条命令,镜像拉下来,端口映射出来,管理后台就有了。反正你本地开发环境本来就要装Docker,用它跑中间件是最省心的。如果你所在的环境确实不能用Docker,那再去官网或者用包管理器装,但思路也是一样的,当成一个独立服务跑起来就行。

3.2 完整的启动命令与参数解释

最简单的启动命令是这样:

docker run -d \ --name rabbitmq \ -p 5672:5672 \ -p 15672:15672 \ -e RABBITMQ_DEFAULT_USER=guest \ -e RABBITMQ_DEFAULT_PASS=guest \ -e RABBITMQ_DEFAULT_VHOST=/ \ rabbitmq:3-management

解释一下参数为什么要这么写。5672是AMQP协议的端口,实际收发消息走的都是它。15672是HTTP管理界面的端口,你在浏览器里可以打开它看队列、交换机、连接等状态。有些镜像不带management后缀,只有纯服务没有管理面板,新手排查问题会非常痛苦,所以请认准带management的标签。

RABBITMQ_DEFAULT_USER和RABBITMQ_DEFAULT_PASS是初始化管理员账号,注意RabbitMQ的guest用户在非本机访问时默认是被禁用的,所以如果你用Docker跑,别指望用guest从外部连,最好一开始就创建你自己的账号。RABBITMQ_DEFAULT_VHOST默认就是斜杠,如果不需要多个虚拟主机,保持默认就好。

启动之后先用docker ps确认容器状态是Up,再访问 http://localhost:15672,能弹出来登录页就说明环境OK了。

3.3 管理后台到底能干什么

管理后台不是给你好看的,它是排查问题的主战场。里面重点看几个页面:

  • Queues页面:看每个队列的消息数量、消费者数量、消息速率、堆积情况。消息有没有积压、消费者是不是挂了、消费速率是多少,这里一眼就能看出来。
  • Exchanges页面:看现有交换机列表,还可以直接往任意交换机上发测试消息,这个功能调试路由特别方便。
  • Connections页面:看当前有哪些客户端连着RabbitMQ,端口对不对、连接是否正常,这里都有状态。
  • Admin页面:管理用户、虚拟主机,给账号授权。

我排查消息相关的问题,90%的时间是泡在Queues页面里的。一个队列有没有消费者、消息数量是不是一直在涨、有没有unacked消息,这三个指标基本就能锁定问题方向。

4. 工程实操:写一个能跑起来的收发消息Demo

4.1 创建工程与依赖

这里用Java和Spring Boot来演示,因为这是目前最常见的技术组合,而且Spring Boot对RabbitMQ的封装非常成熟,小白上手成本低。如果你用别的语言,概念同样适用,只是API差异。

新建一个Spring Boot工程,只需要引入一个依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>

这个starter会把Spring AMQP相关的东西都拉进来,包括RabbitTemplate、@RabbitListener这些后面要用的核心组件。如果你还要自定义连接池参数,再加依赖,但起步这一个就够了。

4.2 配置文件详解

application.yml(或application.properties)里这样配:

spring: rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest virtual-host: / publisher-confirm-type: correlated publisher-returns: true template: mandatory: true listener: simple: acknowledge-mode: manual prefetch: 1

前四个是基础连接配置,注意username和password改成你启动Docker时设置的账号密码。virtual-host就是之前Docker里设置的RABBITMQ_DEFAULT_VHOST。

publisher-confirm-type和publisher-returns是用来开生产者端的消息确认的,template.mandatory配合returns使用,作用是在消息没有找到任何队列路由时把消息退回给生产者。listener.simple.acknowledge-mode手工改成manual,意思是消费者处理完消息之后手动回执,不自动确认。prefetch设为1,意思是消费者每次只从队列取一条消息,处理完再取下一条,防止一批消息拉到本地但没处理完就挂掉导致大量消息进入unacked状态。这些参数都到后面“消息可靠性”部分细说,你配置阶段先照抄就行。

4.3 定义交换机、队列和绑定

这一部分代码是理解RabbitMQ模型的关键。

@Configuration public class RabbitConfig { public static final String EXCHANGE_NAME = "exchange.order"; public static final String QUEUE_NAME = "queue.order"; public static final String ROUTING_KEY = "order.create"; @Bean public TopicExchange orderExchange() { return new TopicExchange(EXCHANGE_NAME, true, false); } @Bean public Queue orderQueue() { return QueueBuilder.durable(QUEUE_NAME).build(); } @Bean public Binding orderBinding() { return BindingBuilder.bind(orderQueue()) .to(orderExchange()) .with(ROUTING_KEY); } }

这里我用的是TopicExchange。TopicExchange允许绑定键写成通配符模式,后续你想把order.update也路由到同一个队列,或者新加一个order.delete的消费者,只需要扩展绑定关系,不用改生产者和交换机。如果你确认只有一个场景,换成DirectExchange是一样的。为什么用QueueBuilder.durable而不是new Queue(name)?因为durable代表队列要在Broker重启后存活,并且队列中的持久化消息不会丢失。生产环境里队列必须是durable的,不然Broker重启一下,你队列没了,后续发消息全部落空。这个细节很多人栽过。

这个配置类一启动,Spring就会自动在RabbitMQ里创建交换机、队列和绑定关系。所以不用去管理后台手点创建,代码就是基础设施即代码的雏形。如果改动绑定关系不生效,可以删掉队列重建,但生产环境不要随意删队列。

4.4 生产者如何发送消息

生产者核心就两三行代码,写一个发送方法:

@Service public class OrderSender { @Autowired private RabbitTemplate rabbitTemplate; public void send(String message) { CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString()); rabbitTemplate.convertAndSend( RabbitConfig.EXCHANGE_NAME, RabbitConfig.ROUTING_KEY, message, correlationData); } }

convertAndSend四个参数:交换机名、路由键、消息体、消息ID。消息体我写的String,实际生产环境一般传JSON字符串,或者序列化后的对象。

CorrelationData是这条消息的全局唯一ID,配合publisher-confirm-type: correlated启用生产端确认。后面提到“消息到底有没有被Broker收下”这个问题,靠的就是这个ID去匹配异步回调结果。如果你不需要生产端确认,可以不传这个参数,但正式项目尽量传。

还有一个细节:发送之后RabbitTemplate默认是不会抛异常的,哪怕交换机不存在,消息也“貌似成功”地发出去然后被丢弃。这就是为什么必须把生产端确认打开,否则消息丢了都不知道。

4.5 消费者如何接收消息

用@RabbitListener注解是最简洁的方式:

@Component public class OrderConsumer { @RabbitListener(queues = RabbitConfig.QUEUE_NAME) public void handle(Message message, Channel channel) throws IOException { long deliveryTag = message.getMessageProperties().getDeliveryTag(); try { String body = new String(message.getBody(), StandardCharsets.UTF_8); System.out.println("收到消息:" + body); // 这里写你的业务处理逻辑 // 处理成功后手动确认 channel.basicAck(deliveryTag, false); } catch (Exception e) { // 处理失败,重新投递到队尾 channel.basicNack(deliveryTag, false, true); } } }

这里的Channel和Message是org.springframework.amqp包下的。deliveryTag是这条消息在当前通道里的递增序号,每次确认都必须带上它。最后一个false表示只确认当前这条消息,不批量确认。

处理失败时我用了basicNack,第三个参数requeue传true,意思是让消息重新回到队列等待下一次投递。这里有一个特容易踩的坑:如果你业务处理一直失败,消息就会无限循环重新投递,日志疯狂报错,队列里的消息永远消费不完。所以生产环境里,重投几次还不成功的消息,最好是确认死掉或者丢到死信队列,而不是无限requeue。后面常见问题部分我会再展开。

5. 实际运行效果与消息链路验证

5.1 启动项目,观察一条消息从发送到消费的完整过程

把上面的代码写好后,同时启动RabbitMQ和Spring Boot服务,然后调用一次发送接口,或者在测试类里直接注入OrderSender发送一条消息。

打开管理后台的Queue页面,点进queue.order,你会看到队列的消息计数从0变成1,然后瞬间又变回0。这是因为消息进来后被消费者立即取走了。这个过程如果你盯着页面看是很快的,新手第一次看到“消息进来又消失”其实就说明基本链路通了。

再发一条消息,然后立刻停掉消费者服务,你会发现队列里的消息数量停在1,既不消费也不消失。消费者恢复后,消息马上被消费掉。这个现象非常直观地解释了“削峰填谷”:生产者可以不受消费者状态影响,消息先在队列里缓冲下来,消费者恢复后慢慢追。

5.2 用管理后台做调试实验

管理后台的Exchange页面支持直接往交换机发消息。你可以手工输入一条消息和路由键,观察它到底进入了哪些队列。

我建议新手做两个实验。第一个实验:往exchange.order发送一个路由键为order.create的消息,观看它进入queue.order并被消费者消费。第二个实验:发送一个路由键为order.other的消息,然后看有没有队列收到它。因为你的绑定只有order.create,这条消息会被丢弃。在管理后台的队列页面看,没有任何队列的计数变化。

这个实验看起来简单,但做一遍,你对路由规则的理解会深刻很多。后面配置再复杂,本质都是这套“交换机按规则分发给队列”的机制。

还有一个调试技巧:在管理后台的Queues页面,点进具体队列,有个Get Message的按钮,可以把消息从队列里拉出来看内容,而不用通过消费者。这对于排查消息体内容是不是对的、序列化是不是有问题,非常方便。

6. 高频踩坑与排查技巧全记录

6.1 连接被拒、连不上RabbitMQ怎么办

最常见的几个原因,按概率排:

  • RabbitMQ容器根本没起来。用docker ps看状态,容器状态不是Up就说明有问题,docker logs查看崩溃原因。
  • 端口号写错。客户端代码里写的是5672,管理后台是15672,两者经常被混。连接不上基本都是5672的问题。
  • 账号密码错误。Docker里创建的账号和代码里写的username/password对不上。另一个原因就是你用了guest账号,而guest默认禁止远程连接。
  • 网络不通。检查防火墙、是否同一个网络命名空间。本地跑一般没这个问题,云服务器上要注意安全组是否放行5672端口。

排查思路:先telnet IP 5672测端口通不通,通的话再看有没有账号权限,不通的话回来看容器状态和防火墙。不要一上来就怀疑代码。

6.2 消息一直堆积在队列里,消费者收不到

打开管理后台Queues页面看,如果队列有消息但消费者数量是0,说明消费者服务没启动或者@RabbitListener没生效。如果消费者数量是1但消息还是不断堆积,那可能是消费者的处理速度跟不上,需要看prefetch和业务处理耗时。

还有一种隐蔽情况:消息进入了unacked状态,但业务线程卡住了。比如消费者里有一个外部RPC调用没有设置超时时间,永远等在那里,这条消息就一直不被确认。时间长了unacked越积越多,看起来就像队列堆积。遇到这种问题,一是给所有RPC调用加超时,二是调小prefetch,避免大量消息一次性拉到本地卡住。

6.3 消息丢失了:三个层面的完整性检查

消息丢失一定是某个环节出了问题,按下面三个层面查:

发送端:有没有开publisher-confirm-type?如果没有,消息发给Broker的过程丢失了你根本不知道。开了Confirm之后,每条消息都会收到ack或nack的回调。注意confirm只能保证“Broker收到”,不能保证“队列存下了”。

Broker端:交换机到队列这个环节,如果找不到匹配的队列,消息会被悄悄丢弃。想查这个环节,把publisher-returns打开,template.mandatory设为true,只要有消息没路由到任何队列,就会通过ReturnCallback回调给生产者。这个配置对排查“消息神秘消失”至关重要。

消费端:默认自动确认模式,消费者一拿到消息就回执“我处理完了”,哪怕业务代码异常炸了,消息也已经确认过了,Broker不会再管。所以重要业务的队列必须手动Ack,拿到消息、处理成功、再回执。

消息丢失的排查,其实就是按这个链路一个个确认过去。缺了哪一环都不行。

6.4 消息重复消费怎么办

手动Ack模式其实不能完全避免重复消费。比如消费者处理完业务,正要执行basicAck的时候,网络断了,Broker会以为这条消息没被消费,等连接恢复后重新投递。只要网络不完美,重复消费就是一定会发生的现象。

所以可靠消息处理有个铁律:消费者必须做幂等。所谓幂等,就是同一条消息处理多少次,结果都和只处理一次一样。常见做法有三个方向:

  • 数据库层面加唯一约束。比如消息里带一个事件ID,你处理前先insert一个去重表,唯一键冲突就说明处理过了。
  • Redis里存已处理消息ID,处理前先setnx,判断是否已经处理过。
  • 业务逻辑本身天然幂等。比如“把库存改成10”,重复执行没有副作用;“库存减1”,重复执行就有问题。

RabbitMQ本身只保证at least once(至少一次),不会帮你做去重。很多新手以为开了手动Ack就万事大吉,其实只解决了一部分问题。

6.5 队列积压越来越严重怎么缓解

队列积压分短期和长期两种情况。短期积压,比如秒杀前几秒消息量暴增,消费者的处理速度暂时跟不上,这是一个正常的缓冲现象,消费端横向扩容几个实例,积压很快就能消化。注意扩容的消费者必须监听同一个队列,RabbitMQ同一个队列的消息会均匀分给所有消费者,这是天然的负载均衡。

长期积压,说明消费速度长期低于生产速度。这时候该检查是不是消费者有性能瓶颈,比如里面查了慢SQL、调用了很慢的外部接口、或者代码里做了同步等待。还有一种情况是消息进入死循环,比如重投后一直失败然后无限requeue,积压不仅不下降,日志还疯狂刷错。这种先看日志解决异常,再考虑丢到死信队列。

6.6 死信队列怎么用

死信队列我认为是RabbitMQ进阶里最实用的功能之一。设想一个场景:订单下单后三十分钟未支付就自动取消。如果不用死信队列,你需要自己写一个定时任务去扫描订单表,费时费力。RabbitMQ的经典做法是:消息设置存活时间,过期后路由到一个“死信交换机”,再由死信交换机投递到专门处理取消订单的队列。

原理也不复杂。当一个队列上的消息出现三种情况之一时,它会被转投到绑定的死信交换机(DLX):消息过期、消费者拒绝且不重新入队、队列长度溢出。

用Spring Boot配置一个带TTL的队列和死信交换机,只需要在声明时加上参数:

@Bean public Queue orderDelayQueue() { Map<String, Object> args = new HashMap<>(); // 消息存活时间,单位毫秒 args.put("x-message-ttl", 60000); // 死信交换机 args.put("x-dead-letter-exchange", "exchange.dead"); // 死信路由键 args.put("x-dead-letter-routing-key", "order.expired"); return QueueBuilder.durable("queue.order.delay").withArguments(args).build(); }

然后定义一个死信交换机,绑定一个处理超时订单的队列。生产者正常把消息发到orderDelayQueue,消息等一分钟没人消费就自动转投到死信队列,死信消费者收到后执行取消订单逻辑。这个模式做延迟任务非常省事,也是面试里高频考点。但注意RabbitMQ的TTL有一个坑:如果队列头部有一条很长的TTL消息挡着,后面的短TTL消息可能不准时过期,这是RabbitMQ延迟消息的先天限制,要求精确延迟的话还是得用专业的延迟队列插件或时间轮方案。

7. 进阶实践:一套可靠消息方案的完整配置

把上面所有经验汇成一套可落地的完整方案,这套配置我建议直接照着用。

生产者侧:

spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true template: mandatory: true

在代码里实现ConfirmCallback和ReturnCallback,确认回调里收到nack就要记录日志并且考虑补发;ReturnCallback里收到没有路由的消息也要告警。

消费者侧:

spring: rabbitmq: listener: simple: acknowledge-mode: manual prefetch: 1

消费者代码里手动Ack,异常时根据异常类型决定Requeue还是丢弃。队列全部durable声明,消息发送时用MessageDeliveryMode.PERSISTENT设置持久化。如果用的是RabbitTemplate,默认就是持久化消息,不用额外设置,但如果手动构造MessageBuilder就要注意这个属性。

这套组合下来,消息从发送端到消费端的丢损风险已经降到很低了。剩下的就是消费者端的幂等处理,这不是配置能解决的,是业务代码必须设计的。

这里再多说一句,很多人一上来就追求“RabbitMQ最终一致性”那一整套复杂方案,其实在小规模业务里根本不需要。先把基础可靠性闭环做好,确认机制、持久化、手动Ack、幂等,这四点做到位就能覆盖绝大多数业务场景。更重的方案等业务量真的到了那个级别再去设计,不然全是过度设计。

我自己平时接一个需要用到消息队列的项目,第一步永远是先把管理后台的队列、交换机、消费者状态截图,让整个链路对双方都透明。消息队列这东西,最怕的就是“看不见”。谁在生产、谁在消费、队列里积压了多少、unacked有多少,这些数据全摆出来,问题基本就浮出水面了。后面的优化都是围绕数据来做的,而不是靠猜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进