零失败零超卖:一种基于Redis的强一致性热点库存扣减方案

目录


一、前言:热点库存扣减的挑战

在电商系统中,库存扣减是一个非常核心且棘手的问题。尤其是遇到"热点商品"场景------比如直播间带货,数万用户同时抢购几百件商品,库存扣减的瞬时压力巨大。

传统的数据库扣减方案在这种场景下往往会成为瓶颈,只能通过限流来保护数据库。但限流意味着拒绝用户请求,直接影响业务营收。

最近读了一篇阿里云开发者社区的文章《库存合并扣减:一种基于分布式缓存的强一致性热点库存扣减方案》,分享了一套非常巧妙的解决方案:既利用了Redis的高性能,又保证了库存数据的强一致性,实现了零超卖、零少卖。

今天把这套方案的核心设计整理出来,希望能给遇到类似问题的同学一些启发。


二、问题分析:传统Redis分桶方案的三大缺陷

说到热点库存扣减优化,最常见的就是"Redis分桶扣减"方案:把库存从MySQL划分到多个Redis分桶,扣减时直接操作Redis。

但文章指出,这种传统方案在实际应用中存在三大缺陷

缺陷一:无法避免库存少卖

Redis操作可能超时,应用层无法判断是成功还是失败:

  • 扣减超时 → 默认当做失败(避免超卖)
  • 库存回补超时 → 默认当做成功(避免少卖)

这导致库存"有损",只适用于卖家限购、权益等非实物类场景(业务接受少卖)。

缺陷二:无法支持复杂库存模型

传统Redis方案使用incr/decr实现库存加减,利用lowBound防止减到负数,value就是剩余库存数量。这种方案只支持单一数值的库存模式

但实际的库存模型更复杂,比如:

  • sq(可售库存):实际可售卖的库存
  • wq(预扣库存):用户下单后,sq转向wq
  • oq(占用库存):付款后,wq转向oq

对于sq-同时wq+这种复杂模式,传统Redis方案无法支持。

缺陷三:完全依赖Redis稳定性

如果Redis异常,整个扣减链路就会异常,系统容错性差。
传统Redis分桶方案
缺陷一: 少卖风险
缺陷二: 不支持复杂模型
缺陷三: 依赖Redis稳定性
超时场景无法判断
只支持单一数值
Redis异常=链路异常


三、核心思路:Redis做计数 + DB明细为准

那么,有没有一种方案既能利用Redis的高性能,又能保证强一致性呢?

文章给出的核心思路非常巧妙:Redis只用来做扣减计数以防止超卖,实际扣减是否成功以库存扣减明细为准。

整体设计原则

  1. DB行的库存提前预留(锁)一部分:锁的库存还在DB行上,同时也初始化到Redis里用于扣减计数
  2. 先扣Redis,再插DB明细:Redis库存够扣,再插入DB明细,明细插入成功才算扣减成功
  3. 合并提交:延迟扫描扣减明细,计算实际扣减数量,一次提交库存变更到DB





异步
用户下单
扣减Redis分桶
Redis库存够?
扣减失败
插入DB明细
明细插入成功?
扣减成功
合并提交模块
扫描扣减明细
计算实际扣减量
合并更新DB库存

核心概念:库存扣减明细(单据)

在介绍具体模块之前,需要理解"库存扣减明细"这个核心概念:

  • 记录扣减信息:下单时交易告知扣减多少库存,后续付款或取消时,库存内部从明细恢复
  • 幂等性保障:同一单据调用两次扣减、单据回补超时重试,都可以通过明细实现幂等
  • 状态流转管理:负责库存扣减的生命周期状态流转

明细是不可或缺的,是整个方案"以DB为准"的基础。


四、方案详解:四大核心模块

基于上述核心思路,方案设计了四大核心模块。

模块一:锁库存模块(sq → lq)

核心动作 :将DB行库存从sq(可售)锁到lq(预锁库存),同时生成锁库存单据。

巧妙设计

  • 不是通过sq - X + lq的方式,而是sq不动,直接加到lq
  • DB扣减时通过WHERE sq - lq > 0控制最终sq不能小于lq

这个设计的优势:库存展示链路完全不受影响,查询可售库存时不需要关心锁的库存值,大大降低了链路复杂性。
原始库存

sq=100, lq=0
锁库存50

sq=100, lq=50
Redis初始化

分桶库存=50

每次锁定的lq数量,会初始化到Redis分桶中进行扣减计数。多个Redis分桶通过分桶缓存key进行索引。

模块二:下单扣减模块

流程

  1. 先扣减Redis分桶库存
  2. 成功则继续插入DB明细
  3. 失败则兼容走老的DB扣减流程





下单请求
扣Redis分桶
扣减成功?
走DB扣减流程
插入扣减明细
插入成功?
返回扣减成功

模块三:合并提交模块

这是方案的"核心中的核心"。

