分布式事务方案选型:从2PC到Saga模式
订单服务扣完库存,支付服务调用超时,本地事务回滚了,可库存已经扣掉。这种场景几乎每个做微服务的团队都撞过。分布式事务不是"选个框架"就完事,方案选错,后面就是无尽的补偿脚本和线上告警。这篇按问题推进的顺序,把2PC、TCC、Saga三条路线拆开讲,最后给出选型判断。
一、先看2PC为什么在互联网场景掉队
两阶段提交(2PC)依赖一个协调者。阶段一协调者问所有参与者"能提交吗",参与者锁定资源并回复;阶段二根据投票结果统一提交或回滚。
Java里最典型的落地是XA,用Atomikos配MySQL:
@Bean
public DataSource dataSource() {
MysqlXADataSource xaDataSource = new MysqlXADataSource();
xaDataSource.setUrl("jdbc:mysql://db1:3306/order");
AtomikosDataSourceBean bean = new AtomikosDataSourceBean();
bean.setXaDataSource(xaDataSource);
bean.setUniqueResourceName("orderDB");
bean.setPoolSize(5);
return bean;
}
这段配置本身没问题,坑在后面。协调者在阶段一就持有数据库行锁,直到阶段二才释放。参与者越多、网络越慢,锁持有时间越长。跨机房调用一次200ms,三个参与者串起来就是600ms的锁窗口,高并发下连接池直接打满。
更致命的是协调者单点。协调者宕机后,参与者卡在"已投票但没收到决定"的状态,资源一直锁着,需要人工介入。所以2PC适合参与者少、并发低、能接受分钟级阻塞的场景,比如内部对账系统。互联网C端交易基本不用。
二、2PC的锁问题,TCC用业务补偿绕开
既然数据库锁是瓶颈,那就别让数据库锁跨服务。TCC(Try-Confirm-Cancel)把锁下沉到业务层:Try阶段预留资源,Confirm确认,Cancel释放。
以扣库存为例,Try不是直接扣减,而是冻结:
public boolean tryDeduct(Long skuId, int count) {
// 冻结库存,不动可用库存
int rows = jdbc.update(
"UPDATE stock SET frozen = frozen + ? " +
"WHERE sku_id = ? AND available >= ?", count, skuId, count);
return rows > 0;
}
public void confirm(Long skuId, int count) {
jdbc.update("UPDATE stock SET frozen = frozen - ?, " +
"available = available - ? WHERE sku_id = ?", count, count, skuId);
}
public void cancel(Long skuId, int count) {
jdbc.update("UPDATE stock SET frozen = frozen - ? " +
"WHERE sku_id = ?", count, skuId);
}
关键点有三个。一是Try必须做幂等,网络重试会重复调用,靠唯一业务号去重。二是空回滚:Try因为网络问题没到,Cancel却先到了,此时不能报错,要识别出"没Try过"直接返回成功。三是悬挂:Cancel先执行完,延迟的Try才到,得记录事务状态把迟到的Try挡掉。这三个坑不处理,线上会出现库存对不上。
TCC的代价是侵入性强,每个参与方都要写三套逻辑,业务复杂时维护成本高。它适合资金、库存这类对一致性要求高、且能明确定义预留语义的场景。
三、流程长、跨方多,Saga把长事务拆成短事务
TCC要求每个参与方实现Try,如果链路里有个第三方支付回调、人工审核节点,根本没法预留资源。Saga的思路不同:把大事务拆成一串本地事务,每个本地事务都有对应的补偿操作,正向执行失败就反向补偿。
Saga分两种编排方式。编排式(Orchestration)由一个中心状态机驱动,状态机引擎如Spring Statemachine或Seata Saga模式;协同式(Choreography)各服务监听事件自行触发下一步。前者可观测性好,后者耦合低但链路难追踪。生产上建议编排式。
一个订单Saga的正向链路和补偿:
// 正向
deductStock(orderId); // 补偿: restoreStock
createPayment(orderId); // 补偿: cancelPayment
notifyLogistics(orderId); // 补偿: cancelLogistics
// 补偿执行顺序与正向相反
// cancelLogistics -> cancelPayment -> restoreStock
注意补偿不是回滚,是"再做一笔反向业务"。restoreStock是把库存加回去,不是撤销那条update。这意味着补偿操作必须幂等,也必须允许失败重试——补偿失败的Saga会卡在中间状态,需要告警加人工兜底。
Saga的软肋是隔离性。正向链路执行到一半,中间状态对外可见:库存已扣但支付未完成,别的请求可能读到这个不一致快照。解决办法是业务层加状态机,让外部只看到终态,或者用语义锁把中间态标记出来。
四、怎么选
给一个判断顺序:
- 单库多表,直接本地事务,别引入分布式方案。
- 参与者少(2-3个)、并发低、能容忍阻塞,2PC/XA够用。
- 资金、库存等强一致场景,且能定义预留语义,选TCC。
- 链路长、跨方多、有第三方或人工节点,选Saga。
- 实在拿不准,先上本地消息表+定时补偿,把一致性降级为最终一致,成本最低。
Seata对AT、TCC、Saga都有支持,AT模式靠全局锁和undolog自动补偿,开发量最小,但全局锁在高并发下同样是瓶颈,别把它当银弹。
核心收获一句话:分布式事务没有最优解,只有和业务一致性要求、并发量、团队维护成本匹配的解。下一步行动建议:拿手头最痛的那条跨服务链路,先画出正向流程和每个节点的补偿动作,再决定用TCC还是Saga——补偿动作画不出来,说明这条链路还不具备上分布式事务的条件。
本文关键词:分布式事务、2PC、TCC、Saga、Seata