news 2026/10/14 6:18:07

高并发库存超卖实战:数据库扣减与Redis Lua原子方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发库存超卖实战:数据库扣减与Redis Lua原子方案

前几天晚上收到一条消息:某促销活动上线十分钟,后台库存显示 -3,运营催着赶紧关接口。做电商或者任何涉及库存业务的同学,应该都对超卖这个词不陌生。高并发的库存抢购超卖问题,本质上是大量请求同时进入下单链路时,扣减动作没有做到原子性,同一份库存被多个订单同时占用。很多人第一反应是加服务器、加缓存,实际上绝大多数超卖事故都出在代码链路里少了关键防护。这篇文章我会把数据库扣减、Redis Lua 原子扣减、订单超时回滚、异步持久化这几条线的实战经验完整拆一遍,适合正在做秒杀和限量购的同学参考,文末也会把我实际踩过的坑一起列出来。

1. 超卖问题的根源:先还原一次典型事故

1.1 最典型的错误写法是先查再扣

很多团队的第一版下单接口通常长这样:先查数据库中剩余库存,然后在业务代码里判断库存大于零,最后再执行一条扣减 SQL。单独看这段逻辑没什么问题,问题是并发场景下,200 个线程可能都在第一步读到了剩余 1,判断和 update 之间没有加锁,所有请求都不会排队,最后库存自然被减成负数。超卖发生的那一刻,往往不是哪台机器变慢了,而是“读库存”和“扣库存”被拆成了两个步骤,两个步骤之间产生了竞争窗口。这个窗口在低流量时几乎不存在,流量一冲起来就会被无限放大。

1.2 扣减动作的原子性才是关键

可以拿超市排队结账做个类比:只有一个收银台,大家依次结账,就不会出现两个人买到同一件商品的问题。如果通道不排队,几个窗口同时声称自己拿到了最后一件商品,库存就超卖。数据库本质上也是这样,单条 update 往往是原子的,但“先查再改”的组合操作如果没有锁机制保护,等于把收银台拆成了两个独立柜台,看起来都有服务能力,实际上谁都没有资格给库存做最终决策。所以超卖的根因是扣减动作缺少原子性,而不是机器的并发能力不够。

注意:不是加了事务注解就能解决。默认的查询语句在多数隔离级别下并不会锁住刚读到的记录,更不能阻止其他请求同时读到同一行数据。事务只能保证自身操作的原子性,无法替代锁机制来保护并发写入。

1.3 限流并不能替代库存防护

有同学觉得超卖无非是流量太大,直接在入口限制并发,比如只允许 100 个请求进入下单接口,库存 50 是不是就安全了?限流只能控制请求总量,无法控制真正通过的用户里有几个会产生订单。即使只有 60 个请求进来,50 个库存也可能被这 60 个请求同时读到,照样超卖。限流保护后端不被打垮没问题,但资源分配的一致性必须靠底层的扣减方案来保证。换句话说,库存防护和流量控制是两个维度,各自都要做,不能互相替代。

2. 数据库扣减方案:从条件更新到行锁

2.1 条件更新 SQL 是最基础的防超卖手段

先把最简单的方案说清楚:不要先查库存,直接把扣减条件放进 update。写出来的效果是这样:

update sku_stock set stock = stock - 1 where sku_id = 1001 and stock > 0;

如果返回受影响行数为 1,说明扣减成功,库存一定没有变成负数;如果返回 0,说明剩余库存不足,可以给用户返回已售罄。MySQL 在执行 update 时本身会加行锁,这一条语句从读到改到写是原子完成的,其他事务想同时改同一行库存就必须排队。这个方案实现成本最低,很多业务量不算特别夸张的电商系统其实完全够用。

有个容易忽略的细节:stock = stock - 1这种写法不能改成先把 stock 查出来,再set stock = oldValue - 1回写,否则又回到 select/update 分离的老路。正确写法必须让库存条件直接落在 where 里,让数据库自己判断剩余量。我见过有人把条件写在 ORM 实体属性判断里,写出来跟下面这种差不多:

Stock stock = stockMapper.selectById(skuId); if (stock.getStock() > 0) { stock.setStock(stock.getStock() - 1); stockMapper.updateById(stock); }

这种做法无论外面包没包事务,并发一上来必然出问题,因为两个请求可能同时读到 stock = 1。

2.2 乐观锁版本号的实际用法

有些团队希望更新时能明确知道数据是否被别人改过,这时候可以用版本号方案。给库存表加一个 version 字段,每次扣减把当前版本号带进 where 条件:

update sku_stock set stock = stock - 1, version = version + 1 where sku_id = 1001 and stock > 0 and version = 1;

执行成功说明版本号没有被其他请求改过;执行失败就重新查一次最新版本再尝试。这个方案的好处是业务层能看到冲突,可以根据冲突次数决定重试还是放弃;坏处是并发高的时候大量请求在重复执行“查询-更新-再查询”,数据库压力并不小。乐观锁适合读多写少、冲突概率低的场景,真正的秒杀流量通常需要更重的防护手段,不会把整个链路压在版本号重试上。

2.3 悲观锁 FOR UPDATE 的适用边界

悲观锁的思路在事务里先把行锁住,其他事务必须等当前事务提交后才能继续执行。代码如下:

select stock from sku_stock where sku_id = 1001 for update;

由于 for update 已经锁定了这行记录,其他请求的相同查询会阻塞在当前事务提交之前,此时再去判断 stock 并执行 update 就不会出现并发冲突。这个方案的缺点是吞吐量被锁串行化,能下多少单完全取决于数据库能处理多少行锁排队。如果热点商品就是一行库存,周围流量全部堵在锁上,TPS 会很难看。实际项目中,悲观锁更适合后台运营工具使用,因为后台操作频率低、一致性要求高,而秒杀入口一般不做这种笨重方案。

2.4 数据库方案怎么选

我把这几种数据库方案放在一起对比,方便根据业务场景判断:

扣减方案并发支撑防超卖效果主要代价常用场景
条件更新中等强接口被数据库瓶颈限制小型活动、日常库存
乐观锁版本号中等强高并发下重试多冲突率低的场景
悲观锁 FOR UPDATE较低很强行锁排队,吞吐受限后台运营、管理端
不做防护低无必然超卖只适合纯测试

3. Redis 层扣减方案:把库存前置到内存

3.1 为什么必须引入 Redis 库存

数据库方案虽然能防超卖,但抢购场景的峰值请求太高,比如一件爆款开售,10 万请求同时到达,如果每次都穿透到 MySQL,数据库连接数会瞬间被打满,CPU 持续飙高甚至把其他业务拖垮。把库存前置到 Redis 的思路是:热点库存放在内存里,利用 Redis 单线程模型保证原子执行,内存操作比数据库快几个数量级,能扛住高并发。但 Redis 只是挡在前面的缓存,最终记录还是要落回数据库,所以链路设计上必须考虑最终一致性,不能把 Redis 当成唯一的账本。

3.2 单独用 DECR 不够安全

不少人听说 Redis 有 DECR 命令,就直接在接口里写:先执行DECR sku:stock:1001,返回负数就说明超卖,再执行INCR补回去。这个思路单独看还算通顺,但补回操作发生在业务代码里,假设请求 A 扣成 0,请求 B 又扣成 -1,此时 A 才发起 INCR 回补,库存又变成 0,可 A 并没有真正扣到库存,总量就不对了。更麻烦的是,如果服务在 DECR 之后、INCR 之前宕机,Redis 里就会留下一个负数,相当于真实库存被多扣了。所以稳妥做法是让判断和扣减在 Redis 端一步完成,也就是用 Lua 脚本。

3.3 Lua 脚本保证判断和扣减的原子性

看一个最常用的扣减脚本:

local key = KEYS[1] local stock = tonumber(redis.call("get", key)) if stock == nil then return -1 end if stock > 0 then redis.call("decr", key) return 1 else return 0 end

调用时把sku:stock:1001作为 KEYS 传进去,脚本返回 1 表示扣减成功,返回 0 表示库存不足,返回 -1 表示 Redis 中还没有对应 key,需要先初始化。因为 Redis 会把整个 Lua 脚本作为原子操作执行,其他命令在脚本运行期间不会插入,所以判断和扣减合在一起,就不会出现并发超卖。实际项目中建议把商品购买数量作为 ARGV 传进去,做成一次可以扣减多件的通用脚本,而不是只能扣 1 件。

如果需要更复杂的占用库存模型,脚本里可以维护两个 key:一个记录可用库存,一个记录已占用库存。下单时扣减可用库存并增加占用数,支付成功后再把占用数转成已售数量,这种设计在预约购买和预售场景里很有用。

3.4 热点 key 和库存分桶的取舍

设计 Redis key 时要考虑热点 key 问题。Redis 虽然快,但如果某个 sku 的请求量特别巨大,实例的单线程 CPU 会集中在同一个 key 上,同样可能成为瓶颈。有些团队会把库存切分成多个桶,比如把 1000 件库存拆成 10 个 key,每 key 100 件,请求根据用户标识路由到不同桶去扣减,进一步分散热点。分桶之后要处理单桶扣完的情况,比如从其他桶转移库存,复杂度会明显上升。我建议业务量还没有大到 Redis 单 key 成瓶颈时,不要急着做分桶,先把整个链路跑稳更重要。

