ElIauk

分布式事务最终一致性方案

先问业务能不能接受“最终” 分布式事务没有银弹。强一致方案(XA、Seata AT)能保证回滚,但会带来全局锁、性能下降和实现限制;最终一致方案接受一段时间的数据不一致,换取吞吐和可用性。选型的第一步不是比技术,而是问业务:这笔订单晚 3 秒看到积分,能不能接受?如果答案是可以,就不该为它上全局锁。

评论
分布式事务最终一致性方案

先问业务能不能接受“最终”

分布式事务没有银弹。强一致方案(XA、Seata AT)能保证回滚,但会带来全局锁、性能下降和实现限制;最终一致方案接受一段时间的数据不一致,换取吞吐和可用性。选型的第一步不是比技术,而是问业务:这笔订单晚 3 秒看到积分,能不能接受?如果答案是可以,就不该为它上全局锁。

这篇讲最终一致性里最常用、也最容易被做错的一条路:本地消息表。核心思想只有一句话——把“发消息”变成和业务数据同一次本地事务里的写入。

四种方案怎么选

方案 一致性 业务侵入 适用场景
Seata AT 准实时,隔离性弱 低 短事务、冲突少的跨库写
TCC 最终一致 高,要写 Try/Confirm/Cancel 资金、库存等需要精细控制的场景
本地消息表 最终一致 中 已有 MQ,跨服务状态通知
Saga / 补偿 最终一致 中高 长流程、跨多个服务

本地消息表的优势是简单、可控、不依赖特定中间件:业务和消息在同一个库里,一次本地事务保证两者要么都成功、要么都失败。缺点是消息表和业务表耦合在同一个库,需要自己做投递、重试、清理和对账。

本地消息表:把发消息变成一次本地写

表结构要能支撑“待发送、已发送、失败重试”三种状态,并且按状态和时间建索引:

CREATE TABLE outbox_message (
  id BIGINT NOT NULL AUTO_INCREMENT,
  biz_key VARCHAR(64) NOT NULL COMMENT '业务唯一键,用于幂等和排查',
  topic VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2失败待人工',
  retry_count INT NOT NULL DEFAULT 0,
  next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  KEY idx_status_retry (status, next_retry_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

业务代码里,订单写入和消息写入必须在同一个 @Transactional 方法内完成:

@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
    orderMapper.insert(command.toOrder());
    outboxMapper.insert(OutboxMessage.pending(
            command.getOrderNo(), "order-created", command.toPayload()));
}

这里不能先发 MQ 再写库,也不能用 REQUIRES_NEW 单独提交消息表——前者会在事务回滚时发出错误消息,后者会在业务失败时留下幽灵消息。

接下来是一个独立的投递任务。它只做三件事:捞待发送的消息、发出去、更新状态。捞取时用 SKIP LOCKED 避免多实例重复处理:

SELECT id, topic, payload, retry_count
FROM outbox_message
WHERE status = 0 AND next_retry_at <= NOW(3)
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;

发送成功后把 status 置为 1;失败则 retry_count + 1,并按指数退避更新 next_retry_at,超过上限置为 2,交给人工或补偿任务。这里要接受一个事实:投递是至少一次,消息可能重复,所以消费端必须幂等。

消费端幂等与对账

幂等最可靠的做法是数据库唯一约束,而不是“先查再插”。建一张消费记录表,用 (consumer_group, biz_key) 做唯一索引,消费时先插入记录,冲突就说明处理过,直接返回成功:

INSERT INTO consume_record (consumer_group, biz_key, created_at)
VALUES (?, ?, NOW(3));

插入成功才继续执行业务;业务和这条记录放在同一个本地事务里,失败一起回滚。Redis 的 SETNX 可以作为前置拦截,减少数据库压力,但不能替代唯一约束——Redis 会丢数据,最终防线还是数据库。

再往下是核对。消息表只能保证“发出去了”,不能保证“消费结果正确”。对账任务每天比对订单状态和下游状态,找出“订单已支付、库存未扣减”这类差异,生成补偿任务。对账不是可选项,它是最终一致方案的最后一道保险。

常见坑

坑一:在事务提交前发消息。 这是最典型的错误。下游收到消息去查订单,查不到,于是当作无效消息丢弃;或者反过来,本地事务回滚了,消息已经发出去。必须用“本地事务同时写消息表 + 异步投递”的方式。

坑二:只监听 @TransactionalEventListener 就以为万事大吉。 这个注解默认在事务提交后执行,但进程在提交后、发送前崩溃,消息就永久丢了。它适合做非关键的副作用(发通知、清缓存),不能替代消息表。

坑三:重试没有上限和退避。 下游故障时,每秒重试一次会把下游彻底打垮。重试间隔按 1s、2s、4s、8s 递增,设上限并进入死信,才是可运维的做法。

坑四:消息表无限增长。 已发送的消息要定期归档或删除,否则单表几千万行后,扫描待发送消息的查询会越来越慢。按 created_at 分区或定期迁移到历史表。

坑五:补偿只做正向,不考虑补偿失败。 退款失败、库存回补失败都需要二次重试和人工介入入口。设计时要假设“补偿也可能失败”,而不是假设一次补偿一定成功。

排查步骤

  1. 按业务单号查 outbox_message,看 status 和 retry_count,确认消息是没写、没发还是发送失败。
  2. 看 MQ 的消费位点和堆积,确认消费者是否在线、是否在重复失败。
  3. 按 biz_key 查消费记录表,确认是否已处理,排除重复消费。
  4. 对比业务库和下游库的关键字段,判断是消息丢失还是业务逻辑错误。
  5. 最后看对账任务的差异清单,多数“偶发不一致”都能在这里被收敛。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者