Spring Cloud 学习与实践(12):RabbitMQ 异步消息、死信队列、手动 ACK 与可靠消息补偿

文章目录

  • [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-ordercloud-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.createdorder.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,专门讲跨服务数据库操作的一致性。