Skip to content

分布式事务落地对比

提出问题

微服务架构下,一个业务操作往往跨越多个服务、多个数据库。传统的本地事务(ACID)在跨服务场景下无能为力——你无法让 A 服务的 MySQL 和 B 服务的 MySQL 在同一个 BEGIN/COMMIT 里完成操作。分布式事务就是为了解决这个问题的。

但问题来了:分布式事务的方案五花八门——本地消息表、事务消息、Seata AT、TCC、Saga。面试官真正想听的不是「你知道几种方案」,而是「你真正在生产里用过哪种?为什么选它?踩过什么坑?」。没有标准答案,只有业务场景下的取舍。

本地消息表:朴素但可靠

原理

本地消息表的核心思想是:把「发消息」和「本地业务操作」放在同一个本地事务里。业务表 + 消息表在同一个数据库,业务操作完成后立即 INSERT 一条消息记录,然后通过一个定时任务把未发送的消息轮询投递到 MQ。

时序流程:

用户下单


① BEGIN 本地事务
  ├─ INSERT INTO orders → 订单表
  └─ INSERT INTO message_queue(status='pending') → 消息表
  COMMIT


② 定时任务轮询(每 1s / 2s)
  ├─ SELECT * FROM message_queue WHERE status='pending' ORDER BY id LIMIT 100
  └─ 逐条发送到 MQ


③ 消费者收到消息,处理业务
  ├─ 处理成功 → 回调更新 message_queue.status = 'done'
  └─ 处理失败 → 生产者重试(次数上限 + 死信)
sql
-- 同一个本地事务
BEGIN;
  INSERT INTO orders (id, user_id, amount) VALUES (1001, 42, 99.00);
  INSERT INTO message_queue (id, biz_type, payload, status) 
    VALUES (uuid(), 'order_created', '{"order_id":1001}', 'pending');
COMMIT;

生产实践

使用场景:某金融公司订单系统,每天约 50 万订单,订单创建后需要同步到风控、积分、消息通知三个下游。本地消息表跑了两年,没出过一致性问题。

关键踩坑

  1. 消息表性能瓶颈:消息表会随着业务增长膨胀。订单表 1000 万行,消息表也可能接近这个量级。需要定时清理(DELETE 已 done 超过 7 天的记录)或定期归档到历史表。否则扫描 pending 消息时,即便有索引,也会因为表过大而越来越慢。

  2. 定时任务延迟:轮询间隔 1 秒,意味着消息至少 1 秒后才投递。如果业务要求秒级以内的实时性,这个方案不合适。可以用 Quarz 或 Elastic Job 做分布式调度,防止多实例重复发送。

  3. 幂等补偿:消费者可能收到重复消息——因为消息发送成功但更新 status 失败。下游必须幂等。例如积分服务用 order_id 做唯一键,重复 INSERT 直接跳过。

  4. 死信处理:重试 3 次仍然失败的消息,转入死信表,钉钉告警人工介入。某次上游接口挂了 15 分钟,死信表积压了 2 万条,人工批量重放后才恢复。

优点:不依赖 MQ 的事务特性,任何 MQ 都能用;实现简单,数据库本身就支持。缺点:消息表耦合在业务数据库里;需要定时扫描,存在秒级延迟;消息表膨胀后扫描效率下降。

事务消息:RocketMQ 的杀手锏

原理

RocketMQ 的事务消息把本地消息表的思想搬到了 MQ 内部,通过半消息机制事务回查保证最终一致性。

时序流程:

生产者                     RocketMQ                   消费者
  │                          │                         │
  ├─ Send Half Message ──────► (暂不投递)              │
  │                          │                         │
  ├─ Execute Local Tx        │                         │
  │  (订单入库)              │                         │
  ├─ Commit ────────────────► (消息可投递)             │
  │                          ├─ 投递消息 ──────────────►
  │                          │                         ├─ 消费处理
  │                          │                         └─ Ack
  │                          │                         │
  │  (如果 Commit 超时):     │                         │
  ◄─ 回查 ──────────────────┤                         │
  ├─ 检查本地事务状态        │                         │
  └─ 回复 Commit             │                         │

