1. 超卖问题的本质与危害到底有多大
干过电商后端的人,基本都碰到过这种灵异事件:明明后台库存只剩下 10 件,下单接口被压测一跑,订单表里哗啦啦生成了 20 多笔待支付订单。库存数据变成了负数,财务对账的时候直接傻眼。这就是典型的超卖问题——并发场景下,多个请求同时读到剩余库存大于 0,然后同时扣减,最终卖出去的数量超过真实库存。
先把这个问题的严重性说透。表面上超卖只是数据不一致,实际影响是连环的:用户下单成功后迟迟不发货,投诉电话被打爆;平台被迫对超卖订单做强制取消或补偿,资金损失和补偿成本直接吃掉利润;库存系统的数据错误还会牵连采购、仓储、财务多个环节的对账工作。一个超卖 bug,足以让一次大促变成一次事故复盘大会。
从技术角度看,超卖的本质是竞态条件(Race Condition)。数据库事务的隔离级别、应用层的并发控制策略、分布式环境下多个节点同时操作同一份库存数据,任何一个环节没有锁好或者没有原子化处理,库存就会失衡。很多团队在业务早期并不当回事,等到用户量上来、并发一高,才被超卖问题按在地上摩擦。
这个内容适合谁看?后端开发、系统架构师,以及负责订单和库存系统的技术负责人。如果你正在设计秒杀系统、抢购活动,或者想优化现有库存扣减逻辑,这篇文章会帮你把思路彻底理清。下面我会从数据库层到缓存层,从悲观锁到乐观锁再到 Lua 脚本,一层层拆解超卖问题的解决方案,每一套方案我都会把适用场景、代码逻辑和缺点短板讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库层的经典防线:条件更新与行锁
2.1 超卖是怎么被代码写出来的
先看最典型的错误写法。很多新手码农会这样实现库存扣减:
java复制public boolean decreaseStock(Long skuId, Integer num) {
// 第一步:查询当前库存
Stock stock = stockMapper.selectBySkuId(skuId);
if (stock.getStock() < num) {
throw new BusinessException("库存不足");
}
// 第二步:扣减库存
stockMapper.decrease(skuId, num);
return true;
}
这里的问题一眼就能看出来:查询库存和扣减库存是两个独立的数据库操作,中间隔着一个应用层判断。并发请求 A 和 B 同时执行第一步查询,都读到库存剩 5 件,都满足购买 5 件的条件,然后都执行扣减,库存就直接变负数了。哪怕你把查询和扣减放在同一个事务里,只要没有加锁或者没有条件约束,数据库默认的读操作不会阻塞,问题依旧存在。
另一种常见错误是纯靠前端按钮防抖、或者靠 Redis 预减库存来限制下单。前端防抖只能挡普通用户连点,挡不住脚本刷接口;Redis 预减库存如果不同步扣减数据库,也会出现 Redis 说还有库存、数据库却已经卖完的窘境。这些都是表面功夫,根子上的问题还是"检查与扣减没有原子化"。
2.2 条件更新:一条 SQL 解决 90% 的秒杀场景
既然问题是检查与扣减分离,那把两步合成一步就行了。数据库里最朴素也最有效的写法是条件更新 SQL:
sql复制UPDATE stock_table
SET stock = stock - #{num}
WHERE sku_id = #{skuId}
AND stock >= #{num};
这条 SQL 的魅力在于,UPDATE 语句在数据库引擎层面是带行锁的。当两个并发请求同时执行这条 SQL 时,第一个请求拿到行锁,扣减成功后提交;第二个请求等待行锁释放后执行,此时stock已经变成 5 件,如果 5 小于购买数量,stock >= num的条件不成立,影响行数为 0,应用层据此判定库存不足即可。
这是我认为在生产环境中性价比最高的方案。秒杀场景、普通下单场景、限购场景都可以用。它的核心思想就是"让数据库帮你做原子判断",应用层不再需要先查再判断再修改,彻底消除竞态窗口。对于中小体量的系统,单靠这条 SQL 就能扛住相当可观的并发量,完全没必要一开始就上分布式锁那套重武器。
用 MyBatis 写的话,大概长这样:
java复制@Update("UPDATE stock_table SET stock = stock - #{num} " +
"WHERE sku_id = #{skuId} AND stock >= #{num}")
int deductStock(@Param("skuId") Long skuId, @Param("num") Integer num);
// 业务层判断返回值
int affected = stockMapper.deductStock(skuId, num);
if (affected == 0) {
throw new BusinessException("库存不足");
}
这里有几个细节必须注意。第一,stock = stock - #{num}这种写法是让数据库在行内做原子运算,不要在应用层先查出来再减,那样又回到老路了。第二,条件里必须带上stock >= #{num},这是保证不扣成负数的关键。第三,如果你的库存表经常有批量更新或者锁竞争激烈,行锁等待可能造成超时,这时要关注数据库的innodb_lock_wait_timeout配置。
2.3 SELECT FOR UPDATE:显式行锁的正确打开方式
条件更新适合扣减逻辑简单的场景,但有些业务很复杂——不是单纯的扣减库存,而是先要检查多个条件(比如库存、限购数量、用户黑名单),再决定是否扣减。这种时候,一条条件更新 SQL 就不够用了,你需要显式地锁行。
sql复制SELECT stock FROM stock_table
WHERE sku_id = #{skuId}
FOR UPDATE;
这条 SQL 会给匹配的行加上排他锁。其他事务想要更新这一行时会被阻塞,直到当前事务提交或回滚。把查询库存、业务校验、扣减库存都放进同一个事务里,前面加了FOR UPDATE,后面再查询就总是能读到最新的未提交前的数据,因为其他事务被锁挡在外面了。
使用这种方案有几个硬性要求:必须是 InnoDB 引擎(MyISAM 没有行锁);查询条件必须命中索引,否则行锁会升级为表锁,并发能力大打折扣;整个事务过程要尽量短,锁的时间越短,系统吞吐量越高。另外,FOR UPDATE一定要放在事务里,如果事务忘了提交,锁会一直持有直到连接超时,那可比超卖可怕多了——直接打挂整个库。
从实际体验看,条件更新方案在低并发场景足够用,FOR UPDATE在条件复杂、必须锁住行做多步校验的场景更顺手。它们的共同缺点是都依赖数据库连接,并发极高时数据库本身会成为瓶颈,这时候就需要往上加一层缓存和限流了。
3. 乐观锁与版本号机制:不锁行也能防超卖
3.1 版本号字段的巧妙设计
乐观锁的思路和悲观锁完全相反。悲观锁是"我怀疑你会改数据,所以先锁住看住你";乐观锁是"我默认你不改数据,只在更新的时候核对一下"。用在库存扣减上,就是给库存表加一个version字段,每次扣减前先查版本号,扣减时把版本号作为更新条件之一。
sql复制-- 第一步:查询库存和版本号
SELECT stock, version FROM stock_table WHERE sku_id = #{skuId};
-- 第二步:扣减时带上版本号条件
UPDATE stock_table
SET stock = stock - #{num}, version = version + 1
WHERE sku_id = #{skuId}
AND version = #{oldVersion};
如果两个并发请求同时读到版本号 1,第一个请求扣减成功后把版本号改成 2;第二个请求执行更新时,version = 1这个条件已经匹配不上了,影响行数为 0,应用层重新读取最新数据再做处理。这样就在不加行锁的情况下实现了并发控制。
这种方案的适用场景很有意思:库存充足、并发冲突概率低的系统最合适。比如一个日常电商平台的普通商品,用户同时下单的概率不高,用版本号机制既能保证数据一致性,又能避免悲观锁带来的数据库连接长时间占用。书籍、日用百货这类长尾商品,用乐观锁完全够用。
但如果是秒杀、限量发售这种热点商品,乐观锁的短板就暴露了:冲突率高,大量请求会在版本号匹配这一步失败,用户体验就是一直提示"库存不足"或者"请重试"。而且每次失败后的重试逻辑如果写得不好,会给数据库带来额外的读压力。
3.2 条件更新和版本号哪个更推荐
经常有同行问我这个问题,我的回答是:两者都不是互斥关系,而是看你的业务形态。条件更新stock >= num直接利用行内值做比较,适合"库存够不够、卖完没有"这种数值型判断;版本号机制适合"有没有人动过这行数据"这种状态型判断。
再搞一个对比:
| 方案 | 核心逻辑 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 条件更新 | 在 SQL 中加库存足量判断 | 简单高效,无需额外字段 | 只能处理单表简单扣减 | 秒杀、抢购、限购 |
| 版本号 | 每次更新比对版本号 | 通用性强,适合复杂校验 | 冲突率高时体验差 | 低并发、写冲突少的业务 |
| FOR UPDATE | 显式锁定查询行 | 可做多步校验 | 锁时间长,并发瓶颈 | 复杂事务、规则校验多 |
我个人的实践经验是:99% 的场景直接用条件更新就行,它把"判断"和"扣减"压进同一个数据库原子操作里,写起来最省心,排查问题也容易。版本号机制更多像是乐观锁思想的通用实现,在库存扣减这种高频写场景里,反而不如条件更新来得直接。所以别纠结,先看业务复杂度和并发量再定。
4. Redis 层的大杀器:分布式锁与 Lua 脚本原子扣减
4.1 传统的 Redis SETNX 锁为什么不够用
单机系统的库存扣减做到数据库这层就够了,但到了多实例部署的微服务架构,问题就变了。应用服务器有三台,三台同时执行数据库更新 SQL,数据库那边用行锁还是能保证一致性,但前提是数据库扛得住。当秒杀流量把所有请求打到数据库,行锁竞争让数据库的 QPS 直线下降,数据库连接池被打满,整个订单系统跟着瘫痪。
这时候需要在数据库前面加一层流量过滤,最常用的手段就是 Redis 分布式锁 + 预减库存。用 Redis 的原子操作先挡住大部分请求,只有拿到锁或者预减成功的请求才放行进数据库,让数据库承受的并发量从万级降到百级。
很多人第一反应就是 SETNX 分布式锁:
java复制Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId);
if (locked) {
// 执行业务逻辑
// 释放锁
redisTemplate.delete(lockKey);
}
这段代码在低并发下貌似没问题,但仔细一想全是坑:setIfAbsent 和delete之间如果业务抛异常,锁永远不会释放;A 请求的锁还没释放就被 B 请求误删了(因为 delete 只认 key 不认 owner);锁没有过期时间,服务宕机直接死锁。这些坑导致很多团队的分布式锁形同虚设,超卖问题依旧。
4.2 正确姿势:SETNX + EXPIRE 原子设置,Lua 脚本释放锁
一套在生产环境验证过的锁做法是这样的。加锁时用一条命令同时设置值、过期时间和持有者标识:
bash复制SET lock:sku_1001 requestId NX EX 30
NX 表示只有当 key 不存在时才设置成功,EX 30 表示 30 秒后自动过期。这条命令是原子性的,不会出现 SETNX 成功但 EXPIRE 失败的中间状态。requestId 用 UUID 之类的唯一标识,目的是释放锁的时候确认"只有锁的持有者才能删锁"。
释放锁时不能用简单的 delete key,需要用 Lua 脚本保证"先比对持有者,再删除"是一个原子操作:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
这段脚本的意思是:先看这个锁是不是自己的(比较 ARGV[1] 和存储的 requestId),是自己的才删,不是自己的说明锁已经过期被别人抢走了,不能乱删。Redis 执行 Lua 脚本是原子的,等于把这套比对和删除操作变成了一条不可分割的命令。
不过分布式锁只是第一层过滤,真正要在 Redis 层面直接做库存扣减,还得靠 Lua 脚本。下面这个脚本是扣减库存的标准玩法:
lua复制-- KEYS[1] 是库存 key,ARGV[1] 是扣减数量
local stock = tonumber(redis.call('get', KEYS[1]))
if not stock then
return -1 -- 库存 key 不存在
end
if stock < tonumber(ARGV[1]) then
return -2 -- 库存不足
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1 -- 扣减成功
这段脚本保证了"读取库存、判断库存、扣减库存"三步的原子性。多个应用实例同时来调用,Redis 单线程执行特性保证同一时刻只有一个请求在执行脚本,后面的请求排队等待。这样在 Redis 层面就不会出现超卖,只有脚本执行成功的请求才会继续往下走。
用 Java 调用 Lua 脚本,Redisson 和 Jedis 都支持,代码类似这样:
java复制DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("stock_deduct.lua")));
script.setResultType(Long.class);
Long result = redisTemplate.execute(script,
Arrays.asList("stock:sku:" + skuId),
String.valueOf(num));
4.3 异步削峰:消息队列兜底数据库扣减
到了这一步,Redis 已经帮你挡住了大部分流量,但还剩一个问题:Redis 扣减成功 ≠ 数据库扣减成功。万一 Redis 和数据库之间的数据不一致,比如数据库扣减失败、或者服务在中间宕机了,Redis 里的库存就会虚低,造成明明有货却不能买的误杀,或者更严重的——Redis 扣减成功但数据库那边没扣,卖出去的和库存对不上。
所以正确的架构是三层配合:Redis 预扣减(快速过滤流量) + 执行订单业务(写订单、生成支付单) + 异步消息扣减数据库库存。用消息队列做最终一致性,比如把"库存扣减"这个动作发到 RocketMQ 或 RabbitMQ,消费者处理数据库扣减,失败就重试,重试多次失败就告警人工介入。
消息队列的好处是削峰填谷。秒杀瞬间可能涌入 10 万个请求,如果把 10 万笔数据库库存扣减全部同步执行,数据库必挂。把请求先放进队列,消费者按照数据库能承受的速度慢慢处理,既保证不超卖,又保证系统不崩。这样做还有一个附带好处——用户看到下单成功的瞬间,实际订单状态可以在写订单表之前先返回"排队中",体验上更接近真实秒杀场景。
5. 多级防护组合拳:秒杀场景的完整方案复盘
5.1 从用户到数据库的四层防线
真正的高并发秒杀场景,任何单一方案都不够。我的做法是四层防线逐层拦截:
第一层,前端/网关限流。把恶意脚本、羊毛党挡在门外,每用户每秒最多请求一次下单接口,同一 IP 限制连接数。这一层不做库存判断,只做流量整形,不让无效请求打到后面任何系统。
第二层,Redis Lua 脚本预扣减。这一层负责真正的流量过滤,只有预扣减成功的请求才有资格进入订单创建流程。预扣减失败的请求直接返回"手慢了,商品已抢完"。
第三层,订单业务逻辑。生成订单号、校验用户限购数量、写订单表。这里可以再做一次用户维度的防重复下单判断,用 Redis 记录"该用户是否已购买此商品",防止恶意重复下单。
第四层,数据库扣减库存。用前面说的条件更新 SQL 扣减库存,并且在这个环节通过事务保证订单记录和库存扣减的一致性(订单表插入成功,库存扣减失败则回滚)。
我把每个环节的核心要点整理成一张速查表,方便直接抄作业:
| 层级 | 核心手段 | 作用 | 注意点 |
|---|---|---|---|
| 网关层 | 限流、IP 黑名单 | 拦截异常流量 | 不要在这一层做库存判断 |
| Redis 层 | Lua 预扣减 | 高频流量过滤 | 注意 key 过期时间和库存预热 |
| 应用层 | 订单创建、限购校验 | 业务幂等控制 | 用唯一订单号或用户 ID 做幂等 |
| 数据库层 | 条件更新扣库存 | 最终一致性保证 | 事务超时要监控 |
5.2 库存预热与 Redis 数据一致性
既然要在 Redis 层做预扣减,那 Redis 里的库存数据是怎么来的?肯定不能在秒杀开始的那一刻才初始化,那样根本来不及。常规做法是活动开始前先做库存预热:把数据库中的库存量同步到 Redis 的 key 上,比如stock:sku_1001 = 100,然后在秒杀期间所有扣减都走 Redis。
活动结束后,Redis 里的剩余库存要回写数据库,同时把 Redis 中被扣减的量和数据库实际扣减的量做对账。对账逻辑很简单:活动期间 Redis 累计扣减了多少,数据库就应该扣减多少。如果对不上,就要查日志看是消息队列丢了消息、还是消费者重试失败。
Redis 里面的库存 key 要设置合理的过期时间,避免长时间占用内存。比如活动结束后 10 分钟自动清理。这里有一个常见坑:如果 Redis key 过期了,而数据库库存还没扣减完,Lua 脚本会返回 -1(key 不存在),正确的业务处理是触发库存回查——从数据库重新加载库存到 Redis,再重试扣减。这个补偿逻辑必须写在业务代码里,否则会出现活动进行中突然全部提示"库存不存在"的故障。
5.3 防超卖必查清单:这几个细节最容易翻车
做库存系统这几年,我踩过不少坑,有些问题排查起来特别费劲。整理成清单,大家开发的时候逐条对照,能省很多时间。
第一,数据库连接池大小。 条件更新 SQL 虽然快,但如果数据库连接池只有默认的 10 个连接,秒杀流量一来,全部请求都在等连接,接口 RT 直接飙升。建议单独给库存扣减服务配置独立的连接池,数量根据压测结果调整,一般 50 到 100 看着办。
第二,事务粒度宁小勿大。 扣库存的事务里只放必须的操作:更新库存表、插入订单表、更新用户订单状态。不要把远程调用、消息发送放在事务里,否则事务时间拉长,锁持有时间变长,并发能力直线下降。消息发送应该放在事务提交之后,用事务同步器或者最终一致性的方案。
第三,库存扣减失败的重试要注意幂等。 消费者从消息队列取到扣减任务,执行失败重试一次没问题,但如果消费者已经扣减成功、却在回执给队列时超时,消息被重新投递,就会重复扣减。解决方法是给每条消息带上唯一的业务订单号,扣减 SQL 里带上"订单号不存在才扣减"的条件,或者在库存流水表里做唯一索引。
第四,下单接口要做用户维度限购。 超卖问题不只是库存并发问题,还有恶意用户批量下单的问题。一个用户通过多个账号或者脚本疯狂下单,库存被锁定了很多,但订单迟迟不支付,真正想买的人买不到。所以除了库存扣减之外,还要配合限购策略:同一用户同一商品限购 N 件,创建订单后限时支付,超时自动取消释放库存。
第五,压测是检验防超卖方案的唯一标准。 写完代码别急着上线,先模拟并发场景压测。压测工具用 JMeter 或者 wrk 都行,把并发量从 100 到 500 到 1000 递进测试,观察数据库连接池、Redis 内存、接口响应时间三个指标。如果压测时数据库连接数接近饱和,就要考虑把预扣减这层做得更厚一点,多拦截一些流量。
5.4 实操心得:这些代码习惯能帮你少写一堆 Bug
写防超卖相关的代码,我有几个执念般的习惯,分享给你。
一个是所有库存操作必须写操作日志。库存表加一张流水明细表,每次扣减库存时,同时记录一条流水:skuId、扣减数量、订单号、操作时间、操作来源。这笔账不能省,它不仅是排查超卖问题的关键线索,也是做对账和数据审计的基础。
另一个是接口的返回信息要友好。库存不足、扣减失败、重复下单,这几个业务异常要用不同的错误码区分,不要把数据库异常直接抛给用户。错误码分清楚之后,监控系统也能精准告警:某个错误码飙升,说明对应环节出了问题。
还有一个是关于锁超时时间的设置。业务处理耗时超过锁的过期时间,锁会自动释放,其他请求就能进入,这会造成并发穿透。解决方式是给锁续期,比如 Redisson 的看门狗机制会自动续期。如果你是自己手写的分布式锁,建议把锁的过期时间设置成业务耗时的 5 到 10 倍,宁可锁多占一会儿,也不要锁提前释放导致超卖。
6. 常见问题排查实录:那些让人头皮发麻的瞬间
6.1 案例一:库存没扣成负数,订单却比库存多
现象:数据库库存是 0,订单流水显示卖出了 50 件,但库存变更记录只扣了 5 次。
排查思路:先查库存流水表,发现 5 次扣减是正常的,但订单创建的数量远大于扣减数量。再看代码,原来是订单服务和库存扣减服务不在同一个事务里,订单服务先写订单表,然后调用库存扣减接口。库存扣减接口因为网络抖动失败了,但订单已经创建成功。这就是典型的分布式事务问题:订单和库存的数据一致性没有被保证。
解决办法:把扣库存改成事务消息模式。订单创建成功后发一条可靠消息,库存服务消费消息后扣减库存,扣减失败就重试。还有一种更简单的做法:下单事务里同时写订单表和库存扣减流水,流水表带订单号唯一索引,重复执行会报冲突,靠数据库约束保证不重复扣。
6.2 案例二:Redis 还有库存,数据库已扣完
现象:用户看到秒杀页面显示还剩 5 件,点击购买却提示"已售罄"。
排查思路:Redis 预扣减成功后才放行到数据库扣减,按理说 Redis 库存不会大于数据库剩余可卖数量。出现这种不一致,极可能是 Redis 的库存 key 在活动期间被重置了。比如运维重启 Redis 导致数据丢失,或者库存 key 设置了过期时间,活动还没结束 key 就过期了,Lua 脚本返回 -1,但页面上的库存数据是从另一个缓存 key 读的,两边不一致。
解决办法:库存 key 的过期时间要设置得足够长,比如活动结束时间再加 24 小时;同时给 Redis 加持久化配置,避免重启丢数据。页面展示库存的 key 和扣减库存的 key 必须共用同一个,不要搞两套数据。
6.3 案例三:分布式锁偶发失效,超卖又复现
现象:上了分布式锁之后,压测时仍有少量超卖。
排查思路:扒日志发现加锁成功和释放锁之间,业务耗时超过了锁的过期时间,锁提前释放。第二个请求接着拿到了锁,和第一个请求同时操作库存,竞态条件复现。另一个可能的原因是 Redisson 的看门狗没有生效,检查配置发现锁释放操作写在了 finally 块里,但业务逻辑是在子线程中执行的,看门狗只对当前线程续期,子线程的耗时没有被统计进去。
解决办法:分布式锁的过期时间要压测后合理设置,同时业务逻辑里不要开异步子线程处理核心库存操作。如果要用 Redisson,设置锁的 leaseTime 为 -1 开启看门狗自动续期,它会每 10 秒检查一次锁是否还持有,持有就续期到 30 秒,直到业务执行完毕释放锁。
6.4 常见问题速查表
| 故障现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 库存扣成负数 | check 与 update 分离 | 查代码是否先查后改 | 用条件更新 SQL |
| 订单比库存多 | 订单与库存事务不一致 | 查事务边界 | 可靠消息或同库事务 |
| 接口提示已售罄但页面有库存 | Redis key 不一致 | 查页面缓存 key | 统一库存 key 和过期时间 |
| 分布式锁偶发失效 | 业务超时锁提前释放 | 查锁过期时间 | 看门狗续期或加长过期时间 |
| 数据库连接耗尽 | 连接池配置过小 | 查监控连接池指标 | 扩大连接池并削峰 |
结尾:最后分享一点压测踩坑后总结出的经验
防超卖方案做得再漂亮,不经过高并发压测都是纸上谈兵。我见过不少团队上线前信心满满,大促当天被超卖打脸的案例。我的建议是:压测时不要只盯着接口平均响应时间,要多关注两个指标——数据库行锁等待时间和 Redis 脚本执行 QPS。行锁等待时间一涨,说明条件更新 SQL 的锁竞争已经很激烈,这时候要么提升数据库配置,要么把更多流量拦截在 Redis 层。Redis 脚本执行 QPS 如果接近 Redis 单实例的性能上限,就要考虑用集群模式把库存 key 打散到多个节点,或者提前把热点 sku 的消息队列消费者扩容。
另外一个被很多人忽略的细节是监控告警。上线之后,库存变成负数、库存扣减失败次数、预扣减失败次数,这三个指标要单独配置告警规则,阈值设低一点,宁可误报也不要漏报。超卖问题的发现时间每早一分钟,挽回的损失可能就是一个数量级的差别。
库存系统不复杂,但坑真的很多。把上面这些方案吃透,遇到具体的业务场景就知道该用什么组合,不会一上来就 Redis 加 MQ 一顿上,结果数据库那层漏洞没补,照样出乱子。希望这篇文章能帮你在超卖问题上少走几个月的弯路。
