前段时间面京东,聊到秒杀系统,面试官直接抛了个场景题:库存只有100件,来了10万请求,你怎么设计库存扣减,保证不超卖?说实话这种题在Java面试里出现频率极高,很多同学背过一些理论,真让他把方案、代码、异常场景串起来讲,就容易卡壳。这篇文章我不讲虚的,直接把这套东西拆开揉碎,从超卖产生的根因、常见扣减方案的选型,到Redis+Lua的落地写法,再到回滚补偿和压测排障,全部过一遍。适合正在准备大厂Java面试的同学,也适合做电商、营销活动后端的朋友拿来当设计参考。
1. 秒杀场景为什么总在超卖上翻车
1.1 秒杀业务的核心模型
先定义一个最简单的秒杀模型。商品表里有个库存字段,用户下单要扣库存,订单表记录购买信息。业务上就两个动作:检查库存是否充足,把库存减一。看起来跟普通下单没区别,但秒杀的特点在于瞬间流量巨大,请求集中在同一个热点商品上。
我见过很多系统在第一版上线时直接用数据库扣减,上线当天库存变负数。原因很简单:多个线程同时读到库存为1,都判断“还有货”,然后都去执行扣减,结果卖出了3件甚至更多。这是典型的并发竞态问题,和语言无关,Java、Python写出来都一样。
用代码表示,最原始的写法是这样的:
java复制// 伪代码,存在并发问题
public boolean createOrder(Long goodsId, Long userId) {
Goods goods = goodsMapper.selectById(goodsId);
if (goods.getStock() > 0) {
goodsMapper.reduceStock(goodsId); // stock = stock - 1
orderMapper.insert(new Order(goodsId, userId));
return true;
}
return false;
}
两个线程同时select,都看到stock=1,都进入if,都执行reduceStock,尽管SQL是原子操作,但因为判断和扣减之间不是原子的,超卖就发生了。就算你把判断和扣减合并成一条SQL,也只能解决单次扣减的原子性,后面还有幂等、防重、回滚一堆问题等着你。
1.2 超卖发生的深层原因
超卖的本质是“检查”和“扣减”之间存在时间窗口。很多方案想通过锁来解决,比如Java里的synchronized、ReentrantLock,或者分布式锁。但锁要慎用,synchronized只能锁单机,分布式锁如果实现不好会产生性能瓶颈甚至死锁风险。
从数据库层面看,还有一个很多人忽略的点:默认的隔离级别是REPEATABLE READ。在这个隔离级别下,如果不用SELECT FOR UPDATE,普通的SELECT是快照读,读到的是快照而不是最新数据。所以即使事务A扣减了库存还没提交,事务B依然能读到扣减前的库存。这就是为什么有人明明在数据库层面做了事务,超卖依然发生。
面试官问到这儿,其实是想看你对数据库事务、隔离级别、锁机制的理解深度,而不仅仅是背一个方案。
1.3 把问题定义清楚再谈方案
做技术方案最忌讳一上来就写代码。先把需求量化:预期QPS多少,库存多少,并发峰值多少,允许的失败率多少,是否接受异步。比如库存1000件,峰值QPS 5000,这种量级用Redis+Lua完全没问题;如果峰值QPS几十万,那还得叠加限流、队列削峰、多级缓存。
同时要明确什么叫“不超卖”:已售数量不能大于库存,同一用户不能重复秒杀,超时未支付要释放库存。这三个约束缺一不可。单纯保证“库存不为负”是不够的,重复下单同样会让活动失去公平性,库存也被无效订单占着。
我把这三点整理成了一个检查清单,做方案时逐条对照,基本能覆盖面试中80%的追问点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常见的库存扣减方案,各自的适用边界
2.1 数据库悲观锁:简单但不适合高并发
纯数据库方案里,最直接的是用SELECT FOR UPDATE把库存行锁住,再判断库存并扣减。这种方式逻辑简单,能坚决防止超卖,但代价是性能差。所有秒杀请求都串行排队等这一把行锁,数据库连接被长时间占用,QPS上到几百就很容易出现连接池耗尽。
java复制@Transactional
public boolean createOrder(Long goodsId, Long userId) {
// 锁定库存行,其他事务必须等待
Goods goods = goodsMapper.selectByIdForUpdate(goodsId);
if (goods.getStock() <= 0) {
return false;
}
goodsMapper.reduceStock(goodsId);
orderMapp
