看起来简单的库存扣减,在金融刚兑场景下真的简单吗?
背景
近期负责重构公司的核心积分商城系统。与传统电商不同,该商城的业务背景是债权置换资产。用户使用积分(债权)兑换实物,这不仅是一次交易,更是一次金融刚性兑付。
目前系统面临的核心约束有三点:
- 信任极度脆弱:用户为存量债权人,对“下单失败”或“回滚”极度敏感。
- 刚性资产:库存即抵债资产,无法补货,绝不允许超卖。
- 高并发读写:存在高价值硬通货(如黄金、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 | public void doDeduct(DeductStockDTO deductStock) { |
三、 魔鬼在细节: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 | // 调用关系:LockMysqlStockTccServiceImpl.java -> deductStock (Try) |
Cancel 阶段:空回滚与幂等
1 | // 代码片段:LockMysqlStockTccServiceImpl.java -> rollback (Cancel) |
四、 性能与一致性的权衡
这套方案上线后,效果如何?
- 一致性:通过 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.