一次下单为什么会跨多个服务
在单体应用里,扣库存、减余额、创建订单可以放在同一个数据库事务里,要么全部成功,要么全部回滚,这是最省心的一致性模型。拆成微服务之后,这三件事分别落在库存服务、账户服务和订单服务各自的数据库里,本地事务管不到别人的库。于是出现了经典的尴尬场面:订单建了、钱扣了,库存服务却挂了;或者库存减了、订单却回滚了,钱货两空。
这类"一个业务动作要跨多个服务同时成功或同时失败"的问题,就是分布式事务要解决的。它的核心矛盾在于:各服务的数据库只能各自提交,没有一个全局的事务管理器能同时掌控所有库。因此工程上几乎不再追求强一致,而是退而求其次——在可接受的短暂时间内,让数据最终一致。
- 最终一致大多数业务不要求瞬时强一致,接受短暂不一致后由补偿收敛。
- 两阶段提交先预备再提交,强一致但阻塞、性能差,互联网场景少用。
- 本地消息表业务与待发消息同库事务写入,再异步投递,最常见也最稳。
- 幂等补偿所有重试与回滚都必须幂等,否则重试会造成二次扣款。
本地事务的 ACID 为什么不够用
ACID 中的原子性与隔离性,依赖的是同一个数据库在一个连接里控制所有语句的执行顺序。跨服务之后,没有任何一个组件能同时持有所有数据库的连接并统一提交,网络还会随时抖动、机器会随时宕机。这就是 CAP 定理在现实中的投影:分布式系统不可能同时满足强一致、高可用与分区容忍,网络分区一旦发生,必须在一致性和可用性之间做取舍。
工程上的主流选择是放弃瞬时强一致,接受最终一致(BASE 思想):基本可用、软状态、最终一致。用户感知到的,只是"优惠券马上到账"变成了"几秒内到账",而系统却获得了高可用与水平扩展的能力。只有钱、库存这类对账场景,才需要额外的对账机制兜底。
两阶段提交:强一致的理想与代价
两阶段提交(2PC)是最直观的方案:先由一个协调者问所有参与者"能不能提交"(预备阶段),大家把事务锁住、回答能;协调者收到全部肯定后,再发指令"正式提交"(提交阶段)。任何一个参与者说不能,就全部回滚。
它的问题也恰恰出在"锁住"上。预备之后参与者会长时间持有行锁,直到收到最终指令;如果协调者在这期间宕机,参与者就一直阻塞等待,连接和锁全被占死。在高并发的互联网服务里,这种同步阻塞与单点故障几乎不可接受,所以 2PC 多用于数据库内部或传统企业系统,而不是在线交易链路。
TCC:把事务拆成显式的三步
TCC(Try-Confirm-Cancel)让每个服务自己实现三个接口:Try 阶段先预留资源(冻结库存、冻结余额,而非真正扣减);如果所有 Try 都成功,Confirm 阶段真正提交;任何一步失败,Cancel 阶段把预留的资源释放掉。
// 账户服务的 TCC 三个接口
// Try:冻结 100 元,不真正扣减
boolean tryFreeze(String userId, int cents);
// Confirm:冻结成功后真正扣款
boolean confirmDeduct(String userId);
// Cancel:其他服务失败时解冻
boolean cancelFreeze(String userId);
TCC 的好处是不长期锁行、性能好;代价是每个业务都要写三套接口,并且必须处理空回滚、幂等、悬挂这三类异常,开发成本明显更高。它适合资金、库存这类对一致性要求高的核心链路,普通业务用它往往得不偿失。
Saga:长事务拆成一串补偿
Saga 把一个长业务流程拆成一连串本地事务 T1、T2、T3,每一步都配有一个对应的补偿动作 C1、C2、C3。正常情况下顺序执行;一旦某一步失败,就反向执行已经成功步骤的补偿,把数据退回去。它不锁资源,适合流程长、参与者多、允许中间态短暂可见的场景,比如旅行预订(订完机票再订酒店,失败就退订)。
Saga 有两种协调方式:编排式由一个中心对象按顺序指挥每一步; choreography 式则靠各服务监听事件自行推进。补偿不是"真正的回滚"——数据库已经提交过了,补偿只是用一个反向业务动作去抵消前向效果,所以它天然只能保证最终一致,且要求业务能接受这种"先做后退"。
本地消息表:最常用的最终一致性
互联网里落地最多的,其实是简单的"本地消息表"方案。订单服务在同一个本地事务里,一边写订单数据,一边往自己库的一张消息表里插入一条"待投递"消息。事务提交后,后台任务轮询消息表,把消息投递给库存、账户等下游;下游消费成功后回执,再把消息标记为已完成。投递失败就一直重试,直到成功。
BEGIN;
INSERT INTO t_order (order_id, user_id, amount, status)
VALUES ('o-1001', 10086, 99.00, 'CREATED');
INSERT INTO tx_message (id, topic, payload, status)
VALUES ('m-1001', 'stock-deduct', '{"sku":1001,"qty":1}', 'PENDING');
COMMIT;
-- 后台任务:扫描 PENDING 消息,投递到 MQ,失败稍后重试
它的妙处在于:业务数据和待发消息在同一个本地事务里写入,要么都成、要么都不成,从而保证了"订单建了就一定有一条消息要发出去"。再配合消息队列的可靠投递,下游最终一定会收到。这套方案不需要引入复杂的分布式事务框架,是中小团队性价比最高的选择。
幂等、重试与空回滚
所有最终一致性方案都建立在一个前提上:重试和补偿必须幂等。因为网络超时后调用方会重试,消息队列也会重投,同一笔扣款可能执行两次。解决办法是给每个业务操作带一个全局唯一的事务号,服务端处理前先查这个号是否已经处理过,处理过就直接返回成功,不再重复扣钱。
TCC 还要额外处理两个边界情况。空回滚:Try 请求因为网络原因没到,Cancel 却先到了,这时服务端要记录"这个事务已回滚",防止后续真正的 Try 落进来却没人管。悬挂:Cancel 已经执行过,迟到的 Try 才到达,看到回滚标记就必须直接拒绝预留。这两类异常平时看不见,一旦触发就是资金事故,务必在代码里用事务号状态机守住。
分布式事务没有银弹,真正的防线是:每条链路都有事务号可追,每步操作都幂等可重试,每天都有对账兜底。
方案选型与落地清单
选型时先问自己:这条链路能不能接受短暂不一致?不能,才考虑 TCC 或 2PC;能,就优先本地消息表或 Saga。下表是一个粗略的决策参照。
| 方案 | 一致性强度 | 开发成本 | 典型场景 |
|---|---|---|---|
| 两阶段提交 | 强一致 | 中 | 传统企业、数据库内部 |
| TCC | 较强 | 高 | 资金、库存核心链路 |
| Saga | 最终一致 | 中 | 长流程、多步骤业务 |
| 本地消息表 | 最终一致 | 低 | 绝大多数在线业务 |
落地时记住三件事:全局事务号贯穿全链路,所有操作幂等可重入;关键链路每天跑对账,把漏处理、重复处理的脏数据找出来人工或自动修复;监控里把"消息积压量""回滚次数"做成曲线,异常第一时间告警。做到这些,分布式事务就从一个听起来很玄的难题,变成了一套可维护的工程实践。