一、学习目标
- 理解消息队列 MQ 的核心价值:异步、解耦、削峰填谷。
- 掌握 MQ 的基本概念:生产者、消费者、Broker、队列、交换机、主题。
- 会在 Spring Boot 中集成 RabbitMQ 完成消息收发。
- 了解 RocketMQ 的核心模型与适用场景,能与 RabbitMQ 对比选型。
- 掌握消息可靠性三要素:不丢、不重、有序,以及消费幂等。
- 理解消息确认机制、手动 ACK、死信队列、延迟消息。
- 能用消息队列把第44天的库存预扣、异步落库、缓存失效真正落地。
- 理解基于消息的最终一致性方案与本地消息表思路。
二、为什么第45天要学消息队列
第40到44天完成了数据库、缓存、并发写。但还有几个问题没解决:
- 第44天秒杀预扣库存后说要异步落库,靠什么异步,怎么保证最终落库成功。
- 第43天说删缓存失败要进重试队列,这个队列用什么实现。
- 下单后要发短信、发优惠券、加积分,如果同步串行调用,接口又慢又脆,任何一个下游失败都影响下单。
- 大促瞬时下单量远超数据库处理能力,需要把请求先缓冲起来慢慢处理。
消息队列正是解决这些问题的核心中间件。
第45天目标:理解 MQ 的价值与模型,掌握 RabbitMQ 收发与可靠性,把前面遗留的异步场景落地。
三、消息队列核心价值
3.1 异步
把非核心、可延迟的操作异步化,缩短主流程响应时间。
text
同步串行:下单 -> 扣库存 -> 发短信 -> 发券 -> 加积分 -> 返回,耗时累加
异步解耦:下单 -> 扣库存 -> 发消息 -> 返回,短信、券、积分由消费者异步处理
3.2 解耦
生产者不需要知道有哪些消费者。新增一个下游,只要订阅消息即可,下单服务代码不用改。
text
下单服务 -> 发布 OrderCreated 消息
|-> 短信服务订阅
|-> 积分服务订阅
|-> 数据分析服务订阅
3.3 削峰填谷
大促瞬时流量先进入队列,消费者按自己的能力匀速消费,保护数据库。
text
瞬时 1 万 QPS -> MQ 缓冲 -> 消费者每秒处理 2 千 -> 数据库平稳
3.4 MQ 也带来的代价
- 系统复杂度上升,多了一个需要维护的中间件。
- 一致性变难,从强一致变最终一致。
- 要处理消息丢失、重复、乱序。
- 排查问题链路变长,需要消息轨迹追踪。
所以不要为了用 MQ 而用 MQ,要看场景。
四、消息队列核心概念
4.1 通用角色
| 角色 | 说明 |
|---|---|
| Producer 生产者 | 发送消息的应用 |
| Consumer 消费者 | 接收并处理消息的应用 |
| Broker | 消息服务器,负责存储和转发 |
| Message 消息 | 传输的数据单元 |
| Queue 队列 | 存放消息的容器 |
| Topic 主题 | 消息的逻辑分类 |
4.2 两种消息模型
点对点,一条消息只被一个消费者消费:
text
Producer -> Queue -> 一个 Consumer 消费
发布订阅,一条消息被多个订阅者各消费一份:
text
Producer -> Topic -> Consumer A 一份
-> Consumer B 一份
4.3 主流 MQ 对比
| 维度 | RabbitMQ | RocketMQ | Kafka |
|---|---|---|---|
| 语言 | Erlang | Java | Scala Java |
| 模型 | 交换机加队列,灵活路由 | Topic 加 Tag | Topic 加 Partition |
| 吞吐 | 万级到十万级 | 十万级 | 百万级 |
| 延迟 | 微秒到毫秒 | 毫秒 | 毫秒 |
| 事务消息 | 支持 | 支持,较成熟 | 支持 |
| 顺序消息 | 较弱 | 支持 | 分区有序 |
| 典型场景 | 业务解耦、复杂路由 | 电商交易、削峰 | 日志、大数据流 |
第45天以 RabbitMQ 入门,RocketMQ 作为电商场景的对比了解。第18天曾涉及 Kafka 流式处理,可对照学习。
五、RabbitMQ 核心模型
5.1 关键组件
text
Producer -> Exchange 交换机 -> 按规则路由 -> Queue 队列 -> Consumer
- Exchange 交换机:接收生产者消息,按规则路由到队列,自己不存消息。
- Binding 绑定:交换机和队列之间的关联规则,含路由键 routing key。
- Queue 队列:真正存消息的地方。
5.2 四种交换机类型
| 类型 | 路由规则 | 场景 |
|---|---|---|
| Direct 直连 | routing key 完全匹配 | 精确路由,如按订单类型 |
| Topic 主题 | routing key 模式匹配,支持 * 和 # | 灵活路由,如 order.* |
| Fanout 广播 | 忽略 routing key,广播到所有绑定队列 | 发布订阅,如缓存失效广播 |
| Headers | 按消息头匹配 | 较少用 |
Topic 通配符规则:星号匹配一个单词,井号匹配零或多个单词。
text
order.created 匹配 order.* 匹配 order.#
order.pay.success 匹配 order.# 不匹配 order.*
六、Spring Boot 集成 RabbitMQ
6.1 依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
6.2 application.yml
yaml
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
publisher-confirm-type: correlated
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
prefetch: 10
retry:
enabled: true
max-attempts: 3
initial-interval: 2000ms
说明:
- publisher-confirm-type correlated,开启发送方确认,确认消息到达 Broker。
- publisher-returns true,开启路由失败回退。
- acknowledge-mode manual,手动 ACK,消费成功才确认。
- prefetch 10,每个消费者一次最多预取 10 条,防止单消费者积压过多。
6.3 声明交换机、队列、绑定
java
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfig {
public static final String ORDER_EXCHANGE = "order.exchange";
public static final String ORDER_CREATE_QUEUE = "order.create.queue";
public static final String ORDER_CREATE_KEY = "order.create";
public static final String ORDER_DLX_EXCHANGE = "order.dlx.exchange";
public static final String ORDER_DLX_QUEUE = "order.dlx.queue";
public static final String ORDER_DLX_KEY = "order.dlx";
@Bean
public DirectExchange orderExchange() {
return new DirectExchange(ORDER_EXCHANGE, true, false);
}
@Bean
public Queue orderCreateQueue() {
return QueueBuilder.durable(ORDER_CREATE_QUEUE)
.withArgument("x-dead-letter-exchange", ORDER_DLX_EXCHANGE)
.withArgument("x-dead-letter-routing-key", ORDER_DLX_KEY)
.build();
}
@Bean
public Binding orderCreateBinding() {
return BindingBuilder.bind(orderCreateQueue())
.to(orderExchange())
.with(ORDER_CREATE_KEY);
}
@Bean
public DirectExchange orderDlxExchange() {
return new DirectExchange(ORDER_DLX_EXCHANGE, true, false);
}
@Bean
public Queue orderDlxQueue() {
return QueueBuilder.durable(ORDER_DLX_QUEUE).build();
}
@Bean
public Binding orderDlxBinding() {
return BindingBuilder.bind(orderDlxQueue())
.to(orderDlxExchange())
.with(ORDER_DLX_KEY);
}
}
队列声明时绑定了死信交换机,消费多次失败的消息会进入死信队列,第九节展开。
6.4 消息体定义
java
import java.io.Serializable;
public class OrderCreatedMessage implements Serializable {
private String messageId;
private Long userId;
private Long productId;
private Integer quantity;
private long timestamp;
// 构造器、getter、setter
}
建议每条消息带全局唯一 messageId,用于消费端幂等去重。
6.5 生产者
java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class OrderMessageProducer {
private final RabbitTemplate rabbitTemplate;
private final ObjectMapper objectMapper;
public OrderMessageProducer(RabbitTemplate rabbitTemplate, ObjectMapper objectMapper) {
this.rabbitTemplate = rabbitTemplate;
this.objectMapper = objectMapper;
}
public void sendOrderCreated(OrderCreatedMessage message) {
try {
String messageId = message.getMessageId() != null
? message.getMessageId() : UUID.randomUUID().toString();
message.setMessageId(messageId);
CorrelationData correlationData = new CorrelationData(messageId);
rabbitTemplate.convertAndSend(
RabbitConfig.ORDER_EXCHANGE,
RabbitConfig.ORDER_CREATE_KEY,
objectMapper.writeValueAsString(message),
correlationData);
} catch (Exception e) {
throw new IllegalStateException("发送订单消息失败", e);
}
}
}
6.6 确认回调,确保消息到达 Broker
java
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
@Component
public class RabbitConfirmCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
@PostConstruct
public void init() {
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
if (!ack) {
String id = correlationData != null ? correlationData.getId() : "unknown";
// 记录日志,触发重发或落本地消息表标记失败
System.err.println("消息未到达 Broker, id=" + id + ", cause=" + cause);
}
});
rabbitTemplate.setReturnsCallback(returned -> {
// 消息到了交换机但没路由到队列,通常是 routing key 配错
System.err.println("消息路由失败: " + returned.getMessage());
});
}
}
6.7 消费者,手动 ACK
java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class OrderCreateConsumer {
private final ObjectMapper objectMapper;
private final OrderPersistService orderPersistService;
public OrderCreateConsumer(ObjectMapper objectMapper,
OrderPersistService orderPersistService) {
this.objectMapper = objectMapper;
this.orderPersistService = orderPersistService;
}
@RabbitListener(queues = RabbitConfig.ORDER_CREATE_QUEUE)
public void onMessage(Message message, Channel channel) throws IOException {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
String body = new String(message.getBody());
OrderCreatedMessage msg = objectMapper.readValue(body, OrderCreatedMessage.class);
orderPersistService.handleOrderCreated(msg);
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// 第二个参数 false 表示不重新入队,直接进死信,避免无限重试
channel.basicNack(deliveryTag, false, false);
}
}
}
要点:
- 消费成功 basicAck 确认。
- 消费失败 basicNack,根据是否可重试决定 requeue。
- 业务不可恢复的错误不要 requeue,否则会死循环占用资源,让它进死信队列人工处理。
七、消息可靠性,不丢
消息可能在三个环节丢失,分别要保障。
7.1 生产者到 Broker 不丢
- 开启 publisher confirm,确认消息到达 Broker。
- 未确认的消息要重发或记录到本地消息表后续补偿。
- 开启 returns callback,处理路由失败。
7.2 Broker 自身不丢
- 交换机持久化 durable true。
- 队列持久化 durable true。
- 消息持久化,Spring 默认 PERSISTENT。
三者都持久化,Broker 重启后消息不丢。注意持久化不等于绝对不丢,极端情况落盘前宕机仍可能丢,要求极高时用镜像队列或仲裁队列。
7.3 Broker 到消费者不丢
- 手动 ACK,业务处理成功才确认。
- 处理失败 nack 或进死信,不要自动 ACK。
- 自动 ACK 模式下,消费者拿到消息还没处理就宕机,消息会丢。
八、消息可靠性,不重,消费幂等
8.1 为什么会重复
- 生产者确认超时重发。
- 消费者处理成功但 ACK 网络丢失,Broker 重投。
- 所以 MQ 通常保证至少一次投递,重复不可避免,必须靠消费端幂等。
8.2 幂等方案,唯一 messageId 加去重表
sql
CREATE TABLE mq_consume_record (
id BIGINT NOT NULL AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL,
consumer VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_msg_consumer (message_id, consumer)
);
消费时先尝试插入去重记录,唯一键冲突说明已消费过,直接 ACK 跳过。
java
@Transactional(rollbackFor = Exception.class)
public void handleOrderCreated(OrderCreatedMessage msg) {
int inserted = consumeRecordMapper.insertIgnore(msg.getMessageId(), "orderCreate");
if (inserted == 0) {
// 已处理过,幂等跳过
return;
}
// 真正业务:落库订单、扣减数据库库存
orderRepository.save(buildOrder(msg));
}
8.3 用 Redis 做幂等
也可用第43天的 Redis setIfAbsent 判断 messageId 是否已处理,与第38天 Idempotency-Key 思路一致。但 Redis 方案在极端情况下不如数据库去重表可靠,关键业务建议用数据库。
九、死信队列与延迟消息
9.1 死信队列 DLX
消息成为死信的三种情况:
- 消息被 nack 或 reject 且 requeue 为 false。
- 消息 TTL 过期。
- 队列达到最大长度。
死信会被转发到绑定的死信交换机,再进入死信队列。用途:
- 收集消费失败的消息,人工排查或补偿。
- 实现延迟队列。
第6.3节已经给订单队列绑定了死信交换机。
9.2 延迟消息,TTL 加死信实现
让消息在普通队列里设置 TTL,不被消费,过期后自动进入死信队列,由死信队列的消费者处理,从而实现延迟。
java
@Bean
public Queue delayQueue() {
return QueueBuilder.durable("order.delay.queue")
.withArgument("x-dead-letter-exchange", "order.dlx.exchange")
.withArgument("x-dead-letter-routing-key", "order.dlx")
.withArgument("x-message-ttl", 1800000) // 30 分钟
.build();
}
典型场景,订单 30 分钟未支付自动取消:
text
下单时发一条延迟 30 分钟的消息
30 分钟后消息进入死信队列
消费者检查订单是否已支付,未支付则取消并回补库存
9.3 延迟插件方案
RabbitMQ 有 rabbitmq-delayed-message-exchange 插件,可直接发送指定延迟时间的消息,比 TTL 加死信更灵活。RocketMQ 原生支持延迟级别消息。
十、用 MQ 落地前面遗留场景
10.1 秒杀异步落库,衔接第44天
java
@Service
public class SeckillService {
private final StockPreDeductService stockService;
private final OrderMessageProducer producer;
public void seckill(Long userId, Long productId, int quantity) {
long result = stockService.preDeduct(productId, quantity);
if (result == -1) {
throw new IllegalStateException("活动未开始");
}
if (result == 0) {
throw new IllegalStateException("库存不足");
}
OrderCreatedMessage msg = new OrderCreatedMessage();
msg.setMessageId(UUID.randomUUID().toString());
msg.setUserId(userId);
msg.setProductId(productId);
msg.setQuantity(quantity);
msg.setTimestamp(System.currentTimeMillis());
producer.sendOrderCreated(msg);
}
}
Redis 预扣成功立即返回,订单落库异步进行,数据库压力被削峰。
10.2 消费失败回补库存
java
@RabbitListener(queues = RabbitConfig.ORDER_CREATE_QUEUE)
public void onMessage(Message message, Channel channel) throws IOException {
long tag = message.getMessageProperties().getDeliveryTag();
OrderCreatedMessage msg = parse(message);
try {
orderPersistService.handleOrderCreated(msg);
channel.basicAck(tag, false);
} catch (BusinessException e) {
// 业务失败,回补 Redis 预扣的库存,消息进死信
stockService.rollback(msg.getProductId(), msg.getQuantity());
channel.basicNack(tag, false, false);
}
}
10.3 缓存失效广播,衔接第43、44天
用 Fanout 交换机广播缓存失效消息,所有应用实例订阅后清本地缓存。
java
@Bean
public FanoutExchange cacheInvalidateExchange() {
return new FanoutExchange("cache.invalidate.exchange", true, false);
}
数据更新后发布失效消息,多实例各自清理本地缓存,解决第44天本地缓存多实例不一致问题。
10.4 下单后异步通知
text
订单落库成功 -> 发 OrderCreated 消息
短信服务消费,发下单成功短信
积分服务消费,增加用户积分
推荐服务消费,更新用户画像
下单主流程不再等待这些下游,互不影响。
十一、最终一致性与本地消息表
11.1 问题,发消息和数据库操作如何保证原子
经典坑,先写数据库再发消息,写库成功但发消息失败,下游收不到,数据不一致。反过来先发消息再写库,写库失败但消息已发出,下游处理了不存在的数据。
11.2 本地消息表方案
把消息先存进本地数据库,和业务在同一个事务里,保证原子。再由定时任务或异步线程把消息发到 MQ。
sql
CREATE TABLE local_message (
id BIGINT NOT NULL AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL,
exchange VARCHAR(64) NOT NULL,
routing_key VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_message_id (message_id)
);
流程:
text
1. 业务操作和插入 local_message 在同一事务,要么都成功要么都回滚
2. 事务提交后,发送线程或定时任务读取待发送消息,发到 MQ
3. 发送成功后更新 status 为已发送
4. 定时任务扫描长时间未发送成功的消息重发
5. 消费端做幂等,处理成功回执
11.3 RocketMQ 事务消息
RocketMQ 原生支持事务消息,分为发送半消息、执行本地事务、提交或回滚、事务回查四步,是本地消息表的中间件级实现,电商交易场景常用。
text
1. 生产者发送半消息,消费者暂时不可见
2. 半消息发送成功后,执行本地事务,如写订单
3. 本地事务成功则 commit,消息变可见,失败则 rollback
4. 若长时间未提交,Broker 回查生产者本地事务状态
11.4 一致性方案对比
| 方案 | 可靠性 | 复杂度 | 适用 |
|---|---|---|---|
| 直接发消息 | 低,可能不一致 | 低 | 非关键通知 |
| 本地消息表 | 高 | 中 | 通用,不依赖特定 MQ |
| RocketMQ 事务消息 | 高 | 中 | 已用 RocketMQ |
| 最大努力通知 | 中 | 低 | 对账补偿兜底 |
十二、消息顺序与积压
12.1 顺序消息
部分场景要求顺序,如同一订单的创建、支付、取消必须按序处理。
- RabbitMQ 单队列单消费者可保证顺序,但牺牲并发。
- RocketMQ 用同一 MessageQueue 加顺序消费保证局部有序。
- 常用做法是按订单 id 哈希到固定队列或分区,保证同一订单有序,不同订单并行。
12.2 消息积压处理
消费速度跟不上生产,队列堆积。应对:
- 增加消费者实例,提高并发。
- 提高 prefetch 和批量消费。
- 排查消费逻辑是否有慢操作,如同步调外部接口。
- 紧急情况临时写一个快速消费者,先把消息转储再慢慢处理。
十三、实战任务
任务 1,搭建 RabbitMQ,约 40 分钟
- 用 Docker 启动 RabbitMQ,带 management 插件。
- 访问管理后台 15672 端口,账号 guest。
- Spring Boot 引入 spring-boot-starter-amqp 并配置连接。
任务 2,第一条消息,约 1 小时
- 声明 Direct 交换机、队列、绑定。
- 写生产者发送一条 OrderCreatedMessage。
- 写消费者手动 ACK 接收并打印。
- 在管理后台观察队列消息进出。
任务 3,可靠性实践,约 1.5 小时
- 开启 publisher confirm 和 returns,故意写错 routing key 观察 returns 回调。
- 消费者手动 ACK,故意抛异常观察消息行为。
- 给队列配置死信交换机,验证 nack 后消息进死信队列。
任务 4,消费幂等,约 1 小时
- 建 mq_consume_record 去重表。
- 消费时先插入去重记录,模拟同一条消息投递两次,验证业务只执行一次。
任务 5,落地秒杀异步落库,约 1.5 小时
- 把第44天 seckill 改成预扣成功后发消息。
- 消费者落库订单并扣减数据库库存。
- 模拟消费失败,回补 Redis 库存,消息进死信。
任务 6,延迟取消订单,进阶
- 用 TTL 加死信或延迟插件实现 30 分钟延迟消息。
- 下单时发延迟消息,到期检查未支付则取消并回补库存。
任务 7,自检清单
- MQ 的三大价值是什么。
- RabbitMQ 四种交换机的区别。
- 消息在哪三个环节可能丢失,分别如何保证。
- 为什么消费端必须幂等。
- 死信队列的作用,如何用它实现延迟消息。
- 本地消息表如何保证发消息与数据库操作的原子性。
十四、常见错误与避坑
- 自动 ACK 导致丢消息,关键业务用手动 ACK。
- 消费失败无脑 requeue true,导致死循环,不可恢复的错误进死信。
- 不做幂等,重复消费造成重复下单、重复加积分。
- 先发消息后写库或先写库后发消息,没有本地消息表或事务消息保障,导致不一致。
- 队列和交换机没持久化,Broker 重启消息全丢。
- 一个消费者处理超大流量,没设 prefetch,内存被打爆。
- 在消费逻辑里同步调慢接口,导致积压,应再异步或优化。