📚 本系列系统梳理了 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());
        // 发短信、加积分等逻辑
    }
}

逐步拆解

  1. RabbitConfig 里声明了"消息往哪存"(Queue)、“消息怎么路由”(Exchange + Binding)——这一步相当于搭好"管道"
  2. OrderService.createOrderrabbitTemplate.convertAndSend(...)order 对象序列化后发到 order.exchange,带上 routing key order.created
  3. Exchange 根据 routing key,把消息路由到绑定的 order.created.queue
  4. OrderCreatedListener 上的 @RabbitListener 表示"我订阅了这个 Queue",一旦 Queue 里有新消息,Spring 会自动调用 handleOrderCreated 方法,参数就是反序列化后的 Order 对象
  5. 整个过程中,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 干货~

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