流程

  1. 失效Redis分桶:防止后续继续有交易流量扣减此分桶
  2. 扫描扣减明细:根据分桶key扫描所有关联明细
  3. 计算扣减数量 :通过SUM(quantity)走覆盖索引得到实际扣减量
  4. 合并更新DB:一次性提交库存变更

合并提交触发
失效Redis分桶
扫描扣减明细
SUM计算实际扣减量

走覆盖索引
合并更新DB库存

性能关键 :明细表设计了覆盖索引(invId, lockOrderId, quantity),压测表明此SQL对DB热点扣减的性能影响非常小。

模块四:库存回收模块

库存回收指将预锁定的库存释放回可售卖状态。

需要回收的场景

  • 商家编辑场景:总库存编辑成0,需要先释放预锁库存
  • 临界场景:Redis分桶只有1件,DB有2件,下单2件时需要先回收Redis库存再继续扣减

实现方式:直接调用分桶合并提交来完成。


五、防超卖少卖设计:扣减屏障机制

文章重点强调了防超卖少卖的设计,这部分非常关键。

DB层防超卖

通过SQL层面的条件控制:

sql 复制代码
UPDATE inventory
SET sq = sq - oq
WHERE sq - lq - oq > 0

保证sq的数量不会小于lq,从数据库层面杜绝超卖。

Redis层防超卖

分桶的剩余库存值不能小于0,保证Redis层面也不超卖。

DB明细为准(防少卖)

如果Redis数据丢失了,实际扣减了多少库存,以DB合并下单明细为准,不会少卖。

扣减屏障机制(高并发防超卖)

高并发场景下,下单扣减和合并提交单据扫描并发执行时,可能会出现少扫描单据的情况,导致超卖。

文章设计了一个"扣减屏障"机制来防止这个问题:


下单扣减
检查扣减屏障
屏障存在?
扣减失败
执行Redis扣减
插入DB明细
合并提交
设置扣减屏障
扫描明细
合并扣减DB
删除扣减屏障

扣减屏障的作用:合并提交开始时设置屏障,阻止新的下单扣减;合并完成后删除屏障,恢复下单。这样可以保证扫描单据的完整性,避免并发导致超卖。


六、性能效果:TPS提升1倍以上

最后看看这套方案的实际效果。

压测效果

单行热点扣减场景下:

  • 非合并扣减:TPS峰值约1.4万
  • 合并扣减:TPS峰值3.2万

整体提升1倍以上的扣减性能。

线上效果

由于入口做了限流,线上达不到压测的流量峰值,但差异体现在成功率和耗时上:

指标 合并扣减 非合并扣减
扣减成功率 100% 80%左右
接口耗时 7ms+ 15ms+

单行热点扣减TPS对比 非合并扣减 合并扣减 4 3.5 3 2.5 2 1.5 1 0.5 0 TPS(万)


七、总结

读完这篇文章,我对这套库存合并扣减方案的核心思想有了清晰的理解:

  1. 设计原则:Redis做计数,DB明细为准 ------ 既利用了Redis的高性能,又保证了强一致性
  2. 巧妙设计:锁库存时sq不动直接加lq,展示链路完全不受影响
  3. 核心机制:合并提交 + 扣减屏障,解决了高并发场景下的超卖问题
  4. 实际效果:TPS提升1倍以上,成功率100%,耗时减半

这套方案给我的启发是:分布式系统的设计不一定要"非此即彼" ------ 可以把不同存储介质的优点结合起来,Redis负责高性能计数,DB负责数据一致性,通过明细机制实现两者的协同。

如果你的业务场景也面临热点库存扣减的挑战,希望这篇文章能给你一些参考。记住:方案的选择要结合业务特点,没有银弹,但有最合适的解法。


参考来源: https://mp.weixin.qq.com/s/_ezTVydFszZnc0ZN-JEtlQ

相关推荐
凤山老林4 分钟前
SpringBoot 使用 H2 文本数据库构建轻量级应用
java·数据库·spring boot·后端
就不掉头发12 分钟前
Linux与数据库进阶
数据库
与衫15 分钟前
Gudu SQL Omni 技术深度解析
数据库·sql
咖啡の猫1 小时前
Redis桌面客户端
数据库·redis·缓存
oradh1 小时前
Oracle 11g数据库软件和数据库静默安装
数据库·oracle
赶路人儿1 小时前
UTC时间和时间戳介绍
java·开发语言
6+h1 小时前
【java】基本数据类型与包装类:拆箱装箱机制
java·开发语言·python
what丶k1 小时前
如何保证 Redis 与 MySQL 数据一致性?后端必备实践指南
数据库·redis·mysql
_半夏曲1 小时前
PostgreSQL 13、14、15 区别
数据库·postgresql
把你毕设抢过来1 小时前
基于Spring Boot的社区智慧养老监护管理平台(源码+文档)
数据库·spring boot·后端