黑马RabbitMQ 从入门到可靠性:收发、持久化、死信延迟一篇打通

前言

做分布式系统,几乎绕不开消息队列。但 MQ 不是银弹------它换来解耦和异步的同时,也把"消息丢失、重复、最终一致性"这些问题甩给了你。所以这篇文章不堆概念,而是按"价值 → 选型 → 收发 → 可靠性"的实战顺序,把 SpringAMQP 这条链路串清楚。


一、为什么用消息队列(MQ)

直接用同步调用(比如 HTTP / RPC)会撞上三个问题:

  • 耦合高:下游每加一个消费者,上游就得改一次代码。
  • 响应慢:多个下游串行调用,延迟不断叠加。
  • 级联故障:链路里一个服务挂了,整条调用链跟着崩。

引入 MQ 后,由中间人(Broker)转发消息,带来四个收益:

  • 解耦:上下游只认队列,不认彼此。
  • 异步提速:主流程不阻塞在下游调用上。
  • 故障隔离:下游挂了自己慢慢恢复,不拖垮上游。
  • 削峰填谷:流量高峰先堆进队列,消费者按自己节奏消化。

代价 :链路变长,你必须自己处理消息丢失、重复消费、最终一致性。后面第八章专门讲这块。


二、主流 MQ 选型

表格

MQ 特点 适用场景
Kafka 吞吐最高 日志收集、大数据管道
RocketMQ 金融级可靠、事务消息 强一致性交易链路
RabbitMQ 稳定、低延迟、文档全、好排查,万级 TPS 中小系统的业务异步解耦

RabbitMQ 的优势是"稳 + 好上手 + 易排查",对绝大多数中小业务的异步解耦场景是首选。


三、核心概念与安装

核心链路:

bash 复制代码
生产者 → 交换机(Exchange) → 路由键(Routing Key) → 绑定(Binding) → 队列 → 消费者

要点:生产者永远把消息发给交换机,由交换机按路由规则投递到队列,生产者不直接对队列发消息。

安装 :用 Docker 一行命令即可启动。开启管理插件后访问 15672 端口使用 Web 控制台。

虚拟主机(vhost) :用于数据隔离,类似 namespace,不同业务用不同 vhost;内置 default。


四、SpringAMQP 收发消息

发送消息

1. 引入 amqp 依赖

bash 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    <!-- 这里的 version 要跟自己的 SpringBoot 版本对应 -->
    <version>2.7.18</version>
</dependency>

2. 在 yml 中配置 RabbitMQ 服务端信息

bash 复制代码
spring:
  rabbitmq:
    host: 127.0.0.1   # RabbitMQ 服务器 IP
    port: 5672        # 注意:客户端连接用 5672
    username: guest   # 用户名
    password: 123     # 密码

注意端口区分:客户端连接用 5672 ;15672 是浏览器访问管理后台、调用管理接口用的。

3. 注入 RabbitTemplate 发送

bash 复制代码
@Slf4j
@SpringBootTest
class SpringAmqpTest {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSimpleQueue() {
        // 1. 队列名
        String queueName = "simple.queue";
        // 2. 消息
        String message = "hello, spring amqp!";
        // 3. 发送消息
        rabbitTemplate.convertAndSend(queueName, message);
    }
}

接收消息

声明一个类加上 @Component 注册成 Bean,方法加 @RabbitListener 成为监听器。接收参数的类型与发送类型对应,对象也能传,Spring 会自动完成对象与消息之间的转换,我们只管处理信息。

bash 复制代码
@Slf4j
@Component
public class SpringRabbitListener {

    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueue(Message message) {
        log.info("监听到 simple.queue 的消息:【{}】", new String(message.getBody()));
        log.info("消息 ID:【{}】", message.getMessageProperties().getMessageId());
    }
}

五、Work Queues 与公平分发

多个消费者绑定同一队列时,一条消息只会被其中一个消费者处理,用来提升消费速度、缓解积压。

  • 默认分发是轮询(round-robin),不看消费者快慢,会造成"忙者更忙"。
  • 把 prefetch(basicQos)设为 1 并配合手动 ack,Broker 才会优先把消息发给空闲消费者,实现"能者多劳"。

六、交换机类型与路由机制

三种交换机的差别,本质是"用什么规则把消息投递到队列"。

1. 广播交换机的 Fanout Exchange

  • 把消息发送到所有绑定的队列,无视路由键。
  • 实现一对多分发,类似广播。

