分布式事务实战:从Saga到TCC的选型决策与踩坑记录
微服务架构下,一个业务操作跨多个服务是常态,分布式事务的选型直接影响系统的一致性保障和吞吐能力。Saga和TCC是两种主流方案,选错代价很高——Saga牺牲隔离性换吞吐,TCC用预留资源换隔离性,各有明确的适用边界。这篇文章从实际业务场景出发,给出选型决策框架和落地中的关键技术细节。
分布式事务的选型决策框架
选型不取决于”哪个更先进”,取决于业务对一致性、性能、复杂度的权衡。决策树如下:
业务是否允许最终一致性?
├── 是 → Saga(推荐)
│ ├── 参与者是否可靠?
│ │ ├── 是 → 编排式Saga(Orchestration)
│ │ └── 否 → 协调式Saga(Choreography)+ 补偿重试
│ └── 补偿操作是否可逆?
│ ├── 是 → 标准Saga
│ └── 否 → 需要人工干预流程
└── 否 → TCC(或本地消息表+最大努力通知)
├── 预留资源成本是否可接受?
│ ├── 是 → TCC
│ └── 否 → 本地消息表方案
└── TCC的Try阶段是否需要锁资源?
├── 是 → 注意死锁风险
└── 否 → 轻量TCC
核心判断点:如果业务操作涉及资金、库存等需要强一致性的场景,选TCC;如果涉及订单流转、通知推送等可容忍短暂不一致的场景,选Saga。
Saga编排模式的实现方案
编排式Saga有一个中心协调器管理事务流程,每个步骤失败时由协调器触发补偿。以电商下单为例:
// Saga定义(Java伪代码)
public class OrderCreateSaga {
public SagaDefinition<OrderState> sagaDefinition() {
return step()
.withParticipant(this::createOrder)
.withCompensation(this::cancelOrder)
.step()
.withParticipant(this::reserveInventory)
.withCompensation(this::releaseInventory)
.step()
.withParticipant(this::processPayment)
.withCompensation(this::refundPayment)
.step()
.withParticipant(this::confirmOrder)
.build();
}
// 库存预留
private void reserveInventory(OrderState state) {
InventoryRequest req = new InventoryRequest(
state.getOrderId(),
state.getSkuList(),
Operation.RESERVE
);
inventoryService.reserve(req);
// 成功后state记录预留ID,供补偿使用
state.setReservationId(req.getReservationId());
}
// 库存补偿
private void releaseInventory(OrderState state) {
inventoryService.release(state.getReservationId());
}
}
Saga落地中最容易踩的坑是补偿操作不是幂等的。网络超时导致补偿请求重试,如果补偿不是幂等的(比如退款执行了两次),就会出问题。所有补偿操作必须设计为幂等的:
// 幂等补偿的关键:用唯一ID去重
public void releaseInventory(String reservationId) {
// 先查询是否已经释放
Optional<InventoryReservation> existing =
reservationRepo.findById(reservationId);
if (existing.isPresent() && existing.get().isReleased()) {
log.info("库存预留已释放,跳过: {}", reservationId);
return; // 幂等返回
}
// 执行释放逻辑
inventoryRepo.release(reservationId);
// 标记为已释放
existing.ifPresent(r -> {
r.setStatus(Released);
reservationRepo.save(r);
});
}
TCC模式的关键实现细节
TCC三阶段:Try(资源预留)、Confirm(确认提交)、Cancel(取消释放)。比起Saga,TCC的难点在于Try阶段需要冻结资源而不是直接扣减。
// TCC Try阶段:冻结库存
@Transactional
public TryResult tryReserveInventory(ReserveRequest req) {
// 查询可用库存
Inventory inv = inventoryRepo.findBySku(req.getSku());
if (inv.getAvailable() < req.getQuantity()) {
return TryResult.failed("库存不足");
}
// 冻结:可用数量减少,冻结数量增加
inv.setAvailable(inv.getAvailable() - req.getQuantity());
inv.setFrozen(inv.getFrozen() + req.getQuantity());
// 记录冻结记录(Confirm/Cancel需要)
FreezeRecord record = new FreezeRecord(
req.getXid(), // 全局事务ID
req.getSku(),
req.getQuantity(),
FreezeStatus.FROZEN
);
freezeRepo.save(record);
return TryResult.success(record.getId());
}
// TCC Confirm阶段:确认扣减
@Transactional
public void confirmReserve(String xid) {
FreezeRecord record = freezeRepo.findByXid(xid);
if (record == null) return; // 幂等
Inventory inv = inventoryRepo.findBySku(record.getSku());
// 冻结数量转为实际扣减
inv.setFrozen(inv.getFrozen() - record.getQuantity());
inv.setSold(inv.getSold() + record.getQuantity());
record.setStatus(FreezeStatus.CONFIRMED);
freezeRepo.save(record);
}
// TCC Cancel阶段:释放冻结
@Transactional
public void cancelReserve(String xid) {
FreezeRecord record = freezeRepo.findByXid(xid);
if (record == null || record.getStatus() != FreezeStatus.FROZEN) return;
Inventory inv = inventoryRepo.findBySku(record.getSku());
// 冻结数量回到可用
inv.setFrozen(inv.getFrozen() - record.getQuantity());
inv.setAvailable(inv.getAvailable() + record.getQuantity());
record.setStatus(FreezeStatus.CANCELLED);
freezeRepo.save(record);
}
TCC的死锁风险常被忽视。当多个TCC事务同时Try同一资源时,如果冻结操作加了行锁,可能出现循环等待。解决方案:
- Try阶段用乐观锁而非悲观锁
- 冻结操作的超时时间要短(建议5秒)
- 全局事务的超时要有上限,超时自动Cancel
消息中间件在分布式事务中的角色
Saga和TCC解决的是同步调用链的一致性问题。服务治理中还有一类场景:主流程完成后异步通知下游,要保证”至少通知一次”。这需要消息中间件配合本地消息表:
// 本地消息表方案
@Transactional
public void createOrderWithMessage(Order order) {
// 1. 业务操作和消息写入同一个事务
orderRepo.save(order);
// 2. 消息存本地表,确保业务和消息要么同时成功要么同时失败
OutboxMessage msg = new OutboxMessage(
"order-created",
JSON.toJSONString(order),
MessageStatus.PENDING
);
outboxRepo.save(msg);
}
// 定时任务扫描未发送消息
@Scheduled(fixedDelay = 1000)
public void sendPendingMessages() {
List<OutboxMessage> messages = outboxRepo
.findByStatusAndCreatedAtBefore(
MessageStatus.PENDING,
LocalDateTime.now().minusSeconds(1)
);
for (OutboxMessage msg : messages) {
try {
kafkaTemplate.send(msg.getTopic(), msg.getContent()).get(5, TimeUnit.SECONDS);
msg.setStatus(MessageStatus.SENT);
} catch (Exception e) {
msg.setRetryCount(msg.getRetryCount() + 1);
if (msg.getRetryCount() >= 5) {
msg.setStatus(MessageStatus.FAILED);
// 告警通知人工处理
}
}
outboxRepo.save(msg);
}
}
本地消息表方案的优势是业务操作和消息发送在同一事务中,不存在”业务成功但消息丢失”的情况。缺点是定时轮询有延迟(通常1-3秒),对实时性要求极高的场景不适用。这种场景下可以考虑事务消息(RocketMQ支持),但引入了新的中间件依赖。
分布式事务没有银弹。选型的核心是明确业务对一致性的要求等级,然后选择匹配的方案。不必要地用TCC会增加复杂度,不适当地用Saga会导致用户看到中间状态。在高并发设计时,这两者带来的性能差异也很明显——Saga的吞吐通常比TCC高30-50%,因为不需要预留资源。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/fen-bu-shi-shi-wu-shi-zhan-saga-yu-tcc-de-xuan-xing-jue-ce/