分布式系统中如何保证幂等,数据一致性 - 案例

积分商城分布式事务解决方案

一、 业务场景描述

用户动作 :用户在积分商城点击"兑换商品"。 业务流程:主服务 A 编排调用三个下游服务:

  1. 服务 B(积分服务):扣减用户积分。
  2. 服务 C(库存服务):扣减商品库存。
  3. 服务 D(订单服务):创建兑换订单记录。

异常场景:服务 B 执行成功(积分已扣),服务 C 执行失败(如库存不足或宕机)。此时必须回滚服务 B,将积分退还给用户。

高并发挑战:在 B 执行完、C 执行失败的间隙,若有其他线程(如用户又兑换了其他商品)修改了用户积分,如何保证回滚时不覆盖新数据?


二、 解决方案选型策略

我们根据业务复杂度采取分层策略:

  1. 策略 A(简单业务) :如果服务 B 的逻辑简单(单表 SQL、计算逻辑少),采用 "重试 + 手写补偿(乐观锁)" 模式。
  2. 策略 B(复杂业务) :如果服务 B 的逻辑复杂(多表关联、计算繁琐),采用 "Seata AT 模式(框架自动回滚)"

三、 方案详细设计

方案 A:简单业务 ------ 手写补偿模式(重试 + 乐观锁)

适用于服务 B 仅仅是 UPDATE user SET points = points - 100 这类简单逻辑。

1. 执行流程

  • 步骤 1(执行):服务 A 调用服务 B 扣减积分。服务 B 需额外返回修改前的数据快照或版本号。
  • 步骤 2(重试):服务 A 调用服务 C 失败。服务 A 进入重试逻辑(如重试 3 次)。
  • 步骤 3(补偿判断) :若重试均失败,服务 A 判定事务失败,调用服务 B 的 "补偿接口(退回积分)"

2. 解决并发脏写(核心难点) 服务 B 的数据库表必须设计 version 字段(乐观锁)。

  • B 服务执行时

    sql 复制代码
    -- 假设用户当前积分 1000,版本号 1
    UPDATE user 
    SET points = points - 100, version = 2 
    WHERE id = 1 AND version = 1;
    -- 执行成功,上下文保存 version=2
  • B 服务补偿接口(回滚时): 补偿 SQL 必须带上版本号条件,防止覆盖其他线程的修改。

    sql 复制代码
    -- 补偿逻辑:将积分加回去,且必须校验版本号
    UPDATE user 
    SET points = points + 100, version = 1 
    WHERE id = 1 AND version = 2; 
  • 结果判定

    • 成功:说明期间无并发修改,数据恢复一致。
    • 失败(影响行数=0):说明 version 已变(被其他线程修改)。
    • 兜底处理 :若更新失败,程序严禁再次强制回滚。应记录"补偿失败日志"到数据库,触发报警,转由人工脚本核对处理。

方案 B:复杂业务 ------ Seata AT 模式(自动回滚 + 脏写兜底)

适用于服务 B 涉及多张表、计算逻辑复杂,不想手写逆向 SQL 的场景。

1. 执行流程

  • 全局事务开启 :服务 A 加上 @GlobalTransactional 注解。
  • B 服务执行 :Seata 拦截 SQL,记录"前镜像"和"后镜像"到 undo_log 表,获取全局锁。
  • C 服务执行失败:抛出异常,全局事务回滚。

2. Seata AT 的并发控制与兜底策略

机制一:全局锁(第一道防线)

  • 在事务 A 执行 B 服务期间,Seata 会持有该记录的全局锁
  • 如果其他事务(也是 Seata 托管的)想修改这条数据,必须申请全局锁。因为 A 还没提交/回滚,锁未释放,其他事务会等待或失败。这直接从根源避免了大部分脏写问题。

机制二:快照校验与兜底(第二道防线) 如果其他事务是非 Seata 管理的(直接 JDBC 操作),它可以绕过全局锁修改数据。此时 Seata 回滚机制如下:

  • 回滚前校验 : Seata 准备执行回滚时,会对比 当前数据库数据undo_log 中的后镜像

    • 当前数据 == 后镜像:说明数据没被动过,安全回滚(生成反向 SQL 恢复数据)。
    • 当前数据 != 后镜像:说明数据已脏(被其他线程改了)。
  • 兜底策略(自动触发) : 当发现数据异常时,Seata 不会强行回滚(避免覆盖别人的修改),而是执行以下逻辑:

    1. 打印异常日志 :抛出 UndoLogException 或类似异常。
    2. 事务状态异常:该全局事务挂在在那,既不提交也不回滚。
    3. 人工介入接口:通过 Seata Server 控制台或 API,管理员可以看到"回滚失败"的事务。

代码实现示例(服务 A):

java 复制代码
@Service
public class ExchangeService {

    @Autowired
    private IntegralServiceB integralServiceB;
    @Autowired
    private StockServiceC stockServiceC;
    @Autowired
    private OrderServiceD orderServiceD;

    // 定义兜底策略(Seata回滚失败后的最后防线)
    @Recover
    public void recoverMethod(Exception e) {
        // 这里写入人工介入队列或发送报警邮件
        log.error("分布式事务回滚失败,需人工介入!原因:{}", e.getMessage());
        // 发送消息给运维平台...
    }

    @GlobalTransactional(rollbackFor = Exception.class, 
                         recover = "recoverMethod") // 指定兜底方法
    public void exchangeGoods(Long userId, Long goodsId) {
        // 1. 扣减积分(复杂逻辑,如:基础积分+活动积分-冻结积分)
        integralServiceB.deductPoints(userId, 100);

        // 2. 扣减库存(此处假设失败)
        // Seata 会自动捕获异常,尝试回滚上面的积分操作
        stockServiceC.reduceStock(goodsId, 1); // 抛出异常
        
        // 3. 创建订单
        orderServiceD.createOrder(userId, goodsId);
    }
}

Seata 兜底逻辑总结表:

阶段 检查点 数据状态 Seata 动作 结果
回滚前 对比当前数据 vs 后镜像 一致(未被修改) 执行反向 SQL 回滚 回滚成功
回滚前 对比当前数据 vs 后镜像 不一致(脏写) 终止回滚,抛出异常 回滚失败,触发报警
并发中 申请全局锁 锁被占用 等待/超时 阻塞后续修改,防止脏写

相关推荐
devlei3 小时前
从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
android·前端·后端
努力的小郑5 小时前
Canal 不难,难的是用好:从接入到治理
后端·mysql·性能优化
Victor3566 小时前
MongoDB(87)如何使用GridFS?
后端
Victor3566 小时前
MongoDB(88)如何进行数据迁移?
后端
小红的布丁6 小时前
单线程 Redis 的高性能之道
redis·后端
GetcharZp6 小时前
Go 语言只能写后端?这款 2D 游戏引擎刷新你的认知!
后端
宁瑶琴8 小时前
COBOL语言的云计算
开发语言·后端·golang
普通网友8 小时前
阿里云国际版服务器,真的是学生党的性价比之选吗?
后端·python·阿里云·flask·云计算
IT_陈寒9 小时前
Vue的这个响应式问题,坑了我整整两小时
前端·人工智能·后端
Soofjan10 小时前
Go 内存回收-GC 源码1-触发与阶段
后端