Java学习第45天 - 消息队列入门、异步解耦与最终一致性(RabbitMQ / RocketMQ)

一、学习目标

  • 理解消息队列 MQ 的核心价值:异步、解耦、削峰填谷。
  • 掌握 MQ 的基本概念:生产者、消费者、Broker、队列、交换机、主题。
  • 会在 Spring Boot 中集成 RabbitMQ 完成消息收发。
  • 了解 RocketMQ 的核心模型与适用场景,能与 RabbitMQ 对比选型。
  • 掌握消息可靠性三要素:不丢、不重、有序,以及消费幂等。
  • 理解消息确认机制、手动 ACK、死信队列、延迟消息。
  • 能用消息队列把第44天的库存预扣、异步落库、缓存失效真正落地。
  • 理解基于消息的最终一致性方案与本地消息表思路。

二、为什么第45天要学消息队列

第40到44天完成了数据库、缓存、并发写。但还有几个问题没解决:

  • 第44天秒杀预扣库存后说要异步落库,靠什么异步,怎么保证最终落库成功。
  • 第43天说删缓存失败要进重试队列,这个队列用什么实现。
  • 下单后要发短信、发优惠券、加积分,如果同步串行调用,接口又慢又脆,任何一个下游失败都影响下单。
  • 大促瞬时下单量远超数据库处理能力,需要把请求先缓冲起来慢慢处理。

消息队列正是解决这些问题的核心中间件。

第45天目标:理解 MQ 的价值与模型,掌握 RabbitMQ 收发与可靠性,把前面遗留的异步场景落地。


三、消息队列核心价值

3.1 异步

把非核心、可延迟的操作异步化,缩短主流程响应时间。

text 复制代码
同步串行:下单 -> 扣库存 -> 发短信 -> 发券 -> 加积分 -> 返回,耗时累加
异步解耦:下单 -> 扣库存 -> 发消息 -> 返回,短信、券、积分由消费者异步处理

3.2 解耦

生产者不需要知道有哪些消费者。新增一个下游,只要订阅消息即可,下单服务代码不用改。

text 复制代码
下单服务 -> 发布 OrderCreated 消息
                |-> 短信服务订阅
                |-> 积分服务订阅
                |-> 数据分析服务订阅

3.3 削峰填谷

大促瞬时流量先进入队列,消费者按自己的能力匀速消费,保护数据库。

text 复制代码
瞬时 1 万 QPS -> MQ 缓冲 -> 消费者每秒处理 2 千 -> 数据库平稳

3.4 MQ 也带来的代价

  • 系统复杂度上升,多了一个需要维护的中间件。
  • 一致性变难,从强一致变最终一致。
  • 要处理消息丢失、重复、乱序。
  • 排查问题链路变长,需要消息轨迹追踪。

所以不要为了用 MQ 而用 MQ,要看场景。


四、消息队列核心概念

4.1 通用角色

角色 说明
Producer 生产者 发送消息的应用
Consumer 消费者 接收并处理消息的应用
Broker 消息服务器,负责存储和转发
Message 消息 传输的数据单元
Queue 队列 存放消息的容器
Topic 主题 消息的逻辑分类

4.2 两种消息模型

点对点,一条消息只被一个消费者消费:

text 复制代码
Producer -> Queue -> 一个 Consumer 消费

发布订阅,一条消息被多个订阅者各消费一份:

text 复制代码
Producer -> Topic -> Consumer A 一份
                  -> Consumer B 一份

4.3 主流 MQ 对比

维度 RabbitMQ RocketMQ Kafka
语言 Erlang Java Scala Java
模型 交换机加队列,灵活路由 Topic 加 Tag Topic 加 Partition
吞吐 万级到十万级 十万级 百万级
延迟 微秒到毫秒 毫秒 毫秒
事务消息 支持 支持,较成熟 支持
顺序消息 较弱 支持 分区有序
典型场景 业务解耦、复杂路由 电商交易、削峰 日志、大数据流

第45天以 RabbitMQ 入门,RocketMQ 作为电商场景的对比了解。第18天曾涉及 Kafka 流式处理,可对照学习。


五、RabbitMQ 核心模型

5.1 关键组件

text 复制代码
Producer -> Exchange 交换机 -> 按规则路由 -> Queue 队列 -> Consumer
  • Exchange 交换机:接收生产者消息,按规则路由到队列,自己不存消息。
  • Binding 绑定:交换机和队列之间的关联规则,含路由键 routing key。
  • Queue 队列:真正存消息的地方。

5.2 四种交换机类型

