资讯动态

秒杀场景库存扣减方案深度拆解:从SQL到Redis+Lua的演进与实践

发布时间:2026/10/14 10:37:35 来源:尧图企业网站定制
面试进行到第三轮面试官轻描淡写丢过来一句“你们项目做过秒杀吗说说库存扣减策略。”这个问题几乎每个做后端的人都会遇到但真正能答出层次的人不多。大部分候选人第一反应是背方案从“乐观锁”背到“Redis预减库存”中间穿插分布式锁和消息队列。可面试官想听的其实不是这些名词而是你有没有真正理解秒杀的本质不是“高并发”而是在极致并发下把数据一致性和可用性同时保住。下面把我这些年做过的、看过的、踩过的库存扣减方案完整拆一遍。1. 秒杀场景为什么总被当成压轴题1.1 三层答案SQL、中间件、一致性链路秒杀类问题在面试里的地位很特殊。它不考某个算法也不考某个框架的API它考察的是你对“从一条SQL到一套分布式系统”的完整认知链条。我习惯把答案拆成三层第一层是数据库层。能不能说清楚普通扣减SQL在并发下为什么超卖悲观锁、乐观锁各自适用的场景这是基本功。第二层是中间件层。为什么秒杀方案普遍收敛到RedisLuaRedis单线程特性如何保证原子性Lua脚本和multi/exec的区别在哪里。第三层是链路层。库存扣完以后订单异步创建、消息可靠性、超时不支付回补、Redis宕机后的库存恢复这些才是区分普通应聘者和资深工程师的分水岭。很多候选人能答到第二层就非常不错但真正做过线上活动的人都知道第二层只是开始第三层才是命脉。面试官反复追问“Redis扣减成功但数据库没扣怎么办”就是在探你的第三层深度。1.2 一个真实上线事故带来的认知转变我早期在某电商公司就叫它模拟项目X参与过一次大促活动当时承担秒杀系统的库存模块。压测阶段我们用的方案是数据库乐观锁并发模拟到200用户下单数据一切正常接口耗时也还能接受。结果正式活动的流量比压测高了一个数量级。活动开始后不到三分钟运营那边就炸了后台看到已售数量大于真实库存超卖了几百单。排查下来原因并不复杂——连接池被打满大量请求在获取数据库连接时排队超时之后用户不断重试每个订单在事务里先查库存再插入订单记录这些操作全部叠加到一起把数据库拖垮了。这次事故让我彻底转变了认知单靠数据库做扣减在极强的瞬时流量面前是撑不住的。库存扣减必须前移到内存层数据库只做最终落账的角色。后来我重构的方案就是“Redis预扣异步落库定时对账”也就是下面要展开讲的这条路线。2. 库存扣减方案从数据库到Redis的演进路径2.1 单条SQL能不能扛先说结论很多人一上来就说“用Redis做库存”但面试官往往会先反问一句“你怎么知道数据库扛不住”这是很关键的问题因为不是所有秒杀都真的需要上Redis。如果库存量小、并发量不高一条具备原子性的SQL其实完全够用。注意我这里说的不是先查询再更新的Java代码而是一条完整的条件更新语句update product_stock set stock stock - 1 where id 12345 and stock 0这句SQL在数据库层面是原子的stock 0条件保证了不会扣成负数影响行数为0就表示抢购失败。靠这种写法每日几十万并发内的小型抢购活动是能撑住的。它的瓶颈也很明显每次扣减都要求对这条记录加行锁update期间同一把锁串行执行数据库能承受的TPS取决于硬件和连接池配置。一旦流量把数据库连接池打满后续请求只能排队等连接整体响应时间和可用性都会崩。所以我的判断标准很简单如果项目并发量在千级以内而且活动时长可控用这条SQL加上乐观锁就足够了如果目标是万级以上直接考虑Redis方案。2.2 select...for update为什么在秒杀场景是自杀式悲观锁是很多刚接触并发问题的同学第一反应的选择select stock from product_stock where id 12345 for update -- 业务逻辑判断库存是否充足 update product_stock set stock stock - 1 where id 12345 commitfor update会让事务持有该行上的排他锁直到事务提交。好处是逻辑简单天然防止并发互相覆盖。但坏处非常致命所有抢购同一商品的请求必须排队等锁数据库连接被长期占用一旦秒杀请求爆发连接池迅速耗尽整个系统响应变慢甚至不可用。更隐蔽的问题在于如果这一行记录被多表关联查询或者被索引设计不当的表锁定锁的粒度还可能扩散到表级别的间隙锁引起大面积阻塞。线上我见过因为for update把一个正常商品详情页拖挂的案例就是因为一条统计SQL不小心访问了同一行记录然后被秒杀事务的锁阻塞。所以悲观锁适合后台管理系统和写入并发极低的场景放在秒杀入口基本是自杀式设计。2.3 乐观锁version与高竞争的活锁问题悲观锁被否掉之后很多人会想到乐观锁// 先查当前版本号 Integer version stockMapper.getVersionById(productId); int affected stockMapper.deductStockByVersion(productId, version); // affected 0 表示扣减失败需要重试每次扣减时带上版本号条件只有版本号匹配才能更新成功。核心思路是“更新时校验数据是否被改动过”。低竞争场景下这个方案很不错毕竟不需要持锁等待吞吐量比悲观锁高。但秒杀恰恰是极端竞争的场景同一时刻可能有上万用户抢同一件商品第一次查询版本号后真正能成功更新的只有最早那批请求其余请求全部更新失败于是客户端不断重试、不断失败。这种反复重试又抢不到锁的情况专业上叫活锁——系统没有死锁但大量请求永远在尝试和失败之间循环资源被白白消耗。更麻烦的是重试逻辑如果设计得不严谨可能把“库存扣减次数”搞错用户发了3次重试请求只有1次需要真正扣减库存其他请求必须被幂等挡住。纯靠乐观锁解决不了这种语义问题。2.4 RedisLua为什么最后都收敛到这条路线聊到这里面试官基本会顺着问那你最后用了什么方案其实业界经过多年实践高性能秒杀系统的库存扣减基本收敛到同一个模式库存预热到Redis用Lua脚本原子扣减。为什么是Redis因为Redis是单线程模型所有命令在事件循环里被逐个执行天然串行化。加上Lua脚本可以把多条Redis命令打包成一个整体脚本执行期间其他命令不会插入进来这就从机制上避免了“读库存→判断→扣减”这组操作之间的并发干扰。为什么是Lua而不是multi/execmulti/exec虽然能把命令打包按顺序执行但它不支持“读完之后根据结果做条件判断和计算”你必须配合WATCH机制去检测key变化冲突时还要手动重试代码复杂且吞吐也受影响。Lua脚本则可以在Redis服务器端完成“检查库存、执行扣减、返回结果”整套逻辑网络往返只有一次性能自然高得多。把演进路线整理成一张表面试时讲起来更有条理方案并发能力数据一致性适用场景条件更新SQL千级强一致小型活动、后台扣减select for update低强一致低并发、管理后台乐观锁version中最终一致中低竞争、非热点商品RedisLua异步落库万级以上最终一致真正意义上的秒杀活动3. Lua脚本逐行拆解原子扣减的工程细节3.1 脚本本体与三个返回码的设计我在模拟项目X里用的扣减脚本经过多次迭代核心逻辑稳定在下面这版-- KEYS[1]库存key例如 seckill:stock:{productId} -- ARGV[1]本次扣减数量通常为1 if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local num tonumber(ARGV[1]) if stock num then return -2 end redis.call(decrby, KEYS[1], num) return stock - num脚本整体四步先检查key是否存在不存在返回-1读取当前库存并转成数字类型注意redis.call(get)返回的是字符串直接用比较会出问题所以tonumber这步不能省如果剩余库存小于购买数量返回-2表示本次抢购不成功正常情况用decrby扣减并把扣减后的剩余库存返回给客户端。返回码的设计是有讲究的。-1表示“没有预热库存或者key已经过期清理”这种情况客户端应当直接结束抢购流程连下单都不用继续。而-2表示“还有库存key但没有余量”暗示商品已经售罄此时可以把该商品在缓存层标记为售罄后续请求直接短路。很多人在这个脚本上翻车是因为把库存判断和扣减分成了两条独立Lua命令中间穿插了网络往返。比如先在Java里执行get拿到库存判断之后又执行decrby。这两个操作之间Redis是原子的但Java判断和第二次调用之间存在时间窗口仍然会有并发问题。正确做法是让“检查扣减”在Lua脚本内部完成确保整个流程中间不可能有别的请求插入。3.2 KEYS与ARGV容易被忽略的集群分片约束Lua脚本里还有一个很容易被忽视的设计点KEYS和ARGV怎么传。我在面试中经常看到候选人写这样的脚本local stock redis.call(get, seckill:stock: .. ARGV[1])把商品ID拼进字符串然后通过ARGV传入。这在单机Redis上没问题但在Redis Cluster集群模式下会直接踩坑。Cluster的key是按哈希槽分布的Lua脚本执行时要保证脚本中涉及的所有key都位于同一个节点。如果你把商品ID藏在字符串里Redis无法在脚本执行前根据key计算哈希槽跨节点的key访问就会被打回。正确做法是把库存key通过KEYS数组传进来KEYS[1] seckill:stock:{productId}让Redis在执行脚本时可以正确路由到对应分片。同时还要注意如果一次扣减涉及多个key比如同时扣商品库存和用户限购额度这些key的名字要尽量带上相同的哈希标签例如seckill:{productId}:stock和seckill:{productId}:limit确保它们落在同一个分片。这个细节太小了很多书本不会专门写但对线上集群环境来说却是决定能否上线的关键。面试时主动提出来非常加分。3.3 Java侧调用代码与超时控制兜底选定了LuaJava调用侧也要谨慎设计。我这里用的Spring Data Redis写法private static final DefaultRedisScriptLong DEDUCT_SCRIPT new DefaultRedisScript(); static { DEDUCT_SCRIPT.setLocation(new ClassPathResource(deductStock.lua)); DEDUCT_SCRIPT.setResultType(Long.class); } public boolean deductStock(String productId, int count) { Long result redisTemplate.execute( DEDUCT_SCRIPT, Collections.singletonList(seckill:stock: productId), String.valueOf(count) ); if (result null) { // 返回null通常是连接异常或脚本执行超时不能视为失败也不能视为成功 throw new StockDeductTimeoutException(库存扣减结果未知需人工补偿); } return result 0; }这里的核心点在于“结果未知”的处理。当Redis访问超时客户端不确定脚本是否真的执行了——可能执行了但响应丢失也可能连命令都没发出去。如果这时候简单告诉用户“成功”或“失败”都会带来数据偏差。我的处理方式是把这类异常记为“待补偿”落到一张补偿表里由后台任务查询Redis最新库存和实际扣减流水来恢复现场。这种设计在平时看不到作用真遇到Redis抖动时能避免一大片对不上的订单。4. 扣完库存之后消息、订单与Redis宕机的最终一致性4.1 预扣模式与支付扣减模式的取舍秒杀库存扣减成功并不意味着订单已经创建。怎么安排下单和扣库存的时序这里有一个经典取舍。预扣模式用户点击抢购立刻扣减库存创建待支付订单超时不付则自动取消并回补库存。优点是下单阶段就能锁定库存避免“下单成功但没货发”的尴尬缺点是大量用户抢到后不付款会占用一段时间的库存。支付扣减模式用户先下单付款成功后真正扣减库存。优点是库存浪费风险小但会造成“很多人付款成功却发现没货”的局面对用户体验伤害极大投诉率会非常高。秒杀场景下我通常选择预扣模式。可以引入“预占时间窗口”下单后15分钟内未支付自动释放库存配合压测经验控制在合理范围内。释放库存的操作也要走Lua脚本做回补并且要记录一条回补流水防止回补和正常扣减并发时把库存改成负数。4.2 本地消息表解决“扣了但订单没生成”Redis扣减成功之后业务逻辑需要创建订单这一步我建议用消息异步来做。但直接用MQ有一个隐患假设先在Redis扣了库存再发消息如果消息发送失败订单根本没创建用户那边完全不知道发生了什么事库存却已经少了。标准解法是本地消息表。在同一业务事务里把订单记录和消息记录写入数据库事务提交后再异步投递消息。例如begin; insert into seckill_order (order_id, user_id, product_id) values (...); insert into mq_message (msg_id, topic, payload, status) values (..., pending); commit;后台线程扫描消息表把statuspending的记录投递到MQ收到消费确认后更新状态。如果投递失败就重试消费端处理业务时依赖订单号做幂等保证重复投递不会产生重复订单。这套机制比单纯“发MQ失败重试”要可靠得多因为消息表和业务表在同一事务里不会出现“订单成功但消息丢失”这种经典问题。面试官多半会顺着问“MQ挂了怎么办”这时候你就可以回答本地消息表定时任务重试是最底层的保底方案MQ只是加速器。4.3 定时对账补偿一次AOF丢数据的复盘即使有了Lua和本地消息表线上仍然可能遇到极端情况。我记得模拟项目X有一次活动Redis配置的AOF持久化是everysec活动进行中Redis实例意外重启丢了一秒左右的写操作。重启后Redis里的库存数字比真实扣减次数多了几十件。客户端继续抢购时明明数据库里订单已经很多了Redis还是不断放行最终导致超卖。这次事故逼着我们把“对账补偿”变成了系统标配。对账逻辑很简单但也最有效活动结束后或者每隔几分钟启动一个任务把Redis里每个商品的剩余库存和数据库里生成的订单数加总后的已售数量做交叉比对。如果发现Redis剩余库存 已售数量 ≠ 预热的初始库存说明两边存在偏差以数据库的已售订单为准反向修正Redis库存。这一节想强调的核心是Redis在秒杀链路里承担的是“流量闸门”角色而不是“数据真相”。最终的数据权威必须落在数据库上。所有扣减流水、订单号、回补记录都应当具备可追溯性这样才能在异常之后做人工和自动化双重修正。5. 把工程面补齐限流、人单防重与库存预热5.1 入口限流先别急着上重型框架库存扣减方案聊完面试官一般会转移话题考察整体设计你怎么保证只有几万用户的请求真正触达库存系统这个问题的答案是限流。很多人一上来就说用Sentinel、用网关流控但中小项目最需要的其实是简单的令牌桶逻辑。我在网关层实现过一个基础令牌桶每个商品ID对应一个令牌桶令牌生成速率和容量根据预热库存和活动时长倒推容量 预热库存 * 2 速率 预热库存 / 活动时长(秒) * 1.2超过令牌容量的请求直接返回“已售罄”或“太火爆请稍后重试”不再往后端转发。这套逻辑不需要额外服务一个本地缓存加计数器就能实现线上效果已经够稳定。大流量场景再考虑把限流状态放进Redis或引入专业流量网关。5.2 一人一单不是setnx那么简单秒杀接口一个非常容易忽略的问题是防重复。同一个用户连续点击100次按钮应该只允许他最多成功下一单。常见的正确做法是用Redis的setnx做用户维度锁String lockKey seckill:user: activityId : userId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofMinutes(15)); if (Boolean.TRUE.equals(locked)) { // 进入下单流程 } else { // 直接返回您已参与过本场秒杀 }初看很完美但注意两点第一这个锁的过期时间至少覆盖到订单落地理想情况是按订单状态单独管理比如下单成功后删除key超时未支付由定时任务清理后允许用户重新抢否则会把“已参与”和“已成功”混为一谈第二setnx本身也要放在Lua脚本里和库存扣减合并成一个原子操作防止同一用户的两个并发请求同时通过检查。我在项目里是把“用户维度防重”和“库存扣减”都塞进同一个Lua脚本的两个key一起传入一次执行完成。这样既防止了超卖也堵住了重复下单的漏洞。5.3 库存预热的初始化和活动收尾库存预热看似简单其实初始化时机和失败处理都有讲究。活动开始前把数据库库存批量写入Redis如果Redis预热失败或部分key没写进去要能在活动开始前告警不能带着残缺数据上线。那个Lua脚本里的exists判断就是为了处理“没预热到”的场景。预热任务里我会额外做一步校验预热完成后读一次所有key比对总数和数据库库存是否一致不一致立刻触发回源重写。活动结束后要记得做清理把不再使用的库存key删除避免占用Redis内存。更要注意的是别误删了正在进行的另一场活动的key所以库存key一定要带上活动ID维度例如seckill:stock:{activityId}:{productId}千万不要图省事只放商品ID。6. 面试官追问清单与答题节奏控制6.1 高频追问Redis挂了、消息丢了、库存恢复把完整方案讲完后面试官通常会压上几个极限场景来测试你是否有真工程经验。整理一下我遇到过的高频追问“Redis崩了怎么办”——答库存key用事先预热重启后的恢复脚本回源数据库扣减流水落到独立的Redis备份或日志重启后重放如果追求更高可用可以用集群模式加主从切换但切换瞬间的写丢失仍然要做对账。“MQ消息丢了怎么办”——答消费端幂等本地消息表兜底重试消息表定时任务扫描保证只要数据库有订单记录消息最终一定被投递。“用户抢到但不付款怎么办”——答预扣模式超时关单库存回补回补操作本身也要触发对账避免回补和正常扣减并发打架。“Lua脚本慢查询怎么办”——答Redis单线程下任何Lua脚本都不要做耗时的循环或复杂计算脚本内只处理高频的库存访问命令请求量级大时宁可拆分脚本也不把逻辑塞在一起。这些追问没有标准答案但核心都围绕着一句话当不靠谱的组件发生意外时你的系统是否还能靠数据库和补偿任务保持最终一致。6.2 从背答案到讲链路判断标准是什么面试官真正想看的不是候选人是否记得几个方案名词而是能否完整讲出“请求从入口进来经过限流、防重、扣库存、建订单、回补库存、对账”这条链路中每一步面临的风险是什么、你在哪里做了兜底。一个能讲链路的人至少会主动提到入口层的令牌桶设置了什么参数为什么参数这么设Lua脚本返回-1和-2分别由谁来处理异步建单失败后消息表如何重试对账任务以哪个数据源为准。能把这些串起来就说明你不是在背书而是真的动手解决过问题。6.3 我常用的答题时间分配与习惯最后分享一下我答题时的习惯。整个回答控制在5到7分钟时间分配大概是方案演进2分钟Lua脚本与原子性1.5分钟异步落库与一致性2分钟限流和防重1分钟剩下的时间留给面试官追问。不要在一开始就把Lua脚本完整背出来先讲演进逻辑等面试官对“为什么最终用Redis”点头之后再深入脚本细节。把最漂亮的原子性设计放在对话中段能起到一个递进的效果。如果面试官不太懂Redis就直接讲回扣减SQL和乐观锁如果面试官很资深就多聊持久化丢数据后的对账补偿。根据对方的技术口味调整深度是表达得分的关键。这条路走下来我最大的体会是秒杀题没有标准答案只有是否自洽的工程方案。面试官要的不是你背出一套完美架构而是你能在资源有限的现实条件下给出一个可以上线、可以兜底、可以解释的完整设计。每次复盘这个主题我都会把对账补偿和数据权威性放在最高优先级因为所有华丽的高性能方案最终都要落回到“数据不能错”这四个字上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