看起来简单的库存扣减,在金融刚兑场景下真的简单吗?

侯乾 Lv4

背景

近期负责重构公司的核心积分商城系统。与传统电商不同,该商城的业务背景是债权置换资产。用户使用积分(债权)兑换实物,这不仅是一次交易,更是一次金融刚性兑付。

目前系统面临的核心约束有三点:

  1. 信任极度脆弱:用户为存量债权人,对“下单失败”或“回滚”极度敏感。
  2. 刚性资产:库存即抵债资产,无法补货,绝不允许超卖。
  3. 高并发读写:存在高价值硬通货(如黄金、3C)的抢购场景,且用户会高频刷新确认库存。

基于上述背景,我们并没有采用业界通用的“Redis 预扣 + MQ 异步”方案,而是选择了一条更为“陡峭”的技术路径:基于 Seata TCC 的异构存储双写方案

一、 通信模型与一致性分析

首先我们来分析一下接入分布式事务后的通信模型。

我们作为一个“兑付平台”,必然要确保用户的债权能够准确、原子地转换为实物资产。这个问题看起来一目了然,没什么难度,其实不然。

为什么放弃“Redis + MQ”最终一致性?

在普通电商场景,为了追求极致性能,通常采用 Redis 预扣减 -> MQ -> DB Async 的链路,这是典型的柔性事务方案。

这种方案在 s2(MQ 发送失败)或 s3(DB 扣减失败)阶段,通常依赖“回滚”或“人工补偿”。但在我们的场景下,用户下单成功意味着“债权已核销”,如果后续告知用户“没货了,积分退还”,会被解读为**“虚假兑付”**。

结论:在金融级刚兑场景下,我们无法接受“最终一致性”带来的不确定性回滚。我们需要“所见即所得”。

为什么放弃 Seata AT 模式?

Seata AT 模式对代码无侵入,是下意识的备选方案。但在热点商品场景下,AT 模式在 Phase 1 提交前需要获取 全局锁(Global Lock)。

当 1000 个用户争抢同一个 SKU 时,所有事务会在 Seata TC 侧排队,数据库连接长时间无法释放,导致吞吐量急剧下降。

二、 换个思路:TCC + 异构双写

目前看来,简单的异步方案不够安全,自动的事务方案不够快。我们需要一个既能保证强一致,又能避开全局锁瓶颈,还能支撑高频读取的方案。

所以最终设计了 Seata TCC (Try-Confirm-Cancel) 模式,并在 Try 阶段实现了 MySQL + Redis 的同步双写

2.1 架构核心逻辑(指挥官模式)

我们在 DeductProductStockProcessor 中编排了双写的逻辑。这里并没有采用死板的“要么全成,要么全败”,而是根据业务实质设计了**“保刚性,降级柔性”**的策略。

下图展示了我们定制的 TCC Try 阶段执行流:

graph TD
    Start((开始下单)) --> A[Try: MySQL 扣减]
    A -->|失败| B[抛出异常 -> 全局回滚]
    A -->|成功| C[Try: Redis 扣减]
    C -->|成功| D[Try 阶段完成]
    C -->|失败| E[不回滚 DB!]
    E --> F[记录日志 & 触发异步回填]
    F --> D
    
    style A fill:#d4edda,stroke:#28a745,stroke-width:2px
    style C fill:#fff3cd,stroke:#ffc107,stroke-width:2px
    style B fill:#f8d7da,stroke:#dc3545,stroke-width:2px

2.2 核心代码实现

看看代码是如何落实这个逻辑的。我们在 doDeduct 方法中,将 MySQL 作为“红线”,Redis 作为“优化”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void doDeduct(DeductStockDTO deductStock) {
String traceId = MdcUtil.getTraceId();

// 1、执行 MySQL 库存扣减 TCC (Try阶段)
// 这是“刚性资产”的扣减,必须成功。如果不成功,抛异常,整个事务回滚。
boolean result = lockMysqlStockTccService.deductStock(null, deductStock, traceId);
if (!result) {
throw new InventoryBizException(InventoryErrorCodeEnum.DEDUCT_PRODUCT_SKU_STOCK_ERROR);
}

// 2、执行 Redis 库存扣减 TCC (Try阶段)
// 这是为了支撑高并发读的“影子库存”
result = lockRedisStockTccService.deductStock(null, deductStock, traceId);

// 3、 =================================容错兜底=====================================
if (!result) {
// 如果 DB 扣成功但 Redis 失败(极低概率),我们选择不回滚 DB,而是触发异步同步。
// 逻辑:不能因为缓存挂了,就阻止用户买到真的货(DB里有货)。
log.info("执行redis库存扣减失败!触发回填同步...");
syncStockToCacheProcessor.doSync(deductStock.getSkuCode());
}
}

三、 魔鬼在细节:TCC 的“三大坑”处理

选型 TCC 意味着我们要自己处理分布式事务的复杂性。尤其是网络抖动带来的**空回滚(Empty Rollback)悬挂(Suspension)**问题。

如果处理不好,就会出现:库存没扣,Cancel 却把库存加回来了(空回滚导致库存虚增);或者 Cancel 跑完了,迟到的 Try 又把库存扣了(悬挂导致库存永久冻结)。