类型 路由规则 场景
Direct 直连 routing key 完全匹配 精确路由,如按订单类型
Topic 主题 routing key 模式匹配,支持 * 和 # 灵活路由,如 order.*
Fanout 广播 忽略 routing key,广播到所有绑定队列 发布订阅,如缓存失效广播
Headers 按消息头匹配 较少用

Topic 通配符规则:星号匹配一个单词,井号匹配零或多个单词。

text 复制代码
order.created     匹配 order.*  匹配 order.#
order.pay.success 匹配 order.#  不匹配 order.*

六、Spring Boot 集成 RabbitMQ

6.1 依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

6.2 application.yml

yaml 复制代码
spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    publisher-confirm-type: correlated
    publisher-returns: true
    listener:
      simple:
        acknowledge-mode: manual
        prefetch: 10
        retry:
          enabled: true
          max-attempts: 3
          initial-interval: 2000ms

说明:

  • publisher-confirm-type correlated,开启发送方确认,确认消息到达 Broker。
  • publisher-returns true,开启路由失败回退。
  • acknowledge-mode manual,手动 ACK,消费成功才确认。
  • prefetch 10,每个消费者一次最多预取 10 条,防止单消费者积压过多。

6.3 声明交换机、队列、绑定

java 复制代码
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitConfig {

    public static final String ORDER_EXCHANGE = "order.exchange";
    public static final String ORDER_CREATE_QUEUE = "order.create.queue";
    public static final String ORDER_CREATE_KEY = "order.create";

    public static final String ORDER_DLX_EXCHANGE = "order.dlx.exchange";
    public static final String ORDER_DLX_QUEUE = "order.dlx.queue";
    public static final String ORDER_DLX_KEY = "order.dlx";

    @Bean
    public DirectExchange orderExchange() {
        return new DirectExchange(ORDER_EXCHANGE, true, false);
    }

    @Bean
    public Queue orderCreateQueue() {
        return QueueBuilder.durable(ORDER_CREATE_QUEUE)
                .withArgument("x-dead-letter-exchange", ORDER_DLX_EXCHANGE)
                .withArgument("x-dead-letter-routing-key", ORDER_DLX_KEY)
                .build();
    }

    @Bean
    public Binding orderCreateBinding() {
        return BindingBuilder.bind(orderCreateQueue())
                .to(orderExchange())
                .with(ORDER_CREATE_KEY);
    }

    @Bean
    public DirectExchange orderDlxExchange() {
        return new DirectExchange(ORDER_DLX_EXCHANGE, true, false);
    }

    @Bean
    public Queue orderDlxQueue() {
        return QueueBuilder.durable(ORDER_DLX_QUEUE).build();
    }

    @Bean
    public Binding orderDlxBinding() {
        return BindingBuilder.bind(orderDlxQueue())
                .to(orderDlxExchange())
                .with(ORDER_DLX_KEY);
    }
}

队列声明时绑定了死信交换机,消费多次失败的消息会进入死信队列,第九节展开。

6.4 消息体定义

java 复制代码
import java.io.Serializable;

public class OrderCreatedMessage implements Serializable {

    private String messageId;
    private Long userId;
    private Long productId;
    private Integer quantity;
    private long timestamp;

    // 构造器、getter、setter
}

建议每条消息带全局唯一 messageId,用于消费端幂等去重。

6.5 生产者

java 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Service
public class OrderMessageProducer {

    private final RabbitTemplate rabbitTemplate;
    private final ObjectMapper objectMapper;

    public OrderMessageProducer(RabbitTemplate rabbitTemplate, ObjectMapper objectMapper) {
        this.rabbitTemplate = rabbitTemplate;
        this.objectMapper = objectMapper;
    }

    public void sendOrderCreated(OrderCreatedMessage message) {
        try {
            String messageId = message.getMessageId() != null
                    ? message.getMessageId() : UUID.randomUUID().toString();
            message.setMessageId(messageId);

            CorrelationData correlationData = new CorrelationData(messageId);
            rabbitTemplate.convertAndSend(
                    RabbitConfig.ORDER_EXCHANGE,
                    RabbitConfig.ORDER_CREATE_KEY,
                    objectMapper.writeValueAsString(message),
                    correlationData);
        } catch (Exception e) {
            throw new IllegalStateException("发送订单消息失败", e);
        }
    }
}

6.6 确认回调,确保消息到达 Broker

java 复制代码
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import jakarta.annotation.PostConstruct;