应用:实时消息发布、通知系统等需要"一条消息发给多个消费者"的场景。

发送时填三个参数:交换机房、Routing Key、消息,其中 Routing Key 可以为空。

bash 复制代码
@Test
public void testFanoutQueue() {
    // 1. 交换机房名
    String exchangeName = "hmall.fanout";
    // 2. 消息
    String message = "hello everyone!";
    // 3. 发送消息(Fanout 的路由键可为空)
    rabbitTemplate.convertAndSend(exchangeName, "", message);
}

路由键 :用于把消息路由到指定队列的关键字。

绑定关系指"队列绑定到交换机房":Direct / Topic 绑定时带路由键,Fanout 不带。

2. 定向交换机的 Direct Exchange

  • 根据消息的路由键,投递到与之完全匹配的队列。
  • 适合一对一传递,如日志处理、任务分发,需要确保消息精确投递时使用。

3. 话题交换机的 Topic Exchange

  • 根据路由键与绑定键的模式匹配投递。
  • 用通配符扩展性更强:* 代表一个词 ,# 代表一个或多个词。
  • 适合主题订阅模型,如邮件分类、日志级别过滤。

七、声明式配置与消息转换器

有两种声明交换机房 / 队列 / 绑定的方式。

方式一:Bean 声明

创建配置类,把队列、交换机房、绑定关系分别声明为 Bean,返回对应对象或用工厂类构造(推荐),启动后会自动创建对应资源。

  • Queue:声明队列,用 QueueBuilder 构建。
  • Exchange:声明交换机房,用 ExchangeBuilder 构建。
  • Binding:声明绑定关系,用 BindingBuilder 构建。

注意:Exchange 只是接口,具体实现是各类交换机房,如 FanoutExchange、DirectExchange、TopicExchange。

bash 复制代码
@Configuration
public class FanoutConfiguration {

    @Bean
    public FanoutExchange fanoutExchange() {
        return ExchangeBuilder.fanoutExchange("TH.fanout").build();
    }

    @Bean
    public Queue fanoutQueue1() {
        return QueueBuilder.durable("TH.queue1").build();
    }

    @Bean
    public Binding bindFanoutQueue1(FanoutExchange fanoutExchange, Queue fanoutQueue1) {
        return BindingBuilder.bind(fanoutQueue1)
                .to(fanoutExchange)
                .with("routingKey");
    }
}

方式二:@RabbitListener 注解声明

直接在监听器上用 bindings 一次性声明队列、交换机房和绑定:

bash 复制代码
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "dlx.queue", durable = "true"),
        exchange = @Exchange(name = "dlx.exchange", type = ExchangeTypes.DIRECT),
        key = {"hi"}
))
public void listenDlxQueue(String message) {
    log.info("消费者监听到 dlx.queue 的消息:【{}】", message);
}
  • value = @Queue(...):定义队列属性。
  • exchange = @Exchange(...):指定关联的交换机房。
  • key = {"hi"}:设置绑定的路由键。

方式三:Jackson2JsonMessageConverter 配置与对象消息收发

当业务需要发送 Java 对象(如订单、用户实体)时,默认的 SimpleMessageConverter 只能处理字符串和字节数组,无法完成对象与 JSON 的互转。此时需要配置 Jackson2JsonMessageConverter,让发送端把对象序列化为 JSON 写入消息体,接收端再反序列化回对应类型。

1. 通过 @Bean 注册到 RabbitTemplate

在配置类中声明 MessageConverter 的 Bean,Spring Boot 会自动把它注入到 RabbitTemplate 和监听容器中,无需手动调用 setMessageConverter():

java 复制代码
@Configuration
public class RabbitMqConfig {

    @Bean
    public MessageConverter messageConverter(ObjectMapper objectMapper) {
        Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);
        // 自动为每条消息生成 messageId,便于幂等去重
        converter.setCreateMessageIds(true);
        return converter;
    }
}

如果项目没有引入 spring-boot-starter-web,需要单独添加 Jackson 依赖:

xml 复制代码
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

2. 发送对象消息

定义订单实体类,发送时直接传入对象,转换器会自动完成序列化:

java 复制代码
public class OrderDTO {
    private Long orderId;
    private String userId;
    private BigDecimal amount;
    // 省略 getter / setter
}
java 复制代码
@Service
public class OrderSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendOrder(OrderDTO order) {
        // 直接发送对象,Jackson2JsonMessageConverter 自动序列化为 JSON
        rabbitTemplate.convertAndSend("order.exchange", "order.create", order);
    }
}

