微服务拆完之后,下一个绕不开的技术就是消息队列。异步、削峰、解耦——这三个词面试被问到的频率极高,但光背概念没用,得真正动手用起来才知道怎么回事。这一周我从零开始学RabbitMQ,从安装到三种交换机模型、从Spring AMQP实战到消息可靠性五层保障,最后对比了RocketMQ。这篇文章完整记录这个过程,重点放在面试最常被问的消息可靠性和延迟消息上。
为什么要学消息队列?
在学微服务的时候我遇到了一个很典型的问题:用户下单后要做三件事——扣库存、发短信通知、加积分。这三个操作用同步调用的话,用户要等所有操作都完成才能看到"下单成功"。更严重的是,如果短信服务挂了,整个下单流程就失败了——用户明明付了钱,就因为一条短信发不出去导致订单创建失败。
消息队列就是来解决这类问题的。核心三个词:
异步 — 订单服务发完消息就返回"下单成功",不用等下游处理完。用户响应时间从秒级降到毫秒级。
削峰 — 双十一10万个订单同时涌入,队列先"存着",后台按数据库能扛住的速度慢慢消费。数据库不会被瞬时流量打垮。
解耦 — 订单服务只管发消息,不关心谁在消费。以后想加一个"推送通知"服务?加一个消费者就行,订单服务代码一行不改。
一、RabbitMQ 核心概念
1.1 消息流转模型
RabbitMQ有四个核心角色:Producer(生产者)、Exchange(交换机)、Queue(队列)、Consumer(消费者)。
消息的流转过程是:Producer创建消息并指定routing key(路由键),发给Exchange。Exchange根据自身类型和绑定规则,决定把消息路由到哪个Queue。Consumer从Queue中取出消息处理。
Producer → Exchange(根据routing key路由)→ Queue → Consumer
面试常问:为什么需要Exchange而不是Producer直接发到Queue?
答案是灵活性。如果Producer直接发到Queue,Producer必须知道所有Queue的名字。新增一个消费者就要改Producer的代码。有了Exchange,Producer只管发消息和贴标签(routing key),"消息去哪个队列"由Exchange的绑定规则决定。新增消费者只需新建Queue绑定到Exchange,Producer代码一行不用改。
1.2 三种交换机
RabbitMQ有三种交换机类型,决定了消息怎么路由:
Direct(直连) — routing key精确匹配。"order.create"只发给绑定了"order.create"的队列。适合一对一精确投递,比如下单消息只给订单处理服务。
Fanout(扇出) — 忽略routing key,广播给所有绑定的队列。适合一对多广播,比如用户注册后同时发邮件、发短信、初始化用户资料。
Topic(主题) — 用通配符匹配。*匹配一个单词,#匹配零个或多个单词。"order.*“能匹配"order.create"但不匹配"order.create.vip”;"order.#"两个都能匹配。适合灵活的多条件路由,比如日志系统按级别分发给不同处理服务。
| 类型 | 匹配方式 | 典型场景 |
|---|---|---|
| Direct | 精确匹配routing key | 一对一精确投递 |
| Fanout | 忽略routing key,广播 | 一对多广播 |
| Topic | 通配符匹配(*和#) | 灵活多条件路由 |
1.3 安装
一行Docker命令搞定:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:management
5672是程序连接端口(AMQP协议),15672是管理界面端口。浏览器打开http://localhost:15672,用guest/guest登录就能看到Web管理控制台。
二、Spring AMQP 实战
2.1 基本配置
引入spring-boot-starter-amqp依赖,配置连接信息:
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
2.2 发送消息
核心就一行:
@Autowired
private RabbitTemplate rabbitTemplate;
// 发送消息到交换机,指定routing key
rabbitTemplate.convertAndSend("order-exchange", "order.create", orderMessage);
如果配置了Jackson2JsonMessageConverter,可以直接发送Java对象,框架自动序列化成JSON。消费端也直接接收Java对象,自动反序列化。
2.3 消费消息
@RabbitListener(queues = "order-queue")
public void handleOrder(OrderMessage msg) {
log.info("收到订单消息:{}", msg.getOrderId());
// 处理业务逻辑...
}
@RabbitListener标在方法上,这个方法就变成了消息监听器——队列有消息就自动调用。
2.4 竞争消费 vs 广播
这是一个容易混淆的点:
竞争消费(多个Consumer监听同一个Queue):每条消息只被一个Consumer处理,RabbitMQ轮询分发。适合负载均衡——多个订单处理实例分担处理压力。
广播(Fanout交换机,每个Consumer各自一个Queue):每条消息所有Consumer都处理到。适合"一件事需要多个系统同时响应"的场景。
简单说:竞争消费是"一条消息一个人干",广播是"一条消息每个人都干"。
三、消息可靠性(面试最最爱考)
这是我花最多时间学的部分,也是面试被问到概率最高的内容。
3.1 消息可能在哪些环节丢失
一条消息从Producer到Consumer要经过三个环节,每个环节都可能丢消息:
| 环节 | 丢消息的原因 | 保障机制 |
|---|---|---|
| Producer→Broker | 网络抖动、Exchange不存在 | Publisher Confirm + Return |
| Broker自身 | RabbitMQ宕机/重启 | 持久化(durable队列 + persistent消息) |
| Broker→Consumer | Consumer收到消息但还没处理就崩了 | 手动ACK + 重试 + 死信队列 |
面试被问"消息丢失怎么办"时,先说这三个环节再逐层回答,面试官会觉得你思路很清晰。
3.2 第一层:Publisher Confirm(生产者确认)
默认情况下,convertAndSend方法返回不代表消息已经到了Queue里。开启Confirm机制后,可以收到消息是否到达Exchange的回调:
spring:
rabbitmq:
publisher-confirm-type: correlated
publisher-returns: true
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
if (ack) {
log.info("消息到达Exchange ✅");
} else {
log.error("消息未到达Exchange:{}", cause);
// 记录日志、从数据库重新发送
}
});
rabbitTemplate.setReturnsCallback(returned -> {
log.warn("消息未路由到任何Queue:{}", returned.getRoutingKey());
});
ConfirmCallback检测消息是否到达Exchange,ReturnsCallback检测消息到了Exchange但没路由到任何Queue。两者互补。
3.3 第二层:持久化
RabbitMQ默认把队列和消息存在内存里。服务重启,内存中的数据全丢。
// 队列持久化:RabbitMQ重启后队列还在
QueueBuilder.durable("order-queue").build();
// 消息持久化:RabbitMQ重启后消息还在
// Spring AMQP默认发送的消息就是PERSISTENT模式(deliveryMode=2),不需要额外配置
持久化的代价是写入磁盘比写内存慢,但在消息可靠性要求高的场景(如订单),这点性能损失完全值得。
3.4 第三层:手动ACK(面试最高频)
默认的自动ACK模式有个致命问题:RabbitMQ把消息发给Consumer的瞬间就标记为"已确认"并从队列删除。如果Consumer还没处理完就崩溃了,消息就丢了。
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual
prefetch: 1
@RabbitListener(queues = "order-queue")
public void handleOrder(String message, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws Exception {
try {
processOrder(message);
channel.basicAck(deliveryTag, false);
// 处理成功:手动确认,消息从队列中删除
} catch (Exception e) {
channel.basicNack(deliveryTag, false, false);
// 处理失败:不重新入队,进入死信队列
}
}
手动ACK模式下,Consumer崩溃时消息还是Unacked状态,RabbitMQ检测到连接断开会自动把它重新入队。这就保证了"至少处理一次"。
3.5 第四层:重试 + 死信队列兜底
Consumer处理失败可以basicNack(requeue=true)放回队列重试。但如果消息本身有问题(如格式错误),每次都失败就会无限循环。
解决方案是设置最大重试次数,超过后basicNack(requeue=false)让消息进入死信队列。死信队列有专门的Consumer处理这些"异常消息"——记录到数据库、发告警、人工排查。
完整的五层保障链路:
Publisher Confirm(到达Exchange)
→ Return(路由到Queue)
→ 持久化(Broker重启不丢)
→ 手动ACK(Consumer处理完才确认)
→ 死信队列(重试失败兜底)
3.6 消息幂等性
有了重试机制后,同一条消息可能被消费多次。如果Consumer做的是扣库存这种非幂等操作,重复消费就会扣多次。
用Redis SETNX做去重:每次消费前检查消息ID是否已处理过,处理过就跳过。处理失败时删除标记允许下次重试。
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent("mq:processed:" + msgId, "1", Duration.ofHours(24));
if (Boolean.FALSE.equals(isNew)) {
channel.basicAck(deliveryTag, false); // 已处理过,跳过
return;
}
四、死信队列
死信队列是消息可靠性的最后一道防线。消息进入死信队列有三种触发条件:
- 消息被拒绝:basicNack/reject且requeue=false
- 消息过期:TTL超时,消息在队列里等太久
- 队列满了:达到max-length限制,最早的消息被挤出去
配置方式是在正常队列声明时指定死信交换机:
QueueBuilder.durable("order-queue")
.deadLetterExchange("dead-letter-exchange")
.deadLetterRoutingKey("dead-letter")
.build();
当消息触发死信条件时,RabbitMQ自动把它路由到死信交换机→死信队列,由专门的死信Consumer处理。
五、延迟消息
延迟消息是指发出去的消息不立刻被消费,等到指定时间后才被处理。典型场景:订单30分钟未支付自动取消。
5.1 TTL + 死信队列实现
RabbitMQ没有原生的延迟消息功能,但可以用TTL+死信队列模拟:
- 创建一个"延迟队列",设TTL(如30分钟),但不设消费者
- 给延迟队列配死信交换机,指向一个"消费队列"
- Producer发消息到延迟队列
- 消息等30分钟后TTL到期→变成死信→路由到消费队列
- Consumer从消费队列取出消息→相当于延迟了30分钟才收到
Producer → delay-queue(TTL=30min,无消费者)
│ 30分钟后过期
↓
order-cancel-queue → Consumer(检查订单状态,未支付则取消)
5.2 队头阻塞问题
消息级别TTL有一个坑:RabbitMQ只检查队头消息是否过期。如果队头消息TTL=60min,后面消息TTL=5min,后面的消息即使到期了也被队头挡住。
解决方案有三种:队列级别TTL(所有消息延迟时间相同,不存在阻塞)、安装延迟消息插件(rabbitmq-delayed-message-exchange,精确延迟无阻塞)、RocketMQ原生延迟消息。
六、RabbitMQ vs RocketMQ
花了半天了解RocketMQ,整理了一个对比表。面试被问"用RabbitMQ还是RocketMQ"的时候能说清楚取舍:
| 对比维度 | RabbitMQ | RocketMQ |
|---|---|---|
| 开发语言 | Erlang | Java |
| 架构模型 | Exchange + Queue | Topic + MessageQueue |
| 消费模式 | 推模式(Broker推给Consumer) | 拉模式(Consumer主动拉) |
| 事务消息 | 不支持(需自己实现) | 原生支持(半消息+回查) |
| 顺序消息 | 需单Queue+单Consumer | 原生支持(MessageQueueSelector) |
| 延迟消息 | TTL+死信模拟(有队头阻塞) | 原生18级延迟 |
| 单机吞吐 | 万级QPS | 十万级QPS |
| 消息延迟 | 微秒级 | 毫秒级 |
| 路由灵活性 | 高(三种Exchange) | 较低(Tag/SQL过滤) |
选型结论: 中小项目用RabbitMQ——路由灵活、延迟低、Spring集成好、社区成熟。大流量电商/金融场景用RocketMQ——吞吐量高、消息堆积强、原生支持事务/顺序/延迟消息。本质是用"路由灵活性+低延迟"换"高吞吐+高级特性"。
RocketMQ最吸引我的特性是事务消息。它解决了"先写数据库还是先发消息"的两难问题——先发半消息(Consumer不可见),执行本地事务,成功commit失败rollback。如果Producer崩溃,Broker定期回查确认事务状态。这保证了数据库操作和消息发送的原子性,金融场景非常需要。
七、个人感悟
这一周最大的收获是把"消息可靠性"这个面试高频题彻底搞明白了。以前背答案只记得"手动ACK"四个字,现在能从消息流转的三个环节逐层分析——生产者确认、持久化、手动ACK、重试、死信兜底——五层保障形成完整链路。面试时先说框架再展开,比直接蹦答案有说服力得多。
还有一个感触:消息队列的很多设计思想和之前学过的技术是相通的。消息幂等性用Redis SETNX去重,和之前学的Redis分布式锁是同一个思路。手动ACK的"处理完才确认",和数据库事务的"提交才生效"异曲同工。死信队列的"兜底"思想,和Sentinel熔断降级的"fallback"逻辑类似——都是为异常情况准备一个退路。
技术学到后面,越来越觉得底层思想是相通的。 把每一个技术点的"为什么这么设计"想明白,比死记硬背用法有用得多。
下周开始进入数据库进阶(MySQL索引优化、事务隔离级别、Redis高级),会继续记录。如果这篇文章对你有帮助,点个赞再走吧。

994

被折叠的 条评论
为什么被折叠?



