Yujun's Blog

库存扣减方案深度思考: DECR or INCR

May 28, 2025 (1w ago)BugRecord

库存扣减方案深度思考: DECR or INCR

最近经历了一场面试,在聊到我们项目中的Redis库存扣减方案时,被面试官一套组合拳问萌了。回来后反思了一下,设计系统时对一些“想当然”的细节一定要仔细考虑,要考虑到维护,不能能运行就行。

在这里记录下后续的思考,关于Redis的INCR/DECR原子操作,以及它们在复杂库存场景(尤其是动态补库存)下的选择。

一、DECR + SETNX

在一些常见的高并发限量场景(秒杀、抽奖、领券等)中,我们经常使用Redis来处理库存扣减,以减轻数据库的压力。一个非常经典且高效的模式是:

  1. 总库存计数器:用一个Redis Key(如stock_total_SKU123)存储当前剩余库存数量。
  2. 扣减操作
    • 客户端请求扣减时,对总库存计数器执行原子性的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已消耗量):

  1. Redis Key - 已消耗库存计数器

    • 例如 stock_consumed_SKU123,初始值为0
    • 每次有请求尝试扣减库存,就对这个key执行原子性的INCR操作。
  2. 总库存上限 (TotalStockConfig)

    • 这个值不再是Redis中一个频繁被DECR的key,而是存储在一个相对静态的配置源,比如:
      • 数据库的活动配置表或商品库存表。
      • 活动开始时加载到Redis的一个只读Key中。
      • 应用配置中(如果库存不常变)。
    • 关键:应用在每次判断库存时,会读取最新的TotalStockConfig作为上限。
  3. 扣减逻辑 (推荐使用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脚本中是最佳实践,确保整体原子性)。
  4. 动态补充库存的操作

    • 当运营需要补充库存时(比如给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、写订单日志)判断是否重复处理某个“消耗事件”的依据。

五、总结

这次面试经历让我深刻体会到,一个看似“完整”的方案,在不同的业务场景(如动态补库存)和更严格的并发一致性要求下,依然有其局限性和可优化的空间。

  1. 没有一劳永逸的方案:技术选型和方案设计永远是权衡的结果。DECR+SETNX(surplus)在无动态补库存或补库存操作极少的场景下,简单高效。但一旦引入频繁的动态补库存,基于INCR已消耗量 + SETNX(consumed_seq) + 外部总库存配置的方案,在逻辑清晰度和健壮性上更胜一筹。
  2. 深入理解原子操作的边界:Redis的原子命令能保证自身执行的原子性,但多个原子命令组合起来的业务流程,其整体原子性需要额外保障(如Lua脚本,或像SETNX这样的机制来协调)。
  3. “兜底”思维非常重要:在高并发、分布式系统中,除了保证正常流程的正确性,还要充分考虑各种异常情况、运维操作、甚至人为错误,并设计相应的“兜底”机制来增强系统的韧性。SETNX在这里就部分承担了这样的角色。
  4. 面试是学习和检验的绝佳机会:感谢面试官的深度提问,它强迫我们跳出舒适区,去思考方案的深层逻辑和潜在风险。

Comments