4. 订单、库存与回滚全链路:防超卖只是第一步

4.1 下单流程的整体状态设计

解决超卖不能只看扣减那一刻,还要看订单创建后没支付、坏单、取消等情况怎么处理。通常的完整流程是:

  1. 用户提交下单请求,先过限流、防重、风控。
  2. 请求 Redis Lua 脚本扣减库存。
  3. 扣减成功后创建订单,订单状态为待支付或者已占用库存。
  4. 用户支付成功后,把订单状态改为已支付,同时异步把扣减结果落库到数据库库存表。
  5. 订单超时未支付,关闭订单并回补 Redis 库存。

这里有个关键点:Redis 扣减成功之后,要立刻落库订单,而不是先异步落订单。订单是库存占用的凭证,负责记录一个可审计的数据,如果订单缺失,后面对账根本无从查起。为了应对 Redis 扣减成功但下单接口异常的情况,还需要在异常分支做 Redis 库存回补,并在订单表加一个唯一约束或业务幂等 ID,避免用户重复点击产生多条有效订单。

4.2 同步扣减和异步扣减怎么选

扣减时机大致分两种。同步扣减是用户提交请求的瞬间,把 Redis 和数据库库存一起扣掉,链路简单、数据一致性好,但数据库压力会跟随峰值波动,在高并发场景下容易出现数据库打满的风险。异步扣减是 Redis 先扣,订单创建成功后投放一条消息,由消费端把 Redis 扣减结果同步到 MySQL。异步的好处是主下单链路不碰数据库,能扛更高的并发;坏处是 MySQL 库存存在短暂延迟,对账时得接受“最终一致”的状态。我个人比较推荐线上秒杀优先考虑异步,因为前端看到的抢购结果由 Redis 决定,MySQL 更多是后台统计和发货依据,允许几秒内的同步延迟。

4.3 超时回补必须幂等

超时回补是库存事故的高发区域。很多人只记得订单超时关闭时执行 INCR,却忘了数据库订单取消和手动退款也可能触发回补,同一个订单被补偿两次,Redis 库存越补越多;也可能回补任务并发产生重复消息,最终库存比初始值还高,用户反而抢不到。实际设计必须保证回补动作幂等:在订单状态流转表里记录回补事件,或者给订单增加“库存是否归还”的标志位,回补前先检查这个标志。归还库存时建议先更新订单状态,再 INCR 库存,通过本地事务或者消息任务组件保证事件不丢,同时用消费端的幂等逻辑去除重复消息。

4.4 数据库与 Redis 库存对账别省略

有些人上线 Redis 扣减后,发现 MySQL 库存和 Redis 库存对不上,开始怀疑方案有问题。其实 Redis 库存管“可抢数量”,MySQL 库存管“账实库存”,两者本来就不是同一时刻的镜像。关键是要定好同步口径:Redis 扣减成功且订单创建成功时,发消息去扣 MySQL;订单取消时,发消息去回补 MySQL。为了保证数据不越偏越远,每天还要跑定时对账任务,把一段时间内的订单扣减明细和库存流水做比对,把差异捞出来告警。对账是整个库存方案的兜底,前面链路如果出现细微纰漏,最后全靠这一步发现问题。

5. 我实际踩过的坑和排查经验

5.1 数据库表结构和索引带来的隐患

库存表设计上,stock 字段建议用 int 或 bigint,不要用短整型,否则高并发长期扣减回补后可能出现溢出。条件更新 SQL 的 where 条件最好能精确定位到单行,比如给 sku_id 建索引,避免全表扫描。热点秒杀场景下,如果每次扣减都全表扫,行锁排队会拖着整个库一起变慢。这里不是要求索引设计得多花哨,而是要注意扣减语句本身能不能快速定位到目标行,这是很多人忽略的性能细节。

5.2 Lua 脚本加载和发布的问题

通过 Redis 客户端调用 Lua 时,如果每个请求都发送一串完整脚本字符串,Redis 的网络带宽和 CPU 会白白浪费。比较好的方式是把脚本注册成 SHA,线上用 EVALSHA 调用,只传哈希值,压测的时候这两者差距非常明显。脚本上线也有坑:如果做了版本管理,某天改动了扣减逻辑,但 Redis 里的老 SHA 还被旧代码引用,就会出现新旧两套逻辑并存的情况。建议发布时先注册新脚本,等旧代码全部下线后再清理旧 SHA,避免线上闪断。

5.3 Redis 连接池大小要压测调优

