Yujun's Blog
库存扣减方案深度思考: DECR or INCR
库存扣减方案深度思考: DECR or INCR
最近经历了一场面试,在聊到我们项目中的Redis库存扣减方案时,被面试官一套组合拳问萌了。回来后反思了一下,设计系统时对一些“想当然”的细节一定要仔细考虑,要考虑到维护,不能能运行就行。
在这里记录下后续的思考,关于Redis的INCR
/DECR
原子操作,以及它们在复杂库存场景(尤其是动态补库存)下的选择。
一、DECR
+ SETNX
在一些常见的高并发限量场景(秒杀、抽奖、领券等)中,我们经常使用Redis来处理库存扣减,以减轻数据库的压力。一个非常经典且高效的模式是:
- 总库存计数器:用一个Redis Key(如
stock_total_SKU123
)存储当前剩余库存数量。 - 扣减操作:
- 客户端请求扣减时,对总库存计数器执行原子性的
DECR
操作。 - 判断
DECR
后的返回值surplus
(剩余量):- 如果
surplus < 0
,说明库存已不足,将计数器恢复为0,并告知用户失败。 - 如果
surplus >= 0
,说明从数量上看,本次扣减“有资格”。
- 如果
- 客户端请求扣减时,对总库存计数器执行原子性的
到这里其实可以通过DECR实现避免超卖的功能,但是,为了确保这个“有资格”的扣减名额不被其他并发请求重复“认领”,我们会为这个具体的surplus
值生成一个唯一的标记key(如stock_total_SKU123_99
,代表剩余99的这个“坑位”),然后尝试用SETNX
去设置它。
- 只有
SETNX
成功的请求,才算真正抢到了这个库存名额。 SETNX
失败的请求,即使DECR
返回值>=0,也应视为扣减失败(名额被抢了)。
这个方案利用了DECR
的原子性保证了总数扣减的准确,利用SETNX
的原子性保证了单个库存单位分配的唯一性,看起来非常完美,在大部分情况下,它确实工作得很好。
面试官发问:动态补库存怎么办?
在我介绍完上述方案后,面试官抛出了一个的问题(大意如此):
“你们这个
DECR
后对surplus
值进行SETNX
标记的方案,在活动进行中,如果运营需要动态补充库存,会发生什么?比如,原来总库存300,消耗到剩余200(此时..._299
到..._200
的标记key都已存在)。现在运营补充了100个库存到总库存计数器,使其变为300。当新的用户来请求,DECR
后得到的surplus
值是299,系统去SETNX ..._299
,会成功吗?”
我当时一愣,开始想:..._299
这个标记key在之前库存从300消耗到299时已经被设置过了。
所以,新的SETNX ..._299
请求必然会失败。
这意味着,即使总库存计数器显示还有库存,但由于历史消耗标记的存在,这批新补充的库存中,那些与旧surplus
值重叠的部分,将无法被正常分配出去。
这,就是一个隐藏的“BUG”或设计缺陷,在“动态补库存”这个特定场景下暴露无遗。
面试官接着引导:“所以,如果仅仅依赖DECR
剩余量,补库存时直接INCRBY
总数,你们的SETNX
标记逻辑可能就不工作了。有没有考虑过其他思路?”
反思:从“减剩余”到“增已耗”的思维转变
那还有什么思路呢?
我们可以从反面来思考,比如不跟踪剩余量,而是跟踪已消耗量?
我们原先的方案,其核心是围绕**“剩余库存量 (surplus
)”** 来构建消耗标记的。当“总库存”这个基准会动态变化(补库存)时,基于相对“剩余量”的标记就很容易出现冲突。
关键在于:将库存跟踪的锚点从“可变的剩余量”切换到“单调递增的已消耗量”。消耗量是不会变的。
优化后的方案思路(基于INCR
已消耗量):
-
Redis Key - 已消耗库存计数器:
- 例如
stock_consumed_SKU123
,初始值为0
。 - 每次有请求尝试扣减库存,就对这个key执行原子性的
INCR
操作。
- 例如
-
总库存上限 (TotalStockConfig):
- 这个值不再是Redis中一个频繁被
DECR
的key,而是存储在一个相对静态的配置源,比如:- 数据库的活动配置表或商品库存表。
- 活动开始时加载到Redis的一个只读Key中。
- 应用配置中(如果库存不常变)。
- 关键:应用在每次判断库存时,会读取最新的
TotalStockConfig
作为上限。
- 这个值不再是Redis中一个频繁被
-
扣减逻辑 (推荐使用Lua脚本保证原子性):
- 当一个请求到来:
a. 应用获取当前最新的
TotalStockConfig
。 b. 对stock_consumed_SKU123
执行INCR
,得到返回值consumed_seq
(消耗序号,从1开始)。 c. 判断是否超卖:如果consumed_seq <= TotalStockConfig
,则表示本次扣减在总库存范围内,有效。 d. 唯一消耗标记 (依然重要):为这个成功的消耗序号consumed_seq
打上SETNX
标记(例如SETNX stock_consumed_SKU123_consumed_seq "locked" EX <expire_time>
)。这个标记主要用于保证后续业务流程(如发MQ、创建订单)基于此“第N次消耗事件”的幂等性,并作为详细的消耗流水记录。 e. 如果consumed_seq > TotalStockConfig
,则表示超卖,本次扣减无效。此时,需要将之前INCR
上去的stock_consumed_SKU123
再原子性地DECR
回去(补偿操作)。 f. (步骤b、c、d、e封装在一个Lua脚本中是最佳实践,确保整体原子性)。
- 当一个请求到来:
a. 应用获取当前最新的
-
动态补充库存的操作:
- 当运营需要补充库存时(比如给
SKU123
增加50个):- 只需要修改配置源中的
TotalStockConfig
的值 (例如,从100更新为150)。 - Redis中的
stock_consumed_SKU123
(已消耗库存计数器)和已经设置的那些SETNX
消耗标记 (..._1
,..._2
等) 完全不需要任何改动!
- 只需要修改配置源中的
- 后续新的扣减请求在执行步骤a时,会读取到新的
TotalStockConfig
(150),然后继续从当前的consumed_seq
(比如之前是70,现在INCR
后是71)开始判断71 <= 150
,流程顺畅进行。
- 当运营需要补充库存时(比如给
为什么这个方案能优雅处理动态补库存?
- 消耗序号的绝对唯一性:
consumed_seq
永远是单调递增的,所以基于它生成的SETNX
标记key (..._consumed_seq
) 永远不会与历史标记冲突。 - 补库存操作的解耦:补库存只改变“天花板”(
TotalStockConfig
),不影响“已爬楼层”(stock_consumed_SKU123
)。 - 逻辑清晰:“已经消耗了多少” vs “总共允许消耗多少”。
四、SETNX
的深层价值
在这次面试的拷打中,我对SETNX
的作用有了更深的理解,这要感谢小傅哥之前的一些总结和面试官的引导。
-
核心并发控制:在我最初的理解中,
SETNX
主要用于确保在高并发扣减时,一个库存单位(无论是基于surplus
还是consumed_seq
)只被一个请求成功标记和获取。这依然是它最直接、最重要的作用,是防止超卖的关键一环。 -
“流水记录”与“消耗凭证”:每一个成功设置的
SETNX
key,就像为那一次成功的库存分配打上了一个唯一的、有时效的电子存根或流水号。 -
增强系统健壮性的“兜底”:
- 应对异常状态:在复杂的分布式环境中,即使
INCR
/DECR
是原子的,但如果发生Redis集群问题(如短暂脑裂、主从切换数据延迟)、数据从备份恢复不当、或运维人员手动误操作了总计数器,这些独立的SETNX
标记可以作为一种交叉验证和状态审计的线索。如果总计数器的值与这些“消耗凭证”记录的模式不符,就可能预示着系统处于不一致的状态。 - “不知道就很可怕”:如果没有这些独立的标记,当总计数器因为某些隐蔽原因出错时,我们可能很难及时发现。
SETNX
标记提供了一种“有迹可循”的方式。 - 辅助问题定位:当出现库存争议时,这些标记可以帮助追溯哪个“名额”在何时被哪个流程(理论上)占用了。
- 应对异常状态:在复杂的分布式环境中,即使
-
为幂等性提供基础:
SETNX
标记的key(尤其是基于消耗序号consumed_seq
时)可以作为后续业务流程(如发MQ、写订单日志)判断是否重复处理某个“消耗事件”的依据。
五、总结
这次面试经历让我深刻体会到,一个看似“完整”的方案,在不同的业务场景(如动态补库存)和更严格的并发一致性要求下,依然有其局限性和可优化的空间。
- 没有一劳永逸的方案:技术选型和方案设计永远是权衡的结果。
DECR
+SETNX(surplus)
在无动态补库存或补库存操作极少的场景下,简单高效。但一旦引入频繁的动态补库存,基于INCR
已消耗量 +SETNX(consumed_seq)
+ 外部总库存配置的方案,在逻辑清晰度和健壮性上更胜一筹。 - 深入理解原子操作的边界:Redis的原子命令能保证自身执行的原子性,但多个原子命令组合起来的业务流程,其整体原子性需要额外保障(如Lua脚本,或像
SETNX
这样的机制来协调)。 - “兜底”思维非常重要:在高并发、分布式系统中,除了保证正常流程的正确性,还要充分考虑各种异常情况、运维操作、甚至人为错误,并设计相应的“兜底”机制来增强系统的韧性。
SETNX
在这里就部分承担了这样的角色。 - 面试是学习和检验的绝佳机会:感谢面试官的深度提问,它强迫我们跳出舒适区,去思考方案的深层逻辑和潜在风险。