先把结论放在最前面:超卖问题是高并发场景下最典型、也最容易被新手写崩的一个库存一致性问题。我见过太多团队在第一个版本里用“先查库存、再判断、再扣减”这种三段式写法,压测一上立刻超卖,库存从5件变成负数。这篇文章不绕弯子,直接把超卖问题的成因、数据库方案、Redis方案、消息队列方案、以及实际落地时的坑全部讲透,给你一套可以直接抄作业的终极解法。
如果你正准备做秒杀、抢购、限量发售这类业务,或者你的系统已经出现库存对不上的情况,这篇文章值得你从头读到尾。我会把每一步为什么这么做、用什么工具、参数怎么定、踩过什么坑都写清楚,保证新手能看懂,老手也能从中找到几个之前忽略的细节。
1. 先把问题定义清楚:超卖到底是怎么发生的
超卖问题的本质,是并发场景下的资源竞争失控。库存是共享资源,但多个请求同时读到同一个库存值,然后各自判断“还有库存”,再各自扣减,结果实际扣减的数量超过了库存总量。看似是代码逻辑问题,背后其实是并发控制没做对。
1.1 经典事故现场还原
先说一个我早期踩过的例子。一个商品SKU库存只有5件,我做了一个下单接口,核心逻辑是这样的:
java复制Integer stock = getStock(skuId);
if (stock > 0) {
// 模拟业务耗时
Thread.sleep(50);
reduceStock(skuId);
createOrder(userId, skuId);
}
这个逻辑单看没有任何问题,库存大于0就扣减,扣减成功就下单。但一旦并发进来,1000个请求同时读到库存是5,每个请求都判断“大于0”,然后在50毫秒的窗口期内,所有请求都执行了扣减和下单。最终库存变成-30,订单创建了35笔,超卖得极其严重。
这个问题的根源不是库存扣减本身,而是检查和扣减之间不是原子操作。你检查完库存之后,别的线程可能已经修改了库存,但你手里还握着旧值。这就是经典的 check-then-act 竞态条件。
1.2 竞态条件的本质:ABA问题
再往深一层说,这种超卖本质上是ABA问题。线程A读取库存得到5,线程B也读取库存得到5,线程B扣减成功变成4,线程A这时候还拿着5继续扣减,变成了3。但是数据库里真实的库存可能已经是1或者负数。
你可以把这个过程想象成只有一个座位的电影院,两个人同时看到座位是空的,都刷了票,结果两个人都进去了,座位还是只有一个。座位不会因为两个人刷卡就变成两个,但库存如果没做并发控制,就会真的变成负数。
所以解决超卖问题的核心思路只有一个:让“检查库存”和“扣减库存”这两个动作变成原子操作,中间不能插进任何其他请求。谁先拿到执行权,谁就扣减;拿不到的,直接返回失败。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库层的三种经典方案
数据库方案是解决超卖问题最直接、最容易理解的做法,也是很多中小团队的首选。好消息是,只要方案选对,数据库就能从根源上阻断超卖;坏消息是,数据库方案在高并发下性能有限,只适合中低压力场景。
2.1 乐观锁:版本号CAS
第一种方案是乐观锁。给库存表增加一个version字段,每次扣减时带上版本号条件,如果版本号匹配才更新成功,同时版本号加一。
sql复制UPDATE t_sku_stock
SET stock = stock - 1, version = version + 1
WHERE sku_id = #{skuId} AND version = #{version}
这个SQL的执行结果是0行还是1行,就是判断是否扣减成功的依据。如果返回0行,说明版本号已被其他请求修改,当前请求需要重试或者直接返回失败。
乐观锁适合读多写少、并发冲突不严重的场景。因为它在更新的时候才做冲突检测,不需要在整个事务期间持有锁。但问题也很明显:如果并发冲突非常严重,大量请求会因为版本号不匹配而失败,用户看到的就是“手慢了”“库存不足”,体验上不太友好。
2.2 悲观锁:SELECT FOR UPDATE
第二种方案是悲观锁,用数据库的行级锁直接把库存行锁住,锁住期间其他请求无法读取或修改这一行,从根上杜绝了并发访问。
sql复制BEGIN;
SELECT stock FROM t_sku_stock WHERE sku_id = #{skuId} FOR UPDATE;
-- 业务判断 stock > 0
UPDATE t_sku_stock SET stock = stock - 1 WHERE sku_id = #{skuId};
COMMIT;
这个方案很粗暴,也很有效。但代价是性能极差。因为所有请求都要排队等锁,1000个并发请求来,可能只有前几个能立刻执行,后面的全部阻塞等待。如果业务逻辑再复杂一点,锁等待时间会更长,数据库连接池被占满,整个系统可能直接雪崩。
我一般只在库存量极低、并发量可控的场景下推荐悲观锁。比如限量款抽签、线下门店库存扣减这种规模,用悲观锁完全够用,还不用引入额外组件。
2.3 条件更新:UPDATE ... WHERE 的巧劲
第三种方案是SQL条件更新,也是最容易被忽视的一种。把库存判断直接写进UPDATE语句,让数据库在更新时自行校验库存是否充足:
sql复制UPDATE t_sku_stock
SET stock = stock - 1
WHERE sku_id = #{skuId} AND stock > 0
这个方案的精髓在于,数据库的UPDATE操作是原子的,同一时刻只有一个事务能修改同一行数据。即使1000个请求一起执行,数据库也会串行化处理,每个请求执行UPDATE时都会检查当前真实的库存值,库存一旦为0,后面的UPDATE影响行数就为0。
这实际上是把“检查库存”和“扣减库存”合并成了同一个SQL,既不需要版本号,也不需要显式加锁,性能比乐观锁和悲观锁都好不少。我实测过,在单库单表、并发3000以下的场景,这个方案完全能顶住,而且实现成本极低。
不过要提醒一点:这种SQL跳跃更新虽然解决了超卖,但返回值是0行时,你需要查一下当前库存是0还是有货但被其他条件挡住了,避免给出错误的失败提示。
2.4 数据库方案的选择逻辑
三种方案怎么选,我建议这样判断:
| 方案 | 适用场景 | 性能 | 实现复杂度 |
|---|---|---|---|
| 乐观锁 | 并发冲突少,要求最终一致性 | 中 | 低 |
| 悲观锁 | 库存量小,并发极低 | 低 | 低 |
| 条件更新 | 中小型秒杀,并发3000以下 | 中高 | 最低 |
注意:数据库方案无论选哪种,都要把库存扣减和订单创建放在同一个事务里,否则会出现扣减成功但订单没生成的情况,比超卖更麻烦。
3. Redis+Lua:高性能场景的主流解法
数据库方案在中小流量下好使,但到了大促秒杀这种动辄几万并发的场景,数据库扛不住。这时候Redis就该上场了。Redis是单线程的,天然串行执行命令,只要把扣减逻辑交给Redis,就能在超高并发下依然保证原子性。
3.1 库存预热:把库存从数据库搬到Redis
直接用Redis做扣减前,先把库存数据预热到Redis里。所谓预热,就是系统启动时或者活动开始前,把数据库中的库存值同步到Redis的String结构里:
bash复制SET sku:stock:1001 5
这一步看起来简单,但有三个细节容易出问题。第一个是缓存过期时间,如果设置了过期时间,活动没结束库存就没了,用户全看到售罄;如果不设置,又可能因为代码漏洞导致Redis内存里留下大量无用key。我的做法是:活动预热时设置一个活动结束时间的时间戳作为过期时间,同时用定时任务兜底清理;如果活动必须常驻,就不设置过期时间,改为逻辑删除。
第二个问题是缓存和数据库的一致性。预热前必须确保数据库库存是准确的,否则Redis里就是错的。第三个问题是缓存穿透,如果某个SKU库存为0,请求还是会打到Redis和数据库,建议对0库存的key也做缓存,返回前置的“已售罄”状态。
3.2 Lua脚本:Redis原子扣减的核心武器
Redis单命令只能保证单个操作的原子性,但扣减库存往往需要“判断库存是否充足”和“扣减”两个逻辑。如果分成两步执行,中间就可能插入其他请求,超卖又重新出现了。
解决办法是用Lua脚本把两步合并成一步。Redis的Lua脚本是原子执行的,脚本运行期间不会插入任何其他命令。我常用的脚本是这样的:
lua复制local stock = tonumber(redis.call('get', KEYS[1]))
if stock <= 0 then
return 0
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1
这段脚本的逻辑很清晰:先拿到当前库存,如果库存不足就直接返回0,否则扣减库存并返回1。因为脚本是原子的,所以无论多少并发请求来,Redis都会一个接一个地执行脚本,不会出现两个请求同时读到库存为1然后都扣减成功的情况。
在Java里,用Spring Data Redis调用Lua脚本也很方便:
java复制private static final String LUA_SCRIPT =
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock <= 0 then return 0 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return 1";
public boolean deductStock(String skuId, int count) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList("sku:stock:" + skuId),
count
);
return result != null && result == 1;
}
注意:Lua脚本里的KEYS和ARGV不能写死,必须用参数传进去,否则Redis无法正确计算key所在的数据分片,集群模式下直接报错。
3.3 异步同步:Redis扣减成功,数据库最终一致
Redis扣减成功后,库存数据在Redis里已经变了,但数据库还是旧值。接下来需要做异步同步,把扣减结果同步到数据库,保证最终一致性。
最常见的做法是发送MQ消息,由消费端处理数据库库存扣减。每次扣减成功就发送一条消息,消息里带skuId和扣减数量,消费端收到后执行数据库扣减。
这里有个很容易踩的坑:重复消费。MQ在极端情况下会消息重投,消费端必须做幂等处理。最简单的方案是在消息里带上一个唯一流水号,消费前先查询流水表,如果流水号已存在就直接跳过,否则执行扣减并插入流水记录。
异步同步的延迟通常在一秒以内,用户在下单后订单状态可能短暂显示“待支付”,但不影响核心体验。真正需要注意的是:如果MQ消费失败,Redis里的库存和数据库库存就会不一致,需要设计对账补偿任务来兜底。
4. 消息队列:削峰填谷+最终一致的双重保障
Redis+Lua解决了扣减的原子性,但还有一个问题没解决:瞬间高并发直接打到Redis,虽然Redis能扛,但下单之后的业务逻辑——创建订单、扣优惠券、锁库存、发短信——如果全部同步执行,系统照样会被打垮。所以需要消息队列把峰值流量先接住,让后端服务按照自己的节奏慢慢消化。
4.1 削峰填谷:请求先落队列再消费
秒杀接口的完整链路应该是这样的:
- 用户请求进入秒杀接口;
- 接口先调用Redis+Lua扣减库存,扣减失败直接返回“已售罄”;
- 扣减成功后,把下单消息发送到MQ;
- 接口立刻返回“请求成功,正在处理中”;
- 下游消费者从MQ拉取消息,异步完成订单创建。
这样做的好处很明显。假设峰值是每秒1万请求,如果全部直接打到订单服务,订单服务大概率瞬间被打挂;但经过MQ缓冲后,订单服务可以按照自己的处理能力(比如每秒500单)慢慢消费,消息积压不影响用户感知,最终所有请求都能被处理完。
我见过很多团队在这个环节直接把消息队列当摆设,下单逻辑还是同步处理,结果秒杀一开始服务就雪崩。记住:秒杀系统的核心思想就是“能异步的都异步,能水平扩展的绝不垂直堆配置”。
4.2 消息幂等与超时关单
消息队列方案,有两个细节必须处理到位。
第一个是幂等。因为MQ有“at least once”的投递语义,消费端可能收到重复消息。建议在创建订单前先查唯一约束,比如用userId+skuId+活动ID作为唯一键,如果订单已存在就直接返回,不重复创建。
第二个是超时关单。用户下单后可能不支付,如果库存一直锁着,其他用户就买不到。所以要设计一个延迟关单机制:用户在下单消息里带一个过期时间,到达过期时间后如果订单仍未支付,库存要回补。
回补库存时也要走Redis+Lua,不过这次是加库存。要注意回补和支付的竞态:用户在截止时间前一秒支付成功,同时定时任务的关单操作也在执行,这时候需要依靠订单状态字段做判断,只有订单状态是“待支付”时才允许关单和回补库存。
5. 防重与一致性核对:容易被忽略的致命细节
库存扣减做对了,不代表整个下单链路就稳了。还有两个问题会让库存数据变得一塌糊涂:一是同一用户反复下单,二是Redis和数据库长时间不一致没人发现。
5.1 一人一单:用户维度防重
超卖问题不只发生在库存维度,用户维度也会出问题。一个用户如果同时开了10个线程抢同一个商品,库存扣减了10次,用户也生成了10笔订单,虽然从库存角度看没超卖,但业务上算不算恶意刷单?
最简单的防重方案是用Redis的SETNX(set if not exists)命令。用户请求进来时,先执行:
bash复制SET order:user:sku:1001:userId:888 1 NX EX 300
如果返回OK,说明这个用户第一次下单,放行;如果返回空,说明该用户已经在处理中,直接拒绝。等下单完成后,可以在支付成功后再删除这个key,或者在过期后自动释放。
这个方案实现成本极低,但能挡住99%的重复下单。注意:key的过期时间不能太短,否则用户还没支付完就能重新下单;也不能太长,否则占用的Redis内存会越来越多。我一般设置为订单超时时间+10秒。
5.2 缓存与数据库的双向对账
前面提到,Redis扣减成功、数据库异步扣减,最终一致性。但万一MQ消息丢失怎么办?万一消费端代码出bug怎么办?Redis里的库存是负的,数据库却还是正数,一查一个准。
所以必须设计一个对账任务。常见的做法是:每分钟跑一次定时任务,扫描所有参与秒杀的活动,把Redis里的库存值和数据库库存值做对比。如果不一致,以数据库为准回滚Redis,或者以Redis流水为准补齐数据库。
我踩过最大的坑是只做了单向核对,即只核对Redis扣减和数据库扣减是否一致,忽略了用户退款导致的库存回补。后来我把对账逻辑扩展成双向:下单扣减核对一条线,退款回补核对另一条线,两边都对了才敢收工。
5.3 库存流水表:出问题时的唯一救命稻草
无论方案设计得多完美,线上总会有意外。这时候唯一能帮你排查真相的就是库存流水表。
流水表至少要有这些字段:id、sku_id、user_id、order_id、change_type(扣减/回补)、change_count(变更数量)、before_stock(变更前库存)、after_stock(变更后库存)、create_time。
每次库存变更,无论来自下单、取消还是退款,都要记录一条流水。这样如果库存对不上,你可以通过流水表完整还原每个SKU的库存变更轨迹,定位是哪一笔操作出了问题。
流水表的写入是高频操作,建议单独建库或者归档老数据,别和核心交易表放在一起,否则表膨胀之后查询越来越慢。
6. 常见问题与排查技巧实录
最后这部分,我把自己和身边朋友在实际项目中遇到的高频问题整理成一个速查表。这些问题看起来都不起眼,但每一个都可能导致库存数据错乱。
6.1 问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 库存扣减成功但订单没生成 | 事务边界错误,扣减和下单不在同一事务 | 检查事务注解是否生效,检查日志中是否有异常 | 把两步操作放到同一事务里,或者用MQ补偿 |
| 数据库扣减失败,Redis已扣成功 | MQ消息丢失或消费失败 | 查MQ控制台的消费情况,查消费者日志 | 增加对账任务,以数据库为准回滚Redis |
| 同一用户重复下单成功 | 防重key过期时间太短,或防重逻辑没加在最外层 | 审查接口代码,看防重key的写入顺序 | 把SETNX放在接口入口处,过期时间设为订单超时+10秒 |
| 定时任务回补库存时重复回补 | 回补没有做幂等 | 查看回补任务日志,确认是否重复执行 | 回补前查唯一约束,或者用Redis分布式锁控制 |
| 库存明明有货,但用户看到售罄 | Redis key过期或缓存被提前清除 | 查看缓存过期时间配置 | 预热时不设置过期时间,用逻辑删除 |
| 数据库库存变成负数 | 使用了先查后扣模式,未做并发控制 | 审查SQL,确认是否走了条件更新 | 改用UPDATE WHERE stock > 0,或者切换Redis方案 |
6.2 实操中的独家避坑技巧
第一个技巧:库存字段一定要设置为无符号整数或有CHECK约束。即使逻辑出了bug,数据库层面也能拦一道,不让库存变成负数。虽然这样做不能根治问题,但能把损失控制在可追溯的范围内。
第二个技巧:给秒杀接口加一个前置的流量控制。在进入业务逻辑之前,先用Sentinel或 Resilience4j做并发限流,超过阈值的请求直接返回“排队中”。这样即使Redis和MQ再稳,也不会被无脑流量打爆。
第三个技巧:上线前务必做压测,但压测不要只看最终库存是否正确,还要关注扣减接口的响应时间分布。我见过一个系统库存不超卖,但P99响应时间到了3秒,用户体验极其糟糕。后来查出来是数据库的悲观锁排队太久,换成Redis方案后响应时间降到50毫秒以内。
第四个技巧:把库存预热和对账任务做成独立的模块,不要和业务代码耦合在同一批发布里。库存预热和数据核对是运维操作,业务发布不该影响它们。我现在的项目里,预热和对账都是单独的定时任务,用配置中心控制开关,出问题可以随时手动触发。
写在最后的一点点个人体会
超卖问题看起来是技术题,实际上是对系统设计思路的全面考量——并发控制、原子性、最终一致性、幂等、削峰、防刷,环环相扣。我做过的项目里,最早都是用数据库条件更新,后来流量大了切到Redis+Lua,再后来引入MQ做全链路异步,每走一步都是被线上故障逼出来的。
如果你现在面临超卖问题,我的建议是不要一步到位直接上最复杂的方案。先把数据库的条件更新用对,确认数据一致,然后根据压测数据决定要不要引入Redis和MQ。架构是为业务服务的,够用就好,过度设计只会增加维护成本。先把基础方案磨到极致,再在它撑不住的时候升级,这条路最稳妥。