我们来看一下时序图,展示这两种极端异常是如何被代码拦截的:

sequenceDiagram
    autonumber
    participant TM as Seata TM
    participant RM as Inventory Service
    participant DB as MySQL

    Note over TM, DB: 场景:网络拥堵导致 Try 超时,触发回滚

    TM->>RM: 1. RPC 调用 Try (网络延迟,未到达)
    TM--xTM: Try 超时!
    TM->>RM: 2. RPC 调用 Cancel (回滚)
    
    activate RM
    RM->>DB: 3. 检查是否有 Try 记录? (空回滚判断)
    DB-->>RM: 无记录
    RM->>DB: 4. 插入"空回滚"防悬挂记录
    RM-->>TM: Cancel 成功 (其实啥也没干)
    deactivate RM

    TM->>RM: 5. 迟到的 Try 终于到了!
    activate RM
    RM->>DB: 6. 检查是否有"空回滚"记录? (防悬挂判断)
    DB-->>RM: 有! (说明已经回滚过了)
    RM--xRM: 7. 拒绝执行 Try
    RM-->>TM: Try 失败
    deactivate RM

3.1 代码实现

Try 阶段:防悬挂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 调用关系:LockMysqlStockTccServiceImpl.java -> deductStock (Try)
@Transactional(rollbackFor = Exception.class)
@Override
public boolean deductStock(BusinessActionContext actionContext, DeductStockDTO deductStock, String traceId) {
// ...省略参数解析

// 【关键点】解决悬挂问题
// 如果 rollback 接口比 try 接口先执行(空回滚),数据库里会有记录。
// 这里必须检查,如果发现已经回滚过,直接返回失败,不再预留资源。
if (isEmptyRollback()) {
return false;
}

// 执行真正的扣减
// 扣减销售库存 SQL (UPDATE ... SET sale_stock_quantity = sale_stock_quantity - #{saleQuantity} ...)
int result = productStockDAO.deductSaleStock(skuCode, saleQuantity, originSaleStock);

// 标识 Try 阶段执行成功,为 Confirm/Cancel 阶段做判断依据
if (result > 0) {
TccResultHolder.tagTrySuccess(getClass(), skuCode, xid);
}
return result > 0;
}

Cancel 阶段:空回滚与幂等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 代码片段:LockMysqlStockTccServiceImpl.java -> rollback (Cancel)
@Override
public void rollback(BusinessActionContext actionContext) {
// ...省略参数解析

// 【关键点】空回滚处理
// 检查 TccResultHolder 或 数据库记录,看 Try 是否真的执行过。
if (TccResultHolder.isTagNull(getClass(), skuCode, xid)) {
log.error("mysql:出现空回滚,插入防悬挂记录");
// 必须插入一条记录,证明该事务已回滚,防止后续的 Try 请求造成悬挂
insertEmptyRollbackTag();
return;
}

// 【关键点】幂等处理
// 防止网络抖动导致 Cancel 重复执行
if (!TccResultHolder.isTrySuccess(getClass(), skuCode, xid)) {
return;
}

// 执行回滚
// 还原销售库存 SQL (UPDATE ... SET sale_stock_quantity = sale_stock_quantity + #{saleQuantity} ...)
productStockDAO.restoreSaleStock(skuCode, saleQuantity, originSaleStock - saleQuantity);

// 移除 Try 成功标识
TccResultHolder.removeResult(getClass(), skuCode, xid);
}

四、 性能与一致性的权衡

这套方案上线后,效果如何?

  • 一致性:通过 TCC 双写,我们在绝大多数时间保证了 MySQL 和 Redis 的毫秒级一致。前端查询走 Redis,下单走 TCC 双写,真正做到了“所见即所得”。
  • 并发能力:TCC 相比于 Seata AT,移除了 Global Lock,将锁竞争限制在数据库行锁(Row Lock)层面。虽然单行更新依然是串行的(受限于 InnoDB 性能,单 SKU 约 500-1000 TPS),但这已经足以应对我们目前的业务量级。
  • Redis 的角色:在我们的设计中,Redis 不仅仅是缓存,它通过 TCC 参与到了事务中(LockRedisStockTccServiceImpl 同样实现了 Try/Confirm/Cancel),这保证了读取的高可用和高准确性。

小结

回到标题的问题:看起来简单的库存扣减,真的简单吗?

如果是为了“快”,也许 Redis decr 也就够了。

但如果是为了“准”,为了在“债权置换”这种高敏感场景下守护用户的信任,我们需要将复杂性从业务逻辑下沉到架构设计中。

通过手写 TCC,我们虽然增加了开发成本(处理悬挂、空回滚等),但换来了对数据流转的绝对控制权。在架构设计中,从来没有最好的方案,只有最适合业务场景的取舍。

  • Title: 看起来简单的库存扣减,在金融刚兑场景下真的简单吗?
  • Author: 侯乾
  • Created at : 2022-09-06 18:55:13
  • Updated at : 2022-09-06 18:55:13
  • Link: http://houqian.github.io/2022/09/06/看起来简单的库存扣减,在金融刚兑场景下真的简单吗?/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments