【Java项目技术亮点】基于MQ的可靠消息最终一致性

写在前面:我之前做过一个用户注册送优惠券的功能,看似简单------注册成功发张券嘛。结果上线第二天就出事了:有的用户注册成功了但没收到券,客服投诉一堆。排查发现是发MQ的时候网络抖了一下,消息丢了。从那以后我才真正重视"消息可靠性"这件事。这篇文章把本地消息表、RocketMQ事务消息、Outbox模式三种方案都讲透,代码都是能直接跑的,希望帮你少踩坑。

文章目录


一、为什么需要消息最终一致性?

场景引入

微服务架构中有大量"不需要强一致,但最终必须一致"的场景:

  • 用户注册后 → 发优惠券
  • 下单成功后 → 发短信通知
  • 支付成功后 → 通知库存发货
  • 评价商品后 → 增加积分

这些操作如果用分布式事务(2PC、TCC),太重了,性能也差。用消息队列来做异步解耦,才是正解。

生活类比

发快递:你下单后快递公司不需要立刻送达,但最终一定会送到。中间你可以查物流追踪(消息状态),如果丢了还能投诉(补偿机制)。消息最终一致性就是这个道理------不要求立刻一致,但最终必须一致。


二、消息一致性三大问题

在用MQ做最终一致性之前,先要搞清楚可能出什么问题。

1. 消息丢失

消息丢失的三个环节:

环节 原因 解决方案
生产者→MQ 发送失败、网络抖动 同步发送 + 重试 / 事务消息
MQ存储 MQ宕机、磁盘故障 消息持久化 + 多副本
MQ→消费者 消费失败、消费者宕机 手动ACK + 重试

2. 消息重复

  • 生产者发送成功但没收到ACK,重试发送 → 消息重复
  • 消费者消费成功但ACK失败,MQ重新投递 → 消息重复

解决方案:消费端幂等性设计(后面专门讲)

3. 消息乱序

  • 多分区/多队列场景下,消息可能不按发送顺序到达
  • 比如先发"扣库存"后发"发通知",结果消费者先收到"发通知"

解决方案:单一分区 / 消息携带序列号 / 业务上保证幂等无序执行


三、方案1:本地消息表

原理

把"发消息"这件事和业务操作放在同一个本地事务中。业务操作成功的同时,把消息记录写到本地数据库的一张"消息表"里。然后由定时任务扫描消息表,把未发送的消息投递到MQ。

复制代码
┌──────────────────────────────────────┐
│           本地数据库(同一事务)         │
│  ┌─────────────┐  ┌──────────────┐   │
│  │  业务表      │  │  消息表      │   │
│  │ (user表)    │  │ (msg表)     │   │
│  └─────────────┘  └──────────────┘   │
└──────────────────────────────────────┘
                    │ 定时扫描
                    ▼
              ┌──────────┐
              │   MQ     │
              └──────────┘
                    │ 消费
                    ▼
              ┌──────────┐
              │ 消费方    │
              └──────────┘

完整Java代码示例

