4. 消息队列与最终一致性:可靠消息最终一致性方案

聊到分布式事务,很多人第一反应就是TCC或者XA。但说实话,在金融交易系统里,最终一致性才是真正扛得住高并发的方案。我这些年做交易系统,大部分场景用的都是消息队列来实现最终一致性。

什么叫最终一致性?说白了就是:我不要求你俩同时成功,但我保证你俩最终都会成功。如果中间出了岔子,系统会自动重试,直到完成为止。

核心思想: 通过可靠的消息投递 + 幂等消费,保证分布式系统的数据最终一致。

4.1 可靠消息最终一致性方案

这个方案的套路其实很简单。我画个图你就明白了:

服务A (发起方) 本地消息表 消息队列 (MQ) 服务B (消费方) ① 写业务+消息 ② 定时投递 ③ 消费消息 ④ ACK确认 可靠消息最终一致性方案流程图 ① 服务A在本地事务中同时写入业务数据和消息记录 ② 定时任务扫描本地消息表,将未发送的消息投递到MQ

这个流程里最关键的一步是什么?是第一步。服务A在自己的数据库里,把业务操作和消息状态放在同一个本地事务里。要么都成功,要么都回滚。这就保证了消息不会丢。

我的经验: 我在做支付系统时,就靠这个方案扛住了日均千万笔交易。核心就一句话:先落库,再发消息。顺序千万不能反。

4.2 本地消息表设计

本地消息表,说白了就是一张用来记录消息状态的数据库表。它跟业务表在同一个数据库里,共享同一个事务。

表结构长这样:

CREATE TABLE `local_message` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `biz_id` varchar(64) NOT NULL COMMENT '业务ID,用于幂等',
  `biz_type` varchar(32) NOT NULL COMMENT '业务类型',
  `message_body` text NOT NULL COMMENT '消息内容(JSON)',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-待发送 1-已发送 2-已确认 3-已死亡',
  `retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '重试次数',
  `max_retry` int(11) NOT NULL DEFAULT '3' COMMENT '最大重试次数',
  `next_retry_time` datetime DEFAULT NULL COMMENT '下次重试时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz_id` (`biz_id`),
  KEY `idx_status_next_retry` (`status`, `next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

嗯,这里要注意几个关键点:

  • biz_id 唯一索引:防止重复插入,这是幂等的基础
  • status 状态机:0→1→2 的流转必须严格
  • next_retry_time 索引:定时任务扫描时,只查需要重试的记录

我曾经踩过的坑: 一开始我没加 max_retry 字段,结果有一条消息因为下游服务挂了,一直重试了十多万次。直接把数据库打满了。后来我加了最大重试次数,超过就标记为死亡状态,人工介入处理。

定时任务的逻辑也很简单:

// 伪代码:定时任务扫描并投递消息
public void scanAndSend() {
    // 1. 查询待发送且到重试时间的消息
    List<LocalMessage> messages = messageMapper.selectPending(
        LocalMessageStatus.PENDING, new Date());
    
    for (LocalMessage msg : messages) {
        try {
            // 2. 投递到MQ
            mqProducer.send(msg.getBizType(), msg.getMessageBody());
            
            // 3. 更新状态为已发送
            messageMapper.updateStatus(msg.getId(), 
                LocalMessageStatus.SENT);
        } catch (Exception e) {
            // 4. 更新重试次数和下次重试时间
            messageMapper.updateRetry(msg.getId(),
                msg.getRetryCount() + 1,
                calculateNextRetryTime(msg.getRetryCount() + 1));
            
            // 5. 超过最大重试次数,标记死亡
            if (msg.getRetryCount() + 1 >= msg.getMaxRetry()) {
                messageMapper.updateStatus(msg.getId(),
                    LocalMessageStatus.DEAD);
                // 发送告警通知
                alertService.notify("消息已死亡: " + msg.getId());
            }
        }
    }
}

你想想看,这个方案的好处是什么?简单、可靠、不依赖外部组件。只要你的数据库不丢数据,消息就绝对不会丢。

4.3 RocketMQ事务消息实现

RocketMQ的事务消息,其实就是把本地消息表的逻辑搬到了MQ服务端。我个人觉得,这是目前最优雅的最终一致性方案。

它的核心流程分三步:

  1. 发送半消息:生产者发送一条「半消息」到MQ,此时消费者不可见
  2. 执行本地事务:生产者执行本地业务操作
  3. 提交/回滚:根据本地事务结果,告诉MQ是提交还是回滚

代码实现大概是这样的:

// 1. 实现 TransactionListener 接口
public class OrderTransactionListener implements TransactionListener {
    
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            // 执行本地事务(比如创建订单)
            orderService.createOrder((OrderDTO) arg);
            // 成功,提交消息
            return LocalTransactionState.COMMIT_MESSAGE;
        } catch (Exception e) {
            // 失败,回滚消息
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }
    
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 回查:检查本地事务是否成功
        String orderId = msg.getKeys();
        boolean success = orderService.checkOrderExists(orderId);
        return success ? 
            LocalTransactionState.COMMIT_MESSAGE : 
            LocalTransactionState.ROLLBACK_MESSAGE;
    }
}

// 2. 发送事务消息
public void sendTransactionMessage(OrderDTO order) {
    Message msg = new Message("order-topic", 
        JSON.toJSONBytes(order));
    msg.setKeys(order.getOrderId());
    
    // 发送事务消息
    transactionProducer.sendMessageInTransaction(
        msg, order);
}

关键点: 回查机制是事务消息的保底方案。如果MQ长时间没收到提交/回滚指令,会主动回调 checkLocalTransaction 方法。所以你的回查逻辑必须能准确判断本地事务的状态。

我记得有一次线上事故,就是因为回查逻辑写错了。订单明明创建成功了,回查却返回了ROLLBACK,导致消息被删了,下游没收到通知。嗯,从那以后我定了个规矩:回查逻辑必须跟本地事务用同一套数据源

4.4 消息去重与幂等性

消息队列有个特点:at-least-once,也就是至少投递一次。这意味着你的消费者可能会收到重复消息。所以,幂等性是必须的

什么叫幂等?说白了就是:同一个操作执行一次和执行多次,结果是一样的

我常用的幂等方案有这几种:

方案 实现方式 适用场景 注意事项
唯一索引 数据库唯一键约束 插入类操作 需处理主键冲突异常
状态机 通过状态字段判断 状态流转类操作 状态变更需原子性
去重表 独立的去重记录表 通用场景 需定期清理历史数据
Redis分布式锁 SETNX + 过期时间 高并发场景 需考虑锁超时问题

我个人最推荐的是去重表方案。它的核心逻辑是:

public void consumeMessage(MessageDTO msg) {
    // 1. 尝试插入去重记录
    try {
        deduplicationMapper.insert(new DeduplicationRecord(
            msg.getBizId(), msg.getBizType()));
    } catch (DuplicateKeyException e) {
        // 已处理过,直接跳过
        log.info("重复消息,已跳过: {}", msg.getBizId());
        return;
    }
    
    // 2. 执行业务逻辑
    try {
        businessService.process(msg);
    } catch (Exception e) {
        // 业务失败,删除去重记录(允许重试)
        deduplicationMapper.deleteByBizId(msg.getBizId());
        throw e;
    }
}

我曾经犯过的错: 一开始我把去重记录和业务数据放在同一个事务里。结果业务处理失败回滚了,去重记录却没删,导致这条消息永远无法重试。正确的做法是:去重记录先插入,业务处理失败再删除

还有一个细节:去重记录要设置过期时间。比如保留7天,超过的就删掉。不然表会越来越大,影响性能。

小结

最终一致性方案,说白了就是用空间换时间,用异步换性能。本地消息表适合中小型系统,RocketMQ事务消息适合大型分布式系统。不管用哪种,幂等性都是最后一道防线

做交易系统这么多年,我最大的体会是:不要追求绝对的一致,要追求可控的最终一致。只要你的补偿机制够完善,最终一致性完全够用。

公众号:蓝海资料掘金营,微信deep3321 - 更多交易系统实战干货,欢迎交流。