恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Redis分桶+DB最终裁决:高并发库存扣减如何做到零超卖零少卖

  • 首页
  • 资讯中心
  • /
  • Redis分桶+DB最终裁决:高并发库存扣减如何做到零超卖零少卖

相关资讯

Docker 多版本 ROS:图形、GPU 与 micro-ROS 避坑 2026/9/19 0:12:38
VBScript 导 CSV 首列带问号?让 Codex 借道 TaoToken 查 BOM 行不行 2026/9/19 0:12:38
Dev C++配置指南:解决自动补全、中文乱码与断点调试 2026/9/19 0:12:38

最新资讯

宿主组合与 agent preset,TaoToken 换 llm 凭据
Markdown技术文档编写全指南:从入门到精通
Codex 能聊天却一跑工具调用就 400?TaoToken 这样改 model_providers
Cursor 连上 TaoToken 后能调通 TypeScript 写的 MCP 服务
AI搜索带来的用户如何进入微信?个人微信API接口与GEO流量承接方案
初中浮力教学PDF:状态判定优先的五步实操资源

今日推荐

oh-my-hermes:打造跨工具的命令编排与插件化工作流
OpenClaw.NET 用 /goal start 跑长任务,模型 Base URL 改到 TaoToken
SYB创业计划书财务逻辑拆解:从销售收入预测到现金流量计划

本周热门

AI SDK Harness 依赖更新指南:掌握 harness 包 SDK 依赖的升级、桥接同步与一致性校验
Refine v5 Ant Design NumberField 组件实战:基于 Intl 的本地化数字格式化
Flutter应用改名全指南:从Android到iOS的配置与工具实践

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

Redis分桶+DB最终裁决:高并发库存扣减如何做到零超卖零少卖

发布时间:2026/9/19 0:12:38
Redis分桶+DB最终裁决:高并发库存扣减如何做到零超卖零少卖 做库存扣减这么多年我最深的体会是超卖和少卖从来不是技术选型问题而是谁说了算的问题。前年大促我负责的系统第一版用纯 Redis DECR 挡流量Redis 一主一从压得很稳可主从切换那几秒库存账就对不上了——超卖和少卖同时出现。第二版改成纯数据库行锁更新数据稳了但秒杀瞬间连接池被打满大量请求直接超时。第三版学网上的Redis 预扣 支付回调改单结果又踩了少卖的坑用户明明看到有库存下单却一直失败。这个问题的本质是Redis 快但不可信DB 可信但不够快。要同时做到零超卖、零少卖、支持 Redis 宕机自动降级就得把 Redis 和 DB 的角色彻底分开——Redis 分桶只当流量门禁DB 明细表才是库存事实的唯一法官。这套方案我在生产环境跑了两个大促零超卖、零少卖Redis 宕机也能自动降级到 DB 继续扣减下面把完整设计、关键代码和踩坑过程都写出来。1. 传统方案的死穴快与稳为什么总是二选一1.1 纯 Redis 扣减坏消息不是慢而是没人给它发最终裁决权很多人觉得 Redis 的 DECR 是原子操作库存扣减这么简单的减法为什么不能直接用原子性确实没问题问题在于 Redis 不承担持久化真相的职责。无论你开不开 AOF、开不开 RDBRedis 的定位都是缓存层它可以把实时剩余量这个派生数据算得很快但一旦发生主从切换、宕机恢复、内存淘汰这个派生数据就可能和数据库里的真实成交记录对不上。我之前遇到过最典型的一幕主库突然宕机从库顶上后扣减量少了 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这样 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 一次扣减请求的完整时序我把正常模式的扣减流程拆成四段这里先给时序再解释每段的目的。请求进来先拼幂等号requestId userId 业务单号 随机因子保证一次业务动作只对应一条扣减明细。检查降级开关如果 Redis 处于熔断降级状态直接走 DB-only 路径。Redis 预占按路由规则取候选桶用单 key Lua 脚本扣减桶库存。这一步只负责快速挡住明确无货的请求不负责最终裁决。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;唯一索引的价值在于幂等。重复请求、重试请求、超时重发只要 requestId 相同第二次插入必然触发 DuplicateKeyException这时不能报错要按成功处理同时把这一次多余的 Redis 预占还回去。3.4 失败回补的两种语义别混为一谈DB 失败并不都是同一类处理方式必须分开。对于确定没写入的失败——比如 NoStockException、死锁被数据库回滚——我们明确归还 Redis 预占返回失败即可。对于不知道写没写入的失败——比如事务提交超时、连接断开、MySQL 主从切换——情况就微妙了DB 可能已经提交了也可能没有。我的处理是不急着归还 Redis先查一次stock_deduct_detail里有没有这条 requestId。查到了说明账已经落上归还这次多余的预占返回成功查不到再归还预占并返回失败同时把补偿任务丢进队列让对账程序最后再核一遍。这套逻辑虽然多了一次查询但避免了扣减成功但 Redis 还占着库存和DB 已成功但告诉用户失败这两种更恶劣的结果。4. 对账与重建零少卖不是靠碰运气是靠定时兜底4.1 对账公式怎么算每次扣减都走 DB为什么还需要对账因为 Redis 是缓存缓存就一定有滞后、有偏差而且任何系统都免不了脏数据。对账的公式其实很简单对任意一个 skuDB 已确认销量 所有 Redis 桶剩余量 总库存其中 DB 已确认销量就是SELECT SUM(qty) FROM stock_deduct_detail WHERE sku_id? AND status1。如果等式两边不相等就需要告警并触发修复。这里有个坑对账任务在运行期间线上扣减还在继续所以Redis 桶剩余量和DB 销量天然存在时间差扫出来有差值很可能是正常现象不能一看到差值就大惊小怪。我的做法是对账只负责发现持续偏差例如连续 3 个周期都差同一个方向或者差值超过阈值才真正触发修复动作。4.2 从 DB 重建 Redis 分桶库存的完整步骤真正要修复的时候不是简单地给某个 key 改个数而要防止边修边扣的并发问题。我的重建流程分五步获取该 sku 的分布式锁锁住之后这个 sku 的扣减请求要么等待要么短暂走降级逻辑。计算真实剩余量bucket_total 之和 - DB 已确认销量。注意如果算出负数说明已经存在真超卖必须立刻暂停该 sku 并告警。把剩余量按桶均分生成每个桶的目标值。用一次性脚本把 N 个桶的目标值写入 Redis。释放锁恢复扣减流量。重建期间整个 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 ClusterMySQL 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而是想清楚谁才是法官门禁和法官分开问题就解决了一大半。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号