主题
分布式事务全景:2PC、TCC、Saga 与本地消息表
本文是分布式系统系统学习系列的 L2 核心篇。前置:29. coordination-zk-etcd。 面试题汇总参考:05-distributed-transaction-2pc-3pc-tcc-saga-seata
问题:跨库/跨服务怎么做原子性
单机事务靠数据库的本地 undo/redo log 就能保证 ACID。一旦扩展成微服务架构,一个业务流程可能跨三个数据库、四个服务——扣库存在订单库、加积分在用户库、发消息在消息队列。任何一个步骤失败,已经提交的步骤没有回滚机制。
这个问题的本质是:没有全局协调者能做原子提交。分布式事务的每种方案都是对这个约束的某种工程妥协。
2PC:从"两阶段提交"看阻塞代价
两阶段提交是最直接的思路:加一个协调者,问所有人"能提交吗?",都点头就真提交。
┌──────┐ ┌─────────┐ ┌─────────┐
│Coord │ │Resource1│ │Resource2│
│ │phase1│ │ │ │
│ │─────▶│prepare │ │ │
│ │ │────────▶│ │ │
│ │◀─────│yes/vote │ │ │
│ │ │◀────────│ │ │
│ │─────▶│ │────▶│prepare │
│ │◀─────│ │◀────│yes/vote │
│ │phase2│ │ │ │
│ │─────▶│commit │ │ │
│ │ │────────▶│ │ │
│ │ │ │────▶│commit │
└──────┘ └─────────┘ └─────────┘阶段一(prepare):协调者问每个参与者"准备好提交了吗?"。参与者写 prepare 日志,给 vote(yes/no)。阶段二(commit/abort):全票 yes 就发 commit,否则发 abort。
问题出在哪儿?
- 阻塞:参与者 prepare 后要锁资源等协调者决策,期间不能释放。协调者挂了,参与者一直死锁。XA 协议的 XA End/XA Prepare/XA Commit 就是这个流程,MySQL 的 XA 事务给个例子:sql
XA START 'xid1'; UPDATE account SET balance = balance - 100 WHERE id = 1; XA END 'xid1'; XA PREPARE 'xid1'; -- 如果两个资源都 prepare 成功 XA COMMIT 'xid1'; - 单点:协调者崩了,所有参与者等它恢复。恢复后读日志才知道该 commit 还是 abort,但日志没写全就崩的话,数据可能不一致。
- 脑裂:协调者 commit 发了,网络断了,部分参与者没收到——它们会一直停留在 prepare 状态。
3PC 加了一个 pre-commit 阶段,参与者在超时后不会一直等,而是自动 abort。但网络分区时照样可能不一致——超时时间怎么定?和分布式共识一样,没有完美的时钟就做不到完美超时。
TCC:业务层的两阶段,比 2PC 更灵活
TCC 把两阶段的资源锁定从"数据库锁行"搬到"业务代码"里,让应用层自己控制粒度。
Try:预留资源(积分占用、库存冻结)
Confirm:确认使用(扣库存、加积分)
Cancel:释放预留(回滚积分、解冻库存)优势是灵活性高——做不到"锁行"的场景(比如调用第三方支付)也能用。代价是业务代码侵入大,而且有三个经典坑:
空回滚:Try 没执行到(网络超时或服务挂了),但 Cancel 被调了。Cancel 得识别"我根本没 Try 过",不能硬扣。做法:本地事务记录 Try 状态,Cancel 时先查状态。
悬挂:Try 超时后 Cancel 先执行了,然后 Try 的请求又到了。此时 Try 看到的预留资源还没释放——它把 Cancel 已经释放的资源又锁住了。解法:接口里带唯一 id,先判断是否有 Cancel 记录。
幂等:Confirm 和 Cancel 可能被执行多次(网络重试)。每个操作必须是幂等的:扣 100 执行两次,不会变成扣 200。
java
// TCC 幂等 + 防悬挂示例
@Compensable(
confirmMethod = "confirmDeduct",
cancelMethod = "cancelDeduct",
asyncConfirm = false
)
public void tryDeduct(TransactionContext ctx, @BusinessId String orderId, int amount) {
// 防悬挂:先查是否有 cancel 记录
if (txRecordDao.existsCancel(orderId)) {
return;
}
// 幂等:判断是否已执行
if (txRecordDao.exists(orderId)) {
return;
}
txRecordDao.insert(orderId, Status.TRYING);
// 冻结库存
inventoryDao.freeze(orderId, amount);
}Saga:长事务拆补偿,不锁资源
Saga 的思路是:不搞什么两阶段锁定,直接执行每个子事务,如果失败了,反向执行补偿操作。每个子事务正常提交,所以资源不阻塞。
Saga = T1 + T2 + ... + Tn
+ C1 + C2 + ... + Cn-1 (补偿)编排(Choreography):每个服务执行完后发事件,下一个服务订阅事件执行。Saga 失败时每个服务各自执行自己的补偿。耦合度高,依赖关系隐藏在各事件中,不好追踪。
协同(Orchestration):一个协调者(Orchestrator)管理流程,直接告诉每个服务做什么。流程在代码里清晰可见,但协调者本身是单点。
java
// 协同式 Saga 伪代码(用状态机管理)
// 订单流程:创建订单 -> 扣库存 -> 扣余额 -> 发物流
Saga saga = SagaBuilder.newSaga("order-creation")
.step()
.action(t -> orderService.create(t))
.compensation(t -> orderService.cancel(t))
.step()
.action(t -> inventoryService.deduct(t))
.compensation(t -> inventoryService.rollback(t))
.step()
.action(t -> paymentService.charge(t))
.compensation(t -> paymentService.refund(t))
.build();Saga 的假设:补偿操作总能成功。如果补偿也失败,就得走人工介入或定时轮询重试。所以补偿操作要设计成幂等 + 可重试。
本地消息表:最大努力最终一致
这可能是工程上用得最多的方案——不是因为它快,而是因为它简单可靠。
思路:业务操作和消息写入在同一个本地事务中完成,另一个服务异步消费。不保证"同时提交",只保证"最终一致"。
sql
-- 表结构:本地消息表
CREATE TABLE local_message (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
business_key VARCHAR(64) NOT NULL, -- 业务唯一键(幂等用)
message_body TEXT NOT NULL, -- JSON 消息体
status TINYINT DEFAULT 0, -- 0:待发送 1:已发送 2:已确认
retry_count INT DEFAULT 0, -- 重试次数
next_retry_at DATETIME, -- 下次重试时间
created_at DATETIME DEFAULT NOW(),
confirmed_at DATETIME,
UNIQUE KEY uk_business_key (business_key)
);java
// 发送和补偿逻辑
@Transactional
public void createOrderAndSendMessage(Order order) {
// 1. 业务操作
orderDao.insert(order);
inventoryDao.deduct(order.getProductId(), order.getQuantity());
// 2. 本地消息:和业务在同一个本地事务中
messageDao.insert(new LocalMessage(
order.getId().toString(),
serialize(order),
Status.PENDING
));
}
// 定时补偿:扫描超过 30 秒还在 PENDING 的消息
@Scheduled(fixedDelay = 15000)
public void retryPendingMessages() {
List<LocalMessage> pending = messageDao.findByStatusAndNextRetryBefore(
Status.PENDING, LocalDateTime.now());
for (LocalMessage msg : pending) {
boolean sent = mqProducer.send(msg);
if (sent) {
messageDao.updateStatus(msg.getId(), Status.SENT);
} else {
messageDao.incrementRetry(msg.getId(), LocalDateTime.now().plusSeconds(30));
}
}
}关键点:
- 业务和消息插入在同一个本地事务——本地要么全做要么全不做,这是可靠性的基础
- 消费方必须幂等(用 business_key 去重)
- 补偿轮询会有延迟(30秒~1分钟),不适合强实时场景
- 和 MQ 的事务消息本质思想一致(MQ 篇 30 提到的事务消息就是把这个逻辑搬到 broker 端)
Seata AT:自动代理 undo log
Seata 是阿里开源的分布式事务框架,支持四种模式。AT 模式最常用,因为它对业务代码零侵入。
At 的原理:解析 SQL,生成前后镜像,自动记录 undo log。提交时先写入 undo log,执行 SQL,等全局事务结束后再清除 undo log。
yaml
# Seata AT 配置(Spring Boot 示例)
seata:
enabled: true
application-id: order-service
tx-service-group: my_test_tx_group
service:
vgroup-mapping:
my_test_tx_group: default
grouplist:
default: 127.0.0.1:8091
# undo log 表在业务库里自动创建
client:
undo:
log-table: undo_logjava
// 业务代码完全不变,加个 @GlobalTransactional 就行
@GlobalTransactional
public void createOrder(OrderRequest req) {
orderService.create(req); // 执行业务——Seata 自动代理 DataSource
inventoryService.deduct(req); // 自动记录 undo_log
pointService.add(req); // 任何一个失败,自动回滚 undo_log
}AT 的代价:全局锁粒度比 TCC 粗(行锁持续到全局事务结束),不适合高并发长事务。TCC 适合并发高、资源紧张的场景,AT 适合代码量少、不想改业务的场景。
常见误区与小结
- 2PC 不是解决方案,是起点。大多数生产系统不用裸 2PC,用 Seata AT 或 XA 的极少。知道 2PC 是为了理解后面的方案为什么做了那些妥协。
- TCC 的 Try 和 Cancel 要设计成幂等的。空回滚和悬挂不是边缘情况,是正常流程——网络延迟和超时是常态。
- Saga 不能保证隔离性。A 事务的中间状态对 B 事务可见,要自己处理脏读。Saga 用于"最终一致"场景,不是"强一致"。
- 本地消息表不是低端方案。最大努力最终一致覆盖了 90% 的非核心流程(发短信、通知、积分),而且没有分布式事务的协调者开销。
- Seata AT 不是万能的。AT 的全局锁在高并发下容易成为瓶颈。长事务(超过 10 秒)建议用 TCC 或 Saga。
小结:分布式事务没有银弹——2PC 太慢、TCC 侵入大、Saga 无隔离、本地消息表有延迟。选型公式:强一致性且短事务用 AT/XA,业务逻辑复杂且高并发用 TCC,长流程无强依赖用 Saga,非核心流程用本地消息表。下一篇 31. sharding-consistent-hashing会讲数据怎么分片,分布式事务解决的是"写到哪",分片解决的是"读/写哪份"。
参考
参考:Seata 官方文档(https://seata.apache.org)AT 模式实现说明。 参考:《Designing Data-Intensive Applications》第 9 章"分布式事务"。 参考:Fowler 关于 Saga 的原始文章(https://microservices.io/patterns/data/saga.html)。