分布式事务落地对比
提出问题
微服务架构下,一个业务操作往往跨越多个服务、多个数据库。传统的本地事务(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'
└─ 处理失败 → 生产者重试(次数上限 + 死信)-- 同一个本地事务
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 万订单,订单创建后需要同步到风控、积分、消息通知三个下游。本地消息表跑了两年,没出过一致性问题。
关键踩坑:
消息表性能瓶颈:消息表会随着业务增长膨胀。订单表 1000 万行,消息表也可能接近这个量级。需要定时清理(DELETE 已 done 超过 7 天的记录)或定期归档到历史表。否则扫描
pending消息时,即便有索引,也会因为表过大而越来越慢。定时任务延迟:轮询间隔 1 秒,意味着消息至少 1 秒后才投递。如果业务要求秒级以内的实时性,这个方案不合适。可以用 Quarz 或 Elastic Job 做分布式调度,防止多实例重复发送。
幂等补偿:消费者可能收到重复消息——因为消息发送成功但更新 status 失败。下游必须幂等。例如积分服务用
order_id做唯一键,重复 INSERT 直接跳过。死信处理:重试 3 次仍然失败的消息,转入死信表,钉钉告警人工介入。某次上游接口挂了 15 分钟,死信表积压了 2 万条,人工批量重放后才恢复。
优点:不依赖 MQ 的事务特性,任何 MQ 都能用;实现简单,数据库本身就支持。缺点:消息表耦合在业务数据库里;需要定时扫描,存在秒级延迟;消息表膨胀后扫描效率下降。
事务消息:RocketMQ 的杀手锏
原理
RocketMQ 的事务消息把本地消息表的思想搬到了 MQ 内部,通过半消息机制和事务回查保证最终一致性。
时序流程:
生产者 RocketMQ 消费者
│ │ │
├─ Send Half Message ──────► (暂不投递) │
│ │ │
├─ Execute Local Tx │ │
│ (订单入库) │ │
├─ Commit ────────────────► (消息可投递) │
│ ├─ 投递消息 ──────────────►
│ │ ├─ 消费处理
│ │ └─ Ack
│ │ │
│ (如果 Commit 超时): │ │
◄─ 回查 ──────────────────┤ │
├─ 检查本地事务状态 │ │
└─ 回复 Commit │ │代码实现
// 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 万+ 消息,半年无事故。
关键踩坑:
回查次数上限:RocketMQ 默认回查 15 次(每次间隔 60 秒),如果 15 次后仍未确定,消息进入死信。如果因为网络抖动导致回查失败,业务方可能永远不知道这比订单没发出去。需要监控死信队列。
回查接口必须幂等:
checkLocalTransaction可能会被多次调用,必须保证多次返回结果一致。如果某个订单在回查时刚好事务还没提交,但下一秒就提交了,下次回查能返回 Commit 吗?检查逻辑必须查数据库,而不是内存缓存。必须 RocketMQ:Kafka 和 RabbitMQ 原生不支持事务消息。Kafka 有事务特性(幂等事务),但那是保证生产者和消费者之间的 Exactly-Once,不是跨服务事务。如果团队用的是 Kafka 或 RabbitMQ,这条路走不通。
半消息写入延迟:半消息写入 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 模式会把并发压到很低。
# Seata AT 配置,业务代码几乎零改动
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: defaultTCC 模式(Try-Confirm-Cancel)
原理:业务方手动实现三个接口——Try 阶段预留资源(如冻结库存),Confirm 阶段真正扣减,Cancel 阶段释放预留资源。
生产教训:某支付团队在转账场景用 TCC,Try 阶段冻结用户余额,Confirm 阶段扣减。但 Cancel 阶段发现用户余额已经被其他事务扣走了,导致 Cancel 失败。问题根源:TCC 的 Cancel 必须保证一定能释放预留资源,不能依赖余额是否还在。
TCC 正确做法:Try 阶段把冻结金额放在一个独立字段(frozen_balance),而不是直接扣减可用余额。Confirm 和 Cancel 只操作 frozen_balance 和 available_balance,不做余额判断。
-- 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 关于分布式事务的讨论、生产实践经验来自《企业级分布式事务落地》