消息表DDL
sql 复制代码
CREATE TABLE `local_message` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `biz_id` varchar(64) NOT NULL COMMENT '业务ID(如订单号)',
  `biz_type` varchar(32) NOT NULL COMMENT '业务类型',
  `topic` varchar(128) NOT NULL COMMENT 'MQ主题',
  `tag` varchar(64) DEFAULT NULL COMMENT 'MQ标签',
  `message_key` varchar(64) DEFAULT NULL COMMENT '消息Key',
  `message_body` text NOT NULL COMMENT '消息体(JSON)',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待发送 1-已发送 2-发送失败',
  `retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '重试次数',
  `max_retry` int(11) NOT NULL DEFAULT '5' COMMENT '最大重试次数',
  `next_retry_time` datetime DEFAULT NULL COMMENT '下次重试时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz` (`biz_id`, `biz_type`),
  KEY `idx_status_retry` (`status`, `next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';
业务操作 + 消息记录(同一事务)
java 复制代码
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private LocalMessageMapper messageMapper;

    /**
     * 创建订单 + 写入本地消息(同一事务)
     */
    @Transactional(rollbackFor = Exception.class)
    public String createOrder(CreateOrderRequest request) {
        // 1. 创建订单
        Order order = new Order();
        order.setOrderId(IdGenerator.nextId());
        order.setUserId(request.getUserId());
        order.setProductId(request.getProductId());
        order.setQuantity(request.getQuantity());
        order.setAmount(request.getAmount());
        order.setStatus(OrderStatus.PAID.getStatus());
        order.setCreateTime(new Date());
        orderMapper.insert(order);

        // 2. 在同一事务中写入本地消息表
        LocalMessage message = new LocalMessage();
        message.setBizId(order.getOrderId().toString());
        message.setBizType("ORDER_CREATE");
        message.setTopic("order-topic");
        message.setTag("order-created");
        message.setMessageKey(order.getOrderId().toString());
        // 消息体:包含订单信息,供消费方使用
        OrderCreatedEvent event = new OrderCreatedEvent();
        event.setOrderId(order.getOrderId());
        event.setUserId(order.getUserId());
        event.setProductId(order.getProductId());
        event.setQuantity(order.getQuantity());
        message.setMessageBody(JSON.toJSONString(event));
        message.setStatus(0); // 待发送
        message.setRetryCount(0);
        message.setMaxRetry(5);
        message.setNextRetryTime(new Date()); // 立即可发送
        messageMapper.insert(message);

        return order.getOrderId().toString();
    }
}
定时任务扫描消息表
java 复制代码
@Component
public class MessageRelayTask {

    private static final Logger log = LoggerFactory.getLogger(MessageRelayTask.class);

    @Autowired
    private LocalMessageMapper messageMapper;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 每10秒扫描一次待发送的消息
     */
    @Scheduled(fixedDelay = 10000)
    public void relayMessages() {
        // 查询待发送且到达重试时间的消息
        List<LocalMessage> messages = messageMapper.selectPendingMessages(
            new Date(), 100 // 一次最多处理100条
        );

        for (LocalMessage msg : messages) {
            try {
                // 发送消息到MQ
                rocketMQTemplate.convertAndSend(
                    msg.getTopic() + ":" + msg.getTag(),
                    msg.getMessageBody()
                );

                // 更新消息状态为已发送
                msg.setStatus(1);
                msg.setUpdateTime(new Date());
                messageMapper.updateStatus(msg);

                log.info("消息发送成功,bizId={}, topic={}", msg.getBizId(), msg.getTopic());
            } catch (Exception e) {
                log.error("消息发送失败,bizId={}", msg.getBizId(), e);
                // 更新重试信息(指数退避)
                msg.setRetryCount(msg.getRetryCount() + 1);
                if (msg.getRetryCount() >= msg.getMaxRetry()) {
                    msg.setStatus(2); // 发送失败,需要人工介入
                } else {
                    // 指数退避:10s, 30s, 90s, 270s, 810s
                    long delaySeconds = (long) (10 * Math.pow(3, msg.getRetryCount()));
                    msg.setNextRetryTime(
                        new Date(System.currentTimeMillis() + delaySeconds * 1000)
                    );
                }
                messageMapper.updateRetryInfo(msg);
            }
        }
    }
}
消费端幂等消费
java 复制代码
@Component
@RocketMQMessageListener(
    topic = "order-topic",
    consumerGroup = "inventory-consumer-group",
    selectorExpression = "order-created"
)
public class InventoryConsumer implements RocketMQListener<String> {

    private static final Logger log = LoggerFactory.getLogger(InventoryConsumer.class);

    @Autowired
    private InventoryMapper inventoryMapper;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public void onMessage(String messageBody) {
        OrderCreatedEvent event = JSON.parseObject(messageBody, OrderCreatedEvent.class);

        // 幂等校验:用bizId作为去重Key
        String idempotentKey = "msg:consumed:" + event.getOrderId();
        Boolean isFirst = redisTemplate.opsForValue()
            .setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
        if (Boolean.FALSE.equals(isFirst)) {
            log.info("重复消息,跳过消费,orderId={}", event.getOrderId());
            return;
        }

        // 执行业务逻辑:扣减库存
        Inventory inventory = inventoryMapper.selectByProductId(event.getProductId());
        if (inventory == null || inventory.getStock() < event.getQuantity()) {
            log.error("库存不足,productId={}", event.getProductId());
            throw new RuntimeException("库存不足"); // 抛异常触发MQ重试
        }
        inventory.setStock(inventory.getStock() - event.getQuantity());
        inventoryMapper.updateStock(inventory);

        log.info("库存扣减成功,orderId={}, productId={}", event.getOrderId(), event.getProductId());
    }
}

优缺点分析

优点 缺点
实现简单,容易理解 业务侵入性强(要建消息表)
可靠性高(本地事务保证) 定时扫描有延迟(非实时)
与具体MQ解耦 消息表数据量会增长,需要定期清理
适合任何MQ 需要额外的定时任务

四、方案2:RocketMQ事务消息

原理

RocketMQ原生支持事务消息,流程如下:

复制代码
生产者                    RocketMQ Broker              消费者
  │                            │                        │
  │──1.发送半消息──────────────>│                        │
  │                            │(半消息对消费者不可见)    │
  │<──2.返回半消息发送结果──────│                        │
  │                            │                        │
  │──3.执行本地事务             │                        │
  │   (如:创建订单)          │                        │
  │                            │                        │
  │──4a.提交/回滚──────────────>│                        │
  │                            │                        │
  │         (如果4a丢失)       │                        │
  │<──5.回查事务状态────────────│                        │
  │──6.返回事务状态────────────>│                        │
  │                            │──7.投递消息──────────>│
  │                            │                        │──8.消费消息

关键概念

  • 半消息:消息先发到Broker,但对消费者不可见。相当于"暂存"
  • 本地事务:生产者执行自己的业务逻辑(如创建订单)
  • 提交/回滚:本地事务成功则提交消息(消费者可见),失败则回滚(消息删除)
  • 回查:如果Broker长时间没收到提交/回滚,会主动回查生产者的事务状态

完整Java代码示例

TransactionListener实现
java 复制代码
@Component
public class OrderTransactionListener implements TransactionListener {

    private static final Logger log = LoggerFactory.getLogger(OrderTransactionListener.class);

    @Autowired
    private OrderService orderService;
    @Autowired
    private TransactionLogMapper transactionLogMapper;

    /**
     * 执行本地事务
     * 半消息发送成功后,Broker会回调这个方法
     */
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            // 从消息中解析业务参数
            String bizData = new String(msg.getBody(), RemotingHelper.DEFAULT_CHARSET);
            CreateOrderRequest request = JSON.parseObject(bizData, CreateOrderRequest.class);

            // 执行本地事务:创建订单
            String orderId = orderService.createOrder(request);

            // 记录事务日志(用于回查)
            TransactionLog txLog = new TransactionLog();
            txLog.setTransactionId(msg.getTransactionId());
            txLog.setBizId(orderId);
            txLog.setBizType("ORDER_CREATE");
            txLog.setStatus(TransactionStatus.COMMITTED.getStatus());
            txLog.setCreateTime(new Date());
            transactionLogMapper.insert(txLog);

            log.info("本地事务执行成功,提交消息,orderId={}", orderId);
            return LocalTransactionState.COMMIT_MESSAGE;

        } catch (Exception e) {
            log.error("本地事务执行失败,回滚消息", e);
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }

    /**
     * 回查本地事务状态
     * 如果Broker长时间没收到提交/回滚,会回调这个方法
     */
    @Override
    public LocalTransactionState checkLocalTransaction(Message msg) {
        String transactionId = msg.getTransactionId();
        log.info("回查事务状态,transactionId={}", transactionId);

        // 查询事务日志
        TransactionLog txLog = transactionLogMapper.selectByTransactionId(transactionId);
        if (txLog == null) {
            // 事务日志不存在,说明本地事务可能还在执行中
            log.warn("事务日志不存在,未知状态,transactionId={}", transactionId);
            return LocalTransactionState.UNKNOW;
        }

        if (TransactionStatus.COMMITTED.getStatus().equals(txLog.getStatus())) {
            return LocalTransactionState.COMMIT_MESSAGE;
        } else if (TransactionStatus.ROLLED_BACK.getStatus().equals(txLog.getStatus())) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        } else {
            return LocalTransactionState.UNKNOW;
        }
    }
}
发送事务消息
java 复制代码
@Service
public class OrderTransactionProducer {

    private static final Logger log = LoggerFactory.getLogger(OrderTransactionProducer.class);

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 发送事务消息
     */
    public void sendOrderTransactionMessage(CreateOrderRequest request) {
        String topic = "order-topic";
        String tag = "order-created";
        String bizData = JSON.toJSONString(request);

        // 发送事务消息
        rocketMQTemplate.sendMessageInTransaction(
            topic + ":" + tag,
            MessageBuilder.withPayload(bizData).build(),
            request // 传递给TransactionListener的arg参数
        );

        log.info("事务消息已发送,等待本地事务执行,userId={}", request.getUserId());
    }
}

半消息、提交消息、回滚消息的细节

阶段 说明 消费者可见?
半消息 消息存入Broker的半消息队列 不可见
提交消息 半消息转移到目标Topic 可见,开始消费
回滚消息 半消息被删除 不可见
回查 Broker主动查询生产者事务状态 取决于回查结果

优缺点分析

优点 缺点
不需要额外的消息表 强依赖RocketMQ
原生支持,侵入性低 只适用于RocketMQ生态
实时性好(无定时扫描延迟) 回查机制增加复杂度
半消息机制天然防消息丢失 需要实现TransactionListener

五、方案3:Outbox Pattern(发件箱模式)

原理

Outbox Pattern和本地消息表类似,但消息投递方式不同。不是用定时任务扫描,而是通过**CDC(Change Data Capture)**工具(如Canal、Debezium)监听数据库的binlog变化,自动将Outbox表中的消息同步到MQ。

复制代码
┌─────────────────────┐
│    应用服务          │
│  ┌───────┐ ┌──────┐│
│  │业务表  │ │Outbox││ ← 同一事务写入
│  └───────┘ └──────┘│
└─────────────────────┘
         │ binlog
         ▼
┌─────────────────────┐
│  CDC工具(Canal)     │ ← 监听binlog变化
└─────────────────────┘
         │
         ▼
┌─────────────────────┐
│      MQ             │ ← 自动投递到MQ
└─────────────────────┘
         │
         ▼
┌─────────────────────┐
│    消费方            │
└─────────────────────┘

Outbox表设计

sql 复制代码
CREATE TABLE `outbox` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `aggregate_type` varchar(64) NOT NULL COMMENT '聚合根类型(如Order)',
  `aggregate_id` varchar(64) NOT NULL COMMENT '聚合根ID',
  `event_type` varchar(64) NOT NULL COMMENT '事件类型',
  `payload` json NOT NULL COMMENT '事件内容',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `processed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已处理:0-未处理 1-已处理',
  `processed_at` datetime DEFAULT NULL COMMENT '处理时间',
  PRIMARY KEY (`id`),
  KEY `idx_aggregate` (`aggregate_type`, `aggregate_id`),
  KEY `idx_processed` (`processed`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发件箱表';

与本地消息表的区别

维度 本地消息表 Outbox Pattern
消息投递方式 定时任务扫描 CDC监听binlog
实时性 有延迟(取决于扫描频率) 接近实时(binlog同步)
额外组件 定时任务 CDC工具(Canal/Debezium)
数据库依赖 依赖数据库 依赖数据库binlog
耦合度 应用自己扫描 由独立组件投递
适用场景 简单项目 大型微服务架构

优缺点分析

优点 缺点
应用层完全解耦,不需要定时任务 需要部署和维护CDC工具
接近实时(binlog同步延迟很低) 依赖数据库binlog(MySQL才有)
天然可靠(binlog不会丢) 架构复杂度高,组件多
适合事件驱动架构(EDA) 调试排查相对困难

六、三种方案对比表格

维度 本地消息表 RocketMQ事务消息 Outbox Pattern
实现复杂度
性能 中(定时扫描有延迟) 高(实时投递) 高(binlog实时同步)
可靠性
侵入性 高(建表+定时任务) 中(实现Listener) 中(建表)
MQ依赖 不依赖特定MQ 强依赖RocketMQ 不依赖特定MQ
实时性 秒级延迟 毫秒级 毫秒级
适用场景 简单项目、任意MQ RocketMQ生态 大型微服务、EDA架构
维护成本 高(CDC组件)

选型建议:小项目用本地消息表,RocketMQ生态用事务消息,大型微服务架构用Outbox Pattern。别纠结哪个"最好",适合你的才是最好的。


七、消费端幂等性设计

不管用哪种方案,消费端都必须做幂等。因为消息重复投递是不可避免的。

方案1:数据库唯一索引

sql 复制代码
-- 消费记录表,用唯一索引保证幂等
CREATE TABLE `consume_record` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `message_id` varchar(64) NOT NULL COMMENT '消息唯一ID',
  `consumer_group` varchar(64) NOT NULL COMMENT '消费组',
  `consume_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_msg_group` (`message_id`, `consumer_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
java 复制代码
// 消费时先插入消费记录
public void consume(String messageId, String consumerGroup, String body) {
    try {
        ConsumeRecord record = new ConsumeRecord();
        record.setMessageId(messageId);
        record.setConsumerGroup(consumerGroup);
        consumeRecordMapper.insert(record);
    } catch (DuplicateKeyException e) {
        log.info("重复消费,跳过,messageId={}", messageId);
        return; // 幂等返回
    }
    // 执行业务逻辑...
}

方案2:Redis去重(SETNX)

java 复制代码
public boolean isDuplicate(String messageId) {
    String key = "consume:" + messageId;
    // SETNX:key不存在才设置成功
    Boolean result = redisTemplate.opsForValue()
        .setIfAbsent(key, "1", 7, TimeUnit.DAYS);
    return Boolean.FALSE.equals(result); // false表示已存在,即重复
}

方案3:全局唯一消息ID

每条消息携带一个全局唯一的ID(如UUID、雪花算法ID),消费端用这个ID做去重。

java 复制代码
// 生产者发送时设置唯一ID
Message message = MessageBuilder.withPayload(body)
    .setHeader("MESSAGE_ID", UUID.randomUUID().toString())
    .build();

// 消费者消费时获取ID做幂等
String messageId = (String) message.getHeaders().get("MESSAGE_ID");

完整幂等消费代码示例

java 复制代码
@Component
@RocketMQMessageListener(
    topic = "order-topic",
    consumerGroup = "inventory-consumer-group"
)
public class IdempotentInventoryConsumer implements RocketMQListener<MessageExt> {

    private static final Logger log = LoggerFactory.getLogger(IdempotentInventoryConsumer.class);

    @Autowired
    private InventoryService inventoryService;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private ConsumeRecordMapper consumeRecordMapper;

    @Override
    public void onMessage(MessageExt messageExt) {
        // 1. 获取消息唯一ID
        String messageId = messageExt.getMsgId();
        String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);

        // 2. Redis快速去重(第一道防线)
        String redisKey = "idempotent:consume:" + messageId;
        Boolean isFirst = redisTemplate.opsForValue()
            .setIfAbsent(redisKey, "1", 24, TimeUnit.HOURS);
        if (Boolean.FALSE.equals(isFirst)) {
            log.info("Redis去重:重复消息,跳过,messageId={}", messageId);
            return;
        }

        try {
            // 3. 数据库唯一索引去重(第二道防线)
            ConsumeRecord record = new ConsumeRecord();
            record.setMessageId(messageId);
            record.setConsumerGroup("inventory-consumer-group");
            consumeRecordMapper.insert(record);

            // 4. 执行业务逻辑
            OrderCreatedEvent event = JSON.parseObject(body, OrderCreatedEvent.class);
            inventoryService.deductInventory(event.getProductId(), event.getQuantity());

            log.info("消费成功,messageId={}, orderId={}", messageId, event.getOrderId());

        } catch (DuplicateKeyException e) {
            // 数据库唯一索引冲突,说明已消费过
            log.info("DB去重:重复消息,跳过,messageId={}", messageId);
        } catch (Exception e) {
            // 消费失败,删除Redis Key,让下次重试能进来
            redisTemplate.delete(redisKey);
            log.error("消费失败,messageId={}", messageId, e);
            throw e; // 抛异常触发MQ重试
        }
    }
}

八、踩坑指南

踩坑提醒:消息最终一致性说起来简单,但生产环境坑多得离谱。下面这些是我踩过的坑和总结的经验。

消息积压处理

消息积压是MQ最常见的运维问题。

常见原因

  • 消费者处理太慢
  • 消费者宕机
  • 突发流量激增

处理方案

  1. 临时扩容:增加消费者实例数(前提是消息分区数足够)
  2. 快速消费:写一个临时消费者,跳过非核心逻辑,快速消费积压消息
  3. 消息转发:把积压消息转发到新的Topic,用专门的消费者处理
  4. 限流保护:上游限流,避免继续积压
java 复制代码
// 临时快速消费示例(跳过校验,只做核心逻辑)
@Component
public class FastInventoryConsumer implements RocketMQListener<String> {
    @Override
    public void onMessage(String body) {
        OrderCreatedEvent event = JSON.parseObject(body, OrderCreatedEvent.class);
        // 跳过所有校验,直接扣库存
        inventoryMapper.fastDeduct(event.getProductId(), event.getQuantity());
    }
}

消费失败重试策略(指数退避)

这个坑我踩过:消费失败后无限重试,结果MQ被无效重试消息打满了。

java 复制代码
// RocketMQ消费失败重试配置
@RocketMQMessageListener(
    topic = "order-topic",
    consumerGroup = "inventory-consumer-group",
    maxReconsumeTimes = 5,  // 最大重试5次
    delayLevel = 3          // 重试级别(对应10s延迟)
)

RocketMQ重试级别和延迟时间:

delayLevel 延迟时间
1 1s
2 5s
3 10s
4 30s
5 1min
6 2min
7 3min
8 4min
9 5min
10 6min
11 7min
12 8min
13 9min
14 10min
15 30min
16 1h

死信队列处理

重试超过上限的消息会进入死信队列(DLQ),需要人工处理。

java 复制代码
// 死信队列消费者
@RocketMQMessageListener(
    topic = "%DLQ%inventory-consumer-group",
    consumerGroup = "dlq-handler-group"
)
public class DlqConsumer implements RocketMQListener<MessageExt> {

    @Override
    public void onMessage(MessageExt messageExt) {
        String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
        String messageId = messageExt.getMsgId();

        // 1. 记录到死信表,方便后续人工处理
        DeadLetter deadLetter = new DeadLetter();
        deadLetter.setMessageId(messageId);
        deadLetter.setTopic(messageExt.getTopic());
        deadLetter.setMessageBody(body);
        deadLetter.setReconsumeTimes(messageExt.getReconsumeTimes());
        deadLetter.setCreateTime(new Date());
        deadLetterMapper.insert(deadLetter);

        // 2. 发送告警通知
        alertService.sendAlert("消息进入死信队列,messageId=" + messageId);
    }
}

消息顺序性保证

如果业务要求消息按顺序消费(比如"先扣库存再发通知"):

  1. 同一业务的消息发到同一队列/分区
  2. 消费端用单线程消费
java 复制代码
// 发送时指定同一个MessageQueue的selector
rocketMQTemplate.sendOrderly(
    "order-topic:order-created",
    message,
    orderId.toString()  // 用orderId作为hashKey,同一订单的消息进同一队列
);

九、问题与解答

Q1:本地消息表的消息一直发送失败怎么办?

A:重试超过上限后,消息状态会被标记为"发送失败"。这时候需要:

  1. 告警通知:第一时间通知开发人员
  2. 人工处理:通过管理后台查看失败消息,手动重新发送或跳过
  3. 根因分析:是MQ挂了?网络问题?还是消息体有问题?
  4. 定期清理:已处理的消息要定期归档,避免消息表无限增长

Q2:RocketMQ事务消息的回查机制是怎么工作的?

A :Broker在发送半消息后,如果超过一定时间(默认60秒)没收到生产者的提交/回滚,就会主动回调生产者的checkLocalTransaction方法。生产者需要在这个方法里查询本地事务状态并返回。Broker最多回查15次,如果都返回UNKNOW,则回滚消息。所以checkLocalTransaction方法一定要实现好,不能一直返回UNKNOW。

Q3:Outbox Pattern中CDC工具挂了怎么办?

A:CDC工具(如Canal)一般会做高可用部署。如果整个CDC集群挂了:

  1. Outbox表中的消息不会被投递,但数据不会丢(存在数据库里)
  2. CDC恢复后会从断点继续同步(基于binlog位点)
  3. 建议对Outbox表做监控,如果processed=0的记录超过阈值就告警
  4. 可以配合本地消息表的定时扫描作为兜底方案

十、面试高频考点汇总

考点1:如何保证消息不丢失?

答案:三个环节分别保证:

  1. 生产者端:使用同步发送 + 重试机制,或者用RocketMQ事务消息/本地消息表
  2. MQ端:消息持久化到磁盘 + 多副本同步(RocketMQ的同步刷盘+主从同步)
  3. 消费者端:手动ACK(消费成功才确认),消费失败不ACK触发重试

考点2:如何保证消息不重复消费?

答案 :消费端做幂等性设计,常用三种方案:

  1. 数据库唯一索引:用消息ID建唯一索引,插入失败说明已消费
  2. Redis SETNX:用消息ID做Key,SETNX成功才消费
  3. 全局唯一消息ID :每条消息携带唯一ID,消费端用ID去重
    生产环境中建议Redis + 数据库双重去重,Redis做快速过滤,数据库做最终保障。

考点3:本地消息表和RocketMQ事务消息怎么选?

答案

  • 如果项目已经用了RocketMQ,优先用事务消息,侵入性更低,实时性更好
  • 如果用的是其他MQ(如RabbitMQ、Kafka),用本地消息表
  • 如果是大型微服务架构,考虑Outbox Pattern + CDC
  • 小项目快速实现,本地消息表最简单

考点4:什么是Outbox Pattern?和本地消息表有什么区别?

答案 :Outbox Pattern是一种通过数据库发件箱表保证消息可靠投递的模式。和本地消息表的区别在于消息投递方式:本地消息表用定时任务扫描投递,Outbox Pattern用CDC工具监听binlog自动投递。Outbox Pattern实时性更好,但需要额外部署CDC组件,架构复杂度更高。

考点5:消费端幂等性有哪些实现方式?各有什么优缺点?

答案

方式 优点 缺点
数据库唯一索引 可靠,持久化 性能较差(写库)
Redis SETNX 性能好,快速 Redis宕机可能丢数据
全局唯一消息ID 简单直接 需要配合存储做去重

推荐组合:Redis做第一道快速过滤 + 数据库唯一索引做最终保障


十一、模拟面试官提问和参考答案

场景题1:用户注册后需要发优惠券、发短信、发邮件,怎么保证这三个操作最终都执行成功?

参考答案:这是典型的"一写多读"场景。用户注册成功后,将消息写入本地消息表(或使用RocketMQ事务消息),消息体包含用户ID。然后由MQ分别投递给优惠券服务、短信服务、邮件服务。每个消费端独立消费、独立重试。一个消费失败不影响其他消费。关键是每个消费端都要做幂等,防止重复发券/重复发短信。

场景题2:订单系统用RocketMQ事务消息,但消费端处理很慢导致消息积压,怎么处理?

参考答案:分几步处理:

  1. 紧急扩容:增加消费者实例,加快消费速度(注意分区数要够)
  2. 临时方案:写一个快速消费者,跳过非核心逻辑(如日志记录、统计),只做核心业务
  3. 消息迁移:如果积压太严重,把积压消息转到临时Topic,用专用消费者处理
  4. 上游限流:通知订单系统降低发送速率
  5. 根因分析:查明消费慢的原因(慢SQL?外部接口超时?),从根本上解决

场景题3:如果MQ宕机了,本地消息表里的消息发不出去,业务还能正常进行吗?

参考答案 :业务操作本身是正常的(本地事务已经提交了),只是消息投递会延迟。MQ恢复后,定时任务会继续扫描消息表,把积压的消息发出去。所以业务不会中断,只是异步操作会延迟。这就是本地消息表的优势------不依赖MQ的可用性。但要注意:如果延迟时间超过业务允许的范围(比如优惠券超过24小时才发到),就需要告警和人工介入。

场景题4:设计一个消费端重试框架,你会怎么设计?

参考答案:核心设计要点:

  1. 指数退避:重试间隔逐渐增大(1s→5s→10s→30s→1min),避免短时间内大量重试
  2. 最大重试次数:超过上限进入死信队列,不再自动重试
  3. 重试分类:区分可重试异常(网络超时、锁冲突)和不可重试异常(参数错误、数据不存在)
  4. 死信处理:死信消息持久化到数据库,提供管理界面支持手动重试和跳过
  5. 监控告警:重试次数、死信数量、消费延迟等指标实时监控
  6. 幂等保证:每次重试都要做幂等校验

场景题5:在金融场景下(如转账通知),对消息可靠性要求极高,你会选择哪种方案?为什么?

参考答案 :金融场景我会选择RocketMQ事务消息 + 消费端双重幂等 + 死信人工处理的组合方案:

  1. RocketMQ事务消息:保证"本地事务"和"消息发送"的原子性,要么都成功要么都失败
  2. 同步刷盘 + 主从同步:保证MQ端消息不丢失
  3. 消费端双重幂等:Redis + 数据库唯一索引,绝对防止重复消费
  4. 手动ACK + 重试:消费成功才确认,失败自动重试(指数退避)
  5. 死信队列 + 人工处理:重试超过上限的消息进入死信队列,触发告警,人工确认
  6. 全链路追踪:每条消息都有唯一ID,从生产到消费全链路可追踪

互动话题

你在项目中用过哪种消息一致性方案?遇到过消息丢失或重复消费的问题吗?是怎么排查和解决的?欢迎在评论区分享你的实战经验,一起讨论。


参考资料

RocketMQ事务消息官方文档