库存扣减链路如果没有连接池,直接新建连接打 Redis,压测时基本都会报连接数异常。连接池太小,请求都排在等待队列里拿不到连接,接口超时;连接池太大,Redis 单实例的 CPU 又被不必要的空闲连接占用。建议压测前先做基准测试,分别测 10 并发、50 并发、100 并发,找到满足业务响应时间的连接池大小,不要一上来就最大连接数跑。线上 Redis 出现毛刺时,先看连接池的等待时间,这个指标往往最能说明问题。

5.4 超卖问题排查速查表

现象重点排查方向处理建议
订单数大于库存是否先查再扣改成条件更新或 Lua 扣减
库存出现负数SELECT 未加锁或回补有误在扣减点加原子防护
订单取消后库存越补越多回补重复触发加幂等状态和唯一事件
Redis 有库存但下单售罄初始化失败或 key 过期检查 Lua 返回值和初始化任务
MySQL 有库存但 Redis 无库存初始化时未同步增加定时对账任务

5.5 上线前一定要做回归实验

我个人的习惯是每次改动库存链路上线前,先起一个模拟抢购脚本,用 200 个并发请求打 100 个库存,断言最后订单数不超过 100。这个实验能快速验证防超卖能力。如果改造用了 Redis Lua,还要在脚本里模拟下单失败回退,确保 Redis 库存只扣不回的问题能被及时暴露。做完这些,心里才有底,不然活动一冲量,后台立刻变成负数的事故现场就又得重演一遍。

最后再多说一句,库存方案并不是越复杂越好。我见过不少团队把 Redis Lua 当成银弹,结果忽略了订单回补和对账,上线照样出事故。正确的顺序应该是先把数据库条件更新跑通,命中了真实瓶颈再加 Redis,然后把回滚幂等链路做完整。不少时候,最基础的方案反而最不容易出错。如果你正准备做下一场抢购活动,可以把我前面列的这套检查思路当清单过一遍,至少能让超卖这个老问题在你这儿少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 6:17:43

读者写者问题:信号量实现、策略对比与避坑指南

1. 读者写者问题到底在解决什么矛盾1.1 从一个真实场景说起你手头有一份配置文件,十个线程要读它,两个线程偶尔要改它。读的时候互不干扰,但只要有一个人在写,其他人不管读还是写都得等着——否则读到的就是半截数据,程…

作者头像 李华
网站建设 2026/10/14 6:17:35

西班牙个人所得税的起征点,具体是多少钱呢?

示意插图 团队在马德里招到第一位员工的那个月,我收到了对方的一封邮件,问:"请问我的 IRPF 预扣比例是多少?今年需不需要做 Renta?“我盯着这两个词看了很久。IRPF 是西班牙的个人所得税,Renta 是年度…

作者头像 李华
网站建设 2026/10/14 6:17:16

TorchAir npu_stream_switch 接口详解:Ascend IR 图内多流并行调度实战指南

人工智能模型编译Ascend 【免费下载链接】torchair TorchAir 支持用户基于PyTorch框架和torch_npu插件在昇腾NPU上使用图模式进行推理。 项目地址: https://gitcode.com/Ascend/torchair 点击查看 免费下载 npu_stream_switch 是 TorchAir 提供的一个 Scope 类上下…

作者头像 李华
网站建设 2026/10/14 6:17:09

DSPE-PEG-FA合成关键技术与靶向药物递送应用

1. 项目背景与核心价值DSPE-PEG-FA这个看似复杂的化学名称,实际上是当前药物递送领域最热门的靶向材料之一。我在实验室第一次合成这个两亲性嵌段共聚物时,就被它精妙的"三位一体"结构所震撼——疏水的DSPE锚定脂质体、柔性的PEG长链提供隐形保…

作者头像 李华
网站建设 2026/10/14 6:16:51

AnyPS5:官方串流+自组网,全屋远程畅玩PS5指南

1. 背景与整体设计思路:为什么叫“AnyPS5”1.1 一个被反复问起的问题先说个实实在在的场景:我在书房里沉迷某款开放世界游戏,客厅的电视被家人占着看剧,游戏机放在客厅,人却在书房。要么不玩,要么搬主机&am…

作者头像 李华
网站建设 2026/10/14 6:16:44

Flask构建域名竞拍系统:技术实现与性能优化

1. 项目概述:域名竞拍系统的业务价值与技术选型域名作为互联网世界的"房地产",其交易市场一直保持着旺盛的需求。传统域名交易平台往往存在手续费高、流程繁琐等问题,而自主搭建的竞拍系统能够为特定用户群体提供更灵活的交易方式。…

作者头像 李华