代码实现

java
// RocketMQ 事务消息生产者
TransactionMQProducer producer = new TransactionMQProducer("tx_group");
producer.setTransactionListener(new TransactionListener() {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        // 执行本地事务
        return orderService.createOrder(msg) 
            ? LocalTransactionState.COMMIT_MESSAGE 
            : LocalTransactionState.ROLLBACK_MESSAGE;
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // MQ 回查,检查本地事务是否已提交
        return orderService.isOrderExists(msg.getKeys()) 
            ? LocalTransactionState.COMMIT_MESSAGE 
            : LocalTransactionState.UNKNOW;
    }
});

生产实践

真实场景:某电商公司订单创建后需要发短信、发邮件、更新搜索索引。用 RocketMQ 事务消息,每天 200 万+ 消息,半年无事故。

关键踩坑

  1. 回查次数上限:RocketMQ 默认回查 15 次(每次间隔 60 秒),如果 15 次后仍未确定,消息进入死信。如果因为网络抖动导致回查失败,业务方可能永远不知道这比订单没发出去。需要监控死信队列。

  2. 回查接口必须幂等checkLocalTransaction 可能会被多次调用,必须保证多次返回结果一致。如果某个订单在回查时刚好事务还没提交,但下一秒就提交了,下次回查能返回 Commit 吗?检查逻辑必须查数据库,而不是内存缓存。

  3. 必须 RocketMQ:Kafka 和 RabbitMQ 原生不支持事务消息。Kafka 有事务特性(幂等事务),但那是保证生产者和消费者之间的 Exactly-Once,不是跨服务事务。如果团队用的是 Kafka 或 RabbitMQ,这条路走不通。

  4. 半消息写入延迟:半消息写入 Broker 后才执行本地事务,如果本地事务执行慢,半消息会占用 Broker 内存。RocketMQ 对半消息不持久化到 CommitLog,而是存在内存中,Broker 重启后丢失——但 RocketMQ 会持久化 Op 记录,重启后能恢复回查状态。

Seata AT / TCC / Saga:三把刀怎么选

Seata 是阿里开源的分布式事务框架,提供三种模式。下面逐个拆解。

AT 模式(自动补偿)

原理:通过 JDBC 代理拦截 SQL,自动记录前后镜像(Before Image / After Image)到 undolog 表。一阶段直接提交本地事务,二阶段如果 Commit 则删除 undolog,如果 Rollback 则用 undolog 的 before image 回滚数据。

性能数据:某团队在 4 核 8G 的 MySQL 实例上压测,AT 模式下 TPS 比裸 SQL 下降约 40%(主要是 undolog 读写和全局锁开销)。QPS 超过 3000 时,undolog 表会成为瓶颈。

适用场景:低并发(< 2000 TPS)、已有 MySQL 基础设施、不想改业务代码。

:全局锁(Global Lock)是 Seata AT 的痛点。在二阶段提交前,Seata 会持有全局锁,防止其他事务修改同一行数据。如果业务有高频热点行更新(比如扣库存),AT 模式会把并发压到很低。

yaml
# Seata AT 配置,业务代码几乎零改动
seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default

TCC 模式(Try-Confirm-Cancel)

原理:业务方手动实现三个接口——Try 阶段预留资源(如冻结库存),Confirm 阶段真正扣减,Cancel 阶段释放预留资源。

生产教训:某支付团队在转账场景用 TCC,Try 阶段冻结用户余额,Confirm 阶段扣减。但 Cancel 阶段发现用户余额已经被其他事务扣走了,导致 Cancel 失败。问题根源:TCC 的 Cancel 必须保证一定能释放预留资源,不能依赖余额是否还在。

TCC 正确做法:Try 阶段把冻结金额放在一个独立字段(frozen_balance),而不是直接扣减可用余额。Confirm 和 Cancel 只操作 frozen_balanceavailable_balance,不做余额判断。