@Component
public class RabbitConfirmCallback {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init() {
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if (!ack) {
                String id = correlationData != null ? correlationData.getId() : "unknown";
                // 记录日志,触发重发或落本地消息表标记失败
                System.err.println("消息未到达 Broker, id=" + id + ", cause=" + cause);
            }
        });

        rabbitTemplate.setReturnsCallback(returned -> {
            // 消息到了交换机但没路由到队列,通常是 routing key 配错
            System.err.println("消息路由失败: " + returned.getMessage());
        });
    }
}

6.7 消费者,手动 ACK

java 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class OrderCreateConsumer {

    private final ObjectMapper objectMapper;
    private final OrderPersistService orderPersistService;

    public OrderCreateConsumer(ObjectMapper objectMapper,
                               OrderPersistService orderPersistService) {
        this.objectMapper = objectMapper;
        this.orderPersistService = orderPersistService;
    }

    @RabbitListener(queues = RabbitConfig.ORDER_CREATE_QUEUE)
    public void onMessage(Message message, Channel channel) throws IOException {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            String body = new String(message.getBody());
            OrderCreatedMessage msg = objectMapper.readValue(body, OrderCreatedMessage.class);

            orderPersistService.handleOrderCreated(msg);

            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            // 第二个参数 false 表示不重新入队,直接进死信,避免无限重试
            channel.basicNack(deliveryTag, false, false);
        }
    }
}

要点:

  • 消费成功 basicAck 确认。
  • 消费失败 basicNack,根据是否可重试决定 requeue。
  • 业务不可恢复的错误不要 requeue,否则会死循环占用资源,让它进死信队列人工处理。

七、消息可靠性,不丢

消息可能在三个环节丢失,分别要保障。

7.1 生产者到 Broker 不丢

  • 开启 publisher confirm,确认消息到达 Broker。
  • 未确认的消息要重发或记录到本地消息表后续补偿。
  • 开启 returns callback,处理路由失败。

7.2 Broker 自身不丢

  • 交换机持久化 durable true。
  • 队列持久化 durable true。
  • 消息持久化,Spring 默认 PERSISTENT。

三者都持久化,Broker 重启后消息不丢。注意持久化不等于绝对不丢,极端情况落盘前宕机仍可能丢,要求极高时用镜像队列或仲裁队列。

7.3 Broker 到消费者不丢

  • 手动 ACK,业务处理成功才确认。
  • 处理失败 nack 或进死信,不要自动 ACK。
  • 自动 ACK 模式下,消费者拿到消息还没处理就宕机,消息会丢。

八、消息可靠性,不重,消费幂等

8.1 为什么会重复

  • 生产者确认超时重发。
  • 消费者处理成功但 ACK 网络丢失,Broker 重投。
  • 所以 MQ 通常保证至少一次投递,重复不可避免,必须靠消费端幂等。

8.2 幂等方案,唯一 messageId 加去重表

sql 复制代码
CREATE TABLE mq_consume_record (
    id BIGINT NOT NULL AUTO_INCREMENT,
    message_id VARCHAR(64) NOT NULL,
    consumer VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_msg_consumer (message_id, consumer)
);

消费时先尝试插入去重记录,唯一键冲突说明已消费过,直接 ACK 跳过。

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void handleOrderCreated(OrderCreatedMessage msg) {
    int inserted = consumeRecordMapper.insertIgnore(msg.getMessageId(), "orderCreate");
    if (inserted == 0) {
        // 已处理过,幂等跳过
        return;
    }
    // 真正业务:落库订单、扣减数据库库存
    orderRepository.save(buildOrder(msg));
}

8.3 用 Redis 做幂等

也可用第43天的 Redis setIfAbsent 判断 messageId 是否已处理,与第38天 Idempotency-Key 思路一致。但 Redis 方案在极端情况下不如数据库去重表可靠,关键业务建议用数据库。


九、死信队列与延迟消息

9.1 死信队列 DLX

消息成为死信的三种情况:

  • 消息被 nack 或 reject 且 requeue 为 false。
  • 消息 TTL 过期。
  • 队列达到最大长度。

死信会被转发到绑定的死信交换机,再进入死信队列。用途:

  • 收集消费失败的消息,人工排查或补偿。
  • 实现延迟队列。

第6.3节已经给订单队列绑定了死信交换机。

9.2 延迟消息,TTL 加死信实现

让消息在普通队列里设置 TTL,不被消费,过期后自动进入死信队列,由死信队列的消费者处理,从而实现延迟。

