Java基础(番外) | 消息队列入门:为什么用、怎么用、踩坑点
📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。
前言
在 28 微服务入门 里提到过,服务之间除了直接调用(OpenFeign),还有一种"不直接调用、丢个消息就走"的通信方式——这就是消息队列(Message Queue, MQ)。
这篇按"够用就上手 + 面试高频点"的程度来讲:先讲清楚为什么要用 MQ(这是面试必问的"动机"题),再讲最基本的生产者/消费者代码,最后讲三个最容易踩坑、也是面试最爱问的点——消息丢失、重复消费、顺序性。
1. 为什么需要消息队列
先看一个没有 MQ 的场景:用户下单后,需要做这些事——扣库存、发短信通知、发邮件、给用户加积分、推送给数据分析系统……
public void createOrder(Order order) {
orderMapper.insert(order);
inventoryService.deduct(order.getProductId(), order.getCount()); // 扣库存
smsService.send(order.getUserPhone(), "下单成功"); // 发短信
emailService.send(order.getUserEmail(), "订单详情"); // 发邮件
pointsService.addPoints(order.getUserId(), 10); // 加积分
analyticsService.report(order); // 上报数据分析
}
这样写有三个问题:
| 问题 | 说明 |
|---|---|
| 耗时叠加 | 下单接口的响应时间 = 插入订单 + 扣库存 + 发短信 + 发邮件 + 加积分 + 上报,用户要等所有步骤都做完 |
| 强耦合 | 下单流程"知道"积分系统、分析系统的存在,新增一个"下单后要做的事"就要改这个方法 |
| 一个失败全部失败 | 如果发邮件这一步因为邮件服务器抽风抛异常,整个下单事务可能跟着回滚——但加积分、发短信这种事,真的需要和"扣库存"绑在同一个事务里吗? |
1.1 MQ 怎么解决这些问题
把"扣库存"之外的非核心步骤,改成:下单成功后,往 MQ 里丢一条"订单已创建"的消息,谁关心这个消息,谁自己订阅去处理。
public void createOrder(Order order) {
orderMapper.insert(order);
inventoryService.deduct(order.getProductId(), order.getCount());
mqProducer.send("order.created", order); // 丢一条消息,立刻返回
}
短信服务、邮件服务、积分服务、分析系统,各自订阅 order.created 这个消息,各自异步处理,互不影响。
1.2 MQ 的三大核心价值
| 价值 | 解释 | 例子 |
|---|---|---|
| 解耦 | 生产者不需要知道有哪些消费者,新增消费者不用改生产者代码 | 后来又要加一个"发优惠券"的逻辑,只需要新增一个消费者订阅 order.created,下单代码完全不用改 |
| 异步 | 生产者发完消息立刻返回,不用等下游处理完 | 下单接口从"等6个步骤全部完成"变成"插入订单+扣库存+发消息,3步就返回" |
| 削峰填谷 | 流量洪峰时,消息先堆在 MQ 里,消费者按自己的处理能力慢慢消费,不会被瞬间流量打垮 | 秒杀活动瞬间10万下单请求,下游的积分系统每秒只能处理1000条,消息堆在MQ里,积分系统按自己的节奏消费,不会宕机 |
一句话记忆:MQ 解决的不是"功能"问题,而是"系统之间怎么协作"的问题——让强依赖变成弱依赖,让同步等待变成异步处理。
2. 核心概念
不同 MQ 产品叫法略有不同,但核心概念是通用的:
| 概念 | 说明 |
|---|---|
| Producer(生产者) | 发送消息的一方,比如"下单服务" |
| Consumer(消费者) | 接收并处理消息的一方,比如"积分服务" |
| Broker(消息中间件服务器) | MQ 本身,负责存储、转发消息,比如 RabbitMQ Server |
| Queue(队列) | 消息的存储载体,消息按顺序进入,消费者从队列里取 |
| Topic(主题) | 消息的分类标识,Kafka 等基于 Topic,一个 Topic 可以有多个消费者订阅(各自处理一份完整的消息流) |
| Exchange(交换机,RabbitMQ 特有) | 生产者并不直接发到 Queue,而是发到 Exchange,由 Exchange 根据规则路由到一个或多个 Queue |
2.1 RabbitMQ 的消息流转
Producer ──► Exchange ──(路由规则)──► Queue ──► Consumer
Exchange 有几种类型,决定路由规则:
| Exchange 类型 | 路由规则 |
|---|---|
| Direct | 精确匹配 routing key,发到指定 Queue |
| Fanout | 广播,发给所有绑定的 Queue(不看 routing key) |
| Topic | 模糊匹配 routing key(支持 */# 通配符) |
3. 常见 MQ 产品对比
| 产品 | 特点 | 典型场景 |
|---|---|---|
| RabbitMQ | 基于 AMQP 协议,功能丰富(路由灵活、支持优先级队列、延迟队列插件),吞吐量中等 | 业务系统之间的解耦,对功能丰富度要求高 |
| RocketMQ | 阿里开源,性能高,原生支持事务消息、延迟消息、消息轨迹 | 电商、金融等对消息可靠性要求高的国内场景 |
| Kafka | 吞吐量极高,基于"日志"设计,消息可以被重复消费(消费者自己维护读取位置) | 日志收集、大数据流处理、埋点数据,海量数据场景 |
| ActiveMQ | 老牌 JMS 实现,功能完善但性能和社区活跃度不如前三者 | 老系统居多,新项目基本不选 |
新人怎么选着学? 公司用什么学什么。如果是从零开始学,建议先学 RabbitMQ——概念清晰、Spring 集成简单(spring-boot-starter-amqp),适合理解 MQ 的基本模型;之后再了解 Kafka 的"日志"模型有什么不同(这部分概念差异面试常考)。
4. 基本使用:Spring Boot + RabbitMQ
4.1 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
4.2 声明 Exchange、Queue 和绑定关系
@Configuration
public class RabbitConfig {
public static final String ORDER_QUEUE = "order.created.queue";
public static final String ORDER_EXCHANGE = "order.exchange";
public static final String ORDER_ROUTING_KEY = "order.created";
@Bean
public Queue orderQueue() {
return new Queue(ORDER_QUEUE, true); // true = 持久化,Broker 重启消息不丢
}
@Bean
public DirectExchange orderExchange() {
return new DirectExchange(ORDER_EXCHANGE);
}
@Bean
public Binding binding(Queue orderQueue, DirectExchange orderExchange) {
return BindingBuilder.bind(orderQueue).to(orderExchange).with(ORDER_ROUTING_KEY);
}
}
逐步拆解:这段配置在创建什么、为什么要绑定
这三个 @Bean 不是"创建 Java 对象"那么简单——Spring Boot 启动时,会通过 RabbitMQ 提供的管理接口,把这三个 Bean 描述的"队列"、“交换机”、“绑定关系"真正在 RabbitMQ 服务器(Broker)上创建出来。这一步叫"声明(declare)”——如果 Broker 上已经存在同名的队列/交换机,Spring 会跳过创建;如果不存在,就新建。所以这段代码相当于:应用启动时,顺便把消息队列需要的"基础设施"在 RabbitMQ 里搭好。
把这三者类比成现实中的"快递分发中心":
快递员(生产者) ──投递到──► 分发中心(Exchange) ──按地址分流──► 你小区的快递柜(Queue) ──你来取(Consumer)
| 概念 | 对应的 Bean | 是什么 | 关键问题 |
|---|---|---|---|
| Queue(队列) | orderQueue() |
真正存放消息的地方——一个"快递柜",消息会一直待在这里直到被消费者取走 | new Queue(ORDER_QUEUE, true) 的 true 是"持久化":Broker 重启后,队列本身和里面已存的消息都不会丢 |
| Exchange(交换机) | orderExchange() |
不存储消息,只负责"分发"——生产者发消息时,目标其实是 Exchange,而不是 Queue | DirectExchange 表示按照精确匹配的 routing key 来路由——routing key 完全相同才会转发到对应的队列 |
| Binding(绑定) | binding(...) |
把 Queue 和 Exchange "连接"起来的路由规则:告诉 Exchange——“带着 ORDER_ROUTING_KEY 这个标签的消息,要转发到 orderQueue 这个队列” |
BindingBuilder.bind(queue).to(exchange).with(routingKey) 三个参数缺一不可 |
为什么生产者不直接把消息发到 Queue,非要绕一层 Exchange?
因为现实中,一条消息可能需要被多个不同的 Queue 接收(比如"订单创建"这个消息,短信服务、积分服务、分析服务都要各自有一份)。如果生产者直接发到某个固定的 Queue,那么每多一个消费方,生产者就要多发一次、多知道一个 Queue 的名字——又变回了最开始"强耦合"的问题(参见第1节)。
Exchange 的作用就是当"中间商":生产者只管把消息发给 Exchange,并打上一个 routing key 标签,至于这条消息最终会被转发到几个 Queue、转发到哪些 Queue,由 Binding 规则决定,生产者完全不用关心。
为什么必须要 Binding? 如果只创建了 Exchange 和 Queue,但没有 Binding,两者之间没有任何关联——Exchange 收到消息后,发现"没有规则告诉我该往哪转发",这条消息就会被直接丢弃(DirectExchange 默认行为)。Binding 就是这两者之间缺失的那根"线":
没有 Binding:
Producer ──► Exchange ──► (不知道发给谁,消息丢弃) ✗
有了 Binding(queue, exchange, "order.created"):
Producer ──发消息,routing key="order.created"──► Exchange ──匹配到 Binding──► orderQueue ✓
这也解释了为什么 4.3 节生产者发送消息时,convertAndSend 的第一个参数是 Exchange 的名字,第二个参数是 routing key,而不是直接写 Queue 的名字——生产者全程"看不到"Queue,它只和 Exchange、routing key 打交道。
4.3 生产者:发送消息
@Service
@RequiredArgsConstructor
public class OrderService {
private final RabbitTemplate rabbitTemplate;
private final OrderMapper orderMapper;
public void createOrder(Order order) {
orderMapper.insert(order);
// 发送消息:发到哪个 Exchange,用什么 routing key,消息内容是什么
rabbitTemplate.convertAndSend(RabbitConfig.ORDER_EXCHANGE, RabbitConfig.ORDER_ROUTING_KEY, order);
}
}
4.4 消费者:监听并处理消息
@Component
public class OrderCreatedListener {
@RabbitListener(queues = RabbitConfig.ORDER_QUEUE)
public void handleOrderCreated(Order order) {
System.out.println("收到订单创建消息,发送通知给用户:" + order.getUserId());
// 发短信、加积分等逻辑
}
}
逐步拆解:
RabbitConfig里声明了"消息往哪存"(Queue)、“消息怎么路由”(Exchange + Binding)——这一步相当于搭好"管道"OrderService.createOrder里rabbitTemplate.convertAndSend(...)把order对象序列化后发到order.exchange,带上 routing keyorder.created- Exchange 根据 routing key,把消息路由到绑定的
order.created.queue OrderCreatedListener上的@RabbitListener表示"我订阅了这个 Queue",一旦 Queue 里有新消息,Spring 会自动调用handleOrderCreated方法,参数就是反序列化后的Order对象- 整个过程中,
createOrder方法在第2步发完消息就返回了,不会等第4步执行完——这就是"异步"
5. 消息确认机制(ACK):消息怎么知道"被处理成功了"
如果消费者在处理消息的过程中宕机了,这条消息怎么办?是不是就丢了?
答案取决于"确认机制"——消费者处理完消息后,需要给 Broker 一个"ACK(确认)",Broker 收到 ACK 才会把这条消息从 Queue 里删除。如果消费者一直没发 ACK(比如宕机了),Broker 会把消息重新投递给其他消费者。
| 模式 | 说明 | 风险 |
|---|---|---|
| 自动 ACK(默认) | 消息一发给消费者,Broker 就认为成功,立即删除 | 如果消费者拿到消息后还没处理就宕机,消息丢失 |
| 手动 ACK | 消费者处理完业务逻辑后,显式调用 channel.basicAck(...) |
处理失败可以不 ACK,消息会重新投递(但要注意避免无限重试) |
Spring AMQP 里开启手动 ACK:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual
@RabbitListener(queues = RabbitConfig.ORDER_QUEUE)
public void handleOrderCreated(Order order, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
try {
// 处理业务逻辑
channel.basicAck(tag, false); // 处理成功,确认
} catch (Exception e) {
channel.basicNack(tag, false, true); // 处理失败,重新入队
}
}
6. 消息丢失:三个环节都可能丢
消息从"生产者发出"到"消费者处理完成",要经过三段链路,每一段都可能丢消息:
生产者 ──①──► Broker ──②──► (持久化存储) ──③──► 消费者
| 环节 | 丢失原因 | 解决方案 |
|---|---|---|
| ① 生产者→Broker | 网络抖动,消息根本没到 Broker | 开启生产者确认(RabbitMQ 的 Publisher Confirm),Broker 收到消息后回一个确认,生产者没收到确认就重发 |
| ② Broker 自身 | Broker 重启,内存里的消息(未持久化)丢失 | Queue 和消息都设置为持久化(durable=true + 消息的 deliveryMode=PERSISTENT) |
| ③ Broker→消费者 | 消费者拿到消息后还没处理完就宕机,但已经自动 ACK 了 | 使用手动 ACK,处理完业务逻辑才确认 |
面试怎么答:“消息从生产到消费要经过生产者发送、Broker存储、消费者处理三个环节,对应的解决方案分别是生产者确认机制、消息持久化、消费者手动ACK,三者结合才能保证消息不丢失(但注意,这只是’不丢失’,不代表’不重复’,这是下一节的内容)。”
7. 消息重复消费:幂等性
刚才第6节的"手动ACK + 失败重新入队"机制,恰好会带来一个新问题:
消费者处理完了业务逻辑(比如已经给用户加了积分),但是在调用
basicAck之前宕机了——Broker 没收到 ACK,认为没处理成功,会把消息重新投递,导致积分被加了两次。
这就是"消息重复消费"——在大多数 MQ 中,"消息至少被消费一次(At Least Once)"是常态,无法100%避免重复。所以业务代码必须自己处理"重复消费"的问题,这叫幂等性。
7.1 常见幂等方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 唯一索引 | 给"业务操作记录表"加唯一索引(比如 order_id + operation_type),重复插入时数据库报错,捕获异常即可 |
加积分、发优惠券等"一次性操作" |
| 状态机判断 | 处理前先查询当前状态,如果已经是"目标状态"就直接返回 | 订单状态从"待支付"→"已支付",重复消息进来发现已经是"已支付",直接忽略 |
| Redis 去重 | 用消息的唯一ID(比如 messageId)作为 Redis key,SETNX 成功才处理,已存在说明处理过了 |
通用方案,配合 21 Redis 里的命令 |
@RabbitListener(queues = RabbitConfig.ORDER_QUEUE)
public void handlePoints(Order order) {
String key = "points:processed:" + order.getId();
Boolean firstTime = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofHours(24));
if (Boolean.FALSE.equals(firstTime)) {
return; // 已经处理过,直接返回,不重复加积分
}
pointsService.addPoints(order.getUserId(), 10);
}
一句话记忆:“MQ 保证消息至少到一次,业务保证处理只生效一次”——前者是 MQ 的责任,后者是代码的责任,缺一不可。
8. 消息顺序性
场景:同一个订单的"创建"、“支付”、"发货"三条消息,必须按顺序处理,否则可能出现"发货消息先到,订单还没创建"的诡异情况。
8.1 为什么默认情况下顺序无法保证
- 如果一个 Queue 配了多个消费者并发消费,三条消息可能被分配给不同的消费者并行处理,处理完成的顺序就乱了
- 如果生产者本身是并发发送的(多个线程同时发),到达 Broker 的顺序也不一定和业务发生的顺序一致
8.2 解决思路
| 思路 | 说明 |
|---|---|
| 同一个业务实体的消息发到同一个 Queue,且该 Queue 只有一个消费者 | 牺牲并发度,保证局部顺序 |
用 routing key / 分区 Key 让同一个订单的消息固定路由到同一个 Queue/分区 |
Kafka 里叫"分区内有序"——同一个 key(如 orderId)的消息会进入同一个分区,分区内严格有序 |
| 消费者内部按业务字段排序/判断状态 | 比如收到"发货"消息时,先检查订单状态是否为"已支付",不是就先暂存或重试 |
面试怎么答:“MQ 默认只能保证单个 Queue 内、单个消费者处理时的顺序。要保证同一业务实体的消息顺序,需要让它们路由到同一个 Queue(或 Kafka 的同一个分区),并且这个 Queue 只能有一个消费者在处理。”
9. 死信队列与延迟队列(了解即可)
| 概念 | 说明 | 例子 |
|---|---|---|
| 死信队列(DLQ, Dead Letter Queue) | 消息一直处理失败(或超过重试次数),被转移到一个专门的"死信队列",供人工排查 | 积分服务连续重试3次都失败,消息进入死信队列,运维收到告警去看日志 |
| 延迟队列 | 消息发出后,不立即投递给消费者,等待一段时间后才投递 | “订单创建30分钟未支付,自动取消”——下单时发一条延迟30分钟的消息,到时间后消费者检查订单状态,未支付就取消 |
RabbitMQ 通过插件(x-delayed-message)或"TTL + 死信队列"组合实现延迟队列;RocketMQ 原生支持延迟消息;这些属于"知道有这个功能、遇到需求知道去查"的程度,新人不需要现在就深入实现细节。
10. 小结
| 主题 | 核心要点 |
|---|---|
| 为什么用MQ | 解耦(生产者不关心消费者)、异步(发完即返回)、削峰填谷(流量洪峰先堆在MQ里) |
| 核心概念 | Producer/Consumer/Broker/Queue/Topic/Exchange,RabbitMQ 是 Producer→Exchange→Queue→Consumer |
| 产品选择 | RabbitMQ 适合入门和业务解耦;Kafka 适合海量日志/数据流;RocketMQ 适合高可靠业务场景 |
| ACK机制 | 自动ACK简单但可能丢消息;手动ACK需要处理完业务逻辑再确认 |
| 消息丢失 | 三个环节(生产者→Broker、Broker存储、Broker→消费者)分别对应生产者确认、持久化、手动ACK |
| 重复消费 | MQ只能保证"至少一次",业务必须自己做幂等(唯一索引/状态机/Redis去重) |
| 顺序性 | 默认不保证,需要同一业务实体的消息路由到同一Queue/分区,且该Queue只有一个消费者 |
这几个点——为什么用MQ、消息丢失怎么解决、重复消费怎么处理、顺序性怎么保证——基本覆盖了面试里关于MQ的高频问题。实际项目里用到具体产品(RabbitMQ/RocketMQ/Kafka)的API细节,等入职后对照公司文档现学即可,思路比API更重要。
🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~
更多推荐



所有评论(0)