Redis分桶+DB最终裁决:高并发库存扣减如何做到零超卖零少卖
2026/9/19 0:12:28 网站建设 项目流程

做库存扣减这么多年,我最深的体会是:超卖和少卖从来不是技术选型问题,而是"谁说了算"的问题。

前年大促,我负责的系统第一版用纯 Redis DECR 挡流量,Redis 一主一从压得很稳,可主从切换那几秒,库存账就对不上了——超卖和少卖同时出现。第二版改成纯数据库行锁更新,数据稳了,但秒杀瞬间连接池被打满,大量请求直接超时。第三版学网上的"Redis 预扣 + 支付回调改单",结果又踩了少卖的坑:用户明明看到有库存,下单却一直失败。

这个问题的本质是:Redis 快但不可信,DB 可信但不够快。要同时做到零超卖、零少卖、支持 Redis 宕机自动降级,就得把 Redis 和 DB 的角色彻底分开——Redis 分桶只当流量门禁,DB 明细表才是库存事实的唯一法官。这套方案我在生产环境跑了两个大促,零超卖、零少卖,Redis 宕机也能自动降级到 DB 继续扣减,下面把完整设计、关键代码和踩坑过程都写出来。

1. 传统方案的死穴:快与稳为什么总是二选一

1.1 纯 Redis 扣减:坏消息不是慢,而是没人给它发"最终裁决权"

很多人觉得 Redis 的 DECR 是原子操作,库存扣减这么简单的减法,为什么不能直接用?原子性确实没问题,问题在于 Redis 不承担持久化真相的职责。无论你开不开 AOF、开不开 RDB,Redis 的定位都是缓存层,它可以把"实时剩余量"这个派生数据算得很快,但一旦发生主从切换、宕机恢复、内存淘汰,这个派生数据就可能和数据库里的真实成交记录对不上。

我之前遇到过最典型的一幕:主库突然宕机,从库顶上后,扣减量少了 2000 多。为什么?因为 AOF 如果配置成 everysec,最多会丢 1 秒写操作;如果主从复制有延迟,从库节点上压根没追平最新的 DECR。库存这种数据,丢 1 条就是一次超卖事故。

更要命的是热点问题。Redis 单实例处理一个 key 的 DECR 可以做到几万 QPS,但在集群模式下,同一个 key 的所有读写都会路由到同一个分片节点。秒杀刚开始那几秒,所有请求都打在同一把"库存锁"上,那个分片的 CPU 被打满,其他数据也跟着遭殃。所以纯 Redis 方案的问题从来不是"不够快",而是它没有资格做最终裁决,同时它自己的高可用也撑不起库存这种强一致数据。

1.2 纯 DB 扣减:正确性满分,吞吐量不及格

纯 DB 方案的正确性是没得挑的。用一条条件更新就能保证不超卖:

UPDATE inventory SET sold_stock = sold_stock + #{qty} WHERE sku_id = #{skuId} AND sold_stock + #{qty} <= total_stock;

affected rows 等于 1 就是扣减成功,等于 0 就是没货。但问题在于,这个操作会让同一 sku 的所有请求去抢同一行 InnoDB 的行锁。库存行只有一行,抢到锁的请求才能更新,后面的请求只能排队。连接池一旦被占满,整个服务的其他查询也跟着超时,数据库 CPU 居高不下,P99 延迟从几毫秒飙升到几百毫秒。

可以说,纯 DB 方案唯一的瓶颈不是 SQL 写得不好,也不是索引加得不对,而是"一行库存"这个事实天然只有一把锁。无论你加多少从库、做多少读写分离,写路径上最终都要落在这把行锁上,并发一上来就是排队,最后整个数据库都被拖垮。后面的分桶设计,主要就是解决这个问题——把一行库存拆成 N 行,把一把锁拆成 N 把锁。

1.3 Redis 预扣 + 回调改单:为什么最容易出现两头都占的尴尬

