Saga 补偿型分布式事务设计与实现

文章目录

  • [Saga 补偿型分布式事务设计与实现](#Saga 补偿型分布式事务设计与实现)
    • 一、引言:从一个真实场景说起
    • [二、灵魂拷问:TCC 的 Try 真的必要吗?](#二、灵魂拷问:TCC 的 Try 真的必要吗?)
      • [2.1 TCC 的工作流程](#2.1 TCC 的工作流程)
      • [2.2 库存场景下,Try 带来了什么?](#2.2 库存场景下,Try 带来了什么?)
      • [2.3 TCC 的另一个问题:多一步就多一次开销](#2.3 TCC 的另一个问题:多一步就多一次开销)
      • [2.4 我们的结论](#2.4 我们的结论)
    • 三、方案选型决策链
      • [3.1 四层决策框架](#3.1 四层决策框架)
    • [四、Saga 补偿逻辑设计](#四、Saga 补偿逻辑设计)
      • [4.1 核心问题:什么情况下需要补偿?](#4.1 核心问题:什么情况下需要补偿?)
      • [4.2 关键设计:库存失败时订单也要补偿](#4.2 关键设计:库存失败时订单也要补偿)
      • [4.3 设计原则](#4.3 设计原则)
    • 五、技术实现
    • [六、 Saga vs TCC vs 事务性发件箱对比](#六、 Saga vs TCC vs 事务性发件箱对比)
    • 七、设计原则总结
    • 八、结语

Saga 补偿型分布式事务设计与实现

一、引言:从一个真实场景说起

先说场景:我们有订单服务和库存服务,订单创建时需要调用库存服务扣减库存。

这个场景有一个关键约束:库存够不够需要同步返回结果。用户下单后需要立即知道库存是否充足,不可能让用户等几分钟后再告诉他"抱歉库存没了"。

面对这个约束,可选的分布式事务方案有哪些?

  • 事务性发件箱:需要异步,无法满足实时性要求 ❌
  • TCC:需要 Try → Confirm → Cancel 三阶段 ⚠️
  • Saga:直接执行 → 失败补偿 ✓

我们最终选择了 Saga。但在做这个选择之前,我仔细思考了一个问题:TCC 的 Try 是否有必要?

二、灵魂拷问:TCC 的 Try 真的必要吗?

2.1 TCC 的工作流程

TCC 需要三个阶段:

复制代码
Try(预留): 冻结库存 → 锁定这部分库存,别人不能用
Confirm(确认): 确认扣减,冻结的库存变为已扣减
Cancel(取消): 回滚释放,冻结的库存释放回去

2.2 库存场景下,Try 带来了什么?

TCC Try 的本质是"预留并锁定"。具体来说:

  • Try 阶段:库存冻结,其他请求看不到 → 只有当前调用方可见
  • Confirm 阶段:冻结的库存变为已扣减 → 对所有人可见
  • Cancel 阶段:冻结的库存释放回去

我们选择的设计是另一种策略:

  • 直接执行扣减 → 成功后立即对所有人可见
  • 失败则回滚补偿 → 加回库存

两种策略的区别:

  • TCC 模式:先锁住,成功后再放开 → 适合需要"隐藏"的场景
  • Saga 模式:直接用,失败就加回来 → 适合"失败可回滚"的场景

2.3 TCC 的另一个问题:多一步就多一次开销

复制代码
TCC: Try(调用) → Confirm(调用) → Cancel(调用)
Saga: 执行(调用) → 补偿(调用)

TCC 需要多一次网络调用。在高并发场景下,这会影响系统吞吐量。

2.4 我们的结论

TCC 的 Try 不是"不必要",而是"不需要"。 两种模式解决不同的问题:

  • 需要资源"隐藏"到"可见"的过程 → 用 TCC
  • 需要"直接用,失败回滚" → 用 Saga

这就是我们选择 Saga 的根本原因:不做 Try,直接执行,失败就补偿

三、方案选型决策链

3.1 四层决策框架

在实际工程中,我们遵循以下决策优先级:

优先级 场景 方案 说明
1 可以异步处理 且 不需要回滚 事务性发件箱 事件驱动,可靠投递
2 需要同步反馈 + 需要回滚 Saga 失败可补偿
3 需要同步反馈 + 需要隐藏 TCC 预留锁定
4 2+ 服务参与 + 复杂协调 引入成熟框架 如 Seata

四、Saga 补偿逻辑设计

4.1 核心问题:什么情况下需要补偿?

Saga 模式下,调用方(订单服务)需要在以下情况发起补偿:

场景 触发条件 补偿动作
订单创建失败 订单服务事务回滚 通知库存服务回滚(加库存)
调用超时 网络超时,未明确返回 订单服务发起补偿(加库存)
响应丢失 库存服务成功了但响应没回到订单 订单服务发起补偿(加库存)

4.2 关键设计:库存失败时订单也要补偿

这里有一个关键点:如果库存服务没有明确返回"扣减成功",订单服务就需要发起补偿

为什么?

因为网络可能出现问题:

  • 订单服务发起调用 → 网络超时 → 库存服务实际已扣减成功
  • 订单服务发起调用 → 库存服务扣减成功 → 响应丢失

这两种情况,库存服务都认为"扣减成功",但订单服务不知道。如果订单服务不发起补偿,库存就白扣了。

4.3 设计原则

复制代码
Saga 设计原则:宁可重试补偿,不可遗漏补偿

只要没有明确成功,就认为可能失败,发起补偿。这是 Saga 实现的核心。

五、技术实现

5.1 整体架构

库存服务
订单服务
调用接口+globalTxId
记录事务状态
幂等检查
执行业务
更新状态
失败/超时
补偿
订单服务
全局事务表
库存服务
全局事务表
库存扣减

5.2 全局事务表设计

调用方(订单服务)和被调用方(库存服务)各自维护全局事务表:

sql 复制代码
-- 调用方全局事务表(订单服务)
CREATE TABLE `order_global_tx` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `global_tx_id` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
  `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-初始化 2-已提交 3-待回滚 4-已回滚完成',
  `retry_count` INT NOT NULL DEFAULT 0 COMMENT '撤销重试次数',
  `compensate_data` TEXT COMMENT '补偿数据JSON',
  `time_out` DATETIME NOT NULL COMMENT '超时时间(建议值:业务正常执行时间 × 3,如正常 3 秒则设为 10 秒)',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_global_tx_id` (`global_tx_id`),
  KEY `idx_time_out` (`time_out`)
);
sql 复制代码
-- 被调用方全局事务表(库存服务)
CREATE TABLE `inventory_global_tx` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `global_tx_id` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
  `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-初始化 2-已提交 3-待回滚 4-已回滚完成',
  `retry_count` INT NOT NULL DEFAULT 0 COMMENT '撤销重试次数',
  `compensate_data` TEXT COMMENT '补偿数据JSON',
  `time_out` DATETIME NOT NULL COMMENT '超时时间(建议值:业务正常执行时间 × 3,如正常 3 秒则设为 10 秒)',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_global_tx_id` (`global_tx_id`),
  KEY `idx_time_out` (`time_out`)
);

关键点:两套表独立存在,互不干扰。调用方记录整个 Saga 状态,被调用方记录子事务执行状态。

状态说明:

  • 1-初始化:事务刚创建,还未明确结果
  • 2-已提交:子事务已执行成功
  • 3-待回滚:需要发起回滚(回滚动作尚未执行)
  • 4-已补偿完成:补偿已执行完成

5.3 调用方实现(订单服务)

  • 全局事务记录使用编程式事务插入,确保独立于业务事务
  • 撤销占用使用「Revert」命名,核心原则:先执行业务(撤销),再更新状态
  • 使用事务监听器监听 Spring 事务回滚,立即尝试撤销占用,配合 Job 兜底
java 复制代码
@Service
public class OrderCommandService {

    @Resource
    private GlobalTransactionService globalTransactionService;
    
    @Resource
    private InventoryCommandCaller inventoryCommandCaller;
    
    @Resource
    private PlatformTransactionManager transactionManager;
    
    /**
     * 创建订单 - 调用库存扣减
     */
    public void createOrder(OrderRequest request) {
        // 1. 生成全局事务ID
        String globalTxId = UUID.randomUUID().toString();
        
        // 2. 写入订单服务全局事务表(初始化状态)
        // 关键点:全局事务记录的插入必须独立于业务事务
        TransactionStatus txStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            GlobalTransactionDTO dto = new GlobalTransactionDTO();
            dto.setGlobalTxId(globalTxId);
            dto.setBizType("ORDER_INVENTORY_OUTBOUND");
            dto.setStatus(TransactionStatusEnum.INITIALIZED);
            
            // 只存储必要的补偿字段
            CompensateData data = new CompensateData();
            data.setOrderId(request.getOrderId());
            data.setSkuId(request.getSkuId());
            data.setQuantity(request.getQuantity());
            dto.setCompensateData(JSON.toJSONString(data));
            
            // 设置超时时间:业务最大执行时间的 2-3 倍
            // 如:业务正常 3 秒,则超时时间设为 10 秒
            dto.setTimeOut(DateUtil.addSeconds(new Date(), 10));
            globalTransactionService.addTransaction(dto);
            
            transactionManager.commit(txStatus);  // 独立事务提交
        } catch (Exception e) {
            transactionManager.rollback(txStatus);
            throw e;
        }
        
        // 3. 执行业务逻辑(创建订单)在另一个独立事务中
        try {
            // 3.1 调用库存服务扣减库存
            Request<XXX> requestVO = Request.<XXX>builder()
                .globalTxId(globalTxId)
                .data(request)
                .build();
            
            Result<XXX> result = inventoryCommandCaller.reserve(requestVO);
            
            // 3.2 创建订单(业务逻辑)
            // orderService.createOrder(request);
            
            // 4. 根据结果处理
            if (result.isSuccess()) {
                // 库存扣减成功,更新状态为已提交
                globalTransactionService.updateTransactionSuccess(globalTxId);
            } else {
                // 库存扣减失败,尝试撤销
                compensate(globalTxId, request);
            }
        } catch (Exception e) {
            // 调用异常,尝试撤销
            compensate(globalTxId, request);
            throw e;
        }
    }
    
    /**
     * 撤销占用逻辑
     * 核心原则:先执行业务(撤销),再更新状态
     */
    private void compensate(String globalTxId, OrderRequest request) {
        try {
            // 1. 先尝试撤销占用(业务动作)
            Request<XXX> requestVO = Request.<XXX>builder()
                .globalTxId(globalTxId)
                .data(request)
                .build();
            inventoryCommandCaller.revert(requestVO);
            
            // 2. 撤销成功,更新状态为「已回滚完成」
            globalTransactionService.updateTransactionRolledBack(globalTxId);
        } catch (Exception e) {
            // 3. 撤销失败,更新状态为「待回滚」
            // Job 扫描「待回滚」+超时的记录来兜底
            globalTransactionService.updateTransactionRollbackPending(globalTxId);
            log.error("撤销占用失败,等待 Job 兜底: {}", globalTxId, e);
        }
    }
}

/**
 * 事务监听器:监听 Spring 事务回滚,立即尝试撤销占用
 * 说明:尽可能快速地回滚库存,作为「立即」执行
 *      配合定时 Job 兜底,实现「立即 + 兜底」双重保障
 * 
 * 触发场景:
 * 1. Spring 事务回滚 → 监听器触发 → 立即尝试撤销占用
 * 2. 撤销成功 → 状态更新为「已回滚完成」
 * 3. 撤销失败 → 状态更新为「待回滚」→ Job 兜底
 */
@Component
public class OrderTransactionRollbackListener {

    @Resource
    private GlobalTransactionService globalTransactionService;
    
    @Resource
    private InventoryCommandCaller inventoryCommandCaller;
    
    /**
     * 监听 Spring 事务回滚,立即尝试撤销占用
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onTransactionRollback(GlobalTransactionRollbackEvent event) {
        String globalTxId = event.getGlobalTxId();
        OrderRequest request = event.getCompensateData();
        
        try {
            // 立即尝试撤销占用
            Request<XXX> requestVO = Request.<XXX>builder()
                .globalTxId(globalTxId)
                .data(request)
                .build();
            inventoryCommandCaller.revert(requestVO);
            
            // 撤销成功,更新状态为「已回滚完成」
            globalTransactionService.updateTransactionRolledBack(globalTxId);
        } catch (Exception e) {
            // 撤销失败,更新状态为「待回滚」
            globalTransactionService.updateTransactionRollbackPending(globalTxId);
            log.error("撤销占用失败,等待 Job 兜底: {}", globalTxId, e);
        }
    }
}

5.4 超时兜底 Job

  • 区分"需要回滚"和"还在执行中",只有状态为「待回滚」且超时才发起回滚
  • 依靠超时时间发现需要撤销的记录,核心:Job 通过「超时」+「待回滚状态」来发现需要撤销的记录
  • 状态更新失败的异常处理:依靠超时时间兜底

Job 部署在调用方(订单服务),扫描超时且状态为「待回滚」的记录。

兜底范围

  • 状态为「待回滚」且超时:调用方已标记需要回滚,但撤销操作未成功,Job 兜底处理
扫描条件 含义 Job 处理方式
待回滚 + 超时 调用方已标记需要回滚但未成功 直接尝试撤销
java 复制代码
@Component
public class OrderGlobalTransactionJob {

    @Resource
    private GlobalTransactionService globalTransactionService;
    
    @Scheduled(cron = "0 */5 * * * ?")
    public void scanTimeoutAndRollbackPending() {
        // 扫描超时且状态为「待回滚」的记录
        List<GlobalTransactionDTO> timeoutList = globalTransactionService.selectTimeoutAndRollbackPending();
        
        for (GlobalTransactionDTO dto : timeoutList) {
            try {
                // 直接尝试撤销占用
                compensate(dto);
                
                // 撤销成功,更新状态为「已回滚完成」
                globalTransactionService.updateTransactionRolledBack(dto.getGlobalTxId());
            } catch (Exception e) {
                // 撤销失败,保持「待回滚」状态,Job 下次还会继续扫描
                log.error("撤销占用失败,等待下次重试: {}", dto.getGlobalTxId(), e);
                globalTransactionService.incrementRetryCount(dto.getGlobalTxId());
            }
        }
    }
    
    private void compensate(GlobalTransactionDTO dto) {
        // 根据补偿数据发起撤销占用
        OrderRequest request = JSON.parseObject(dto.getCompensateData(), OrderRequest.class);
        // ... 调用撤销占用接口
    }
}

/**
 * 查询远程服务状态的响应对象
 */
public enum RemoteTransactionStatus {
    COMMITTED,    // 已提交
    ROLLED_BACK,  // 已回滚
    UNKNOWN       // 未知,需要补偿
}

5.5 被调用方实现(库存服务)

  • 全局事务记录独立于业务事务,使用编程式事务确保插入在独立事务中
  • 撤销占用接口使用「撤销占用」(Revert)命名,推荐使用方案二(依赖库存变更流水表)
  • Job 兜底核心:依靠超时时间发现需要撤销的记录
java 复制代码
@RestController
public class InventoryCommandController {

    @Resource
    private GlobalTransactionService globalTransactionService;
    
    @PostMapping("/v1/reserve/availableStock")
    public Result reserveAvailableStock(
        @RequestHeader("X-Global-Tx-Id") String globalTxId,
        @RequestBody ReserveAvailableStockDTO dto
    ) {
        // 1. 幂等检查
        GlobalTransactionDTO exist = globalTransactionService.selectByGlobalTxId(globalTxId);
        if (exist != null && exist.getStatus() == TransactionStatusEnum.COMMITTED) {
            return Result.success(); // 已执行,直接返回成功
        }
        
        // 2. 悬挂检查 - 检查是否已被回滚
        if (exist != null && exist.getStatus() == TransactionStatusEnum.ROLLED_BACK) {
            return Result.fail("事务已回滚");
        }
        
        // 3. 执行业务(库存扣减)
        // ... 库存扣减逻辑
        // 同时记录库存变更流水表(方案二需要)
        inventoryChangeService.record(globalTxId, dto, ChangeTypeEnum.OCCUPATION);
        
        // 4. 更新库存服务全局事务表
        globalTransactionService.updateTransactionSuccess(globalTxId);
        
        return Result.success();
    }
}

5.6 撤销占用接口实现

  • 撤销占用接口命名推荐使用「撤销占用」(Revert)
  • 方案二(依赖库存变更流水表)是强烈推荐的方案,不依赖调用方传入参数
方案一:依赖调用方传入的占用信息
java 复制代码
/**
 * 撤销占用接口 - 方案一:依赖调用方传入的占用信息
 * 说明:调用方在调用撤销接口时,需要传入当初占用的参数
 */
@PostMapping("/v1/revert/availableStock")
public Result revertAvailableStock(
    @RequestHeader("X-Global-Tx-Id") String globalTxId,
    @RequestBody ReserveAvailableStockDTO dto
) {
    // 1. 检查是否已撤销
    GlobalTransactionDTO exist = globalTransactionService.selectByGlobalTxId(globalTxId);
    if (exist != null && exist.getStatus() == TransactionStatusEnum.ROLLED_BACK) {
        return Result.success(); // 已撤销,直接返回
    }
    
    // 2. 依赖调用方传入的参数来回滚(加回库存)
    inventoryService.addStock(dto.getSkuId(), dto.getQuantity());
    
    // 3. 更新状态为已回滚
    globalTransactionService.updateTransactionRollback(globalTxId);
    
    return Result.success();
}

方案一说明

  • 调用方在调用撤销接口时,需要传入当初占用的参数(skuId、quantity 等)
  • 被调用方根据这些参数执行反向操作(加回库存)
  • 优点:实现简单,不需要额外存储
  • 缺点:依赖调用方正确传入参数,如果参数丢失或不一致会导致回滚失败
方案二:依赖库存变更流水表回滚(强烈推荐)
java 复制代码
/**
 * 撤销占用接口 - 方案二:依赖库存变更流水表回滚(强烈推荐)
 * 说明:不需要调用方传入任何业务参数,只需传入 globalTxId
 *       被调用方根据自己维护的库存变更流水表来执行回滚
 */
@PostMapping("/v1/revert/availableStock")
public Result revertAvailableStock(
    @RequestHeader("X-Global-Tx-Id") String globalTxId
) {
    // 1. 幂等检查 - 检查是否已撤销
    GlobalTransactionDTO exist = globalTransactionService.selectByGlobalTxId(globalTxId);
    if (exist != null && exist.getStatus() == TransactionStatusEnum.ROLLED_BACK) {
        return Result.success(); // 已撤销,直接返回
    }
    
    // 2. 查询库存变更流水表,获取之前库存变更的记录
    InventoryChangeRecord record = inventoryChangeService.selectByGlobalTxId(globalTxId);
    if (record == null) {
        return Result.fail("未找到库存变更记录,无法撤销");
    }
    
    // 3. 根据流水表记录执行回滚(加回库存)
    inventoryService.addStock(record.getSkuId(), record.getChangeQuantity());
    
    // 4. 更新流水表状态为已撤销
    record.setStatus(ChangeStatusEnum.REVERTED);
    inventoryChangeService.update(record);
    
    // 5. 更新全局事务表状态
    globalTransactionService.updateTransactionRollback(globalTxId);
    
    return Result.success();
}

方案二说明

  • 被调用方维护通用的「库存变更流水表」,记录所有库存变更操作
  • 撤销接口不需要调用方传入任何业务参数,只需传入 globalTxId
  • 被调用方根据流水表记录自己执行回滚
  • 优点:不依赖调用方传入的参数,通用性强,即使调用方参数丢失也能正确回滚

两种方案对比

维度 方案一(依赖传入参数) 方案二(依赖流水表)
实现复杂度
调用方传入参数 需要 不需要
参数丢失风险
适用场景 参数简单、调用方可信 通用场景,强烈推荐
推荐程度 可用 强烈推荐
附:库存变更流水表(方案二需要)
sql 复制代码
CREATE TABLE `inventory_change_record` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `global_tx_id` VARCHAR(64) NOT NULL COMMENT '全局事务ID',
  `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型:OCCUPATION-占用 RELEASE-释放 ADJUST-调整',
  `sku_id` VARCHAR(64) NOT NULL COMMENT '商品ID',
  `change_quantity` INT NOT NULL COMMENT '变更数量(正数为增加,负数为扣减)',
  `before_quantity` INT NOT NULL COMMENT '变更前库存',
  `after_quantity` INT NOT NULL COMMENT '变更后库存',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-变更中 2-已确认 3-已撤销',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_global_tx_id` (`global_tx_id`)
);

六、 Saga vs TCC vs 事务性发件箱对比

维度 事务性发件箱 Saga TCC
模式类型 消息可靠投递 分布式事务 分布式事务
实时性 异步 同步 同步
资源状态 不锁定 不锁定 锁定
业务侵入 补偿接口 Try+Confirm+Cancel 三接口
一致性 最终一致 最终一致 强一致
适用场景 可异步 + 不需回滚 同步 + 需回滚 同步 + 需隐藏 + 需要回滚

七、设计原则总结

原则 说明
发件箱优先 尽可能用异步消息解决最终一致
按需选择模式 Saga 和 TCC 解决不同问题:根据是否需要"隐藏"来选择
简化协调 1+1 场景不引入复杂框架
宁可重试补偿 没有明确成功就认为失败,发起补偿
边界清晰 复杂协调交给成熟框架

事务性发件箱模式

八、结语

Saga 补偿型事务不是一个"更优"的方案,它只是一个"更简单"的方案。

在工程实践中,复杂度是一种成本。我们的选择逻辑是:

  1. 优先用发件箱:能异步就异步,复杂度最低
  2. 其次用 Saga:需要同步但不需要预留,简单高效
  3. 再用 TCC:需要预留锁定,用户无感知
  4. 最后用框架:2+ 服务参与,复杂度高,交给专业框架
相关推荐
__土块__2 天前
Java 大厂一面模拟:从本地缓存到分布式事务的连环追问
seata·分布式事务·caffeine·java面试·spring事务·本地缓存·大厂一面
鬼先生_sir4 天前
SpringCloud Seata 四大模式(AT/TCC/SAGA/XA)全解析
seata·springcloud·分布式事务
better_liang7 天前
每日Java面试场景题知识点之-分布式事务
java·微服务·seata·分布式事务·一致性·saga·tcc
却话巴山夜雨时i8 天前
Java大厂面试:从Spring Boot到微服务的深度剖析
java·spring boot·spring cloud·微服务·分布式事务·大厂面试
都说名字长不会被发现9 天前
事务性发件箱模式设计与实现
数据库·分布式事务·幂等·事务性发件箱·可靠投递
恼书:-(空寄17 天前
Seata TCC 生产级(空回滚+悬挂+幂等)+ AT/TCC 混合使用
java·seata·分布式事务
only-qi25 天前
空回滚、悬挂、幂等——TCC 分布式事务的三道暗礁
架构·分布式事务·空回滚、悬挂、幂等
only-qi25 天前
主流分布式事务框架与方案:从 XA 到 Seata 四模式
分布式·seata·分布式事务·xa·tcc
only-qi25 天前
分布式系统四问:幂等、时钟、隔离、权衡
架构·分布式事务·幂等性·时钟回拨·性能与一致性权衡