如何保障电商下单场景的数据一致性?

业务场景

电商下单流程涉及多个服务的数据变更:

用户下单
    │
    ├── 订单服务:创建订单
    │
    ├── 库存服务:扣减库存
    │
    ├── 账户服务:扣减余额/积分
    │
    └── 优惠券服务:核销优惠券

问题:如何保证这四个服务要么全部成功,要么全部回滚?


方案对比

方案 一致性 性能 复杂度 适用场景
Seata AT 最终一致 推荐首选
TCC 最终一致 复杂业务
本地消息表 最终一致 简单场景
2PC/XA 强一致 金融核心

方案一:Seata AT模式(推荐)

架构图

┌──────────────────────────────────────────────────────────┐
│                      订单服务                             │
│                                                          │
│    @GlobalTransactional                                  │
│    createOrder()                                         │
│        │                                                 │
│        ├── orderService.create()                         │
│        ├── stockService.deduct()    ← RPC                │
│        ├── accountService.deduct()  ← RPC                │
│        └── couponService.use()      ← RPC                │
│                                                          │
└──────────────────────────────────────────────────────────┘
                          │
                          ↓
          ┌───────────────────────────────┐
          │         Seata Server          │
          │       (事务协调者 TC)          │
          └───────────────────────────────┘

代码实现

1. 主事务入口

@Service
public class OrderService {
    
    @Autowired
    private StockFeignClient stockClient;
    
    @Autowired
    private AccountFeignClient accountClient;
    
    @Autowired
    private CouponFeignClient couponClient;
    
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    public Order createOrder(OrderRequest request) {
        // 1. 创建订单
        Order order = doCreateOrder(request);
        
        // 2. 扣减库存
        stockClient.deduct(request.getProductId(), request.getQuantity());
        
        // 3. 扣减余额
        accountClient.deduct(request.getUserId(), order.getTotalAmount());
        
        // 4. 核销优惠券
        if (request.getCouponId() != null) {
            couponClient.use(request.getCouponId());
        }
        
        return order;
    }
}

2. 分支事务

@Service
public class StockServiceImpl implements StockService {
    
    @Transactional  // 本地事务即可
    @Override
    public void deduct(Long productId, int quantity) {
        int affected = stockMapper.deduct(productId, quantity);
        if (affected == 0) {
            throw new StockNotEnoughException();
        }
    }
}

Seata配置

seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default
    grouplist:
      default: seata-server:8091
  config:
    type: nacos
  registry:
    type: nacos

原理

1. TM(事务管理器)开启全局事务,生成XID
2. XID通过RPC调用传递到各分支
3. RM(资源管理器)执行本地事务前记录undo_log
4. TM发起commit/rollback
5. 如果rollback,根据undo_log反向补偿

方案二:本地消息表

适用场景

  • 不想引入Seata依赖
  • 对延迟不敏感(秒级)

实现步骤

1. 订单服务(主事务)

@Service
public class OrderService {
    
    @Transactional
    public Order createOrder(OrderRequest request) {
        // 1. 创建订单
        Order order = doCreateOrder(request);
        
        // 2. 写入本地消息表(同一事务)
        LocalMessage message = new LocalMessage();
        message.setMessageId(UUID.randomUUID().toString());
        message.setTopic("order-created");
        message.setBody(JSON.toJSONString(order));
        message.setStatus(0);  // 待发送
        localMessageMapper.insert(message);
        
        return order;
    }
}

2. 定时任务发送消息

@Scheduled(fixedDelay = 1000)
public void sendMessages() {
    List<LocalMessage> messages = localMessageMapper.findPending(100);
    
    for (LocalMessage msg : messages) {
        try {
            kafkaTemplate.send(msg.getTopic(), msg.getBody()).get();
            localMessageMapper.updateStatus(msg.getId(), 1);  // 已发送
        } catch (Exception e) {
            log.error("消息发送失败: {}", msg.getId(), e);
        }
    }
}

3. 消费者处理(幂等)

@KafkaListener(topics = "order-created")
public void processOrder(String message) {
    Order order = JSON.parseObject(message, Order.class);
    
    // 幂等检查
    if (processedOrders.contains(order.getOrderId())) {
        return;
    }
    
    try {
        // 扣库存
        stockService.deduct(order.getProductId(), order.getQuantity());
        // 扣余额
        accountService.deduct(order.getUserId(), order.getAmount());
        // 标记已处理
        processedOrders.add(order.getOrderId());
    } catch (Exception e) {
        // 失败后会重试,走补偿
        throw e;
    }
}

方案三:TCC

适用场景

  • 复杂业务逻辑
  • 需要精细化控制

库存服务TCC实现

@LocalTCC
public interface StockTccService {
    
    @TwoPhaseBusinessAction(name = "deductStock", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean tryDeduct(@BusinessActionContextParameter(paramName = "productId") Long productId,
                      @BusinessActionContextParameter(paramName = "quantity") int quantity);
    
    boolean confirm(BusinessActionContext context);
    
    boolean cancel(BusinessActionContext context);
}

@Service
public class StockTccServiceImpl implements StockTccService {
    
    @Override
    public boolean tryDeduct(Long productId, int quantity) {
        // Try阶段:冻结库存
        return stockMapper.freeze(productId, quantity) > 0;
    }
    
    @Override
    public boolean confirm(BusinessActionContext context) {
        // Confirm阶段:扣减冻结的库存
        Long productId = context.getActionContext("productId");
        Integer quantity = context.getActionContext("quantity");
        return stockMapper.confirmDeduct(productId, quantity) > 0;
    }
    
    @Override
    public boolean cancel(BusinessActionContext context) {
        // Cancel阶段:释放冻结的库存
        Long productId = context.getActionContext("productId");
        Integer quantity = context.getActionContext("quantity");
        return stockMapper.unfreeze(productId, quantity) > 0;
    }
}

异常处理

空回滚问题

Cancel阶段执行时,Try可能还没执行,需要防止空回滚:

@Override
public boolean cancel(BusinessActionContext context) {
    Long productId = context.getActionContext("productId");
    
    // 检查是否执行过Try
    TccFreezeRecord record = freezeRecordMapper.find(context.getXid(), productId);
    if (record == null) {
        // Try没执行,直接返回成功
        return true;
    }
    
    // 正常回滚
    return stockMapper.unfreeze(productId, record.getQuantity()) > 0;
}

悬挂问题

Cancel比Try先执行,需要在Try时检查:

@Override
public boolean tryDeduct(Long productId, int quantity) {
    // 检查是否已经Cancel过
    if (cancelRecordMapper.exists(context.getXid(), productId)) {
        return false;  // 拒绝执行
    }
    
    return stockMapper.freeze(productId, quantity) > 0;
}

面试答题框架

场景分析:下单涉及订单、库存、账户、优惠券四个服务

方案选型:
- 推荐Seata AT:无侵入,开箱即用
- 简单场景:本地消息表
- 复杂场景:TCC

Seata实现:
- @GlobalTransactional注解入口
- 分支事务自动记录undo_log
- 全局回滚时反向补偿

异常处理:
- 空回滚:检查Try是否执行
- 悬挂:检查Cancel是否已执行
- 幂等:业务去重

总结

方案 优点 缺点 推荐度
Seata AT 无侵入,简单 依赖Seata Server ⭐⭐⭐⭐⭐
本地消息表 简单可靠 有延迟 ⭐⭐⭐⭐
TCC 灵活,高性能 开发成本高 ⭐⭐⭐