3. 消费者接收对象消息

接收端监听方法直接声明对象类型参数,转换器会自动反序列化回 OrderDTO:

java 复制代码
@Slf4j
@Component
public class OrderListener {

    @RabbitListener(queues = "order.queue")
    public void handleOrder(OrderDTO order) {
        log.info("收到订单消息:orderId={}, userId={}, amount={}",
                order.getOrderId(), order.getUserId(), order.getAmount());
        // 执行业务逻辑
    }
}

要点:发送端和接收端必须使用同一个 Jackson2JsonMessageConverter(即两个服务都扫描到该配置类),否则接收端无法正确反序列化。若消息体带 __TypeId__ 头,转换器会据此还原具体类型;跨服务传输时建议保持实体类的包路径一致,或通过 setTypePrecedence 调整类型解析策略。

八、消息可靠性

消息在 MQ 链路上有三段会丢:发送端 → Broker、MQ 存储、Broker → 消费端。逐段处理。

8.1 发送端可靠性

8.1.1 发送者重连

网络波动会导致发送者连接 MQ 失败,可在 yml 开启发送重试:

bash 复制代码
spring:
  rabbitmq:
    connection-timeout: 1s
    template:
      retry:
        enabled: true
        initial-interval: 1000ms
        multiplier: 1
        max-attempts: 3   # 实际 yml 注意缩进,否则会报错

表格

参数 含义 本配置效果
connection-timeout: 1s 建立 TCP 连接超时时间 1 秒连不上即失败
template.retry.enabled: true 是否开启发送重试 开启生产者发送重试
initial-interval: 1000ms 首次失败后等待多久重试 失败先等 1 秒
multiplier: 1 重试间隔倍数 每次间隔不增长,都是 1 秒
max-attempts: 3 最多尝试几次(含首次) 最多 3 次:第 1 次 + 重试 2 次

弊端 :同步阻塞拖慢业务线程、可能重复投递、只解决瞬时抖动、参数配错会放大影响,而且不替代生产者确认和消费端幂等。

8.1.2 发送者确认机制(Publisher Confirm + Return)

为解决"消息到底有没有到达 Broker",引入两半机制:

  • Publisher Confirm :确认消息是否到达 Exchange。
  • Publisher Return :确认消息是否从 Exchange 到达 Queue。

发送者与 Exchange 之间是投递 关系,Exchange 与 Queue 之间是路由关系。

整体判定规则:

  1. 消息投递到 MQ 但路由失败 → 先 Publisher Return 返回路由异常原因,再返回 ACK(多因自身代码问题,重发也大概率失败,故算投递成功)。
  2. 临时消息投递到 MQ 且入队成功 → 返回 ACK。
  3. 持久消息投递到 MQ 且完成持久化 → 返回 ACK。
  4. 其他情况一律返回 NACK。

配置 Confirm 与 Return(发送端 yml):

bash 复制代码
spring:
  rabbitmq:
    publisher-confirm-type: correlated  # 开启 confirm 回调
    publisher-returns: true             # 开启 return 回调
    template:
      mandatory: true                   # 路由失败时触发 return

publisher-confirm-type 三种模式:

表格

模式 含义 是否推荐
NONE 关闭 confirm 不推荐
SIMPLE 同步阻塞等待 MQ 回执 低吞吐场景可用
CORRELATED 异步回调返回每条消息确认结果 生产推荐

注册 ReturnCallback(全局注册一次,捕获路由失败的消息):

bash 复制代码
@Slf4j
@Configuration
public class RabbitConfig {

    @Bean
    public RabbitTemplateCustomizer rabbitTemplateCustomizer() {
        // 返回 RabbitTemplate 的定制器,增强内置 RabbitTemplate
        return template -> {
            // 双保险:即使 yml 没配,也强制 mandatory
            // 消息已到 Exchange 但无法路由到任何 Queue 时,Broker 退回给生产者而非丢弃
            template.setMandatory(true);

            // confirm 回调:ack / nack
            template.setConfirmCallback((correlationData, ack, cause) -> {
                String id = correlationData == null ? null : correlationData.getId();
                if (ack) {
                    log.info("publisher confirm ack, id={}", id);
                } else {
                    log.error("publisher confirm nack, id={}, cause={}", id, cause);
                }
            });

            // return 回调:消息无法路由时触发
            template.setReturnsCallback(returned -> log.error(
                    "publisher return, code={}, text={}, exchange={}, routingKey={}, message={}",
                    returned.getReplyCode(), returned.getReplyText(),
                    returned.getExchange(), returned.getRoutingKey(), returned.getMessage()));
        };
    }
    // 一般放在项目 config 包下,和启动类同级或子级即可
}

