分布式事务之本地消息表:简单而有效的最终一致性实现

234 阅读6分钟

​ 在分布式系统里,保证跨服务数据一致是个难题。之前聊了 2PC、TCC,今天讲讲本地消息表方案—— 它以 “最终一致性” 为目标,用简单思路解决复杂问题,尤其适合高并发、可容忍短暂不一致的场景。

一、核心思想:本地事务 + 消息异步补偿

本地消息表方案的核心逻辑:
用本地事务保证 “业务操作 + 消息记录” 原子性,再通过异步任务把消息投递给消费者,实现跨服务数据同步。即使中间环节出问题,也能靠重试、幂等保证最终一致。

​编辑

以 “注册送积分” 场景为例:
用户服务新增用户后,需通知积分服务加积分。用本地消息表,流程分三步:

  1. 写业务 + 写消息:用户服务在同一本地事务里,完成 “新增用户” 和 “记录积分消息日志”。
  2. 发消息(异步) :定时任务扫描消息表,把未发送的消息投递给 MQ。
  3. 消费消息:积分服务监听 MQ,消费消息后完成 “加积分”,并通过 MQ 的 ACK 机制保证不丢消息。

二、流程拆解:从注册到积分到账

(一)阶段 1:用户注册(本地事务保证一致性)

用户服务需同时完成 “新增用户” 和 “记录积分消息” ,用本地事务(数据库事务)保证二者要么都成功,要么都回滚。

伪代码(以 MySQL 为例)

// 开启本地事务
begin transaction;
try {
    // 1. 新增用户(业务操作)
    userMapper.insertUser(user); 
    // 2. 记录积分消息日志(消息落库)
    messageMapper.insertMessage(
        new Message(
            "积分服务", 
            "增加积分", 
            "{userId:123, points:10}"
        )
    ); 
    // 提交事务
    commit; 
} catch (Exception e) {
    // 回滚:用户和消息都不生效
    rollback; 
    throw e;
}

这一步的关键:本地事务保证业务与消息的强一致。只要数据库事务提交,就说明 “用户已新增 + 积分消息已记录”,后续即使服务宕机,消息也能靠定时任务补发。

(二)阶段 2:定时任务扫描,投递消息到 MQ

消息落库后,需异步投递给 MQ。定时任务(如 Spring Boot 的 @Scheduled)会周期性扫描消息表,把 “未发送” 状态 的消息发给 MQ。

伪代码(定时任务逻辑)

// 每 1 分钟扫描一次消息表
@Scheduled(cron = "0 0/1 * * * ?") 
public void sendMessageToMQ() {
    // 查询未发送的消息
    List<Message> unsentMessages = messageMapper.findUnsentMessages(); 
    for (Message msg : unsentMessages) {
        try {
            // 发送消息到 MQ
            mqProducer.send(msg.getTopic(), msg.getContent()); 
            // 标记为已发送
            msg.setStatus("SENT"); 
            messageMapper.updateMessage(msg);
        } catch (Exception e) {
            // 发送失败,保留状态,等待下次重试
            log.error("消息发送失败,待重试: {}", msg); 
        }
    }
}

这一步的关键:重试机制。发送失败的消息会留在表中,下次定时任务继续尝试,直到发送成功(或人工干预)。

(三)阶段 3:积分服务消费消息,实现最终一致

积分服务监听 MQ,收到消息后执行 “增加积分” 操作。为避免重复消费(如 MQ 重试投递),需保证 幂等性(同一消息执行多次,结果一致)。

积分服务消费逻辑

// 监听 MQ 消息
@RabbitListener(queues = "积分队列") 
public void handleMessage(String msg) {
    // 解析消息:userId、points
    Map<String, Object> data = JSON.parseObject(msg, Map.class);
    Long userId = data.get("userId");
    Integer points = data.get("points");
    
    // 幂等性校验:查流水表,判断是否已处理
    if (pointFlowMapper.existsByUserIdAndMsgId(userId, msgId)) { 
        // 已处理,直接返回
        return; 
    }
    
    try {
        // 增加用户积分
        pointService.addPoints(userId, points); 
        // 记录流水(幂等性依据)
        pointFlowMapper.insert(userId, points, msgId); 
        // 手动 ACK:通知 MQ 消息已消费
        channel.basicAck(deliveryTag, false); 
    } catch (Exception e) {
        // 消费失败,MQ 会重试投递
        channel.basicNack(deliveryTag, false, true); 
        log.error("积分增加失败,待重试: {}", msg);
    }
}

这一步的关键:幂等 + ACK 机制。幂等性避免重复加积分,ACK 机制保证消息不丢失(消费失败则 MQ 重试)。

订单扣库存场景扩展

本地消息表方案也适用于订单扣库存等场景,流程如下:

​编辑

  1. 提交订单:订单中心记录订单和扣库存消息。
  2. MQ 异步处理:库存系统消费消息,执行扣库存操作。
  3. Task 系统兜底:轮询消息表,处理未确认的消息,保证流程完整性。

三、优缺点:适合什么场景?

(一)优点:简单可靠,兼容性强

  1. 实现简单:基于本地事务和定时任务,不依赖复杂框架(如 TCC 的事务协调器)。
  2. 最终一致性:通过重试、ACK 保证消息必达,适合对实时性要求不高的场景(如积分、通知)。
  3. 兼容性好:支持各种语言、数据库,只要能写本地事务 + 定时任务,就能实现。

(二)缺点:数据库压力 + 实时性弱

  1. 数据库瓶颈:消息表频繁读写(定时任务扫描、业务写消息),高并发下可能拖慢数据库。
  2. 实时性差:消息靠定时任务扫描投递,会有延迟(如 1 分钟扫描一次,延迟最多 1 分钟)。
  3. 幂等性依赖业务实现:消费端需自己保证幂等,增加开发成本。

四、优化思路:缓解数据库压力

本地消息表的核心痛点是 “消息表读写影响业务性能” ,可通过这些方式优化:

  1. 异步化消息落库
    业务操作和消息落库虽在同一事务,但消息落库可异步执行(如用线程池),减少主流程耗时。不过要注意:异步落库可能导致事务提交后消息未落库,需结合日志或补偿机制。
  2. 分库分表 / 独立消息库
    把消息表放到独立数据库,或分库分表,避免业务库被消息读写拖垮。
  3. 替换消息存储
    高并发场景下,可把消息表换成 Redis、RocketMQ 等自带持久化的中间件,利用其高效读写和重试机制,减少数据库压力。

五、总结:本地消息表的适用场景

本地消息表是 “最终一致性” 的经典实践,适合以下场景:

  • 对实时性要求不高(如积分、通知、物流状态同步)。
  • 业务逻辑简单,不想引入复杂分布式事务框架。
  • 高并发场景,但能通过优化(如分库分表、异步落库)缓解数据库压力。

它的核心逻辑是 “本地事务保证业务 + 消息的一致性,异步重试保证最终一致” 。理解这套思路后,面对 “跨服务数据同步” 需求,就能快速判断是否适用本地消息表,或结合其他方案(如 TCC + 本地消息表兜底)。

(拓展思考:如果消息表数据量极大,如何设计归档策略?定时任务扫描效率低,有没有更高效的消息投递方式?可以结合 Kafka 的事务消息、RocketMQ 的定时投递等特性深入优化~)

如果这篇文章对大家有帮助可以点赞关注,你的支持就是我的动力😊!