积分商城分布式事务解决方案
一、 业务场景描述
用户动作:用户在积分商城点击“兑换商品”。 业务流程:主服务 A 编排调用三个下游服务:
- 服务 B(积分服务):扣减用户积分。
- 服务 C(库存服务):扣减商品库存。
- 服务 D(订单服务):创建兑换订单记录。
异常场景:服务 B 执行成功(积分已扣),服务 C 执行失败(如库存不足或宕机)。此时必须回滚服务 B,将积分退还给用户。
高并发挑战:在 B 执行完、C 执行失败的间隙,若有其他线程(如用户又兑换了其他商品)修改了用户积分,如何保证回滚时不覆盖新数据?
二、 解决方案选型策略
我们根据业务复杂度采取分层策略:
- 策略 A(简单业务):如果服务 B 的逻辑简单(单表 SQL、计算逻辑少),采用 “重试 + 手写补偿(乐观锁)” 模式。
- 策略 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 服务执行时:
-- 假设用户当前积分 1000,版本号 1 UPDATE user SET points = points - 100, version = 2 WHERE id = 1 AND version = 1; -- 执行成功,上下文保存 version=2 -
B 服务补偿接口(回滚时): 补偿 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 不会强行回滚(避免覆盖别人的修改),而是执行以下逻辑:
- 打印异常日志:抛出
UndoLogException或类似异常。 - 事务状态异常:该全局事务挂在在那,既不提交也不回滚。
- 人工介入接口:通过 Seata Server 控制台或 API,管理员可以看到“回滚失败”的事务。
- 打印异常日志:抛出
代码实现示例(服务 A):
@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 后镜像 | 不一致(脏写) | 终止回滚,抛出异常 | 回滚失败,触发报警 |
| 并发中 | 申请全局锁 | 锁被占用 | 等待/超时 | 阻塞后续修改,防止脏写 |