很多人折中了一下:下单瞬间 Redis DECR 先扣,支付成功后再拿订单回调去改 DB。这个方案看起来既快又能落地,实际上是把问题推迟了。Redis 扣了,DB 不知道;支付超时、用户取消、回调丢失、补偿任务执行失败,任何一种情况都会造成 Redis 和 DB 的长期不一致。

我亲眼见过一个线上事故:回调补偿任务半夜挂掉,第二天早上 Redis 里显示还有 3000 件库存,数据库明细里其实已经卖出 4300 件。用户继续下单,Redis 一路放行,等到 DB 侧最终校验的时候才发现没货,又得批量回滚订单。结果就是用户看到有货却买不了——少卖;系统内部账目混乱——超卖。所以这个方案本质上缺少一个"法官",Redis 只是一个没有最终裁决权的门卫,门卫放行得再多,法官不认账,系统照样乱。

2. 分桶设计:先拆散热点,再把账本也拆了

2.1 热点 Key 是怎么拖垮 Redis Cluster 的

先别急着写代码,想清楚一个问题:为什么要分桶?

原因就是我上面说的,集群模式下同一个 key 只能落在一个分片节点上。库存扣减这种写入频率极高的操作,如果所有流量都打在一个 key 上,哪怕单次操作只有零点几毫秒,累积起来也足够把那个节点打穿。分桶之后,库存被拆到几十上百个 key 上,集群的多个分片可以并行扛流量,热点就从"一把锁"变成了"多把锁同时工作"。

但分桶有一个副作用,也是很多方案翻车的地方:桶数不能通过 hash tag 把所有桶聚到同一个 slot。如果你图省事,把 key 写成stock:{skuId}:{bucketId},Redis Cluster 会以{skuId}作为 hash tag 计算 slot,结果所有桶还是落在同一个节点上,分桶等于白分。正确的 key 设计应该不带 hash tag,比如stock:10086:32这种形式,让不同桶能散到不同分片。

2.2 桶数量、初始容量和路由规则怎么定

桶数量要结合库存量和预估并发来定,我给一个可以直接参考的区间,这些数值是我自己压测和线上调整后的经验值,你可以按业务规模微调:

场景桶数量说明
普通商品常规促销64库存不大,桶太多反而增加探测成本
秒杀爆款256适合瞬时流量大、单 sku 库存几千到几万的场景
超大规模秒杀512+需要结合集群分片数,尽量摊平到所有分片

初始容量就是total / N均分,余数从第一桶开始逐个 +1,保证所有桶的容量加起来严格等于总库存。

路由规则我推荐"用户固定桶 + 溢出探测"。第一次优先选hash(userId) % N,理由是同一个用户的重复请求尽量落到同一把锁上,后面做幂等和排查都方便。如果第一桶已经空了,再按顺序探测(primary + 1) % N(primary + 2) % N,最多探测 3 个桶。这里有一个 key 设计上的细节——三个候选桶必须用单 key Lua 脚本分别扣减,不能在一条 Lua 里同时操作三个 key,否则在 Redis Cluster 下会报 CROSSSLOT 错误。

2.3 关键一步:DB 侧也要按桶建账本

如果只有 Redis 分桶,DB 里仍然是一行 total_stock,那么每次成功扣减还是会去抢同一行的锁,Redis 再快,DB 也会成为瓶颈。所以我在 DB 侧也建了一张分桶账本表inventory_bucket,一个 sku 对应 N 行,每行就是 Redis 一个桶的权威记录。

