分布式事务实战:Saga与TCC的选型决策与落地关键细节

分布式事务实战:从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/

(0)
小编小编
上一篇 16小时前
下一篇 16小时前

相关推荐