发送消息并关联 confirm 结果:

bash 复制代码
@Service
public class MessageSender {

    private final RabbitTemplate rabbitTemplate;

    public MessageSender(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void send(String payload) {
        String messageId = UUID.randomUUID().toString();
        CorrelationData correlationData = new CorrelationData(messageId);
        rabbitTemplate.convertAndSend(
                "biz.exchange",
                "biz.key",
                payload,
                message -> {
                    message.getMessageProperties().setMessageId(messageId);
                    message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
                    return message;
                },
                correlationData
        );
        // 异步获取该消息的 confirm 结果
        correlationData.getFuture().whenComplete((confirm, ex) -> {
            if (ex != null) {
                log.error("confirm error, id={}", messageId, ex);
                return;
            }
            if (!confirm.isAck()) {
                log.error("confirm nack, id={}, reason={}", messageId, confirm.getReason());
                return;
            }
            // 注意:ack 不代表一定路由成功,还要看是否被 return
            if (correlationData.getReturned() != null) {
                log.error("message returned, id={}, returned={}", messageId, correlationData.getReturned());
                return;
            }
            log.info("message accepted and routed, id={}", messageId);
        });
    }
}

CorrelationData 是发送方与 MQ 之间用来标记一次发送的对象。

@PostConstruct 标记方法在 Bean 实例化、依赖注入完成后自动执行一次。

ApplicationContextAware 让 Bean 能拿到 ApplicationContext 从而手动 getBean,用于绕过某些版本的循环依赖;实际开发中用 @RequiredArgsConstructor 声明要注入的 Bean、自动生成构造器即可。

实际开发场景:发送消息 + 本地消息表 + 定时兜底

主线程业务(订单与消息记录同事务,事务提交后再发):

bash 复制代码
@Transactional
public void createOrderAndNotifyStock(OrderDTO order) {
    // 1. 订单入库(状态=待确认)
    orderService.save(order);
    // 2. 本地消息表入库(状态=PENDING)------ 与订单同事务
    String msgId = IDUtil.getSnowflakeNextIdStr(); // 雪花算法生成全局唯一 id
    MessageRecord record = MessageRecord.pending(msgId, order.getOrderId(),
            "inventory.exchange", "stock.reduce", buildPayload(order));
    messageRecordMapper.insert(record);
    // 3. 事务提交后再发消息(避免消息先到、DB 未提交导致消费方查不到)
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            doSend(record);
        }
    });
}

doSend 把消息发到 MQ,并根据 Broker 的 ACK/NACK 更新消息记录状态:

bash 复制代码
private void doSend(MessageRecord record) {
    CorrelationData cd = new CorrelationData(record.getMsgId());
    cd.getFuture().addCallback(new ListenableFutureCallback<>() {
        @Override public void onFailure(Throwable ex) {
            log.error("消息发送失败 msgId={}", record.getMsgId(), ex);
            messageRecordMapper.markStatus(record.getMsgId(), "EXCEPTION");
        }
        @Override public void onSuccess(CorrelationData.Confirm r) {
            // 回执有两种:ACK(已到达 Exchange) / NACK(未到达 Exchange)
            messageRecordMapper.markStatus(record.getMsgId(), r.isAck() ? "SUCCESS" : "FAIL");
        }
    });
    try {
        rabbitTemplate.convertAndSend(
                record.getExchange(), record.getRoutingKey(), record.getPayload(), cd);
    } catch (Exception e) {
        log.error("send 抛异常 msgId={}", record.getMsgId(), e);
        messageRecordMapper.markStatus(record.getMsgId(), "SEND_EXCEPTION");
    }
}

定时任务兜底重发:

bash 复制代码
@Scheduled(fixedDelay = 30_000)
public void retryFailedMessages() {
    List<MessageRecord> list = messageRecordMapper.findRetryable(3, 50);
    for (MessageRecord r : list) {
        if (r.getRetryCount() >= MAX_RETRY) {
            alertService.notifyOps(r); // 告警 + 人工介入
            continue;
        }
        doSend(r);
        messageRecordMapper.incrRetry(r.getMsgId());
    }
}

