1. 分布式事务的本质挑战
在单体应用时代,事务管理相对简单,一个本地数据库连接就能保证ACID特性。但随着微服务架构的普及,一个业务操作往往需要跨多个服务、多个数据源完成,这就引出了分布式事务的核心矛盾:如何在网络不可靠、节点可能故障的分布式环境下,保证业务数据的一致性?
我经历过一个典型的电商场景:用户支付成功后,需要同时更新订单状态、扣减库存、增加会员积分。这三个操作分别属于订单服务、库存服务和会员服务,各自有独立的数据库。如果简单地依次调用:
- 订单服务将状态改为"已支付"
- 库存服务扣减对应SKU的库存
- 会员服务增加用户积分
当第二步扣减库存失败时,订单状态却已经变为"已支付",这就产生了数据不一致。更糟的是,如果第三步增加积分成功但第二步失败,系统将处于更混乱的状态。
2. 2PC:经典的分布式事务协议
2.1 两阶段提交的工作原理
两阶段提交(2PC)是最早的分布式事务解决方案,它的核心思想是将事务的提交过程分为两个阶段:
准备阶段:
- 协调者向所有参与者发送prepare请求
- 参与者执行事务操作但不提交,记录undo/redo日志
- 参与者回复"同意"或"中止"
提交阶段:
- 如果所有参与者都同意,协调者发送commit指令
- 任一参与者反对,则发送rollback指令
- 参与者完成最终提交或回滚
// 伪代码示例:协调者逻辑 public boolean execute2PC(List<Participant> participants) { // 阶段一:准备阶段 boolean allPrepared = true; for (Participant p : participants) { if (!p.prepare()) { allPrepared = false; break; } } // 阶段二:提交或回滚 if (allPrepared) { for (Participant p : participants) { p.commit(); } return true; } else { for (Participant p : participants) { p.rollback(); } return false; } }2.2 2PC的优缺点分析
优势:
- 强一致性保证
- 原生支持关系型数据库
- 实现原理相对简单
缺陷:
- 同步阻塞:参与者在准备阶段后进入阻塞状态,等待协调者指令
- 单点故障:协调者宕机会导致整个系统阻塞
- 数据不一致风险:第二阶段协调者发送commit后,如果部分参与者收不到指令,会出现部分提交的情况
实际经验:MySQL的XA事务就是基于2PC实现的。我们在金融系统中使用XA时,必须额外考虑超时处理和补偿机制,避免长时间锁表影响性能。
3. TCC:柔性事务的典型代表
3.1 Try-Confirm-Cancel模式
TCC(Try-Confirm-Cancel)通过业务层面的设计,将分布式事务拆分为三个阶段:
Try:预留业务资源
- 订单服务:创建订单状态为"处理中"
- 库存服务:冻结库存而非直接扣减
- 会员服务:预存积分变更记录
Confirm:确认执行业务
- 所有Try成功则执行确认操作
- 订单状态改为"已完成"
- 冻结库存正式扣减
- 预存积分生效
Cancel:取消业务
- 任一Try失败则执行取消
- 订单状态改为"已取消"
- 释放冻结库存
- 删除预存积分记录
-- 库存服务的TCC示例SQL -- Try阶段 UPDATE inventory SET frozen_count = frozen_count + 1 WHERE sku_id = '123' AND count - frozen_count >= 1; -- Confirm阶段 UPDATE inventory SET count = count - 1, frozen_count = frozen_count - 1 WHERE sku_id = '123'; -- Cancel阶段 UPDATE inventory SET frozen_count = frozen_count - 1 WHERE sku_id = '123';3.2 TCC的适用场景与注意事项
最佳实践场景:
- 高并发秒杀系统
- 资金账户变动
- 需要精确控制资源预留的业务
必须注意的问题:
- 空回滚:Try未执行但收到了Cancel请求
- 解决方案:增加事务状态记录表
- 幂等控制:网络重试可能导致重复Confirm/Cancel
- 解决方案:每个分支事务记录执行状态
- 悬挂问题:Cancel比Try先到达
- 解决方案:检查Try是否已执行
踩坑实录:我们在实现优惠券发放的TCC时,最初没有处理空回滚,导致用户取消订单时错误地返还了未使用的优惠券。后来通过增加tcc_transaction表记录事务状态解决了这个问题。
4. SAGA:长事务的解决方案
4.1 事件驱动的补偿机制
SAGA模式将一个长事务拆分为多个本地事务,每个本地事务都有对应的补偿操作:
正向操作序列:
- 创建订单(T1)
- 扣减库存(T2)
- 增加积分(T3)
补偿操作序列:
- 取消订单(C1)
- 恢复库存(C2)
- 扣除积分(C3)
执行流程:
- 按顺序执行T1→T2→T3
- 任一Ti失败,则逆向执行Ci→...→C1
4.2 SAGA的实现模式
编排式(Choreography):
- 每个服务产生事件并监听其他服务的事件
- 无中心协调者,服务间通过事件驱动
- 适合简单的业务流程
命令式(Orchestration):
- 中央协调器控制流程
- 协调器调用服务并处理补偿逻辑
- 适合复杂业务流程
# 编排式SAGA示例(订单创建流程) def create_order(): try: order = order_service.create(request) # T1 inventory_service.reduce(order.items) # T2 member_service.add_points(order.user_id, order.amount) # T3 except Exception as e: # 补偿执行 if 'T3 failed' in str(e): inventory_service.restore(order.items) # C2 order_service.cancel(order.id) # C1 elif 'T2 failed' in str(e): order_service.cancel(order.id) # C1 raise e4.3 SAGA的优缺点对比
优势:
- 适合长时间运行的业务流程
- 参与者可以异步执行
- 无全局锁,性能较好
挑战:
- 补偿操作不是总可行(如已发送的短信通知)
- 必须设计等幂的补偿操作
- 调试和问题排查较复杂
5. 基于消息的最终一致性
5.1 消息队列+本地事务表
这是目前互联网公司最常用的方案,核心思路是:
- 业务操作和消息发送在同一个本地事务中完成
- 消息消费者保证等幂处理
- 定时任务处理异常情况
具体实现方式:
// 伪代码:订单支付后的处理 @Transactional public void handlePayment(Payment payment) { // 1. 更新订单状态 orderDao.updateStatus(payment.getOrderId(), "PAID"); // 2. 写入本地消息表 localMessageDao.insert( new Message("inventory", "reduce", order.getItemsJson()) ); // 3. 事务提交后,消息会被扫描发送 } // 定时任务扫描未发送消息 @Scheduled(fixedRate = 5000) public void sendMessages() { List<Message> messages = localMessageDao.findUnsent(); messages.forEach(msg -> { mqProducer.send(msg.getTopic(), msg.getContent()); localMessageDao.markSent(msg.getId()); }); }5.2 事务消息方案
RocketMQ等消息中间件提供了事务消息功能:
- 发送半消息(对消费者不可见)
- 执行本地事务
- 根据本地事务结果提交或回滚消息
// RocketMQ事务消息示例 TransactionMQProducer producer = new TransactionMQProducer("group"); producer.setTransactionListener(new TransactionListener() { @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { // 执行本地事务 orderService.pay((Payment)arg); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 检查本地事务状态 Order order = orderService.getById(msg.getKeys()); return order.isPaid() ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } });5.3 消息方案的注意事项
- 消息等幂:消费者必须处理重复消息
- 方案:唯一业务ID+处理状态记录
- 消息顺序:某些场景需要保证顺序消费
- 方案:同一业务ID的消息发到同一队列
- 死信处理:设置合理的重试策略和死信队列
实战技巧:我们在处理库存扣减消息时,发现网络抖动会导致消息重复。最终解决方案是在redis中记录已处理的messageId,设置合理的过期时间。
6. 方案对比与选型建议
6.1 四种方案特性对比
| 特性 | 2PC | TCC | SAGA | 消息最终一致 |
|---|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | 最终一致 | 最终一致 |
| 隔离性 | 完全隔离 | 业务隔离 | 无隔离 | 无隔离 |
| 性能影响 | 高 | 中 | 低 | 低 |
| 实现复杂度 | 低 | 高 | 中 | 中 |
| 适用场景 | 短事务 | 短事务 | 长事务 | 异步场景 |
| 业务侵入性 | 低 | 高 | 中 | 中 |
6.2 选型决策树
- 是否需要强一致性?
- 是 → 考虑2PC
- 否 → 进入下一步
- 事务执行时间?
- 短事务(秒级) → 考虑TCC
- 长事务(分钟级以上) → 考虑SAGA或消息
- 业务是否可异步?
- 可异步 → 消息队列方案
- 需同步 → SAGA
6.3 混合使用实践
在实际系统中,我们经常混合使用多种方案:
- 支付核心:使用TCC保证资金准确
- 订单履约:使用SAGA管理长流程
- 数据同步:使用消息队列最终一致
- 对账系统:使用2PC保证强一致
例如电商下单流程:
- 库存预占(TCC Try)
- 支付核心(TCC Confirm)
- 物流派送(SAGA)
- 积分发放(消息队列)
这种混合方案既保证了关键路径的可靠性,又不会因为过度追求一致性而影响系统性能。