文章目录
- [Spring Cloud 学习与实践(12):RabbitMQ 异步消息、死信队列、手动 ACK 与可靠消息补偿](#Spring Cloud 学习与实践(12):RabbitMQ 异步消息、死信队列、手动 ACK 与可靠消息补偿)
-
- [1、前言:为什么这一章要引入 RabbitMQ](#1、前言:为什么这一章要引入 RabbitMQ)
- 2、本章环境
- [3、先把 MQ、Feign、@Async 的边界讲清楚](#3、先把 MQ、Feign、@Async 的边界讲清楚)
- [4、RabbitMQ 核心角色](#4、RabbitMQ 核心角色)
- [5、阶段一:跑通 RabbitMQ 最小收发链路](#5、阶段一:跑通 RabbitMQ 最小收发链路)
-
- [5.1 本阶段目标](#5.1 本阶段目标)
- [5.2 引入 RabbitMQ 依赖](#5.2 引入 RabbitMQ 依赖)
- [5.3 Nacos 配置 RabbitMQ](#5.3 Nacos 配置 RabbitMQ)
- [5.4 阶段一版本的订单创建消息对象](#5.4 阶段一版本的订单创建消息对象)
- [5.5 定义 RabbitMQ 常量](#5.5 定义 RabbitMQ 常量)
- [5.6 声明 Exchange、Queue、Binding](#5.6 声明 Exchange、Queue、Binding)
- [5.7 发送订单创建测试消息](#5.7 发送订单创建测试消息)
- [5.8 临时测试接口](#5.8 临时测试接口)
- [5.9 消费订单创建消息](#5.9 消费订单创建消息)
- [5.10 RabbitMQ 管理页面核心位置说明](#5.10 RabbitMQ 管理页面核心位置说明)
- [5.11 阶段一验证结果](#5.11 阶段一验证结果)
- 6、阶段二:创建订单成功后发送订单创建消息
-
- [6.1 本阶段目标](#6.1 本阶段目标)
- [6.2 修改 OrderServiceImpl:注入生产者](#6.2 修改 OrderServiceImpl:注入生产者)
- [6.3 构建订单创建消息](#6.3 构建订单创建消息)
- [6.4 订单保存成功后注册 afterCommit](#6.4 订单保存成功后注册 afterCommit)
- [6.5 验证正常下单后发送消息](#6.5 验证正常下单后发送消息)
- [6.5 失败下单不会发送消息](#6.5 失败下单不会发送消息)
-
- [6.5.1 库存不足场景](#6.5.1 库存不足场景)
- [6.5.2 商品不存在场景](#6.5.2 商品不存在场景)
- [6.5 本阶段链路说明](#6.5 本阶段链路说明)
- 7、阶段三:消费者异步记录订单事件日志
-
- [7.1 本阶段目标](#7.1 本阶段目标)
- [7.2 给 OrderCreatedMessage 增加 messageId](#7.2 给 OrderCreatedMessage 增加 messageId)
- [7.3 修改真实下单消息构建方法](#7.3 修改真实下单消息构建方法)
- [7.4 同步改造测试接口](#7.4 同步改造测试接口)
- [7.5 创建订单事件日志表](#7.5 创建订单事件日志表)
- [7.6 新增订单事件类型常量](#7.6 新增订单事件类型常量)
- [7.7 新增实体、Mapper、Service](#7.7 新增实体、Mapper、Service)
- [7.8 修改消费者:写入事件日志](#7.8 修改消费者:写入事件日志)
- [7.9 阶段三验证](#7.9 阶段三验证)
- [7.10 失败下单不会写入事件日志](#7.10 失败下单不会写入事件日志)
-
- [7.10.1 库存不足](#7.10.1 库存不足)
- [7.10.2 商品不存在](#7.10.2 商品不存在)
- 8、阶段四:消费者异常时消息会发生什么
-
- [8.1 本阶段目标](#8.1 本阶段目标)
- [8.2 加入临时异常代码](#8.2 加入临时异常代码)
- [8.3 默认重新入队演练](#8.3 默认重新入队演练)
- [8.4 不重新入队演练](#8.4 不重新入队演练)
- 9、阶段五:死信交换机与死信队列
-
- [9.1 本阶段目标](#9.1 本阶段目标)
- [9.2 什么是 DLX / DLQ](#9.2 什么是 DLX / DLQ)
- [9.3 删除旧队列再重建](#9.3 删除旧队列再重建)
- [9.4 修改 MQ 常量](#9.4 修改 MQ 常量)
- [9.3 修改 RabbitMqConfig:声明 DLX / DLQ](#9.3 修改 RabbitMqConfig:声明 DLX / DLQ)
- [9.4 查看RabbitMQ 管理页面](#9.4 查看RabbitMQ 管理页面)
-
- [9.4.1 Exchanges 页面](#9.4.1 Exchanges 页面)
- [9.4.2 正常队列页面](#9.4.2 正常队列页面)
- [9.4.2 死信队列页面](#9.4.2 死信队列页面)
- [9.5 保留不重新入队配置](#9.5 保留不重新入队配置)
- [9.6 重新加入临时异常代码验证 DLQ](#9.6 重新加入临时异常代码验证 DLQ)
- [9.7 在 DLQ 页面查看死信消息](#9.7 在 DLQ 页面查看死信消息)
- [9.7 验证正常消息没有被影响](#9.7 验证正常消息没有被影响)
- [10、阶段六:手动 ack、有限重试与死信队列](#10、阶段六:手动 ack、有限重试与死信队列)
-
- [10.1 本阶段目标](#10.1 本阶段目标)
- [10.2 Nacos 开启手动 ack](#10.2 Nacos 开启手动 ack)
- [10.3 修改消费者为手动 ack 版本](#10.3 修改消费者为手动 ack 版本)
- [10.4 阶段六验证](#10.4 阶段六验证)
- 11、阶段七:重复消费与幂等处理
-
- [11.1 本阶段目标](#11.1 本阶段目标)
- [11.2 改造 OrderEventLogService](#11.2 改造 OrderEventLogService)
- [11.3 改造 ServiceImpl:messageId 幂等](#11.3 改造 ServiceImpl:messageId 幂等)
- [11.4 修改消费者:重复消息也 ack](#11.4 修改消费者:重复消息也 ack)
- [11.5 增加重复消息测试接口](#11.5 增加重复消息测试接口)
- [11.6 验证重复消息幂等](#11.6 验证重复消息幂等)
- [12、阶段八:生产者 Confirm 与 Return](#12、阶段八:生产者 Confirm 与 Return)
-
- [12.1 本阶段目标](#12.1 本阶段目标)
- [12.2 Nacos 开启 Confirm / Return](#12.2 Nacos 开启 Confirm / Return)
- [12.3 配置 RabbitTemplate 回调](#12.3 配置 RabbitTemplate 回调)
- [12.4 改造生产者:发送时带 CorrelationData](#12.4 改造生产者:发送时带 CorrelationData)
- [12.5 OrderMessageProducer增加两个故障测试发送方法](#12.5 OrderMessageProducer增加两个故障测试发送方法)
- [12.6 `OrderMessageTestController`增加 Confirm / Return 测试接口](#12.6
OrderMessageTestController增加 Confirm / Return 测试接口) - [12.7 验证三种情况](#12.7 验证三种情况)
-
- [12.7.1 验证正常消息 Confirm](#12.7.1 验证正常消息 Confirm)
- [12.7.2 验证错误 routing key:Return](#12.7.2 验证错误 routing key:Return)
- [12.7.3 验证错误 exchange:Confirm 失败或发送异常](#12.7.3 验证错误 exchange:Confirm 失败或发送异常)
- [12.7.4 三种发送结果对比](#12.7.4 三种发送结果对比)
- 13、阶段九:本地消息表记录发送状态
-
- [13.1 为什么需要本地消息表](#13.1 为什么需要本地消息表)
- [13.2 创建本地消息表](#13.2 创建本地消息表)
- [13.3 新增状态和类型常量](#13.3 新增状态和类型常量)
- [13.4 OrderMqConstant 增加 messageId header](#13.4 OrderMqConstant 增加 messageId header)
- [13.5 新增 MqMessageLog 实体、Mapper、Service](#13.5 新增 MqMessageLog 实体、Mapper、Service)
- [13.6 实现 MqMessageLogServiceImpl](#13.6 实现 MqMessageLogServiceImpl)
- [13.7 改造生产者:写入 x-message-id header](#13.7 改造生产者:写入 x-message-id header)
- [13.8 改造 RabbitTemplateCallbackConfig:更新本地消息表](#13.8 改造 RabbitTemplateCallbackConfig:更新本地消息表)
- [13.9 修改 OrderServiceImpl:订单事务内保存本地消息记录](#13.9 修改 OrderServiceImpl:订单事务内保存本地消息记录)
- [13.10 测试接口也保存本地消息记录](#13.10 测试接口也保存本地消息记录)
- [13.11 手动重试逻辑补 x-message-id header](#13.11 手动重试逻辑补 x-message-id header)
- [13.12 验证正常发送](#13.12 验证正常发送)
- [13.13 验证错误 routing key](#13.13 验证错误 routing key)
- [13.14 验证错误 exchange](#13.14 验证错误 exchange)
- [13.15 验证正常下单](#13.15 验证正常下单)
- 14、阶段十:本地消息表补偿重发
-
- [14.1 本阶段目标](#14.1 本阶段目标)
- [14.2 状态增加 RETRY_EXHAUSTED](#14.2 状态增加 RETRY_EXHAUSTED)
- [14.3 OrderMessageProducer 增加指定路由发送方法](#14.3 OrderMessageProducer 增加指定路由发送方法)
- [14.4 扩展 MqMessageLogService](#14.4 扩展 MqMessageLogService)
- [14.5 实现补偿方法](#14.5 实现补偿方法)
- [14.6 开启定时任务](#14.6 开启定时任务)
- [14.7 新增补偿任务](#14.7 新增补偿任务)
- [14.8 增加只保存本地消息、不立即发送的测试接口](#14.8 增加只保存本地消息、不立即发送的测试接口)
- [14.9 验证 PENDING 消息自动补偿](#14.9 验证 PENDING 消息自动补偿)
- [14.9 验证错误 exchange 重试耗尽](#14.9 验证错误 exchange 重试耗尽)
- 15、本章核心链路总结
- 16、本章核心对比表
-
- [16.1 Confirm 和 Return 对比](#16.1 Confirm 和 Return 对比)
- [16.2 AUTO ack 和 MANUAL ack 对比](#16.2 AUTO ack 和 MANUAL ack 对比)
- [16.3 消息状态表](#16.3 消息状态表)
- [16.4 本地消息表、事件日志表、死信队列对比](#16.4 本地消息表、事件日志表、死信队列对比)
- 17、生产边界
- 18、本章总结
- 19、下一章预告
Spring Cloud 学习与实践(12):RabbitMQ 异步消息、死信队列、手动 ACK 与可靠消息补偿
1、前言:为什么这一章要引入 RabbitMQ
第 11 章我们用 Redis 解决了商品详情接口的缓存问题:缓存穿透、缓存击穿、缓存雪崩,以及扣库存后的缓存删除。
这一章开始引入 RabbitMQ。
不过这一章不是简单演示"发一条消息、收一条消息",而是围绕订单创建场景,逐步解决真实项目里经常会遇到的问题:
text
下单成功后,是否所有后续动作都要同步完成?
消费者处理消息失败时,消息会不会丢?
失败消息能不能留痕排查?
手动 ack 到底解决什么问题?
同一条消息重复消费怎么办?
生产者怎么知道消息有没有发到 RabbitMQ?
订单已经创建成功,但 MQ 发送失败时怎么补偿?
本章主线是:
text
最小收发
↓
真实下单后发送消息
↓
消费者写事件日志
↓
消费者异常导致无限重试
↓
加死信队列
↓
改手动 ack
↓
加有限重试
↓
加幂等
↓
加 Confirm / Return
↓
加本地消息表
↓
加补偿重发任务
先跑通简单版,再故意制造问题,再改造代码,这是本章的学习路线。
2、本章环境
本章仍然基于前面章节的项目:
| 项目 | 当前值 |
|---|---|
| JDK | 17 |
| Spring Boot | 2.7.18 |
| Spring Cloud | 2021.0.8 |
| Spring Cloud Alibaba | 2021.0.5.0 |
| Nacos | 2.2.0 |
| Sentinel Dashboard | 1.8.6 |
| RabbitMQ | 本地已安装 RabbitMQ 3.8.18 |
| Erlang | 23.3 |
| RabbitMQ 管理页面 | http://localhost:15672 |
| RabbitMQ 账号密码 | guest/guest,仅本机演练使用 |
本章没有使用 Docker 启动 RabbitMQ,因为本机已经安装了 RabbitMQ,并且机器内存占用比较高。学习环境用本地安装版完全可以。
需要注意:
text
guest/guest 只适合本机学习演练。
生产环境不要使用默认账号密码。
本章涉及模块:
| 模块 | 作用 |
|---|---|
cloud-api |
放跨模块复用的消息对象 |
cloud-order |
发送订单创建消息、消费订单消息、记录事件日志、本地消息补偿 |
cloud-gateway |
继续负责 JWT 校验和路由转发 |
本章继续使用 Gateway 访问 order 服务:
text
POST http://localhost:9000/api/order/...
3、先把 MQ、Feign、@Async 的边界讲清楚
RabbitMQ 不应该被理解成"另一个 Feign"。
| 对比项 | OpenFeign | @Async |
RabbitMQ |
|---|---|---|---|
| 调用关系 | 服务 A 直接调用服务 B | 当前服务内部开异步线程 | 服务 A 发消息到 MQ,消费者从 MQ 取消息 |
| 是否强依赖对方在线 | 是 | 不涉及其它服务 | 生产者通常不直接依赖消费者在线 |
| 是否需要立刻拿结果 | 通常需要 | 不需要 | 不需要 |
| 适合场景 | 查询商品、扣库存 | 本服务内部异步任务 | 跨服务异步解耦、事件通知、削峰 |
| 本项目中的例子 | cloud-order 调 cloud-product |
第 9 章上下文异步传递 | 订单创建后发送 OrderCreatedMessage |
一句话:
text
Feign 解决"我现在就要调用你";
@Async 解决"我自己服务里有些事可以异步做";
RabbitMQ 解决"我发出一个事件,后续动作不要阻塞主流程"。
4、RabbitMQ 核心角色
阶段一开始前,先把几个角色记住。
| 概念 | 作用 | 类比 | 本章使用 |
|---|---|---|---|
| Producer | 发送消息的一方 | 寄信的人 | cloud-order |
| Exchange | 接收消息并决定发到哪些队列 | 邮局分拣中心 | cloud.order.exchange |
| Queue | 存放消息,等待消费者消费 | 收件箱 | cloud.order.created.queue |
| Binding | 绑定 Exchange 和 Queue 的规则 | 邮局到收件箱的路线 | routing key = order.created |
| Routing Key | 消息路由关键字 | 信封上的地址标签 | order.created |
| Consumer | 消费消息的一方 | 收信并处理的人 | OrderCreatedMessageListener |
| Exchange 类型 | 路由方式 | 适合场景 | 本章是否采用 |
|---|---|---|---|
| Direct | routing key 精确匹配 | 简单一对一事件 | 后面死信交换机采用 |
| Fanout | 不看 routing key,广播给所有绑定队列 | 广播通知 | 暂不需要 |
| Topic | routing key 支持模式匹配 | 订单事件后续扩展,如 order.created、order.cancelled |
正常订单交换机采用 |
| Headers | 根据消息 header 匹配 | 特殊复杂路由 | 暂不需要 |
本章正常订单事件交换机使用 TopicExchange,死信交换机使用 DirectExchange。
本章正常订单创建消息链路是:
text
Producer
↓
cloud.order.exchange
↓ routing key = order.created
cloud.order.created.queue
↓
Consumer
5、阶段一:跑通 RabbitMQ 最小收发链路
5.1 本阶段目标
阶段一先不要改真实下单逻辑,只验证:
text
cloud-order 能连接 RabbitMQ;
能发送消息;
能消费消息;
能在管理页面看到 exchange、queue、binding、consumer。
本阶段是最小链路,代码也保持最简单,不提前加入 messageId、Confirm、Return、本地消息表这些后面才需要的东西。
5.2 引入 RabbitMQ 依赖
修改:
text
cloud-order/pom.xml
增加:
xml
<!-- RabbitMQ:用于订单创建后的异步消息通知 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
5.3 Nacos 配置 RabbitMQ
修改 Nacos:
text
cloud-order-dev.yaml
本章使用本地 RabbitMQ,所以配置为:
yaml
spring:
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
virtual-host: /
注意不要重复写多个 spring: 根节点,要合并到原有配置中。

5.4 阶段一版本的订单创建消息对象
新建:
text
cloud-api
└── src/main/java
└── com.example.cloud.api.message
└── OrderCreatedMessage.java
java
package com.example.cloud.api.message;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.io.Serializable;
/**
* 订单创建消息。
*
* 这不是数据库实体,也不是前端请求对象。
* 它表示"订单已经创建"这个业务事件。
*
* 后续 cloud-order 创建订单成功后,
* 会把这个消息发送到 RabbitMQ。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderCreatedMessage implements Serializable {
/**
* 订单 ID。
*
* 阶段一先用测试值,
* 阶段二再改成真实订单 ID。
*/
private Long orderId;
/**
* 当前登录用户 ID。
*
* 来自 Gateway 写入的 X-User-Id,
* 再由 cloud-order 的 UserContext 读取。
*/
private Long userId;
/**
* 商品 ID。
*/
private Long productId;
/**
* 购买数量。
*/
private Integer quantity;
/**
* 订单金额。
*
* 阶段一先用测试值,
* 阶段二再使用真实订单金额。
*/
private BigDecimal amount;
/**
* 消息创建时间。
*
* 这里先用 String,避免 LocalDateTime 在消息 JSON 序列化时引入额外配置。
*/
private String createdAt;
}
这里暂时使用 String createdAt,避免一开始就引入 LocalDateTime 的 JSON 序列化问题。
5.5 定义 RabbitMQ 常量
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.mq
└── OrderMqConstant.java
java
package com.example.cloud.order.mq;
/**
* 订单服务 RabbitMQ 常量。
*/
public final class OrderMqConstant {
private OrderMqConstant() {
}
/**
* 订单事件交换机。
*/
public static final String ORDER_EXCHANGE =
"cloud.order.exchange";
/**
* 订单创建队列。
*/
public static final String ORDER_CREATED_QUEUE =
"cloud.order.created.queue";
/**
* 订单创建消息 routing key。
*/
public static final String ORDER_CREATED_ROUTING_KEY =
"order.created";
}
5.6 声明 Exchange、Queue、Binding
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.config
└── RabbitMqConfig.java
阶段一只声明正常交换机和正常队列,不加死信队列。
java
package com.example.cloud.order.config;
import com.example.cloud.order.mq.OrderMqConstant;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置。
*
* 阶段一声明:
* 1. 订单事件交换机
* 2. 订单创建队列
* 3. exchange 和 queue 之间的绑定关系
* 4. JSON 消息转换器
*/
@Configuration
public class RabbitMqConfig {
/**
* 订单事件 Topic 交换机。
*/
@Bean
public TopicExchange orderExchange() {
return ExchangeBuilder
.topicExchange(OrderMqConstant.ORDER_EXCHANGE)
.durable(true)
.build();
}
/**
* 订单创建队列。
*/
@Bean
public Queue orderCreatedQueue() {
return QueueBuilder
.durable(OrderMqConstant.ORDER_CREATED_QUEUE)
.build();
}
/**
* 订单创建消息绑定。
*/
@Bean
public Binding orderCreatedBinding(
Queue orderCreatedQueue,
TopicExchange orderExchange
) {
return BindingBuilder
.bind(orderCreatedQueue)
.to(orderExchange)
.with(OrderMqConstant.ORDER_CREATED_ROUTING_KEY);
}
/**
* JSON 消息转换器。
*/
@Bean
public MessageConverter jackson2JsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
5.7 发送订单创建测试消息
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.mq
└── OrderMessageProducer.java
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* 订单消息生产者。
*
* 负责把订单相关事件发送到 RabbitMQ。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发送订单创建消息。
*
* 消息发送到:
* exchange: cloud.order.exchange
* routing key: order.created
*/
public void sendOrderCreatedMessage(
OrderCreatedMessage message
) {
rabbitTemplate.convertAndSend(
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message
);
log.info("发送订单创建消息成功,message={}", message);
}
}
5.8 临时测试接口
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.controller
└── OrderMessageTestController.java
java
package com.example.cloud.order.controller;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.mq.OrderMessageProducer;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 订单消息测试接口。
*
* 仅用于阶段一验证 MQ 最小收发链路。
*/
@RestController
@RequestMapping("/mq/order-created")
@RequiredArgsConstructor
public class OrderMessageTestController {
private final OrderMessageProducer orderMessageProducer;
/**
* 发送一条订单创建测试消息。
*/
@PostMapping("/test")
public Result<Void> sendTestMessage(
@RequestParam(defaultValue = "1") Long productId,
@RequestParam(defaultValue = "1") Integer quantity
) {
Long userId = UserContext.requireUserId();
OrderCreatedMessage message = OrderCreatedMessage
.builder()
.orderId(System.currentTimeMillis())
.userId(userId)
.productId(productId)
.quantity(quantity)
.amount(new BigDecimal("99.00"))
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
orderMessageProducer.sendOrderCreatedMessage(message);
return Result.success();
}
}
5.9 消费订单创建消息
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.mq
└── OrderCreatedMessageListener.java
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* 订单创建消息消费者。
*
* 阶段一先只打印日志,
* 用来确认消息可以从 RabbitMQ 被消费。
*/
@Slf4j
@Component
public class OrderCreatedMessageListener {
/**
* 监听订单创建队列。
*
* 只要 cloud.order.created.queue 中有消息,
* 当前方法就会被触发。
*/
@RabbitListener(queues = OrderMqConstant.ORDER_CREATED_QUEUE)
public void handleOrderCreatedMessage(
OrderCreatedMessage message
) {
log.info("收到订单创建消息,message={}", message);
}
}

5.10 RabbitMQ 管理页面核心位置说明
阶段一测试成功后,除了看 cloud-order 控制台日志,也要在 RabbitMQ 管理页面看几个位置。
| 页面 | 应该看到什么 |
|---|---|
| Exchanges | cloud.order.exchange |
| Queues and Streams | cloud.order.created.queue |
| Exchange 详情 / Bindings | cloud.order.created.queue 绑定到 order.created |
| Queue 详情 / Consumers | 至少 1 个消费者,状态 up |
Exchange 页面最重要的是这条绑定关系:
text
cloud.order.exchange
↓ routing key = order.created
cloud.order.created.queue
Queue 页面里如果看到队列消息数短暂变成 1 又回到 0,这是正常现象:
text
消息不是没进队列,
而是已经被消费者消费掉了。
Get messages 区域不要随便点。RabbitMQ 页面提示这是 destructive action,意思是手动从队列取消息可能影响队列状态。本地演练排查时可以用,正常流程主要看消费者日志。
5.11 阶段一验证结果
通过 Gateway 访问:
http
POST http://localhost:9000/api/order/mq/order-created/test?productId=1&quantity=2
Authorization: Bearer {{token}}




本阶段验证项:
text
通过 Gateway 调测试接口返回 success
cloud-order 打印"发送订单创建消息成功"
cloud-order 打印"收到订单创建消息"
队列消息数量最终回到 0
到这里,最小收发链路成立。
6、阶段二:创建订单成功后发送订单创建消息
6.1 本阶段目标
阶段一只是测试接口发消息。阶段二开始接入真实下单链路:
text
创建订单成功
↓
事务提交成功
↓
afterCommit 发送 OrderCreatedMessage
↓
消费者异步收到消息
这里最重要的是:不要在订单事务还没提交时就发送 MQ 消息。
如果订单保存后立刻发送消息,后面事务又回滚,就可能出现:
text
消息已经发出去了,
但是数据库里没有这个订单。
所以本阶段使用:
text
TransactionSynchronizationManager.registerSynchronization(... afterCommit ...)
订单事务真正提交成功后再发送 MQ。
6.2 修改 OrderServiceImpl:注入生产者
修改:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.service.impl
└── OrderServiceImpl.java
增加依赖注入:
java
private final OrderMessageProducer orderMessageProducer;
需要 import:
java
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.mq.OrderMessageProducer;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
6.3 构建订单创建消息
在 OrderServiceImpl 增加一个私有方法:
java
/**
* 构建订单创建消息。
*
* 注意:
* 这个消息表示"订单已经创建"这个业务事件。
* 它不是数据库实体,也不是前端请求对象。
*/
private OrderCreatedMessage buildOrderCreatedMessage(Order order) {
return OrderCreatedMessage
.builder()
.orderId(order.getId())
.userId(order.getUserId())
.productId(order.getProductId())
.quantity(order.getQuantity())
.amount(order.getAmount())
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
}
6.4 订单保存成功后注册 afterCommit
在创建订单成功后增加:
java
OrderCreatedMessage message = buildOrderCreatedMessage(order);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
orderMessageProducer.sendOrderCreatedMessage(message);
}
}
);
本项目中 createOrder() 方法本身确定有 @Transactional,所以这里不需要额外写:
java
if (TransactionSynchronizationManager.isSynchronizationActive()) {
...
} else {
...
}

6.5 验证正常下单后发送消息
先恢复库存:
sql
UPDATE t_product
SET stock = 100
WHERE id = 1;
重启CloudOrderApplication后创建订单:
http
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期:
text
订单创建成功;
cloud-order 日志应该看到:发送订单创建消息成功,message=OrderCreatedMessage(...)
随后消费者日志看到:收到订单创建消息,message=OrderCreatedMessage(...)
RabbitMQ 管理页面中:
Exchange message rates 可能短暂出现 Publish In / Publish Out
Queue messages 最终回到 0
Queue consumers 仍然有 1 个消费者,状态 up

6.5 失败下单不会发送消息
失败下单,比如库存不足或商品不存在,不应该发送 MQ 消息。
6.5.1 库存不足场景
先把库存改成 0:
sql
UPDATE t_product
SET stock = 0
WHERE id = 1;
再创建订单:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期:
bash
接口返回商品库存不足
订单不新增
不会打印"发送订单创建消息成功"
不会打印"收到订单创建消息"

6.5.2 商品不存在场景
请求不存在商品:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 999999,
"quantity": 1
}
同样是:
bash
订单不新增
消息不发送
消费者不触发
6.5 本阶段链路说明
正常链路:
bash
客户端创建订单
↓
Gateway 校验 JWT
↓
cloud-order 读取 UserContext
↓
Feign 查询用户
↓
Feign 查询商品
↓
Feign 扣减库存
↓
保存订单
↓
事务提交成功
↓
afterCommit 发送 OrderCreatedMessage
↓
RabbitMQ 路由到 cloud.order.created.queue
↓
消费者异步收到消息
失败链路:
bash
库存不足 / 商品不存在 / 用户异常
↓
抛出 BizException
↓
事务回滚或不提交
↓
afterCommit 不执行
↓
不发送 MQ 消息
7、阶段三:消费者异步记录订单事件日志
7.1 本阶段目标
前面消费者只是打印日志,还不算真正处理业务。
阶段三让消费者做一件实际的后续动作:
text
收到订单创建消息
↓
写入订单事件日志表 t_order_event_log
同时,从本阶段开始给消息增加 messageId,为后面的幂等、Confirm、Return、本地消息表做准备。
7.2 给 OrderCreatedMessage 增加 messageId
修改:
text
cloud-api/.../OrderCreatedMessage.java
完整代码变成:
java
package com.example.cloud.api.message;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 订单创建消息。
*
* 这不是数据库实体,也不是前端请求对象。
* 它表示"订单已经创建"这个业务事件。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderCreatedMessage implements Serializable {
/**
* 消息唯一 ID。
*
* 后续用于消费日志记录、重复消费判断、幂等处理。
*/
private String messageId;
/**
* 订单 ID。
*/
private Long orderId;
/**
* 当前登录用户 ID。
*/
private Long userId;
/**
* 商品 ID。
*/
private Long productId;
/**
* 购买数量。
*/
private Integer quantity;
/**
* 订单金额。
*/
private BigDecimal amount;
/**
* 消息创建时间。
*/
private String createdAt;
}
7.3 修改真实下单消息构建方法
修改 OrderServiceImpl#buildOrderCreatedMessage:
java
import java.util.UUID;
java
private OrderCreatedMessage buildOrderCreatedMessage(Order order) {
return OrderCreatedMessage
.builder()
# 增加 messageId赋值
.messageId(UUID.randomUUID().toString())
.orderId(order.getId())
.userId(order.getUserId())
.productId(order.getProductId())
.quantity(order.getQuantity())
.amount(order.getAmount())
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
}
7.4 同步改造测试接口
这个地方很重要。
阶段一的 /test 测试接口最早没有 messageId。也应该同步补上:
java
.messageId(UUID.randomUUID().toString())
也就是:
java
OrderCreatedMessage message = OrderCreatedMessage
.builder()
.messageId(UUID.randomUUID().toString())
.orderId(System.currentTimeMillis())
.userId(userId)
.productId(productId)
.quantity(quantity)
.amount(new BigDecimal("99.00"))
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();

7.5 创建订单事件日志表
执行 SQL:
sql
CREATE TABLE t_order_event_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
message_id VARCHAR(64) NOT NULL COMMENT '消息唯一 ID',
order_id BIGINT NOT NULL COMMENT '订单 ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
message_body TEXT NOT NULL COMMENT '消息内容 JSON',
consume_status TINYINT NOT NULL COMMENT '消费状态:1-成功,2-失败',
error_message VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
created_at DATETIME NOT NULL COMMENT '创建时间',
UNIQUE KEY uk_message_id (message_id),
KEY idx_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单事件日志表';
字段说明:
| 字段 | 作用 |
|---|---|
message_id |
消息唯一 ID,后面做幂等 |
order_id |
关联订单 |
event_type |
当前是 ORDER_CREATED |
message_body |
原始消息 JSON,便于排查 |
consume_status |
当前先记录成功 |
error_message |
后面失败场景可扩展 |
7.6 新增订单事件类型常量
新建:
text
cloud-order/.../event/OrderEventType.java
java
package com.example.cloud.order.event;
/**
* 订单事件类型。
*/
public final class OrderEventType {
private OrderEventType() {
}
/**
* 订单已创建事件。
*/
public static final String ORDER_CREATED = "ORDER_CREATED";
}
7.7 新增实体、Mapper、Service
实体:
java
package com.example.cloud.order.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 订单事件日志。
*/
@Data
@TableName("t_order_event_log")
public class OrderEventLog {
@TableId(type = IdType.AUTO)
private Long id;
private String messageId;
private Long orderId;
private String eventType;
private String messageBody;
private Integer consumeStatus;
private String errorMessage;
private LocalDateTime createdAt;
}
Mapper:
java
package com.example.cloud.order.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.order.entity.OrderEventLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 订单事件日志 Mapper。
*/
@Mapper
public interface OrderEventLogMapper
extends BaseMapper<OrderEventLog> {
}
Service:
java
package com.example.cloud.order.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.entity.OrderEventLog;
/**
* 订单事件日志服务。
*/
public interface OrderEventLogService
extends IService<OrderEventLog> {
/**
* 记录订单创建事件消费成功日志。
*/
void recordOrderCreated(OrderCreatedMessage message);
}
ServiceImpl:
java
package com.example.cloud.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.order.entity.OrderEventLog;
import com.example.cloud.order.event.OrderEventType;
import com.example.cloud.order.mapper.OrderEventLogMapper;
import com.example.cloud.order.service.OrderEventLogService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* 订单事件日志服务实现。
*
* 当前阶段消费者收到订单创建消息后,
* 会调用这里把事件写入数据库。
*/
@Service
@RequiredArgsConstructor
public class OrderEventLogServiceImpl
extends ServiceImpl<OrderEventLogMapper, OrderEventLog>
implements OrderEventLogService {
private final ObjectMapper objectMapper;
@Override
public void recordOrderCreated(OrderCreatedMessage message) {
OrderEventLog eventLog = new OrderEventLog();
eventLog.setMessageId(message.getMessageId());
eventLog.setOrderId(message.getOrderId());
eventLog.setEventType(OrderEventType.ORDER_CREATED);
eventLog.setConsumeStatus(1);
eventLog.setErrorMessage(null);
eventLog.setCreatedAt(LocalDateTime.now());
try {
eventLog.setMessageBody(
objectMapper.writeValueAsString(message)
);
} catch (JsonProcessingException e) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"订单事件消息序列化失败"
);
}
save(eventLog);
}
}
7.8 修改消费者:写入事件日志
阶段三的消费者还是自动 ack 版本,只是从打印日志改成写数据库。
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.service.OrderEventLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* 订单创建消息消费者。
*
* 当前阶段收到订单创建消息后,
* 不再只是打印日志,
* 而是写入订单事件日志表。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderCreatedMessageListener {
private final OrderEventLogService orderEventLogService;
/**
* 监听订单创建队列。
*
* 只要 cloud.order.created.queue 中有消息,
* 当前方法就会被触发。
*/
@RabbitListener(queues = OrderMqConstant.ORDER_CREATED_QUEUE)
public void handleOrderCreatedMessage(
OrderCreatedMessage message
) {
log.info("收到订单创建消息,准备记录订单事件日志,message={}",
message);
orderEventLogService.recordOrderCreated(message);
log.info("订单创建消息处理完成,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
}
}

7.9 阶段三验证
重启 cloud-order,为了方便观察,可以先清理一下事件日志表:
bash
DELETE FROM t_order_event_log;
恢复商品库存:
bash
UPDATE t_product
SET stock = 100
WHERE id = 1;
创建订单:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}

cloud-order 日志应该看到:
bash
发送订单创建消息成功,message=OrderCreatedMessage(...)
收到订单创建消息,准备记录订单事件日志,message=OrderCreatedMessage(...)
订单创建消息处理完成,messageId=...,orderId=...
查询事件日志表:
sql
SELECT
id,
message_id,
order_id,
event_type,
consume_status,
error_message,
created_at
FROM t_order_event_log
ORDER BY id DESC
LIMIT 5;
预期:
| 字段 | 预期 |
|---|---|
message_id |
非空 UUID |
event_type |
ORDER_CREATED |
consume_status |
1 |
error_message |
NULL |
库存不足、商品不存在时,不应该发送 MQ,也不应该新增事件日志。

7.10 失败下单不会写入事件日志
7.10.1 库存不足
bash
UPDATE t_product
SET stock = 0
WHERE id = 1;
创建订单:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
7.10.2 商品不存在
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 999999,
"quantity": 1
}

8、阶段四:消费者异常时消息会发生什么
8.1 本阶段目标
前面一直是正常消费。阶段四故意让消费者失败,看消息怎么变化。
要观察两种配置:
| 配置 | 现象 | 问题 |
|---|---|---|
default-requeue-rejected: true |
消息反复回原队列,消费者一直报错 | 毒消息死循环 |
default-requeue-rejected: false,但没有 DLQ |
消息不再反复消费 | 失败消息不可追踪 |
8.2 加入临时异常代码
修改 OrderCreatedMessageListener,在写事件日志前加:
java
/*
* 第 12 章阶段四临时故障演练代码。
*
* 当购买数量为 66 时,故意抛出异常,
* 用来观察消费者异常后 RabbitMQ 消息会如何处理。
*
* 演练结束后必须删除。
*/
if (Integer.valueOf(66).equals(message.getQuantity())) {
log.error("模拟消费者处理订单创建消息失败,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
throw new RuntimeException("模拟消费者异常");
}
完整代码是:
java
@RabbitListener(queues = OrderMqConstant.ORDER_CREATED_QUEUE)
public void handleOrderCreatedMessage(
OrderCreatedMessage message
) {
log.info("收到订单创建消息,准备记录订单事件日志,message={}",
message);
/*
* 第 12 章阶段四临时故障演练代码。
*
* 当购买数量为 66 时,故意抛出异常,
* 用来观察消费者异常后 RabbitMQ 消息会如何处理。
*
* 演练结束后必须删除。
*/
if (Integer.valueOf(66).equals(message.getQuantity())) {
log.error("模拟消费者处理订单创建消息失败,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
throw new RuntimeException("模拟消费者异常");
}
orderEventLogService.recordOrderCreated(message);
log.info("订单创建消息处理完成,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
}
8.3 默认重新入队演练
Nacos 显式配置:
yaml
spring:
rabbitmq:
listener:
simple:
default-requeue-rejected: true

先恢复商品库存,避免库存不足导致订单创建失败:
bash
UPDATE t_product
SET stock = 1000
WHERE id = 1;
发送异常订单:
http
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 66
}

现象:
text
消费者反复收到同一条消息;
日志反复打印"模拟消费者处理订单创建消息失败";
队列消息数量可能反复变化。

链路:
bash
消费者抛异常
↓
消息没有被成功确认
↓
消息重新回到队列
↓
RabbitMQ 再次投递给消费者
↓
消费者再次抛异常
↓
循环
结论:
text
只靠重新入队,会导致毒消息死循环。
8.4 不重新入队演练
把 Nacos 改成:
yaml
spring:
rabbitmq:
listener:
simple:
default-requeue-rejected: false
再次发送 quantity=66。

查看事件日志无新增:

现象:
text
消费者失败一次后,不再无限重复消费;
但是此时还没有死信队列和写入事件日志,失败消息基本不可追踪。
阶段四结束后:
text
注释或删除 quantity=66 临时异常代码;
暂时保留 default-requeue-rejected=false,下一阶段配合 DLQ 使用。
9、阶段五:死信交换机与死信队列
9.1 本阶段目标
阶段四看到:
text
重新入队会死循环;
不重新入队又会丢失失败消息。
阶段五加入 DLX / DLQ:
text
消费者异常
↓
不重新入原队列
↓
进入死信交换机
↓
进入死信队列
↓
等待排查或补偿
9.2 什么是 DLX / DLQ
| 缩写 | 全称 | 含义 |
|---|---|---|
| DLX | Dead Letter Exchange | 死信交换机 |
| DLQ | Dead Letter Queue | 死信队列 |
| Dead Letter | 死信消息 | 不能被正常消费、被拒绝、过期或超限后的消息 |
9.3 删除旧队列再重建
如果原来已经创建过:
text
cloud.order.created.queue
现在给它增加死信参数,可能会报:
text
PRECONDITION_FAILED - inequivalent arg 'x-dead-letter-exchange'
因为 RabbitMQ 中同名队列已经存在,不能直接改队列参数。
本地学习环境处理方式:
text
1. 停止 cloud-order
2. 在 RabbitMQ 管理页面删除旧的 cloud.order.created.queue
3. 重启 cloud-order
4. 让 Spring AMQP 按新参数重新声明队列
生产环境不能随便删队列,要提前规划或通过 policy 管理。
9.4 修改 MQ 常量
修改 OrderMqConstant.java,增加死信交换机、死信队列和死信 routing key。完全代码:
java
package com.example.cloud.order.mq;
/**
* 订单服务 RabbitMQ 常量。
*/
public final class OrderMqConstant {
private OrderMqConstant() {
}
/**
* 订单事件交换机。
*/
public static final String ORDER_EXCHANGE =
"cloud.order.exchange";
/**
* 订单创建队列。
*/
public static final String ORDER_CREATED_QUEUE =
"cloud.order.created.queue";
/**
* 订单创建消息 routing key。
*/
public static final String ORDER_CREATED_ROUTING_KEY =
"order.created";
/**
* 订单死信交换机。
*
* 消费失败并且不重新入队的消息,
* 会被路由到这个交换机。
*/
public static final String ORDER_DLX_EXCHANGE =
"cloud.order.dlx.exchange";
/**
* 订单创建死信队列。
*
* 用来保存订单创建消息消费失败后的死信消息。
*/
public static final String ORDER_CREATED_DLQ =
"cloud.order.created.dlq";
/**
* 订单创建死信 routing key。
*/
public static final String ORDER_CREATED_DLQ_ROUTING_KEY =
"order.created.dead";
}
9.3 修改 RabbitMqConfig:声明 DLX / DLQ
代码:
java
package com.example.cloud.order.config;
import com.example.cloud.order.mq.OrderMqConstant;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置。
*
* 当前配置包含:
* 1. 订单正常交换机
* 2. 订单创建正常队列
* 3. 正常消息绑定关系
* 4. 订单死信交换机
* 5. 订单创建死信队列
* 6. 死信消息绑定关系
* 7. JSON 消息转换器
*/
@Configuration
public class RabbitMqConfig {
/**
* 订单事件 Topic 交换机。
*/
@Bean
public TopicExchange orderExchange() {
return ExchangeBuilder
.topicExchange(OrderMqConstant.ORDER_EXCHANGE)
.durable(true)
.build();
}
/**
* 订单创建队列。
*
* 这里比之前多了两个配置:
*
* deadLetterExchange:
* 当前队列里的消息如果变成死信,
* 会被发送到哪个死信交换机。
*
* deadLetterRoutingKey:
* 死信消息发送到死信交换机时,
* 使用哪个 routing key。
*/
@Bean
public Queue orderCreatedQueue() {
return QueueBuilder
.durable(OrderMqConstant.ORDER_CREATED_QUEUE)
.deadLetterExchange(OrderMqConstant.ORDER_DLX_EXCHANGE)
.deadLetterRoutingKey(
OrderMqConstant.ORDER_CREATED_DLQ_ROUTING_KEY
)
.build();
}
/**
* 正常订单创建消息绑定。
*
* cloud.order.exchange
* -- order.created -->
* cloud.order.created.queue
*/
@Bean
public Binding orderCreatedBinding() {
return BindingBuilder
.bind(orderCreatedQueue())
.to(orderExchange())
.with(OrderMqConstant.ORDER_CREATED_ROUTING_KEY);
}
/**
* 订单死信交换机。
*
* 这里使用 DirectExchange。
* 因为死信路由暂时只需要精确匹配:
* order.created.dead
*/
@Bean
public DirectExchange orderDlxExchange() {
return ExchangeBuilder
.directExchange(OrderMqConstant.ORDER_DLX_EXCHANGE)
.durable(true)
.build();
}
/**
* 订单创建死信队列。
*
* 消费订单创建消息失败后,
* 死信消息最终会进入这个队列。
*/
@Bean
public Queue orderCreatedDeadLetterQueue() {
return QueueBuilder
.durable(OrderMqConstant.ORDER_CREATED_DLQ)
.build();
}
/**
* 死信消息绑定。
*
* cloud.order.dlx.exchange
* -- order.created.dead -->
* cloud.order.created.dlq
*/
@Bean
public Binding orderCreatedDeadLetterBinding() {
return BindingBuilder
.bind(orderCreatedDeadLetterQueue())
.to(orderDlxExchange())
.with(OrderMqConstant.ORDER_CREATED_DLQ_ROUTING_KEY);
}
/**
* JSON 消息转换器。
*/
@Bean
public MessageConverter jackson2JsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}

9.4 查看RabbitMQ 管理页面
重启cloud-order后,在管理页面检查。
9.4.1 Exchanges 页面
Exchanges 页面:
| Exchange | Type | 作用 |
|---|---|---|
cloud.order.exchange |
topic |
正常订单事件交换机 |
cloud.order.dlx.exchange |
direct |
订单死信交换机 |

进入 cloud.order.exchange,应该看到正常交换机绑定:
| To | Routing key |
|---|---|
cloud.order.created.queue |
order.created |
进入 cloud.order.dlx.exchange,应该看到死信交换机绑定:
| To | Routing key |
|---|---|
cloud.order.created.dlq |
order.created.dead |
9.4.2 正常队列页面
正常队列参数:
| 参数 | 值 | 说明 |
|---|---|---|
x-dead-letter-exchange |
cloud.order.dlx.exchange |
消息变成死信后发往哪个 exchange |
x-dead-letter-routing-key |
order.created.dead |
死信消息使用哪个 routing key |
9.4.2 死信队列页面
进入cloud.order.created.dlq死信队列:
| 页面位置 | 预期 |
|---|---|
| Queue 名称 | cloud.order.created.dlq |
| Consumers | 0,本阶段先不消费 DLQ |
| Messages | 一开始是 0,触发失败后变成 1 |

9.5 保留不重新入队配置
Nacos 保留:
yaml
spring:
rabbitmq:
listener:
simple:
default-requeue-rejected: false
这表示消费者异常后不回原队列。有了 DLX / DLQ 后,失败消息会进入死信队列。
9.6 重新加入临时异常代码验证 DLQ
再次在消费者里临时加入:
java
if (Integer.valueOf(66).equals(message.getQuantity())) {
log.error("模拟消费者处理订单创建消息失败,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
throw new RuntimeException("模拟消费者异常,验证死信队列");
}
先恢复库存:
bash
UPDATE t_product
SET stock = 1000
WHERE id = 1;
重启cloud-order发送:
http
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 66
}
预期:
| 位置 | 预期 |
|---|---|
cloud.order.created.queue |
消息数最终为 0 |
cloud.order.created.dlq |
新增 1 条 |
| DLQ headers | 能看到 x-death |
| 消费者日志 | 不再无限刷屏 |


死信日志不再重复:

9.7 在 DLQ 页面查看死信消息
进入:
bash
Queues
↓
cloud.order.created.dlq
↓
Get messages
这里要注意页面提示:
bash
getting messages from a queue is a destructive action
默认Ack Mode :
bash
Nack message requeue true
这样查看后,消息会重新放回当前队列,不会被直接确认删除。

9.7 验证正常消息没有被影响
两次发送正常订单:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期:
bash
下单成功
正常队列收到消息
消费者写入 t_order_event_log
正常队列消息数回到 0
DLQ 不新增

10、阶段六:手动 ack、有限重试与死信队列
10.1 本阶段目标
本章先不用 Spring Retry,也不引入延迟队列。
前面消费者异常由容器自动处理。阶段六改为手动 ack:
text
消费成功:basicAck
消费失败但未超过 3 次:重新投递,并 ack 当前失败消息
消费失败且达到 3 次:basicNack(requeue=false),进入 DLQ
10.2 Nacos 开启手动 ack
修改:
yaml
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual
default-requeue-rejected: false
10.3 修改消费者为手动 ack 版本
这版消费者逻辑会做三件事:
bash
1. 消费成功:手动 basicAck
2. 消费失败但未达到最大重试次数:重新投递并 ack 原消息
3. 消费失败且达到最大重试次数:basicNack(requeue=false),进入 DLQ
阶段六的 OrderCreatedMessageListener 完整代码如下。
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.service.OrderEventLogService;
import com.rabbitmq.client.Channel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 订单创建消息消费者。
*
* 当前阶段使用手动 ack:
*
* 1. 消费成功:basicAck
* 2. 消费失败且未超过最大重试次数:重新投递消息,并 ack 当前消息
* 3. 消费失败且超过最大重试次数:basicNack(requeue=false),进入死信队列
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderCreatedMessageListener {
/**
* 最大重试次数。
*
* 表示第一次消费失败后,最多再重试 3 次。
*/
private static final int MAX_RETRY_COUNT = 3;
/**
* 自定义消息头:当前重试次数。
*/
private static final String RETRY_COUNT_HEADER = "x-retry-count";
private final OrderEventLogService orderEventLogService;
private final RabbitTemplate rabbitTemplate;
/**
* 监听订单创建队列。
*
* 注意:
* 当前 Nacos 中已经配置 acknowledge-mode: manual,
* 所以这里必须手动 ack 或 nack。
*/
@RabbitListener(queues = OrderMqConstant.ORDER_CREATED_QUEUE)
public void handleOrderCreatedMessage(
OrderCreatedMessage message,
Message amqpMessage,
Channel channel
) throws IOException {
long deliveryTag = amqpMessage
.getMessageProperties()
.getDeliveryTag();
int retryCount = getRetryCount(amqpMessage);
try {
log.info("收到订单创建消息,准备记录订单事件日志,message={},retryCount={}",
message,
retryCount);
/*
* 第 12 章阶段六临时故障演练代码。
*
* quantity=66 时故意失败,
* 用来验证手动 ack、有限重试和最终进入 DLQ。
*
* 演练结束后必须删除。
*/
if (Integer.valueOf(66).equals(message.getQuantity())) {
throw new RuntimeException("模拟消费者异常,验证手动 ack 和有限重试");
}
orderEventLogService.recordOrderCreated(message);
/*
* 只有业务处理真正成功后,才 ack。
*
* ack 之后,RabbitMQ 才会认为这条投递已经处理完成,
* 可以从队列中删除。
*/
channel.basicAck(deliveryTag, false);
log.info("订单创建消息处理完成并 ack,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
} catch (Exception e) {
handleConsumeFailure(
message,
amqpMessage,
channel,
deliveryTag,
retryCount,
e
);
}
}
/**
* 处理消费失败。
*/
private void handleConsumeFailure(
OrderCreatedMessage message,
Message amqpMessage,
Channel channel,
long deliveryTag,
int retryCount,
Exception exception
) throws IOException {
if (retryCount < MAX_RETRY_COUNT) {
int nextRetryCount = retryCount + 1;
log.warn("订单创建消息消费失败,准备第 {} 次重试,messageId={},orderId={},原因={}",
nextRetryCount,
message.getMessageId(),
message.getOrderId(),
exception.getMessage());
/*
* 重新投递一条消息到原交换机。
*
* 这次会在消息 header 中写入新的 x-retry-count。
*/
republishForRetry(message, nextRetryCount);
/*
* 重新投递成功后,ack 当前这条失败消息。
*
* 否则当前消息还留在队列里,
* 新消息又被重新投递,会导致重复更多。
*/
channel.basicAck(deliveryTag, false);
log.warn("订单创建消息已重新投递并 ack 原消息,messageId={},nextRetryCount={}",
message.getMessageId(),
nextRetryCount);
return;
}
/*
* 已经达到最大重试次数。
*
* requeue=false:
* 不再回到原队列。
*
* 因为原队列配置了 DLX,
* 所以这条消息会进入死信交换机和死信队列。
*/
log.error("订单创建消息消费失败且达到最大重试次数,准备进入死信队列,messageId={},orderId={},retryCount={}",
message.getMessageId(),
message.getOrderId(),
retryCount,
exception);
channel.basicNack(deliveryTag, false, false);
}
/**
* 重新投递消息,用于有限重试。
*/
private void republishForRetry(
OrderCreatedMessage message,
int nextRetryCount
) {
rabbitTemplate.convertAndSend(
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message,
retryMessage -> {
retryMessage.getMessageProperties()
.setHeader(
RETRY_COUNT_HEADER,
nextRetryCount
);
return retryMessage;
}
);
}
/**
* 获取当前重试次数。
*/
private int getRetryCount(Message amqpMessage) {
Object retryCount = amqpMessage
.getMessageProperties()
.getHeaders()
.get(RETRY_COUNT_HEADER);
if (retryCount == null) {
return 0;
}
if (retryCount instanceof Integer) {
return (Integer) retryCount;
}
if (retryCount instanceof Long) {
return ((Long) retryCount).intValue();
}
if (retryCount instanceof String) {
return Integer.parseInt((String) retryCount);
}
return 0;
}
}

10.4 阶段六验证
重启 cloud-order,恢复库存:
bash
UPDATE t_product
SET stock = 1000
WHERE id = 1;
发送正常订单:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期`:
text
1. 下单成功
2. 消费者打印"准备记录订单事件日志"
3. 消费者打印"订单创建消息处理完成并 ack"
4. t_order_event_log 新增记录
5. cloud.order.created.queue 消息数最终为 0
6. cloud.order.created.dlq 不新增


异常消息 quantity=66:
text
retryCount=0,准备第 1 次重试;
retryCount=1,准备第 2 次重试;
retryCount=2,准备第 3 次重试;
retryCount=3,进入 DLQ。
事件日志表不新增成功记录
RabbitMQ 页面预期:
| 位置 | 预期 |
|---|---|
cloud.order.created.queue |
消息数最终为 0 |
cloud.order.created.dlq |
消息数变成 1 |
| DLQ header | x-retry-count=3 和 x-death |



11、阶段七:重复消费与幂等处理
11.1 本阶段目标
MQ 不能保证业务层绝对只消费一次。同一条消息可能因为网络、ack 失败、生产者重发等原因被重复投递。
所以 RabbitMQ 的可靠消费通常不是追求:消息绝对只消费一次。而是要做到:即使同一条消息被投递多次,业务结果也只生效一次。也就是幂等。
什么是幂等?可以简单理解成:同一个操作执行一次,和执行多次,最终结果一样。
本阶段目标:
text
同一个 messageId 的消息,
第一次消费写入事件日志,
第二次消费识别为重复,直接 ack。
11.2 改造 OrderEventLogService
接口从 void 改成 boolean:
java
package com.example.cloud.order.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.entity.OrderEventLog;
/**
* 订单事件日志服务。
*/
public interface OrderEventLogService
extends IService<OrderEventLog> {
/**
* 记录订单创建事件消费成功日志。
*
* @return true 表示首次处理;false 表示重复消费。
*/
boolean recordOrderCreated(OrderCreatedMessage message);
}
11.3 改造 ServiceImpl:messageId 幂等
java
package com.example.cloud.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.order.entity.OrderEventLog;
import com.example.cloud.order.event.OrderEventType;
import com.example.cloud.order.mapper.OrderEventLogMapper;
import com.example.cloud.order.service.OrderEventLogService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* 订单事件日志服务实现。
*
* 当前版本增加了幂等处理:
* 同一个 messageId 只允许写入一条事件日志。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderEventLogServiceImpl
extends ServiceImpl<OrderEventLogMapper, OrderEventLog>
implements OrderEventLogService {
private final ObjectMapper objectMapper;
/**
* 记录订单创建事件消费成功日志。
*
* 返回 true:
* 说明本次是第一次处理这条消息。
*
* 返回 false:
* 说明这条消息之前已经处理过,本次属于重复消费。
*/
@Override
public boolean recordOrderCreated(OrderCreatedMessage message) {
/*
* 第一层防线:
* 先根据 messageId 查询是否已经处理过。
*/
long count = lambdaQuery()
.eq(OrderEventLog::getMessageId, message.getMessageId())
.count();
if (count > 0) {
log.warn("订单创建消息已处理过,本次重复消费直接忽略,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
return false;
}
OrderEventLog eventLog = new OrderEventLog();
eventLog.setMessageId(message.getMessageId());
eventLog.setOrderId(message.getOrderId());
eventLog.setEventType(OrderEventType.ORDER_CREATED);
eventLog.setConsumeStatus(1);
eventLog.setErrorMessage(null);
eventLog.setCreatedAt(LocalDateTime.now());
try {
eventLog.setMessageBody(
objectMapper.writeValueAsString(message)
);
save(eventLog);
return true;
} catch (DuplicateKeyException e) {
/*
* 第二层防线:
* 如果两个相同 messageId 的消息并发进来,
* 可能都会先查到 count=0。
*
* 这时最终由数据库唯一索引兜底。
* 谁先插入成功,谁就是首次处理;
* 后插入的触发唯一索引冲突,按重复消费处理。
*/
log.warn("订单创建消息重复插入,触发唯一索引保护,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
return false;
} catch (JsonProcessingException e) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"订单事件消息序列化失败"
);
}
}
}
这里是双保险:
| 防线 | 作用 |
|---|---|
先查 messageId |
大多数重复消息直接拦住 |
| 数据库唯一索引 | 并发重复消息最终兜底 |
捕获 DuplicateKeyException |
把唯一索引冲突识别为重复消费 |
11.4 修改消费者:重复消息也 ack
阶段七开始,正常消费逻辑改成:
java
boolean firstConsumed = orderEventLogService.recordOrderCreated(message);
channel.basicAck(deliveryTag, false);
if (firstConsumed) {
log.info("订单创建消息处理完成并 ack,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
} else {
log.info("订单创建消息重复消费,已直接 ack,messageId={},orderId={}",
message.getMessageId(),
message.getOrderId());
}
注意:
text
重复消息不是异常。
重复消息应该直接 ack,不应该进入重试或 DLQ。
11.5 增加重复消息测试接口
修改 OrderMessageTestController,增加:
java
/**
* 发送两条 messageId 相同的订单创建测试消息。
*
* 用于验证消费者幂等:
* 同一条消息重复投递时,
* t_order_event_log 只能写入一条记录。
*
* 示例:
* POST /mq/order-created/duplicate-test
*/
@PostMapping("/duplicate-test")
public Result<String> sendDuplicateMessage() {
Long userId = UserContext.requireUserId();
String messageId = UUID.randomUUID().toString();
OrderCreatedMessage message = OrderCreatedMessage
.builder()
.messageId(messageId)
.orderId(System.currentTimeMillis())
.userId(userId)
.productId(1L)
.quantity(1)
.amount(new BigDecimal("99.00"))
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
/*
* 故意连续发送两次相同 messageId 的消息,
* 用来模拟重复投递。
*/
orderMessageProducer.sendOrderCreatedMessage(message);
orderMessageProducer.sendOrderCreatedMessage(message);
return Result.success(messageId);
}

11.6 验证重复消息幂等
可以先清空事件日志表,方便观察:
bash
DELETE FROM t_order_event_log;
重启 cloud-order后调用重复消息测试接口:
bash
POST http://localhost:9000/api/order/mq/order-created/duplicate-test
Authorization: Bearer {{token}}
预期返回:
bash
{
"code": 0,
"message": "success",
"data": "某个 UUID"
}


观察消费者日志
第一次:
bash
收到订单创建消息,准备记录订单事件日志,retryCount=0
订单创建消息处理完成并 ack,messageId=...
第二次:
bash
收到订单创建消息,准备记录订单事件日志,retryCount=0
订单创建消息已处理过,本次重复消费直接忽略,messageId=...
订单创建消息重复消费,已直接 ack,messageId=...
这说明:
bash
第二条重复消息没有写入新事件日志,
也没有进入重试,
更没有进入 DLQ。
按返回的 messageId 查询:
bash
SELECT
id,
message_id,
order_id,
event_type,
consume_status,
created_at
FROM t_order_event_log
WHERE message_id = '替换成接口返回的 messageId';
预期只有一条。

验证:
sql
SELECT
message_id,
COUNT(*) AS cnt
FROM t_order_event_log
GROUP BY message_id
HAVING COUNT(*) > 1;
预期没有结果。
12、阶段八:生产者 Confirm 与 Return
12.1 本阶段目标
前面主要解决消费者侧可靠性。阶段八开始解决生产者侧两个问题:
text
1. 消息有没有到 RabbitMQ / Exchange?
2. 消息有没有从 Exchange 路由到 Queue?
| 机制 | 解决的问题 |
|---|---|
| Publisher Confirm | 消息有没有到达 RabbitMQ / Exchange |
| Publisher Return | 消息有没有从 Exchange 路由到 Queue |
一句话:
text
Confirm 看消息有没有到 RabbitMQ;
Return 看消息有没有进队列。
详细对比
| 对比项 | Publisher Confirm | Publisher Return |
|---|---|---|
| 关注哪一段 | 生产者 → RabbitMQ / Exchange | Exchange → Queue |
| 解决什么问题 | 消息有没有被 RabbitMQ 接收 | 消息有没有成功路由到队列 |
| 典型失败场景 | exchange 不存在、Broker 异常、连接异常 | routing key 错误、binding 不存在 |
| Spring 回调 | ConfirmCallback |
ReturnsCallback |
| 正常结果 | ack=true |
不触发 |
| 异常结果 | ack=false 或发送异常 |
触发 NO_ROUTE |
| 本章用途 | 更新本地消息表为发送成功或确认失败 | 更新本地消息表为路由失败 |
12.2 Nacos 开启 Confirm / Return
修改:cloud-order-dev.yamlcloud-order-dev.yaml增加:
yaml
spring:
rabbitmq:
publisher-confirm-type: correlated
publisher-returns: true
template:
mandatory: true
| 配置 | 作用 |
|---|---|
| publisher-confirm-type: correlated | 开启带 CorrelationData 的发布确认 |
| publisher-returns: true | 开启消息不可路由时的 return 回调 |
| template.mandatory: true | 消息无法路由到队列时,不直接丢弃,而是触发 return |
12.3 配置 RabbitTemplate 回调
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.config
└── RabbitTemplateCallbackConfig.java
阶段八只打印日志,不更新本地消息表。本地消息表是阶段九才引入。
java
package com.example.cloud.order.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
/**
* RabbitTemplate 回调配置。
*
* 用于观察生产者侧消息发送结果:
*
* 1. ConfirmCallback:
* 消息是否到达 RabbitMQ Broker / Exchange。
*
* 2. ReturnsCallback:
* 消息是否从 Exchange 成功路由到 Queue。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RabbitTemplateCallbackConfig {
private final RabbitTemplate rabbitTemplate;
@PostConstruct
public void init() {
/*
* 消息到达 Broker / Exchange 后触发 confirm。
*
* ack=true:
* RabbitMQ 已经确认收到消息。
*
* ack=false:
* RabbitMQ 没有确认这条消息。
*/
rabbitTemplate.setConfirmCallback(
(CorrelationData correlationData,
boolean ack,
String cause) -> {
String correlationId = correlationData == null
? null
: correlationData.getId();
if (ack) {
log.info("MQ 发送确认成功,correlationId={}",
correlationId);
} else {
log.error("MQ 发送确认失败,correlationId={},cause={}",
correlationId,
cause);
}
}
);
/*
* 消息到达 Exchange,但没有路由到任何 Queue 时触发 return。
*
* 注意:
* 只有开启 publisher-returns=true,
* 并且 template.mandatory=true,
* 才能稳定观察到不可路由消息的 returnedMessage 回调。
*/
rabbitTemplate.setReturnsCallback(
(ReturnedMessage returned) -> log.error(
"MQ 消息路由失败,replyCode={},replyText={},exchange={},routingKey={},message={}",
returned.getReplyCode(),
returned.getReplyText(),
returned.getExchange(),
returned.getRoutingKey(),
returned.getMessage()
)
);
}
}
这里使用的是:
java
javax.annotation.PostConstruct
因为当前项目是 Spring Boot 2.7.x,不是 Spring Boot 3.x。

12.4 改造生产者:发送时带 CorrelationData
修改 OrderMessageProducer。
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* 订单消息生产者。
*
* 负责把订单相关事件发送到 RabbitMQ。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发送订单创建消息。
*
* 消息发送到:
* exchange: cloud.order.exchange
* routing key: order.created
*/
public void sendOrderCreatedMessage(
OrderCreatedMessage message
) {
/*
* CorrelationData 用于关联 confirm 回调。
*
* 这里直接使用 messageId,
* 方便从日志中追踪是哪条业务消息发送成功或失败。
*/
CorrelationData correlationData = new CorrelationData(
message.getMessageId()
);
rabbitTemplate.convertAndSend(
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message,
correlationData
);
log.info("发送订单创建消息完成,messageId={},message={}",
message.getMessageId(),
message);
}
}
这里把日志从"发送成功"改成"发送完成",因为 convertAndSend() 返回不等于 Confirm 已经成功。
12.5 OrderMessageProducer增加两个故障测试发送方法
错误 routing key:用于触发 Return
bash
/**
* 使用错误 routing key 发送订单创建消息。
*
* 用于演练:
* 消息到达 exchange,
* 但无法路由到 queue,
* 触发 ReturnsCallback。
*/
public void sendWithWrongRoutingKey(
OrderCreatedMessage message
) {
CorrelationData correlationData = new CorrelationData(
message.getMessageId()
);
rabbitTemplate.convertAndSend(
OrderMqConstant.ORDER_EXCHANGE,
"order.created.wrong",
message,
correlationData
);
log.info("使用错误 routing key 发送订单创建消息完成,messageId={}",
message.getMessageId());
}
错误 exchange:用于触发 Confirm 失败或发送异常
bash
/**
* 使用错误 exchange 发送订单创建消息。
*
* 用于演练:
* exchange 不存在时,生产者侧会收到 confirm 失败
* 或直接抛出 AmqpException。
*/
public void sendWithWrongExchange(
OrderCreatedMessage message
) {
CorrelationData correlationData = new CorrelationData(
message.getMessageId()
);
rabbitTemplate.convertAndSend(
"cloud.order.exchange.not.exists",
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message,
correlationData
);
log.info("使用错误 exchange 发送订单创建消息完成,messageId={}",
message.getMessageId());
}
12.6 OrderMessageTestController增加 Confirm / Return 测试接口
为了复用构建逻辑,先抽方法:
java
private OrderCreatedMessage buildTestMessage() {
Long userId = UserContext.requireUserId();
return OrderCreatedMessage
.builder()
.messageId(UUID.randomUUID().toString())
.orderId(System.currentTimeMillis())
.userId(userId)
.productId(1L)
.quantity(1)
.amount(new BigDecimal("99.00"))
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
}
错误 routing key:
java
@PostMapping("/wrong-routing-key")
public Result<Void> sendWithWrongRoutingKey() {
OrderCreatedMessage message = buildTestMessage();
orderMessageProducer.sendWithWrongRoutingKey(message);
return Result.success();
}
错误 exchange:
java
@PostMapping("/wrong-exchange")
public Result<Void> sendWithWrongExchange() {
OrderCreatedMessage message = buildTestMessage();
orderMessageProducer.sendWithWrongExchange(message);
return Result.success();
}

12.7 验证三种情况
12.7.1 验证正常消息 Confirm
先调用测试接口:
bash
POST http://localhost:9000/api/order/mq/order-created/test?productId=1&quantity=1
Authorization: Bearer {{token}}
预期日志顺序大概是:
bash
发送订单创建消息完成,messageId=...
MQ 发送确认成功,correlationId=...
收到订单创建消息,准备记录订单事件日志...
订单创建消息处理完成并 ack...

12.7.2 验证错误 routing key:Return
调用:
bash
POST http://localhost:9000/api/order/mq/order-created/wrong-routing-key
Authorization: Bearer {{token}}
预期日志:
bash
使用错误 routing key 发送订单创建消息完成,messageId=...
MQ 消息路由失败,replyCode=312,replyText=NO_ROUTE,exchange=cloud.order.exchange,routingKey=order.created.wrong...
MQ 发送确认成功,correlationId=...

12.7.3 验证错误 exchange:Confirm 失败或发送异常
调用:
bash
POST http://localhost:9000/api/order/mq/order-created/wrong-exchange
Authorization: Bearer {{token}}
预期日志:
bash
使用错误 exchange 发送订单创建消息完成,messageId=...
MQ 发送确认失败,correlationId=...,cause=channel error...

12.7.4 三种发送结果对比
| 场景 | Exchange 是否存在 | Routing key 是否匹配队列 | ConfirmCallback | ReturnsCallback | 消费者是否收到 |
|---|---|---|---|---|---|
| 正常发送 | 是 | 是 | ack=true |
不触发 | 是 |
| 错误 routing key | 是 | 否 | ack=true |
触发,常见 NO_ROUTE |
否 |
| 错误 exchange | 否 | 无法判断 | ack=false 或发送异常 |
通常不触发 | 否 |
结论:
text
Confirm 成功,只能说明消息到了 RabbitMQ / Exchange;
不能说明消息一定进了队列。
13、阶段九:本地消息表记录发送状态
13.1 为什么需要本地消息表
Confirm / Return 能发现问题,但不能自动补偿。
如果出现:
text
订单事务已经提交
↓
afterCommit 准备发送 MQ
↓
服务宕机 / RabbitMQ 不可用 / 网络异常
↓
消息没有成功发出去
这时需要一个地方记录:
text
这条消息应该发送;
现在有没有发送成功;
如果失败,后面能不能补发。
这就是本地消息表。
阶段九增加本地消息表:
text
订单事务内保存本地消息记录,send_status=0
↓
afterCommit 发送 MQ
↓
Confirm / Return 回调更新 send_status
13.2 创建本地消息表
sql
CREATE TABLE t_mq_message_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
message_id VARCHAR(64) NOT NULL COMMENT '消息唯一 ID',
message_type VARCHAR(64) NOT NULL COMMENT '消息类型',
exchange_name VARCHAR(128) NOT NULL COMMENT '交换机名称',
routing_key VARCHAR(128) NOT NULL COMMENT '路由键',
message_body TEXT NOT NULL COMMENT '消息内容 JSON',
send_status TINYINT NOT NULL COMMENT '发送状态:0-待发送,1-已确认到交换机,2-确认失败,3-路由失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
error_message VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
created_at DATETIME NOT NULL COMMENT '创建时间',
updated_at DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_message_id (message_id),
KEY idx_send_status (send_status),
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MQ 本地消息表';
状态:
| 状态值 | 含义 |
|---|---|
0 |
待发送 |
1 |
已确认到交换机 |
2 |
Confirm 失败 |
3 |
路由失败 |
13.3 新增状态和类型常量
新建:
bash
cloud-order
└── src/main/java
└── com.example.cloud.order.mq
└── MqMessageStatus.java
代码:
java
package com.example.cloud.order.mq;
/**
* MQ 本地消息发送状态。
*/
public final class MqMessageStatus {
private MqMessageStatus() {
}
/**
* 待发送。
*/
public static final int PENDING = 0;
/**
* 已确认到交换机。
*/
public static final int SEND_CONFIRMED = 1;
/**
* Confirm 确认失败。
*/
public static final int CONFIRM_FAILED = 2;
/**
* Return 路由失败。
*/
public static final int ROUTING_FAILED = 3;
}
新建:
bash
cloud-order
└── src/main/java
└── com.example.cloud.order.mq
└── MqMessageType.java
代码:
bash
package com.example.cloud.order.mq;
/**
* MQ 消息类型。
*/
public final class MqMessageType {
private MqMessageType() {
}
/**
* 订单创建消息。
*/
public static final String ORDER_CREATED = "ORDER_CREATED";
}
13.4 OrderMqConstant 增加 messageId header
Return 回调拿不到 CorrelationData,所以要把 messageId 放进消息 header。
增加:
java
/**
* 消息 ID header。
*
* ReturnCallback 中拿不到 CorrelationData,
* 所以需要把 messageId 放到消息 header 里,
* 用来更新本地消息表。
*/
public static final String MESSAGE_ID_HEADER =
"x-message-id";
13.5 新增 MqMessageLog 实体、Mapper、Service
实体:
java
package com.example.cloud.order.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* MQ 本地消息记录。
*/
@Data
@TableName("t_mq_message_log")
public class MqMessageLog {
@TableId(type = IdType.AUTO)
private Long id;
private String messageId;
private String messageType;
private String exchangeName;
private String routingKey;
private String messageBody;
private Integer sendStatus;
private Integer retryCount;
private String errorMessage;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
Mapper:
java
package com.example.cloud.order.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.order.entity.MqMessageLog;
import org.apache.ibatis.annotations.Mapper;
/**
* MQ 本地消息 Mapper。
*/
@Mapper
public interface MqMessageLogMapper
extends BaseMapper<MqMessageLog> {
}
Service:
java
package com.example.cloud.order.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.entity.MqMessageLog;
/**
* MQ 本地消息服务。
*/
public interface MqMessageLogService
extends IService<MqMessageLog> {
/**
* 保存订单创建消息记录。
*/
void saveOrderCreatedMessage(
OrderCreatedMessage message,
String exchangeName,
String routingKey
);
/**
* 标记消息已确认到交换机。
*/
void markSendConfirmed(String messageId);
/**
* 标记 Confirm 失败。
*/
void markConfirmFailed(String messageId, String errorMessage);
/**
* 标记路由失败。
*/
void markRoutingFailed(String messageId, String errorMessage);
}
13.6 实现 MqMessageLogServiceImpl
新建:
bash
cloud-order
└── src/main/java
└── com.example.cloud.order.service.impl
└── MqMessageLogServiceImpl.java
代码:
java
package com.example.cloud.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.order.entity.MqMessageLog;
import com.example.cloud.order.mapper.MqMessageLogMapper;
import com.example.cloud.order.mq.MqMessageStatus;
import com.example.cloud.order.mq.MqMessageType;
import com.example.cloud.order.service.MqMessageLogService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* MQ 本地消息服务实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MqMessageLogServiceImpl
extends ServiceImpl<MqMessageLogMapper, MqMessageLog>
implements MqMessageLogService {
private final ObjectMapper objectMapper;
/**
* 保存订单创建消息记录。
*
* 注意:
* 这条记录最好和订单创建放在同一个事务里。
* 这样订单创建成功时,本地消息记录也一定存在。
*/
@Override
public void saveOrderCreatedMessage(
OrderCreatedMessage message,
String exchangeName,
String routingKey
) {
MqMessageLog messageLog = new MqMessageLog();
messageLog.setMessageId(message.getMessageId());
messageLog.setMessageType(MqMessageType.ORDER_CREATED);
messageLog.setExchangeName(exchangeName);
messageLog.setRoutingKey(routingKey);
messageLog.setSendStatus(MqMessageStatus.PENDING);
messageLog.setRetryCount(0);
messageLog.setErrorMessage(null);
LocalDateTime now = LocalDateTime.now();
messageLog.setCreatedAt(now);
messageLog.setUpdatedAt(now);
try {
messageLog.setMessageBody(
objectMapper.writeValueAsString(message)
);
save(messageLog);
} catch (DuplicateKeyException e) {
log.warn("MQ 本地消息记录已存在,messageId={}",
message.getMessageId());
} catch (JsonProcessingException e) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"MQ 消息内容序列化失败"
);
}
}
/**
* 标记消息已确认到交换机。
*
* 注意:
* 如果消息已经被 Return 标记为路由失败,
* 后续 confirm ack=true 不应该把它覆盖成成功。
*/
@Override
public void markSendConfirmed(String messageId) {
lambdaUpdate()
.eq(MqMessageLog::getMessageId, messageId)
.ne(MqMessageLog::getSendStatus,
MqMessageStatus.ROUTING_FAILED)
.set(MqMessageLog::getSendStatus,
MqMessageStatus.SEND_CONFIRMED)
.set(MqMessageLog::getErrorMessage, null)
.set(MqMessageLog::getUpdatedAt,
LocalDateTime.now())
.update();
log.info("MQ 本地消息已标记为发送确认成功,messageId={}",
messageId);
}
/**
* 标记 Confirm 失败。
*/
@Override
public void markConfirmFailed(
String messageId,
String errorMessage
) {
lambdaUpdate()
.eq(MqMessageLog::getMessageId, messageId)
.set(MqMessageLog::getSendStatus,
MqMessageStatus.CONFIRM_FAILED)
.set(MqMessageLog::getErrorMessage, shorten(errorMessage))
.set(MqMessageLog::getUpdatedAt,
LocalDateTime.now())
.update();
log.error("MQ 本地消息已标记为 Confirm 失败,messageId={},error={}",
messageId,
errorMessage);
}
/**
* 标记路由失败。
*/
@Override
public void markRoutingFailed(
String messageId,
String errorMessage
) {
lambdaUpdate()
.eq(MqMessageLog::getMessageId, messageId)
.set(MqMessageLog::getSendStatus,
MqMessageStatus.ROUTING_FAILED)
.set(MqMessageLog::getErrorMessage, shorten(errorMessage))
.set(MqMessageLog::getUpdatedAt,
LocalDateTime.now())
.update();
log.error("MQ 本地消息已标记为路由失败,messageId={},error={}",
messageId,
errorMessage);
}
private String shorten(String errorMessage) {
if (errorMessage == null) {
return null;
}
if (errorMessage.length() <= 500) {
return errorMessage;
}
return errorMessage.substring(0, 500);
}
}
13.7 改造生产者:写入 x-message-id header
修改OrderMessageProducer
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* 订单消息生产者。
*
* 负责把订单相关事件发送到 RabbitMQ。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发送订单创建消息。
*/
public void sendOrderCreatedMessage(
OrderCreatedMessage message
) {
send(
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message,
"发送订单创建消息完成"
);
}
/**
* 使用错误 routing key 发送订单创建消息。
*/
public void sendWithWrongRoutingKey(
OrderCreatedMessage message
) {
send(
OrderMqConstant.ORDER_EXCHANGE,
"order.created.wrong",
message,
"使用错误 routing key 发送订单创建消息完成"
);
}
/**
* 使用错误 exchange 发送订单创建消息。
*/
public void sendWithWrongExchange(
OrderCreatedMessage message
) {
send(
"cloud.order.exchange.not.exists",
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message,
"使用错误 exchange 发送订单创建消息完成"
);
}
private void send(
String exchange,
String routingKey,
OrderCreatedMessage message,
String logPrefix
) {
ensureMessageId(message);
CorrelationData correlationData = new CorrelationData(
message.getMessageId()
);
rabbitTemplate.convertAndSend(
exchange,
routingKey,
message,
amqpMessage -> {
amqpMessage.getMessageProperties()
.setHeader(
OrderMqConstant.MESSAGE_ID_HEADER,
message.getMessageId()
);
return amqpMessage;
},
correlationData
);
log.info("{},messageId={},exchange={},routingKey={},message={}",
logPrefix,
message.getMessageId(),
exchange,
routingKey,
message);
}
/**
* 确保消息有唯一 messageId。
*/
private void ensureMessageId(OrderCreatedMessage message) {
if (message.getMessageId() == null
|| message.getMessageId().trim().isEmpty()) {
message.setMessageId(UUID.randomUUID().toString());
}
}
}
13.8 改造 RabbitTemplateCallbackConfig:更新本地消息表
java
package com.example.cloud.order.config;
import com.example.cloud.order.mq.OrderMqConstant;
import com.example.cloud.order.service.MqMessageLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
/**
* RabbitTemplate 回调配置。
*
* 用于观察和记录生产者侧消息发送结果。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RabbitTemplateCallbackConfig {
private final RabbitTemplate rabbitTemplate;
private final MqMessageLogService mqMessageLogService;
@PostConstruct
public void init() {
/*
* 这里再显式设置一次 mandatory。
* Nacos 中也配置了 template.mandatory=true,
* 这里属于双保险,方便演练 Return。
*/
rabbitTemplate.setMandatory(true);
rabbitTemplate.setConfirmCallback(
(CorrelationData correlationData,
boolean ack,
String cause) -> {
String messageId = correlationData == null
? null
: correlationData.getId();
if (messageId == null) {
log.warn("MQ Confirm 回调缺少 messageId,ack={},cause={}",
ack,
cause);
return;
}
if (ack) {
log.info("MQ 发送确认成功,messageId={}",
messageId);
mqMessageLogService.markSendConfirmed(messageId);
} else {
log.error("MQ 发送确认失败,messageId={},cause={}",
messageId,
cause);
mqMessageLogService.markConfirmFailed(
messageId,
cause
);
}
}
);
rabbitTemplate.setReturnsCallback(
(ReturnedMessage returned) -> {
Object messageIdObj = returned
.getMessage()
.getMessageProperties()
.getHeaders()
.get(OrderMqConstant.MESSAGE_ID_HEADER);
String messageId = messageIdObj == null
? null
: messageIdObj.toString();
String errorMessage =
"replyCode=" + returned.getReplyCode()
+ ", replyText=" + returned.getReplyText()
+ ", exchange=" + returned.getExchange()
+ ", routingKey=" + returned.getRoutingKey();
log.error("MQ 消息路由失败,messageId={},{},message={}",
messageId,
errorMessage,
returned.getMessage());
if (messageId != null) {
mqMessageLogService.markRoutingFailed(
messageId,
errorMessage
);
}
}
);
}
}
13.9 修改 OrderServiceImpl:订单事务内保存本地消息记录
修改 OrderServiceImpl。
改成:
java
OrderCreatedMessage message = buildOrderCreatedMessage(order);
mqMessageLogService.saveOrderCreatedMessage(
message,
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY
);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
orderMessageProducer.sendOrderCreatedMessage(message);
}
}
);
13.10 测试接口也保存本地消息记录
阶段九要让 /test、/wrong-routing-key、/wrong-exchange 都能在 t_mq_message_log 里看到状态变化。
/test:
java
@PostMapping("/test")
public Result<Void> sendTestMessage(
@RequestParam(defaultValue = "1") Long productId,
@RequestParam(defaultValue = "1") Integer quantity
) {
Long userId = UserContext.requireUserId();
OrderCreatedMessage message = OrderCreatedMessage
.builder()
.messageId(UUID.randomUUID().toString())
.orderId(System.currentTimeMillis())
.userId(userId)
.productId(productId)
.quantity(quantity)
.amount(new BigDecimal("99.00"))
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
mqMessageLogService.saveOrderCreatedMessage(
message,
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY
);
orderMessageProducer.sendOrderCreatedMessage(message);
return Result.success();
}
wrong-routing-key:
java
@PostMapping("/wrong-routing-key")
public Result<Void> sendWithWrongRoutingKey() {
OrderCreatedMessage message = buildTestMessage();
mqMessageLogService.saveOrderCreatedMessage(
message,
OrderMqConstant.ORDER_EXCHANGE,
"order.created.wrong"
);
orderMessageProducer.sendWithWrongRoutingKey(message);
return Result.success();
}
wrong-exchange:
java
@PostMapping("/wrong-exchange")
public Result<Void> sendWithWrongExchange() {
OrderCreatedMessage message = buildTestMessage();
mqMessageLogService.saveOrderCreatedMessage(
message,
"cloud.order.exchange.not.exists",
OrderMqConstant.ORDER_CREATED_ROUTING_KEY
);
orderMessageProducer.sendWithWrongExchange(message);
return Result.success();
}
13.11 手动重试逻辑补 x-message-id header
阶段六的 republishForRetry 需要同步升级。
java
import org.springframework.amqp.rabbit.connection.CorrelationData;
java
private void republishForRetry(
OrderCreatedMessage message,
int nextRetryCount
) {
CorrelationData correlationData = new CorrelationData(
message.getMessageId()
);
rabbitTemplate.convertAndSend(
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY,
message,
retryMessage -> {
retryMessage.getMessageProperties()
.setHeader(
RETRY_COUNT_HEADER,
nextRetryCount
);
retryMessage.getMessageProperties()
.setHeader(
OrderMqConstant.MESSAGE_ID_HEADER,
message.getMessageId()
);
return retryMessage;
},
correlationData
);
}

13.12 验证正常发送
重启 cloud-order后调用:
bash
POST http://localhost:9000/api/order/mq/order-created/test?productId=1&quantity=1
Authorization: Bearer {{token}}
预期日志:
bash
发送订单创建消息完成,messageId=...
MQ 发送确认成功,messageId=...
MQ 本地消息已标记为发送确认成功,messageId=...
收到订单创建消息,准备记录订单事件日志...
订单创建消息处理完成并 ack...
查询本地消息表:
bash
SELECT
id,
message_id,
message_type,
exchange_name,
routing_key,
send_status,
retry_count,
error_message,
created_at,
updated_at
FROM t_mq_message_log
ORDER BY id DESC
LIMIT 5;
预期最新记录:
| 字段 | 预期 |
|---|---|
| message_type | ORDER_CREATED |
| exchange_name | cloud.order.exchange |
| routing_key | order.created |
| send_status | 1 |
| error_message | NULL |


13.13 验证错误 routing key
调用:
bash
POST http://localhost:9000/api/order/mq/order-created/wrong-routing-key
Authorization: Bearer {{token}}
预期:
bash
Return 回调触发 NO_ROUTE
Confirm 可能 ack=true
本地消息表最终 send_status=3

13.14 验证错误 exchange
调用:
bash
POST http://localhost:9000/api/order/mq/order-created/wrong-exchange
Authorization: Bearer {{token}}

13.15 验证正常下单
调用:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
预期:
bash
t_order 新增订单
t_mq_message_log 新增 ORDER_CREATED,最终 send_status=1
t_order_event_log 消费者处理成功后新增事件日志
RabbitMQ 正常队列 最终为 0
DLQ 不新增

14、阶段十:本地消息表补偿重发
14.1 本阶段目标
阶段九有了本地消息表,但还没有真正补偿。
阶段十做定时任务:
text
扫描 send_status=0 或 send_status=2 的消息
↓
retry_count < 3 时重新投递
↓
达到 3 次后标记为重试耗尽
路由失败 send_status=3 暂不自动重试,因为它通常是配置问题。
14.2 状态增加 RETRY_EXHAUSTED
修改 MqMessageStatus:
java
/**
* 重试耗尽。
*/
public static final int RETRY_EXHAUSTED = 4;
更新数据库注释:
sql
ALTER TABLE t_mq_message_log
MODIFY send_status TINYINT NOT NULL
COMMENT '发送状态:0-待发送,1-已确认到交换机,2-确认失败,3-路由失败,4-重试耗尽';
14.3 OrderMessageProducer 增加指定路由发送方法
java
/**
* 按指定 exchange 和 routing key 发送订单创建消息。
*
* 主要用于本地消息表补偿重发。
*/
public void sendOrderCreatedMessageTo(
String exchange,
String routingKey,
OrderCreatedMessage message
) {
send(
exchange,
routingKey,
message,
"补偿重发订单创建消息完成"
);
}
14.4 扩展 MqMessageLogService
java
import com.example.cloud.order.entity.MqMessageLog;
import java.util.List;
java
/**
* 查询需要补偿重发的消息。
*/
List<MqMessageLog> listRetryMessages(
int limit,
int maxRetryCount
);
/**
* 增加重试次数。
*/
void increaseRetryCount(String messageId);
/**
* 标记重试耗尽。
*/
void markRetryExhausted(String messageId, String errorMessage);
/**
* 将已经达到最大重试次数的消息标记为重试耗尽。
*/
void markRetryExhaustedMessages(int maxRetryCount);
14.5 实现补偿方法
在 MqMessageLogServiceImpl 增加:
java
/**
* 查询需要补偿重发的消息。
*
* 当前只自动补偿:
* 1. PENDING
* 2. CONFIRM_FAILED
*
* 不自动补偿 ROUTING_FAILED,
* 因为路由失败通常是 exchange、routing key、binding 配置问题,
* 盲目重试意义不大。
*/
@Override
public List<MqMessageLog> listRetryMessages(
int limit,
int maxRetryCount
) {
return lambdaQuery()
.in(MqMessageLog::getSendStatus,
Arrays.asList(
MqMessageStatus.PENDING,
MqMessageStatus.CONFIRM_FAILED
))
.lt(MqMessageLog::getRetryCount, maxRetryCount)
.orderByAsc(MqMessageLog::getCreatedAt)
.last("LIMIT " + limit)
.list();
}
/**
* 增加重试次数。
*/
@Override
public void increaseRetryCount(String messageId) {
lambdaUpdate()
.eq(MqMessageLog::getMessageId, messageId)
.setSql("retry_count = retry_count + 1")
.set(MqMessageLog::getUpdatedAt, LocalDateTime.now())
.update();
log.info("MQ 本地消息重试次数加 1,messageId={}", messageId);
}
/**
* 标记重试耗尽。
*/
@Override
public void markRetryExhausted(
String messageId,
String errorMessage
) {
lambdaUpdate()
.eq(MqMessageLog::getMessageId, messageId)
.set(MqMessageLog::getSendStatus,
MqMessageStatus.RETRY_EXHAUSTED)
.set(MqMessageLog::getErrorMessage, shorten(errorMessage))
.set(MqMessageLog::getUpdatedAt, LocalDateTime.now())
.update();
log.error("MQ 本地消息已标记为重试耗尽,messageId={},error={}",
messageId,
errorMessage);
}
/**
* 将已经达到最大重试次数的消息标记为重试耗尽。
*/
@Override
public void markRetryExhaustedMessages(int maxRetryCount) {
lambdaUpdate()
.in(MqMessageLog::getSendStatus,
Arrays.asList(
MqMessageStatus.PENDING,
MqMessageStatus.CONFIRM_FAILED
))
.ge(MqMessageLog::getRetryCount, maxRetryCount)
.set(MqMessageLog::getSendStatus,
MqMessageStatus.RETRY_EXHAUSTED)
.set(MqMessageLog::getErrorMessage,
"MQ 消息补偿重试次数已耗尽")
.set(MqMessageLog::getUpdatedAt, LocalDateTime.now())
.update();
}
14.6 开启定时任务
修改 CloudOrderApplication:
java
import org.springframework.scheduling.annotation.EnableScheduling;
启动类增加:
java
@EnableScheduling
完整代码:
java
/**
* 订单服务启动类。
* @EnableDiscoveryClient:
* 启用服务注册与发现能力。
* 服务启动后,会向 Nacos 注册:
* 服务名、IP、端口等实例信息。
* 扫描 com.example.cloud 根包,
* 使当前模块和 cloud-common 中的 Spring Bean 都能被识别。
*/
@EnableFeignClients(basePackages = "com.example.cloud.order.client")
@EnableDiscoveryClient
@SpringBootApplication(scanBasePackages = "com.example.cloud")
@EnableScheduling
public class CloudOrderApplication {
public static void main(String[] args) {
SpringApplication.run(CloudOrderApplication.class, args);
}
}
14.7 新增补偿任务
新建:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.task
└── MqMessageRetryTask.java
java
package com.example.cloud.order.task;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.order.entity.MqMessageLog;
import com.example.cloud.order.mq.OrderMessageProducer;
import com.example.cloud.order.service.MqMessageLogService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* MQ 本地消息补偿任务。
*
* 定期扫描 t_mq_message_log,
* 将待发送或 Confirm 失败的消息重新投递到 RabbitMQ。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MqMessageRetryTask {
/**
* 每次最多扫描多少条消息。
*/
private static final int BATCH_SIZE = 20;
/**
* 最大补偿重试次数。
*/
private static final int MAX_RETRY_COUNT = 3;
private final MqMessageLogService mqMessageLogService;
private final OrderMessageProducer orderMessageProducer;
private final ObjectMapper objectMapper;
/**
* 每 30 秒扫描一次本地消息表。
*
* fixedDelay 表示上一次执行结束 30 秒后,
* 再开始下一次执行。
*/
@Scheduled(fixedDelay = 30000)
public void retryMqMessages() {
/*
* 先把已经达到最大重试次数的消息标记为重试耗尽,
* 避免一直被扫描出来。
*/
mqMessageLogService.markRetryExhaustedMessages(MAX_RETRY_COUNT);
List<MqMessageLog> messages =
mqMessageLogService.listRetryMessages(
BATCH_SIZE,
MAX_RETRY_COUNT
);
if (messages.isEmpty()) {
return;
}
log.info("开始执行 MQ 本地消息补偿任务,本次扫描到 {} 条待重发消息",
messages.size());
for (MqMessageLog messageLog : messages) {
retryOneMessage(messageLog);
}
}
/**
* 补偿重发单条消息。
*/
private void retryOneMessage(MqMessageLog messageLog) {
try {
OrderCreatedMessage message = objectMapper.readValue(
messageLog.getMessageBody(),
OrderCreatedMessage.class
);
/*
* 先增加重试次数,再重新发送。
*
* 这样即使发送过程抛异常,
* retry_count 也能反映这次补偿尝试。
*/
mqMessageLogService.increaseRetryCount(
messageLog.getMessageId()
);
orderMessageProducer.sendOrderCreatedMessageTo(
messageLog.getExchangeName(),
messageLog.getRoutingKey(),
message
);
log.info("MQ 本地消息补偿重发已提交,messageId={},exchange={},routingKey={}",
messageLog.getMessageId(),
messageLog.getExchangeName(),
messageLog.getRoutingKey());
} catch (JsonProcessingException e) {
mqMessageLogService.markRetryExhausted(
messageLog.getMessageId(),
"MQ 消息反序列化失败:" + e.getMessage()
);
} catch (Exception e) {
mqMessageLogService.markConfirmFailed(
messageLog.getMessageId(),
"MQ 消息补偿重发异常:" + e.getMessage()
);
}
}
}
14.8 增加只保存本地消息、不立即发送的测试接口
java
/**
* 只保存本地消息记录,不立即发送 MQ。
*/
@PostMapping("/pending-local-message-test")
public Result<String> createPendingLocalMessage() {
OrderCreatedMessage message = buildTestMessage();
mqMessageLogService.saveOrderCreatedMessage(
message,
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY
);
return Result.success(message.getMessageId());
}

14.9 验证 PENDING 消息自动补偿
重启 cloud-order调用:
http
POST http://localhost:9000/api/order/mq/order-created/pending-local-message-test
Authorization: Bearer {{token}}
返回:
bash
{
"code": 0,
"message": "success",
"data": "某个 messageId"
}
立即查询本地消息表:
bash
SELECT
message_id,
send_status,
retry_count,
error_message,
created_at,
updated_at
FROM t_mq_message_log
WHERE message_id = '替换成返回的 messageId';
预期:
bash
send_status 0
retry_count 0
error_message NULL

30秒后查看日志:
bash
开始执行 MQ 本地消息补偿任务,本次扫描到 1 条待重发消息
MQ 本地消息重试次数加 1,messageId=...
补偿重发订单创建消息完成,messageId=...
MQ 发送确认成功,messageId=...
MQ 本地消息已标记为发送确认成功,messageId=...
收到订单创建消息,准备记录订单事件日志...
订单创建消息处理完成并 ack...
并再次查询:
bash
SELECT
message_id,
send_status,
retry_count,
error_message,
updated_at
FROM t_mq_message_log
WHERE message_id = '替换成返回的 messageId';
预期:
bash
send_status 1
retry_count 1
error_message NULL

14.9 验证错误 exchange 重试耗尽
调用:
http
POST http://localhost:9000/api/order/mq/order-created/wrong-exchange
Authorization: Bearer {{token}}
经过几轮补偿后,预期:
| 字段 | 预期 |
|---|---|
exchange_name |
cloud.order.exchange.not.exists |
send_status |
4 |
retry_count |
3 |

15、本章核心链路总结
到这里,本章完整链路是:
text
订单事务内:
保存订单
保存 t_mq_message_log,send_status=0
事务提交后:
afterCommit 发送 MQ
生产者侧:
Confirm 成功 -> send_status=1
Confirm 失败 -> send_status=2
Return 路由失败 -> send_status=3
补偿任务:
扫描 send_status=0 或 2
retry_count < 3 时补偿重发
retry_count >= 3 时 send_status=4
消费者侧:
手动 ack
有限重试
失败进入 DLQ
messageId 幂等
成功写 t_order_event_log
16、本章核心对比表
16.1 Confirm 和 Return 对比
| 对比项 | Publisher Confirm | Publisher Return |
|---|---|---|
| 关注哪一段 | 生产者 → RabbitMQ / Exchange | Exchange → Queue |
| 解决什么问题 | 消息有没有被 RabbitMQ 接收 | 消息有没有成功路由到队列 |
| 典型失败场景 | exchange 不存在、Broker 异常、连接异常 | routing key 错误、binding 不存在 |
| Spring 回调 | ConfirmCallback |
ReturnsCallback |
| 正常结果 | ack=true |
不触发 |
| 异常结果 | ack=false 或发送异常 |
触发 NO_ROUTE |
| 本章用途 | 更新本地消息表为发送成功或确认失败 | 更新本地消息表为路由失败 |
16.2 AUTO ack 和 MANUAL ack 对比
| 对比项 | 自动 ack:AUTO |
手动 ack:MANUAL |
|---|---|---|
| 谁确认消息 | Spring AMQP 容器 | 业务代码自己确认 |
| 成功时 | 方法正常结束后容器自动 ack | 业务处理成功后调用 basicAck |
| 失败时 | 抛异常后由容器按配置处理 | catch 异常后自己决定重试或 nack |
| 优点 | 简单 | 控制力强 |
| 缺点 | 失败处理不够直观 | 代码更复杂 |
| 本章最终采用 | 否 | 是 |
16.3 消息状态表
| 状态值 | 常量 | 含义 | 是否自动补偿 |
|---|---|---|---|
0 |
PENDING |
待发送 | 是 |
1 |
SEND_CONFIRMED |
已确认到交换机 | 否 |
2 |
CONFIRM_FAILED |
Confirm 失败 | 是 |
3 |
ROUTING_FAILED |
Return 路由失败 | 暂不自动补偿 |
4 |
RETRY_EXHAUSTED |
重试耗尽 | 否,等待人工处理 |
16.4 本地消息表、事件日志表、死信队列对比
| 位置 | 关注哪一段 | 记录什么 | 典型用途 |
|---|---|---|---|
t_mq_message_log |
生产者侧 | 消息有没有成功发送到 RabbitMQ | Confirm / Return 状态追踪、补偿重发 |
t_order_event_log |
消费者侧 | 消息有没有被业务消费者成功处理 | 幂等判断、消费结果追踪 |
cloud.order.created.dlq |
Broker 侧 | 消费失败后进入死信队列的原始消息 | 排查 payload、headers、x-death |
17、生产边界
本章实现的是学习版可靠消息方案,已经覆盖可靠消息的核心链路,但不是生产最终版。
| 问题 | 本章做法 | 生产中还可以怎么加强 |
|---|---|---|
| 多实例同时扫描本地消息表 | 当前定时任务直接扫描 | 增加 SENDING 状态、乐观锁、分布式锁或任务分片 |
| 补偿重试间隔 | 固定 30 秒 | 按失败次数退避,例如 1 分钟、5 分钟、30 分钟 |
| 路由失败消息 | 不自动重试 | 修复配置后人工改状态重发 |
| 消费者失败消息 | 进入 DLQ | 增加死信消费者、人工补偿后台或告警 |
| 本地消息表膨胀 | 暂未处理 | 定期归档或清理已成功消息 |
| 消息类型扩展 | 当前只处理 ORDER_CREATED |
按 message_type 做多类型分发 |
| 发送成功但消费失败 | 生产者侧状态已成功,消费者侧可能失败 | 结合事件日志、DLQ、告警和补偿工具处理 |
18、本章总结
这一章从最小消息收发开始,一步步把 RabbitMQ 用在了订单创建场景里。
一开始我们只是通过测试接口发送和消费一条消息。随后把消息接入真实下单流程,并通过 afterCommit 确保订单事务提交后再发送 MQ。
接着,我们让消费者写入订单事件日志,并通过 messageId + 唯一索引 解决重复消费问题。
消费者失败这块,我们先看到了默认重新入队带来的毒消息死循环,再看到不重新入队但没有 DLQ 时失败消息不可追踪。然后引入死信交换机和死信队列,让失败消息可以进入 DLQ 留痕。
后面我们把消费者改成手动 ack,并实现有限重试:失败未超过次数就重新投递,达到最大次数就 basicNack(requeue=false) 进入死信队列。
生产者侧,我们加入 Confirm 和 Return:Confirm 负责判断消息有没有到 RabbitMQ / Exchange,Return 负责判断消息有没有路由到 Queue。
最后我们加入本地消息表和补偿任务,让订单事务内先保存消息记录,事务提交后发送 MQ,发送失败或待发送消息可以被定时任务重新投递。
本章最终记住三句话:
text
本地消息表看"我有没有发出去";
事件日志表看"消费者有没有处理成功";
死信队列看"消费者处理失败后留下了什么"。
19、下一章预告
第 12 章解决的是 RabbitMQ 异步消息和最终一致性问题。
下一章可以进入 Seata 分布式事务。
原因很自然:当前订单链路里已经有:
text
cloud-order
↓
Feign 调 cloud-product 扣库存
↓
cloud-order 本地保存订单
这里会引出一个新问题:
text
扣库存成功了,但订单保存失败怎么办?
第 13 章就可以围绕这个问题,引入 Seata,专门讲跨服务数据库操作的一致性。