前段时间面京东,聊到秒杀系统,面试官直接抛了个场景题:库存只有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);
orderMapper.insert(new Order(goodsId, userId));
return true;
}
这个方案放在低并发场景比如后台手工补单是可以的,但秒杀这种高并发场景,它会在数据库层直接形成瓶颈。如果你在面试里答这个方案,一定要补充说明它的性能和并发上限问题,否则面试官会认为你缺乏实战经验。
2.2 数据库乐观锁:能解决但不优雅
乐观锁的思路是给库存表加一个version字段,更新时校验版本号。如果版本不匹配,说明数据被其他事务改过,本次更新失败。代码大概是:
java复制@Transactional
public boolean createOrder(Long goodsId, Long userId) {
int count = goodsMapper.reduceStockByVersion(goodsId, 1);
if (count == 0) {
return false; // 版本冲突或库存不足,重试或失败
}
orderMapper.insert(new Order(goodsId, userId));
return true;
}
这条SQL类似:
sql复制UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = #{goodsId} AND stock > 0 AND version = #{version}
用stock > 0作为条件,实际上已经解决了超卖的核心问题:数据库层面的条件更新是原子的。就算1万个请求同时执行这条UPDATE,最终也只有库存数量的请求能成功。
但乐观锁在高并发下有个明显的副作用:冲突概率高,失败的请求需要重试,大量无效请求打到数据库。而且它没有解决热点行竞争的问题,库存行依然是全局唯一的写入热点。
2.3 Redis预扣减:高并发场景的主流思路
把库存预扣放在Redis里,利用Redis单线程模型和原子操作天然规避并发问题。Redis的INCR/DECR本身就是原子的,但直接用DECR有一个问题:库存扣到负数怎么办。需要组合判断和扣减的原子性,就得用Lua脚本。
整体架构大致是这样的:活动开始前把库存加载到Redis;用户请求进来,先用限流组件挡掉大部分流量;接着通过Lua脚本判断用户是否已购买、库存是否充足,满足条件就扣减库存并标记用户;扣减成功后发MQ消息异步创建订单;数据库在后台消费消息,完成最终的订单落库和数据核对。
为什么用Redis而不是数据库?关键在于Redis是单线程处理命令,天然串行,不会出现两个请求同时读到相同库存的问题。而且Redis的QPS可以轻松达到10万以上,远高于数据库能承受的写入能力。面试时你要讲清楚这个量化对比,而不是只说“Redis快”。
2.4 方案对比:没有银弹,只有合适不合适
我做了一张表,对比几种方案的核心差异:
| 方案 | 并发能力 | 数据一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 低(几百QPS封顶) | 强 | 低 | 后台管理、低并发内部系统 |
| 数据库乐观锁 | 中(上千QPS) | 强 | 中 | 普通秒杀,库存量很小 |
| Redis原子扣减+Lua | 高(上万QPS) | 最终一致 | 较高 | 大促秒杀、高并发活动 |
| Redis + MQ异步落库 | 高 | 最终一致,需补偿 | 高 | 企业级秒杀标准方案 |
面试官听到你能把各个方案的边界说清楚,比听到你只会背一个方案要加分得多。实际工作里也没有哪个方案是万能的,核心是知道自己的业务量级,以及愿意接受哪种一致性级别。
3. 实操:Redis+Lua库存扣减的完整实现
3.1 架构流程和Redis中的数据结构设计
秒杀请求的完整链路我一般是这么设计的:
- 第一步:接入层限流,比如基于Sentinel或自研计数器,挡住瞬时流量峰值;
- 第二步:校验用户是否登录、活动是否开始、是否在白名单内;
- 第三步:执行Lua脚本扣减Redis库存,同时记录已秒杀用户;
- 第四步:扣减成功则发送MQ消息,进行异步订单创建和数据库库存扣减;
- 第五步:定期对账,处理Redis和数据库之间的数据不一致。
Redis里需要两个核心key:一个用来存剩余库存,一个用来存已秒杀成功的用户标识。之所以用Set存用户ID,是为了配合Lua脚本做防重判断。过期时间要设置成比活动时间长一些,比如活动结束后一天,防止活动期间key过期导致数据错乱。
我习惯用这样的key设计:
seckill:stock:{goodsId},类型String,存储剩余库存;seckill:users:{goodsId},类型Set,存储已成功抢购的用户ID。
库存预热的时候,直接set一个初始值,比如1000。要注意:预热之前必须保证没有历史残留数据,否则会出现库存和实际不一致。
3.2 扣减脚本的写法与返回值设计
Lua脚本是整个扣减逻辑的核心。它要做的事包括:判断用户是否已购买、判断库存是否充足、执行扣减、记录用户。四个操作在同一个脚本里,保证原子性,不会出现扣了库存但没标记用户之类的中间状态。
lua复制-- KEYS[1]: 库存key
-- KEYS[2]: 用户set key
-- ARGV[1]: 用户ID
-- ARGV[2]: 购买数量,默认1
local stock = tonumber(redis.call('get', KEYS[1]) or '0')
if stock < tonumber(ARGV[2]) then
return -1 -- 库存不足
end
local isMember = redis.call('sismember', KEYS[2], ARGV[1])
if isMember == 1 then
return -2 -- 已购买,防重
end
redis.call('decrby', KEYS[1], ARGV[2])
redis.call('sadd', KEYS[2], ARGV[1])
return 1 -- 扣减成功
这里有几个细节值得展开说。第一个细节,库存用tonumber转换,避免从Redis取到nil时直接比较出错。第二个细节,先查用户是否已购买,再查库存,顺序有讲究:防重判断更优先,避免已购买用户反复消耗库存查询性能。第三个细节,返回值用负数区分失败原因,可以精确告诉上层“是没货了还是重复购买了”,方便做不同的用户提示。
曾经有同事问我为什么不用两个Redis命令分开做,先判断再扣减。这里的关键在于:分开执行的话,两个命令之间可能有其他请求插进来,导致超卖。Lua脚本在Redis里是原子执行的,整个脚本执行期间不会插入其他命令,这才保证了判断和扣减之间没有缝隙。
3.3 Spring Boot集成Lua脚本的Java代码
有了脚本,Java端的调用就简单了。常规做法是在项目启动时加载Lua脚本,用DefaultRedisScript封装,后续通过RedisTemplate的执行方法调用。
java复制@Component
public class SeckillStockService {
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Autowired
private StringRedisTemplate stringRedisTemplate;
public SeckillResult doSeckill(Long goodsId, Long userId) {
// 限流校验
// 活动时间校验
List<String> keys = Arrays.asList(
"seckill:stock:" + goodsId,
"seckill:users:" + goodsId
);
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
keys,
userId.toString(),
"1"
);
if (result == null) {
return SeckillResult.fail("系统繁忙");
}
if (result == -1) {
return SeckillResult.fail("已售罄");
}
if (result == -2) {
return SeckillResult.fail("请勿重复购买");
}
// 发送MQ消息,异步落库
sendOrderMessage(goodsId, userId);
return SeckillResult.success("抢购成功");
}
}
这里我用的StringRedisTemplate,原因是Lua脚本里调用get拿到的库存默认就是字符串,用StringRedisTemplate序列化更直接。如果使用RedisTemplate自带的JDK序列化,Redis里存的value会有二进制前缀,Lua脚本里redis.call('get')拿到的东西根本不是纯数字,转换时会出问题。这个坑很多人踩过,面试时主动讲出来,效果很好。
MQ发消息这一步要注意:消息不能丢,不能重复消费。生产端用带事务消息或确认机制的消息队列,消费端做幂等,消费时用INSERT IGNORE或者唯一索引防重。
3.4 库存预热与售罄标记
活动开始前,需要把库存从数据库加载到Redis。这里有个容易被忽略的问题:如果活动期间Redis崩溃或者key过期,库存数据就丢了。所以预热脚本要写在活动启动的初始化流程里,并且要有一个兜底方案,比如用定时任务自动检测Redis库存key是否存在,不存在则从数据库重新加载。
售罄标记也值得单独说。当Lua脚本返回库存为0时,可以顺便在Redis里设置一个售罄状态key,后续请求直接读这个key短路返回。这样可以大幅减少真正执行Lua脚本的请求量。为什么?因为大部分流量其实集中在售罄之后,如果所有请求都打到Lua脚本上,虽然Redis能扛住,但完全没有必要。
java复制if (result == -1) {
stringRedisTemplate.opsForValue().set("seckill:soldout:" + goodsId, "1", 10, TimeUnit.MINUTES);
return SeckillResult.fail("已售罄");
}
这个key不要设置太长过期时间,10分钟左右比较合适。如果是为了防止活动期内反复查询,可以设置成和活动结束时间对齐。售罄标记相当于一道快速失败的闸门,把无效流量挡在Lua脚本之外。
4. 库存回滚与最终一致性,面试追问的高危区
4.1 Redis扣减成功但订单创建失败怎么办
这是面试里最容易被追问的场景。Lua脚本把Redis库存扣了,也标记了用户,结果MQ发送失败,或者异步创建订单时数据库异常,用户的钱花了或者资格占了,但实际没有订单,这时必须回滚。
我的方案是两阶段补偿。第一阶段是发送MQ时如果发送失败,立即执行回滚脚本,把库存加回来,从用户集合里移除该用户。第二阶段是异步落库失败时,由消费者捕获异常后重新发送或进入死信队列,由补偿任务处理。
回滚脚本同样要写成Lua:
lua复制-- KEYS[1]: 库存key
-- KEYS[2]: 用户set key
-- ARGV[1]: 用户ID
-- ARGV[2]: 回滚数量
redis.call('incrby', KEYS[1], ARGV[2])
redis.call('srem', KEYS[2], ARGV[1])
return 1
回滚看起来只是把操作反过来,但有一个细节:必须判断用户确实在已购集合里才回滚。如果用户不在集合里,说明扣减时就已经失败,此时加库存会导致超卖。所以更严谨的回滚脚本要先sismember判断,再执行加库存。
4.2 用户超时未支付,库存怎么释放
秒杀订单一般有时间限制,5分钟或15分钟内不支付就自动取消。订单取消后,用户的购买资格要释放,库存要加回来。这里最容易踩的坑是:数据库订单状态已经变成取消,但Redis里的用户标记还在,导致该用户不能再次购买。
我处理这类问题的方式是:库存释放也通过MQ消息驱动。订单超时取消时,发送一条释放库存的MQ消息,消费者在释放数据库库存的同时,也删除Redis里的用户标记和加回Redis库存。释放库存和删除用户标记这两个操作,还是要放在同一个Lua脚本里执行,保证原子性和最终一致性。
4.3 数据库和Redis数据不一致的兜底方案
即使有上面的机制,系统运行时间长了,依然可能出现Redis有库存但数据库库存已经为0,或者反过来。为什么?因为异步链路中任何一个环节的消息丢失,都会造成两侧数据漂移。所以定时对账是必须的。
对账的逻辑不复杂:定时任务扫描订单表和库存操作流水表,把一段时间内所有成功扣减的记录汇总,和数据库当前库存做比对,发现差异就走补偿流程。补偿流程要么回滚多扣的部分,要么补充扣减的记录。
我一般会为库存操作建一张流水表,每条流水记录包含商品ID、用户ID、变动数量、操作类型(扣减/回滚)、关联订单号。这张表在追数据的时候价值巨大,没有流水表,对账基本靠猜,非常被动。
4.4 事务边界怎么定义才合理
有些团队喜欢在Redis扣减成功后就开一个数据库事务,把订单创建和数据库库存扣减放在同一个事务里。这在高并发场景下要谨慎。因为数据库事务意味着持有连接和锁,而异步落库方案下,消息消费是削峰后的流量,不会造成太大压力,可以在事务里完成。
但如果同步创建订单,数据库的写入压力会直接传导到请求链路,Redis也白做了。所以我的建议是:同步接口只做校验和Redis扣减,任何数据库写入都异步化。这是秒杀系统性能设计的核心思想:把请求链路缩短,把重操作往后放。
5. 压测结果分析与线上问题排障实录
5.1 我实际压测的一组数据
为了验证这套方案,我用JMeter做过一次压测。机器配置是4核8G的普通云服务器,Redis和MySQL都在同一台机器上(生产环境不建议这样部署,但压测图方便)。模拟1000件库存,并发线程500,总请求量5万。
结果是这样的:Java接口平均响应时间38ms,99线95ms,Redis QPS峰值约8000,Lua脚本执行没有报错,最终数据库订单数1000,库存流水1000条,无一条超卖。这个数据在单机部署下已经够看。如果上Redis集群、数据库分库分表,支撑几万QPS的秒杀是没有问题的。
压测时最值得关注的指标不是平均响应时间,而是错误率。我把错误类型打点记录后发现,大部分错误集中在Redis连接超时和数据库连接池耗尽。这暴露了另一个问题:虽然Redis扣减很快,但应用层如果没做好连接池配置,照样会成为瓶颈。
5.2 高频问题排查速查表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 库存出现负数 | 数据库直扣没有加库存大于0条件 | 检查UPDATE SQL是否带stock > 0 |
| 用户重复下单成功 | Set去重没用或Lua脚本没判断sismember | 查看Redis Set中用户ID是否存在 |
| Redis库存扣了,数据库没扣 | MQ消息丢失或消费失败 | 检查MQ消费日志和重试机制 |
| 用户下单后不能再次抢购 | 取消订单后Redis用户标记未删除 | 检查释放库存Lua脚本是否执行 |
| Redis连接超时 | 连接池最大连接数不够 | 调大lettuce或jedis连接池参数 |
| 数据库锁等待严重 | 异步消费并发太高,热点行竞争 | 对消费线程做并发控制或分桶 |
| 活动一开始就显示售罄 | 预热脚本没执行或key过期 | 检查初始化任务和执行日志 |
这张表里的问题我基本都遇到过。尤其是第一条,理论上不可能出现,但如果你在压测时同时用两种扣减方案(比如Redis和数据库混用),数据库直扣那段代码就会漏掉条件,瞬间打出负数。
5.3 Lua脚本在集群模式下的坑
之前把一个活动切到Redis集群环境,结果Lua脚本报出CROSSSLOT错误。原因很简单:Redis Cluster要求同一个Lua脚本里的所有key必须落在同一个哈希槽里。我的key是seckill:stock:1001和seckill:users:1001,如果商品ID相同,理论上用哈希标签可以解决。
解决办法是用哈希标签把key写到同一个槽位:
java复制String key = "seckill:stock:{" + goodsId + "}";
String userKey = "seckill:users:{" + goodsId + "}";
加了花括号之后,Redis只对花括号内的内容计算哈希槽,所以商品ID相同的所有key都会落在同一个槽里。这个问题在单机Redis下根本不会暴露,只有上集群才会遇到,属于典型的压测覆盖不到、上线才炸的问题。
5.4 监控和日志:别等出了事故才找原因
秒杀系统必须前置做好监控和日志。我总结的监控项有:Redis命令耗时和错误数、Lua脚本返回值分布(-1、-2、1的比例)、MQ积压量、数据库库存和Redis库存差值、接口P99延迟。
日志方面,每个关键节点都要打印traceId串联起来。从接到请求到Redis扣减再到MQ消息发送,我用同一个traceId贯穿全程。问题是出在限流、Redis扣减还是MQ发送,日志一查就知道,不需要靠猜。
一个额外的心得:上线前一定做一次完整的“回放演练”。把真实流量录下来,在测试环境重新打一遍,观察每个环节的表现。这个习惯帮我提前发现了至少两处隐患,比上线后救火划算得多。
最后分享一点个人的体会
秒杀的库存扣减问题,光背一个方案是没有意义的。面试官真正想看的是你能否针对不同量级和场景做出合理设计,以及你对一致性、性能、可靠性的权衡理解。我自己的经验是:先想清楚业务约束(库存多少、并发多少、能否异步、失败怎么办),再选技术方案。技术选型上没有银弹,只有边界是否匹配。这套Redis+Lua+MQ的方案,是我在大促场景下验证过、踩过坑、也完善过的落地路线,希望对你准备面试和设计系统都有实质帮助。