8.2 MQ 端可靠性:持久化

为什么需要持久化? RabbitMQ 把消息存在内存里以降低收发延迟,这会带来两个问题:① MQ 宕机时消息全丢;② 内存有限,消费慢或故障时消息堆积引发堵塞。

  • 交换机房持久化:交换机房元数据被保存,默认持久化,也可建临时交换机房。
  • 队列持久化 :队列元数据被保存。注意它只保证队列结构不变,重启后队列里的消息仍会丢失;默认持久化,也可建临时队列。
  • 消息持久化 :协议层默认非持久化 ;但 SpringAMQP 发送的消息默认是持久化的,也可自定义构造非持久化消息。

Spring AMQP 常用构造器有三类:ExchangeBuilder、QueueBuilder、BindingBuilder,配合 Binding / Exchange / Queue 的构造方法和 @RabbitListener 使用。

MessageBuilder 构造消息本身:

bash 复制代码
Message message = MessageBuilder
        .withBody("订单创建成功".getBytes(StandardCharsets.UTF_8)) // 消息体
        .setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN) // 内容类型
        .setDeliveryMode(MessageDeliveryMode.PERSISTENT)          // 持久化
        .setHeader("source", "order-service")                     // 自定义头
        .setHeader("traceId", UUID.randomUUID().toString())       // 自定义头
        .setExpiration("60000")                                   // 过期时间
        .setPriority(5)                                           // 优先级,越大越优先
        .build();                                                 // 构建
rabbitTemplate.send("order.direct", "order.create", message);

当生产者开启了 Publisher Confirm、发送的是持久化消息、队列也是持久化队列时,RabbitMQ 会在消息成功写入所有匹配队列、且持久化消息完成持久化后,才回 ACK。

Lazy Queue(惰性队列)

Lazy Queue 收到消息后直接存入磁盘,不常驻内存;消费时才从磁盘读取并加载到内存(可预缓存部分消息到内存,最多 2048 条)。

RabbitMQ 3.12 之后经典队列默认就按类似 Lazy 的方式运行 ,x-queue-mode=lazy 会被忽略,无法切回旧的"非 lazy"模式。也就是说只要 RabbitMQ ≥ 3.12,创建的队列默认就类似 Lazy Queue;旧版本才需要手动设置为 Lazy Queue。

bash 复制代码
// 1. SpringAMQP:QueueBuilder(旧版本手动 lazy)
@Bean
public Queue lazyQueue() {
    return QueueBuilder.durable("lazy.queue")
            .lazy()  // 等价于 x-queue-mode=lazy
            .build();
}

// 2. SpringAMQP:@RabbitListener
@RabbitListener(queuesToDeclare = @Queue(
        name = "lazy.queue",
        durable = "true",
        arguments = @Argument(name = "x-queue-mode", value = "lazy")))
public void listenLazyQueue(String msg) {
    // 处理消息
}

8.3 接收端可靠性

消费者确认机制

确认消费者是否成功处理消息,处理完向 MQ 发回执:

  • ACK:成功处理,MQ 删除该消息。
  • NACK:处理失败,MQ 需再次投递。
  • REJECT:处理失败并拒绝,MQ 删除该消息。

Spring AMQP 的 acknowledge-mode 有三种:

  • NONE:不确认,投递后直接删除。
  • AUTO:Spring 自动管理。方法正常返回 ACK;抛异常不 ACK,消息可能重投。
  • MANUAL :手动确认,必须自己调用 basicAck / basicNack / basicReject。有业务侵入,但更灵活。

生产环境尤其是订单、支付、一致性场景,通常用手动确认。

bash 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual  # 手动确认
        prefetch: 1               # 每次只预取一条,处理完再拉下一条

业务异常要 catch 或走死信 / 落库,否则不 ack 会触发无限重投(毒消息刷屏)。

失败重试机制

Spring AMQP 提供消费者本地重试,异常时在本地重试而不是无限投回 MQ:

bash 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true          # 开启重试
          initial-interval: 1000ms # 第一次重试间隔
          multiplier: 1          # 失败后重试间隔倍数
          max-attempts: 3        # 最大重试次数
          stateless: true        # true 无状态;业务含事务设 false