java 复制代码
@Bean
public Queue delayQueue() {
    return QueueBuilder.durable("order.delay.queue")
            .withArgument("x-dead-letter-exchange", "order.dlx.exchange")
            .withArgument("x-dead-letter-routing-key", "order.dlx")
            .withArgument("x-message-ttl", 1800000) // 30 分钟
            .build();
}

典型场景,订单 30 分钟未支付自动取消:

text 复制代码
下单时发一条延迟 30 分钟的消息
30 分钟后消息进入死信队列
消费者检查订单是否已支付,未支付则取消并回补库存

9.3 延迟插件方案

RabbitMQ 有 rabbitmq-delayed-message-exchange 插件,可直接发送指定延迟时间的消息,比 TTL 加死信更灵活。RocketMQ 原生支持延迟级别消息。


十、用 MQ 落地前面遗留场景

10.1 秒杀异步落库,衔接第44天

java 复制代码
@Service
public class SeckillService {

    private final StockPreDeductService stockService;
    private final OrderMessageProducer producer;

    public void seckill(Long userId, Long productId, int quantity) {
        long result = stockService.preDeduct(productId, quantity);
        if (result == -1) {
            throw new IllegalStateException("活动未开始");
        }
        if (result == 0) {
            throw new IllegalStateException("库存不足");
        }

        OrderCreatedMessage msg = new OrderCreatedMessage();
        msg.setMessageId(UUID.randomUUID().toString());
        msg.setUserId(userId);
        msg.setProductId(productId);
        msg.setQuantity(quantity);
        msg.setTimestamp(System.currentTimeMillis());

        producer.sendOrderCreated(msg);
    }
}

Redis 预扣成功立即返回,订单落库异步进行,数据库压力被削峰。

10.2 消费失败回补库存

java 复制代码
@RabbitListener(queues = RabbitConfig.ORDER_CREATE_QUEUE)
public void onMessage(Message message, Channel channel) throws IOException {
    long tag = message.getMessageProperties().getDeliveryTag();
    OrderCreatedMessage msg = parse(message);
    try {
        orderPersistService.handleOrderCreated(msg);
        channel.basicAck(tag, false);
    } catch (BusinessException e) {
        // 业务失败,回补 Redis 预扣的库存,消息进死信
        stockService.rollback(msg.getProductId(), msg.getQuantity());
        channel.basicNack(tag, false, false);
    }
}

10.3 缓存失效广播,衔接第43、44天

用 Fanout 交换机广播缓存失效消息,所有应用实例订阅后清本地缓存。

java 复制代码
@Bean
public FanoutExchange cacheInvalidateExchange() {
    return new FanoutExchange("cache.invalidate.exchange", true, false);
}

数据更新后发布失效消息,多实例各自清理本地缓存,解决第44天本地缓存多实例不一致问题。

10.4 下单后异步通知

text 复制代码
订单落库成功 -> 发 OrderCreated 消息
    短信服务消费,发下单成功短信
    积分服务消费,增加用户积分
    推荐服务消费,更新用户画像

下单主流程不再等待这些下游,互不影响。


十一、最终一致性与本地消息表

11.1 问题,发消息和数据库操作如何保证原子

经典坑,先写数据库再发消息,写库成功但发消息失败,下游收不到,数据不一致。反过来先发消息再写库,写库失败但消息已发出,下游处理了不存在的数据。

11.2 本地消息表方案

把消息先存进本地数据库,和业务在同一个事务里,保证原子。再由定时任务或异步线程把消息发到 MQ。

sql 复制代码
CREATE TABLE local_message (
    id BIGINT NOT NULL AUTO_INCREMENT,
    message_id VARCHAR(64) NOT NULL,
    exchange VARCHAR(64) NOT NULL,
    routing_key VARCHAR(64) NOT NULL,
    payload TEXT NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    retry_count INT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_message_id (message_id)
);

流程:

text 复制代码
1. 业务操作和插入 local_message 在同一事务,要么都成功要么都回滚
2. 事务提交后,发送线程或定时任务读取待发送消息,发到 MQ
3. 发送成功后更新 status 为已发送
4. 定时任务扫描长时间未发送成功的消息重发
5. 消费端做幂等,处理成功回执

11.3 RocketMQ 事务消息

RocketMQ 原生支持事务消息,分为发送半消息、执行本地事务、提交或回滚、事务回查四步,是本地消息表的中间件级实现,电商交易场景常用。

text 复制代码
1. 生产者发送半消息,消费者暂时不可见
2. 半消息发送成功后,执行本地事务,如写订单
3. 本地事务成功则 commit,消息变可见,失败则 rollback
4. 若长时间未提交,Broker 回查生产者本地事务状态