CREATE TABLE `inventory_bucket` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `sku_id` BIGINT NOT NULL, `bucket_id` INT NOT NULL, `bucket_total` INT NOT NULL, `sold_stock` INT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0, UNIQUE KEY `uk_sku_bucket` (`sku_id`, `bucket_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这样 DB 的写热点也从一行分散到了 N 行,Redis 桶是这 N 行实时剩余量的缓存,inventory_bucket才是权威账本。扣减时 Redis 预占哪个桶,DB 就更新哪个桶的行,条件更新照样保证不超卖。后面所有对账、重建,都以这张表加明细表为准,不依赖 Redis 里任何瞬时值。

2.4 单桶耗尽不等于无货:少卖是怎么被拦下来的

分桶之后最经典的少卖场景是:A 用户的固定桶已经卖完,但其实其他桶还有货,按固定路由直接返回"无货"就会误伤。

我的处理是三层兜底。第一层,溢出探测,多试两个候选桶;第二层,三个候选桶都试完还是没货,不能直接判死,需要回源 DB 做一次最终判定——因为 Redis 桶剩余量只是缓存,缓存可能滞后;第三层,如果 DB 判定确实有货,走 DB 桶行条件更新完成扣减,同时触发一次该 sku 的桶重平衡,把高水位桶的货匀到低水位桶。

这里要注意,回源 DB 的查询不能无节制地打。真正无货的时候,每个请求都会进来做一次全桶扫描,这就是所谓的"无货风暴",会把数据库打挂。我的做法是:回源 DB 判定确实无货后,在本地 JVM 里对该 sku 做短时无货标记,比如 5 秒内不再回源,直接返回无货。这样既保住少卖兜底,又不会在库存耗尽瞬间把 DB 压垮。

这段逻辑也决定了整套方案的一个根本原则:Redis 缓存里的数字只是实时剩余量的投影,Redis 说"没货"不一定是真没货,Redis 说"有货"也不一定是真有货,最终谁说了算?数据库里那行桶账本,以及围绕它产生的明细流水。理解这一点,后面所有对账、重建、降级的设计就都顺了。

3. 扣减主链路:Redis 预占、DB 落账、失败回补

3.1 一次扣减请求的完整时序

我把正常模式的扣减流程拆成四段,这里先给时序,再解释每段的目的。

  1. 请求进来,先拼幂等号:requestId = userId + 业务单号 + 随机因子,保证一次业务动作只对应一条扣减明细。
  2. 检查降级开关:如果 Redis 处于熔断降级状态,直接走 DB-only 路径。
  3. Redis 预占:按路由规则取候选桶,用单 key Lua 脚本扣减桶库存。这一步只负责快速挡住明确无货的请求,不负责最终裁决。
  4. DB 落账:在同一个事务里,条件更新inventory_bucket行 + 插入一条stock_deduct_detail明细。DB 事务提交成功,才真正算扣减成功。

对应扣减服务的核心伪代码如下:

public DeductResult deduct(DeductRequest req) { String requestId = buildRequestId(req); // 降级开关优先判断,Redis 不可用时一步到位 if (degradeManager.isDegraded(req.getSkuId())) { return deductByDbOnly(req, requestId); } // 1. Redis 分桶预占,最多探测 3 个候选桶 Integer reservedBucket = tryReserveInRedis(req, requestId); if (reservedBucket == null) { // 桶缓存说没货,但可能只是桶间不均衡,回源 DB 兜底判定 return deductByDbOnly(req, requestId); } // 2. DB 事务落账 try { inventoryBucketDao.deductAndInsertDetail(req, requestId, reservedBucket); return DeductResult.success(reservedBucket); } catch (DuplicateKeyException e) { // 同一个 requestId 已经落过账,当前这次 Redis 预占要归还 redisService.incr(bucketKey(req.getSkuId(), reservedBucket), req.getQty()); return DeductResult.success(reservedBucket); } catch (Exception e) { // DB 失败:归还 Redis 预占,并交给补偿任务继续核对 redisService.incr(bucketKey(req.getSkuId(), reservedBucket), req.getQty()); compensateProducer.send(new CompensateTask(req, requestId, reservedBucket)); return DeductResult.fail(DeductError.DB_ERROR); } }

3.2 Redis 预占为什么用 Lua 脚本

Redis 的 GET 和 DECRBY 分开执行会有竞态:两个请求同时读到剩余 1,都认为够扣,结果就把库存扣成负数。所以预占必须用 Lua 脚本保证"读判断 + 扣减"是原子的:

-- KEYS[1]: 分桶 key,如 stock:10086:32 -- ARGV[1]: 需要扣减的数量 local stock = tonumber(redis.call('GET', KEYS[1]) or '0') local need = tonumber(ARGV[1]) if stock >= need then redis.call('DECRBY', KEYS[1], need) return 1 end return 0

这段脚本很短,但它做了三件事:判断桶剩余量、扣减、返回结果,整个过程不会被其他请求插队。为什么强调原子性?因为库存扣减最忌讳"读了一个数,用另一个数去扣",两个请求同时读到 99,一个扣成 98,另一个也按 99 扣,库存就被透支了。脚本本身失败或者 Redis 异常,由外层熔断和降级逻辑负责,这里不处理业务回滚。

3.3 DB 事务里到底做了什么

DB 侧的核心是inventory_bucket桶行的条件更新,以及明细表的唯一索引:

UPDATE inventory_bucket SET sold_stock = sold_stock + #{qty}, version = version + 1 WHERE sku_id = #{skuId} AND bucket_id = #{bucketId} AND sold_stock + #{qty} <= bucket_total;

这条 SQL 的 affected rows 等于 1,才允许继续插入明细;等于 0,说明这个桶确实没货了,抛 NoStockException,整个事务回滚。

明细表结构也很简单,但唯一索引是重中之重:

CREATE TABLE `stock_deduct_detail` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `request_id` VARCHAR(64) NOT NULL, `sku_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `qty` INT NOT NULL, `bucket_id` INT NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-确认 2-取消', `create_time` DATETIME NOT NULL, UNIQUE KEY `uk_request_id` (`request_id`), KEY `idx_sku_status` (`sku_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

唯一索引的价值在于幂等。重复请求、重试请求、超时重发,只要 requestId 相同,第二次插入必然触发 DuplicateKeyException,这时不能报错,要按成功处理,同时把这一次多余的 Redis 预占还回去。

3.4 失败回补的两种语义,别混为一谈

DB 失败并不都是同一类,处理方式必须分开。对于"确定没写入"的失败——比如 NoStockException、死锁被数据库回滚——我们明确归还 Redis 预占,返回失败即可。对于"不知道写没写入"的失败——比如事务提交超时、连接断开、MySQL 主从切换——情况就微妙了,DB 可能已经提交了,也可能没有。

我的处理是:不急着归还 Redis,先查一次stock_deduct_detail里有没有这条 requestId。查到了,说明账已经落上,归还这次多余的预占,返回成功;查不到,再归还预占并返回失败,同时把补偿任务丢进队列,让对账程序最后再核一遍。这套逻辑虽然多了一次查询,但避免了"扣减成功但 Redis 还占着库存"和"DB 已成功但告诉用户失败"这两种更恶劣的结果。

4. 对账与重建:零少卖不是靠碰运气,是靠定时兜底

4.1 对账公式怎么算

每次扣减都走 DB,为什么还需要对账?因为 Redis 是缓存,缓存就一定有滞后、有偏差,而且任何系统都免不了脏数据。对账的公式其实很简单,对任意一个 sku:

DB 已确认销量 + 所有 Redis 桶剩余量 = 总库存

其中 DB 已确认销量就是SELECT SUM(qty) FROM stock_deduct_detail WHERE sku_id=? AND status=1。如果等式两边不相等,就需要告警并触发修复。

这里有个坑:对账任务在运行期间,线上扣减还在继续,所以"Redis 桶剩余量"和"DB 销量"天然存在时间差,扫出来有差值很可能是正常现象,不能一看到差值就大惊小怪。我的做法是对账只负责"发现持续偏差",例如连续 3 个周期都差同一个方向,或者差值超过阈值,才真正触发修复动作。

4.2 从 DB 重建 Redis 分桶库存的完整步骤

真正要修复的时候,不是简单地给某个 key 改个数,而要防止"边修边扣"的并发问题。我的重建流程分五步:

  1. 获取该 sku 的分布式锁,锁住之后,这个 sku 的扣减请求要么等待,要么短暂走降级逻辑。
  2. 计算真实剩余量:bucket_total 之和 - DB 已确认销量。注意如果算出负数,说明已经存在真超卖,必须立刻暂停该 sku 并告警。
  3. 把剩余量按桶均分,生成每个桶的目标值。
  4. 用一次性脚本把 N 个桶的目标值写入 Redis。
  5. 释放锁,恢复扣减流量。

重建期间整个 sku 会被锁住几十毫秒,这个代价是值得的。否则重建写到一半又有新请求扣减,Redis 的桶剩余量和 DB 桶行会对不上,问题会越修越乱。

4.3 Redis 偏多和偏少的处理优先级完全不同

对账发现偏差后,要先判断方向。

Redis 桶剩余量偏多,说明 Redis 放行的量大于 DB 实际能成交的量,后果是大量请求冲到 DB 才发现没货,用户体验差,但不会超卖,因为 DB 条件更新会拦截。

Redis 桶剩余量偏少,说明 Redis 比 DB 更悲观,后果是明明有货却被告知无货,这就是少卖,直接损失销售额。所以偏少要尽快触发重建,偏多可以放到低峰期再处理。

这个优先级判断看起来不起眼,却是"零少卖"能不能落地很关键的一环。很多系统只对账不分类,结果该修的优先级排错了,Redis 里少计了库存还慢慢悠悠等低峰,用户早就流失了。

5. Redis 宕机自动降级:切换逻辑与恢复路径

5.1 降级开关怎么设计,别等超时才动手

Redis 宕机的降级不能依赖"手动切开关",也不能等服务全面超时才触发。我用的是本地熔断器加配置中心开关双保险。

本地熔断器的规则:连续 5 次 Redis 操作抛异常或超时超过 300ms,就进入熔断状态,持续 30 秒。熔断期间所有扣减请求都走 DB-only 路径,不再碰 Redis。30 秒后进入半开状态,放一小部分流量探测 Redis 是否恢复,恢复则关闭熔断,失败则重新进入熔断并延长窗口。

配置中心开关是用来兜底的。例如知道了 Redis 要重启维护,可以直接在配置中心把开关打到降级模式,避免熔断器反复试错浪费请求。

5.2 DB-only 降级模式的扣减实现

降级模式下不存在 Redis 预占,直接走 DB 条件更新和明细插入。这和正常模式的 DB 段是同一套代码,只是省掉了 Redis 预占,所以正确性是一样的——DB 桶行条件更新保证不超卖,唯一索引保证幂等。因为没有 Redis 指引,降级模式需要从起始桶开始遍历尝试,我用hash(userId) % bucketCount作为起点,然后按序包一圈,尽量避免每次都从 0 号桶开始,把 0 号桶也变成热点:

@Transactional(rollbackFor = Exception.class) public void deductByDbOnly(DeductRequest req, String requestId) { int cnt = inventoryBucketDao.countBySku(req.getSkuId()); int start = hashUserId(req.getUserId()) % cnt; for (int i = 0; i < cnt; i++) { int bucketId = (start + i) % cnt; int affected = inventoryBucketDao.deduct(req.getSkuId(), bucketId, req.getQty()); if (affected == 1) { detailDao.insert(new StockDeductDetail(requestId, req, bucketId)); return; } } throw new NoStockException(req.getSkuId()); }

但要注意,DB-only 模式的写压力全落在 MySQL 上,行锁竞争会明显升高。我在生产上的经验是:降级模式要单独隔离数据库连接池,并且把扣减服务的线程数降下来,宁可让请求排队,也不要让连接池被打穿。同时,秒杀期间如果 Redis 挂了,最好在前端或网关做一层限流,先拦住大头流量,DB 只处理真正能成交的量。

5.3 恢复流程:先重建,再放量,不要直接切回去

Redis 恢复了,立刻切回正常模式是大忌。因为降级期间 DB 可能已经卖出不少货,Redis 桶里还是宕机前的旧数据,直接切回去会让 Redis 门禁瞬间失真,大量请求涌入 DB,和降级模式没有区别。

我的恢复顺序是:先检测 Redis 连续一段时间健康,然后按第 4 节的重建流程,把 DB 账本的最新状态刷回所有分桶,最后再把熔断器从半开放为全开。整个切换过程对外是无感的,用户不会看到任何报错。

另外,降级期间如果有新库存补充,不要只在 DB 加总量,Redis 桶里没有对应记录,等恢复重建时一起刷。如果库存补充后 Redis 还不可用,那段窗口期的库存展示可以显示"未知",或者直接读 DB 的最新值,不要用脏缓存误导用户。

6. 压测怎么设计、数据怎么读、上线前查哪些坑

6.1 压测怎么设计,数据才可信

我压测这套方案时用的是一个 3 节点 Redis Cluster,MySQL 8C16G,应用 8 个实例,单 sku 库存 10000,请求总量 30 万,爆发时长 60 秒。数据只供参考,因为每个人的机器、网络、数据规模都不一样,但相对关系是有借鉴意义的:

方案端到端 QPSP99 延迟是否超卖是否少卖
纯 Redis 预扣约 2800022ms有风险有风险
本方案正常模式约 900018ms
本方案 DB-only 降级约 320065ms

可以看到,正常模式端到端 QPS 并没有纯 Redis 高,因为每次成功扣减都要落一条 DB 明细和更新一次桶行。这是强一致性的代价——用户看到的库存变化和系统账目严格一致,就必须让 DB 参与最终裁决。在实际秒杀场景里,库存总量不大,这个吞吐是够用的;如果库存量特别大,就要进一步做库存分片或者批量写明细,那是另一个层面的优化。

6.2 上线前必须检查的几个隐形坑

第一,Redis 分桶 key 绝对不能设短的 TTL,更不能依赖内存淘汰来管库存。曾经有人给库存 key 统一设了 24 小时过期,第二天秒杀启动时 key 已经过期,GET读到空值,Lua 脚本当成 0 处理,所有用户都看到无货,少卖事故直接炸穿。如果确实需要清理过期 key,要单独走重建任务,而不是靠过期机制。

第二,熔断器的状态要线程安全,并且要防止抖动的 Redis 导致熔断器反复开关。我见过一个案例,Redis 偶尔超时 500ms,熔断器开了又关、关了又开,结果大量请求在降级和正常模式之间来回横跳,性能比一直降级还差。后来加了最小熔断持续时间和半开探测,才好起来。

第三,补偿任务要做幂等,补偿逻辑本身不能重复扣减。补偿任务一直在重试同一个 requestId,只要 DB 明细表唯一索引在,重复执行只会触发 DuplicateKeyException,不会重复入账。

第四,上线前要做好 Redis 异常演练,而不是只做压测。我习惯在预发环境直接停掉一个 Redis 节点,观察熔断是否自动触发、DB-only 是否正常、恢复后重建是否成功。这套流程跑顺了,线上 Redis 真的宕机时才不会手忙脚乱。

最后说一下我个人的体会。这套方案最核心的转变,是接受了一个事实:Redis 再快,也不能承担最终裁决的角色,它只是把最贵的数据库写操作拦在门外。分桶确实增加了一些复杂性,但换来的是热点分散和 DB 行锁分散,对账和重建又把这部分复杂性兜了回来。如果你也在做高并发库存扣减,不要一上来就想着"用 Redis 替代 DB",而是想清楚谁才是法官,门禁和法官分开,问题就解决了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询