耗尽重试仍失败,用 MessageRecoverer 处理,有三种实现:

  1. RejectAndDontRequeueRecoverer:直接 reject 丢弃(默认)。
  2. ImmediateRequeueMessageRecoverer:返回 nack,消息重新入队。
  3. RepublishMessageRecoverer :把失败消息投递到指定交换机房(推荐)。
bash 复制代码
@Configuration
@ConditionalOnProperty(
        prefix = "spring.rabbitmq.listener.simple.retry",
        name = "enabled",
        havingValue = "true")
public class RabbitMQRecoverConfig {

    @Bean
    public DirectExchange errorExchange() {
        return new DirectExchange("error.direct");
    }

    @Bean
    public Queue errorQueue() {
        return new Queue("error.queue", true);
    }

    @Bean
    public Binding errorBinding(Queue errorQueue, DirectExchange errorExchange) {
        return BindingBuilder.bind(errorQueue).to(errorExchange()).with("error");
    }

    @Bean
    public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
        return new RepublishMessageRecoverer(
                rabbitTemplate,
                "error.direct", // 失败消息投递到的交换机房
                "error");       // 路由键
    }
}

@ConditionalOnProperty 由 Spring 提供,根据配置属性值决定是否加载某 Bean / 配置类:prefix 前缀、name 属性名、havingValue 必须精确等于的值、matchIfMissing(默认 false,配置缺失时条件是否成立)。

8.4 业务幂等性

幂等性:同一业务执行一次或多次,对业务状态的影响一致。

为什么必须做幂等? RabbitMQ 是 at-least-once(至少一次) 投递语义,重复不可避免;MQ 本身没有内置幂等 ,幂等必须由业务在消费端实现。核心思路都是 唯一标识 + 去重。

方案一:每条消息带唯一 Id

每条消息生成唯一 id 随消息投递,消费者处理成功后把 id 落库;再收到相同 id 就放弃处理。

bash 复制代码
// 生产者为消息打唯一 id
rabbitTemplate.convertAndSend(exchange, routingKey, payload, message -> {
    // 优先用业务唯一键(如订单号),没有就用 UUID
    message.getMessageProperties().setMessageId(orderNo);
    return message;
});

去重方案 1:数据库唯一索引

建一张只含主键 msg_id 的记录表。消费者先插一条,成功即首次消费继续业务;插入抛 DuplicateKeyException 说明已处理,直接 ACK 跳过。

bash 复制代码
@Service
public class OrderConsumer {

    @Autowired
    private MsgLogMapper msgLogMapper;
    @Autowired
    private OrderService orderService;

    @RabbitListener(queues = "order.queue")
    @Transactional(rollbackFor = Exception.class)
    public void handle(Message message) {
        String msgId = message.getMessageProperties().getMessageId();
        try {
            msgLogMapper.insert(msgId); // 唯一约束:重复插入会抛 DuplicateKeyException
        } catch (DuplicateKeyException e) {
            log.info("消息 {} 已消费,跳过", msgId);
            return; // 方法正常返回 → 自动 ACK
        }
        // 执行业务(与去重记录同事务,一起提交 / 回滚)
        orderService.process(message.getBody());
    }
}

去重方案 2:Redis SETNX

bash 复制代码
@RabbitListener(queues = "order.queue")
public void handle(Message message, Channel channel,
                   @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
    String msgId = message.getMessageProperties().getMessageId();
    if (msgId == null) { return; }
    String key = "mq:consume:" + msgId;
    // SET key 1 NX EX 86400 ------ 原子操作
    Boolean first = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofDays(1));
    if (Boolean.FALSE.equals(first)) {
        log.info("重复消息 {},直接 ACK", msgId);
        channel.basicAck(tag, false);
        return;
    }
    try {
        orderService.process(message.getBody());
        channel.basicAck(tag, false);
    } catch (Exception e) {
        // 业务失败:删除去重标记,让消息重投时能再次处理
        redisTemplate.delete(key);
        channel.basicNack(tag, false, true);
    }
    // 若 basicAck 抛异常(如连接断开)消息会重投,但 Redis 标记仍在 → 重投消息被正确跳过
}

去重方案 3:状态机 / 状态前置校验

复杂业务对象有明确、不可逆的状态流转(订单:待支付→已支付→已发货→已完成;文件:新建→编辑中→已审核)。用"状态机"思维做安全校验:

bash 复制代码
int rows = orderMapper.updateStatusPaid(orderNo);
if (rows == 0) {
    // 已经处理过或状态不对,幂等返回
    log.info("订单 {} 状态非待支付,忽略重复消费", orderNo);
    return;
}

去重方案 4:乐观锁 / 版本号

MyBatis-Plus 的 Lambda 链式更新:

bash 复制代码
// UPDATE t_order SET status = 2, pay_time = '当前时间' WHERE id = #{orderId} AND status = 1;
orderService.lambdaUpdate()
        .set(Order::getStatus, 2)               // 设置为已支付
        .set(Order::getPayTime, LocalDateTime.now()) // 记录支付时间
        .eq(Order::getId, orderId)              // 锁定订单
        .eq(Order::getStatus, 1)                // 只有未支付才能更新(防重)
        .update();                              // 返回影响行数

Message 类与关键属性

bash 复制代码
public class Message {
    private final byte[] body;                       // 消息体:二进制数据
    private final MessageProperties messageProperties; // 消息属性:元数据
}

messageProperties 常用:

  • getMessageId():之前生成的 messageId(幂等去重)。
  • getContentType():消息体类型(如 application/json)。
  • getDeliveryTag():极其重要,手动确认(ACK/NACK)的标签。
  • getTimestamp():发送时间。
  • getReceivedExchange() / getReceivedRoutingKey():来自哪个交换机房与路由键。

8.5 配置消息转换器

RabbitMQ 底层传字节流,发送实体类要配消息转换器(发送端 + 接收端都配)。发送端 toMessage() 把 Java 对象序列化写入消息体并设 content-type;接收端 fromMessage() 反序列化回 Java 对象。

bash 复制代码
// 若项目已有 spring-boot-starter-web,一般不必再单独引 Jackson;否则引 jackson-databind
@Configuration
public class RabbitMqConfig {

    @Bean
    public MessageConverter messageConverter(ObjectMapper objectMapper) {
        Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);
        converter.setCreateMessageIds(true);
        return converter;
    }
}

只要这个 Bean 在两个服务里都被扫描到,RabbitTemplate 和监听容器会自动使用同一个转换器,无需手动设置。


8.6 死信队列(DLX)实战配置

死信交换机(DLX)与死信队列的作用 :当消息在业务队列中满足死信条件(消费失败且 requeue = false、消息过期、队列堆积溢出)时,RabbitMQ 不会直接丢弃,而是把消息转发到该队列指定的死信交换机(Dead Letter Exchange),再由死信交换机按路由键投递到死信队列。死信队列专门用于收集这些异常消息,方便后续排查、人工补偿或二次处理,避免消息静默丢失。

1. 通过 @Bean 声明死信交换机、死信队列及绑定关系

在配置类中分别声明死信交换机、死信队列,并把业务队列通过 dead-letter-exchange 和 dead-letter-routing-key 属性关联到死信交换机:

java 复制代码
@Configuration
public class DlxConfig {

    // 1. 死信交换机(Direct 类型)
    @Bean
    public DirectExchange dlxExchange() {
        return new DirectExchange("dlx.exchange");
    }

    // 2. 死信队列
    @Bean
    public Queue dlxQueue() {
        return new Queue("dlx.queue", true);
    }

    // 3. 死信队列绑定到死信交换机,路由键为 error
    @Bean
    public Binding dlxBinding(Queue dlxQueue, DirectExchange dlxExchange) {
        return BindingBuilder.bind(dlxQueue).to(dlxExchange).with("error");
    }

    // 4. 业务队列:声明死信交换机与死信路由键
    @Bean
    public Queue businessQueue() {
        return QueueBuilder.durable("business.queue")
                .deadLetterExchange("dlx.exchange")   // 指定死信交换机
                .deadLetterRoutingKey("error")        // 死信消息使用的路由键
                .build();
    }

    // 5. 业务交换机(可选,用于正常投递)
    @Bean
    public DirectExchange businessExchange() {
        return new DirectExchange("business.exchange");
    }

    // 6. 业务队列绑定到业务交换机
    @Bean
    public Binding businessBinding(Queue businessQueue, DirectExchange businessExchange) {
        return BindingBuilder.bind(businessQueue).to(businessExchange).with("business.key");
    }
}

2. 将消费失败的消息转发到死信队列

消费者处理失败时,通过 basicNack 或 basicReject 并设置 requeue = false,消息就会进入死信交换机,再被投递到死信队列:

java 复制代码
@Slf4j
@Component
public class BusinessListener {