11.4 一致性方案对比

方案 可靠性 复杂度 适用
直接发消息 低,可能不一致 非关键通知
本地消息表 通用,不依赖特定 MQ
RocketMQ 事务消息 已用 RocketMQ
最大努力通知 对账补偿兜底

十二、消息顺序与积压

12.1 顺序消息

部分场景要求顺序,如同一订单的创建、支付、取消必须按序处理。

  • RabbitMQ 单队列单消费者可保证顺序,但牺牲并发。
  • RocketMQ 用同一 MessageQueue 加顺序消费保证局部有序。
  • 常用做法是按订单 id 哈希到固定队列或分区,保证同一订单有序,不同订单并行。

12.2 消息积压处理

消费速度跟不上生产,队列堆积。应对:

  • 增加消费者实例,提高并发。
  • 提高 prefetch 和批量消费。
  • 排查消费逻辑是否有慢操作,如同步调外部接口。
  • 紧急情况临时写一个快速消费者,先把消息转储再慢慢处理。

十三、实战任务

任务 1,搭建 RabbitMQ,约 40 分钟

  1. 用 Docker 启动 RabbitMQ,带 management 插件。
  2. 访问管理后台 15672 端口,账号 guest。
  3. Spring Boot 引入 spring-boot-starter-amqp 并配置连接。

任务 2,第一条消息,约 1 小时

  1. 声明 Direct 交换机、队列、绑定。
  2. 写生产者发送一条 OrderCreatedMessage。
  3. 写消费者手动 ACK 接收并打印。
  4. 在管理后台观察队列消息进出。

任务 3,可靠性实践,约 1.5 小时

  1. 开启 publisher confirm 和 returns,故意写错 routing key 观察 returns 回调。
  2. 消费者手动 ACK,故意抛异常观察消息行为。
  3. 给队列配置死信交换机,验证 nack 后消息进死信队列。

任务 4,消费幂等,约 1 小时

  1. 建 mq_consume_record 去重表。
  2. 消费时先插入去重记录,模拟同一条消息投递两次,验证业务只执行一次。

任务 5,落地秒杀异步落库,约 1.5 小时

  1. 把第44天 seckill 改成预扣成功后发消息。
  2. 消费者落库订单并扣减数据库库存。
  3. 模拟消费失败,回补 Redis 库存,消息进死信。

任务 6,延迟取消订单,进阶

  1. 用 TTL 加死信或延迟插件实现 30 分钟延迟消息。
  2. 下单时发延迟消息,到期检查未支付则取消并回补库存。

任务 7,自检清单

  • MQ 的三大价值是什么。
  • RabbitMQ 四种交换机的区别。
  • 消息在哪三个环节可能丢失,分别如何保证。
  • 为什么消费端必须幂等。
  • 死信队列的作用,如何用它实现延迟消息。
  • 本地消息表如何保证发消息与数据库操作的原子性。

十四、常见错误与避坑

  • 自动 ACK 导致丢消息,关键业务用手动 ACK。
  • 消费失败无脑 requeue true,导致死循环,不可恢复的错误进死信。
  • 不做幂等,重复消费造成重复下单、重复加积分。
  • 先发消息后写库或先写库后发消息,没有本地消息表或事务消息保障,导致不一致。
  • 队列和交换机没持久化,Broker 重启消息全丢。
  • 一个消费者处理超大流量,没设 prefetch,内存被打爆。
  • 在消费逻辑里同步调慢接口,导致积压,应再异步或优化。
相关推荐
fallen_fish1 小时前
多路径写入一致性:从一次 Debug 到系统性防御
后端
用户298698530141 小时前
Word 文档字符级格式化:Java 实现方案详解
java·后端
血小溅1 小时前
Skill 脚本语言选型:Python、Node.js、Shell 到底怎么选?
人工智能·后端
Heracles10241 小时前
一篇文章教你学会MCP
后端
范闲1 小时前
Charmbracelet TUI 生态系统指南
后端
颜进强1 小时前
AI性能参数-截断、延迟与流式输出
前端·后端·ai编程
浮游本尊1 小时前
Java学习第44天 - 本地二级缓存 Caffeine、Redis 分布式锁与热点 Key / 库存预扣
后端
浮游本尊2 小时前
Java学习第43天 - Redis 缓存基础、Cache-Aside 模式与缓存一致性
后端
云技纵横2 小时前
线程池 OOM 实战:无界队列配错,5 万个任务撑爆 JVM
后端