sql
-- Try: 冻结 100 元
UPDATE account 
SET frozen_balance = frozen_balance + 100, 
    available_balance = available_balance - 100 
WHERE id = 42;

-- Confirm: 确认扣减,清除冻结
UPDATE account 
SET frozen_balance = frozen_balance - 100 
WHERE id = 42;

-- Cancel: 释放冻结,退回可用余额
UPDATE account 
SET frozen_balance = frozen_balance - 100, 
    available_balance = available_balance + 100 
WHERE id = 42;

Saga 模式(长事务补偿)

原理:一个大事务拆成多个本地事务,每个步骤都记录补偿操作。如果某一步失败,按逆序执行之前所有步骤的补偿操作。

适用场景:旅游预订(机票+酒店+租车),订单创建→支付→发货→评价这类长流程。

:Saga 不保证隔离性。如果 Step 1 提交了(扣库存),Step 2 失败触发补偿,Step 1 回滚了库存。但 Step 1 提交后到补偿之间,其他事务可能已经读了这行数据(脏读)。业务方需要自己处理:比如用乐观锁版本号,或者补偿时不是直接回滚,而是发一条「订单取消」的事件让下游自行处理。

方案对比表

方案一致性代码侵入性能适用场景生产坑点
本地消息表最终中(受限于 DB 扫描)简单异步场景消息表膨胀,延迟秒级
事务消息最终高(MQ 原生)已有 RocketMQ必须 RocketMQ,回查超时
Seata AT强(隔离)最低低(降 40% TPS)低并发、已有 MySQL全局锁热点行,性能差
TCC强(隔离)最高高(无锁)高并发、短事务接口设计复杂,Cancel 需保证幂等
Saga最终长流程、低一致性无隔离性,需业务处理脏读

我踩过的坑:Seata AT 全局锁导致线上雪崩

2024 年某次大促,我们用了 Seata AT 做库存扣减。库存表是典型的热点行——一条 SKU 的库存记录被所有下单请求并发操作。Seata 的全局锁导致同一行数据只能串行提交,QPS 从 2000 直接掉到 200 多,用户疯狂报错「库存不足」。

解决方案:把库存的 Seata AT 改为 TCC,Try 阶段用 Redis 的 INCR 做预扣,Confirm 阶段异步写 MySQL。库存扣减从 200ms 降到 5ms,全局锁消失。

教训:Seata AT 不是万能药,热点行场景不要用 AT 模式。

拆解场景:8 年 Java 后端面试官会怎么问

Q:订单系统用分布式事务,你怎么选?

分两层回答:

  • 订单创建本身(订单表入库):用本地消息表 + MQ,因为订单创建完成后只需要通知下游,不要求强一致。
  • 库存扣减(资金相关):如果并发高,用 TCC 或 Redis 预扣 + 异步落库;如果并发低,用 Seata AT。

Q:事务消息和本地消息表,你选哪个?

如果团队已经上了 RocketMQ,优先事务消息,省去自己维护消息表的成本。如果团队用的是 Kafka/RabbitMQ,只能用本地消息表。不要为了「分布式事务」去强行引入 RocketMQ,MQ 选型是基础设施决策,不是事务的附属品。

总结

选分布式事务方案,本质是业务场景倒逼技术选型:

  • 强一致性要求(资金扣减)→ Seata AT 或 TCC,但要有心理准备——AT 性能差,TCC 开发量大
  • 最终一致性可接受(订单创建后的积分/通知)→ 本地消息表或事务消息,成本低、好维护
  • 长流程编排(旅游预订)→ Saga,失败了逐级补偿
  • 已有 RocketMQ 基础设施 → 优先事务消息,省掉 Seata 的运维复杂度

真正投产中,80% 的场景用本地消息表或事务消息就够了,没必要上来就上 Seata。对账 + 补偿 + 幂等三件套做好,比任何框架都靠谱。

参考

参考:RocketMQ 事务消息官方文档、Seata 官方文档(AT/TCC/Saga 模式对比)、Fowler 关于分布式事务的讨论、生产实践经验来自《企业级分布式事务落地》

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。