    @RabbitListener(queues = "business.queue")
    public void handle(Message message, Channel channel,
                       @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
        try {
            // 业务处理逻辑
            process(message);
            // 处理成功,手动确认
            channel.basicAck(tag, false);
        } catch (Exception e) {
            log.error("业务处理失败,消息转入死信队列,msgId={}",
                    message.getMessageProperties().getMessageId(), e);
            // requeue = false:不重回业务队列,直接进入死信交换机
            channel.basicNack(tag, false, false);
        }
    }

    private void process(Message message) {
        // 模拟业务处理
    }
}

3. 消费死信队列中的消息

单独写一个监听器消费死信队列,用于告警、落库或人工补偿:

java 复制代码
@Slf4j
@Component
public class DlxListener {

    @RabbitListener(queues = "dlx.queue")
    public void handleDlx(Message message) {
        log.error("收到死信消息:msgId={}, body={}",
                message.getMessageProperties().getMessageId(),
                new String(message.getBody()));
        // 落库记录、发送告警或人工介入处理
    }
}

要点:死信交换机与死信队列的声明方式与普通交换机、队列完全一致,核心在于业务队列必须通过 deadLetterExchange 属性指向死信交换机;消费失败时务必设置 requeue = false,否则消息会无限重投而无法进入死信队列。

九、延迟消息与死信

延迟消息:发送时指定一个时间,消费者不会立刻收到,而是在指定时间后才收到。可用于延时任务。

死信(dead letter):队列中消息满足下列任一情况即成为死信:

  • 消费者用 basic.reject / basic.nack 声明消费失败且 requeue = false;
  • 消息是过期消息(达到队列或消息本身设置的过期时间),超时无人消费;
  • 要投递的队列堆积满了,最早的消息可能成为死信。

这些消息经死信交换机房(DLX) 转入死信队列,用于排查与人工补偿。

DLX 死信交换机房 :若队列通过 dead-letter-exchange 属性指定了一个交换机房,该队列的死信就会投递到这个交换机房。可利用该机制实现延迟消息。

延迟插件 :rabbitmq_delayed_message_exchange 插件可把普通交换机房改造为支持延迟消息的交换机房,消息投递后可暂存一定时间、到期再投递到队列(Docker 安装配置插件见 RabbitMQ 安装部分)。

延迟消息两种做法:

  • TTL + DLX:传统方案,存在精度与队头阻塞问题。
  • 延迟插件:更直观。

典型场景:订单创建后超时未支付则自动关闭。


结语

一条完整的可靠消息链路,本质是三段加固的叠加:

  • 发送端:confirm + return + 本地消息表 + 定时兜底,保证"消息一定到 Exchange、路由一定进 Queue"。
  • MQ 端:交换机房 / 队列 / 消息三者都持久化(必要时用 Lazy Queue)。
  • 消费端:手动 ack + 本地重试 + 失败 Republish + 业务幂等。

把这三段都想清楚,RabbitMQ 才真正从"能发消息"进化成"消息不会丢、不会乱、重复了也不出错"。


写在最后

这一篇从"为什么用 MQ"写到了"可靠性三段加固",是我在真实项目里踩坑后整理的,建议 收藏,做订单 / 支付 / 异步解耦时随时回来对照。

相关推荐
Bazingga1 小时前
Transformer与自注意力机制:一台"全员检索"机器的说明书
人工智能·后端
星辰云-1 小时前
# 基于Spring Boot的德州特产扒鸡商城系统(毕设|源码|论文|演示视频)
spring boot·后端·课程设计
武子康1 小时前
同为7/8,错误样本却不同:Agent Grader 分歧复算
人工智能·后端·agent
茉莉玫瑰花茶1 小时前
GO [ 内存与变量逃逸分析 ]
开发语言·后端·golang
星辰云-1 小时前
基于Spring Boot的旅游景点票务系统的设计与实现(毕设项目,附完整源码)
spring boot·后端·课程设计
Rain的Java大神之路2 小时前
百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成
java·后端·面试
山间小僧2 小时前
Agent Memory(三)团队记忆:以 TencentDB Agent Memory 为例
后端·agent
十年Java程序媛2 小时前
JVM 基础与 GC 垃圾回收详解|可达性分析、分代模型、垃圾收集器、STW
java·开发语言·jvm·spring boot·后端·面试
苏三说技术2 小时前
为什么越来越多人用MinerU?
后端