Redis分布式锁与Lua原子扣减:秒杀库存防超卖实战

发布时间:2026/10/30 0:07:01
Redis分布式锁与Lua原子扣减:秒杀库存防超卖实战 简介围绕Redis分布式锁实现抢单秒杀场景的SpringBoot示例代码包面向有Java与Spring基础、希望理解高并发库存控制与请求削峰的中级开发者。内容以分布式锁加锁与释放、库存原子扣减、队列缓冲请求、抢单结果状态返回为主线兼及限流与FIFO处理、锁自动释放、重复抢单幂等和事务一致性等常见难点可用于搭建本地实验环境或作为课程设计参考。压缩包13个文件、约59KB包含5个Java源文件、2个properties配置、1个依赖jar以及工程构建与说明文档等结构紧凑便于导入IDE阅读调试。已有4057人学习读者可据此观察锁过期、库存归零、抢购失败等分支的处理方式并理解状态码与业务返回值的关系从而梳理从加锁、扣库存到返回结果的完整实现思路也可据此复现超卖防护与失败回滚。1. 从一次超卖事故说起redis分布式锁在抢单秒杀里到底锁住了什么抢单秒杀活动的库存数字是redis分布式锁唯一要守住的东西。凌晨两点被叫起来看线上事故一百件库存的商品卖出了三百多单运营在群里刷屏问怎么回事事后复盘发现不是库存没扣是扣的时候两个请求同时读到了同一个余量。这个项目要解决的正是这一幕Spring Boot 起服务活动开始前把库存预热进 Redis抢单请求进来先抢锁、再校验资格、再原子扣减、最后把订单丢进队列异步落库保证一百件库存只生成一百笔订单。它适合两类人一类是想把分布式锁从「背八股」变成「能写对」的后端锁的加锁命令、释放脚本、续期机制都在代码里另一类是手里有个秒杀需求要落地需要一份能跑起来的骨架改掉 key 前缀和业务字段就能接进自己的系统。下面按「架构怎么分 → 锁怎么写 → 库存怎么扣 → 队列怎么消费 → 哪里会翻车 → 怎么改造压测」的顺序拆。2. 架构拆解一次抢单请求要穿过限流、锁、Lua 与队列四道关动手写代码之前先要把这条链路上每一层负责什么定下来。秒杀和普通下单最大的区别是瞬时并发量——活动开始那一秒可能是平时一小时的流量如果所有请求都直接打到数据库行锁会立刻把连接池吃干净后面排队的请求全部超时页面显示「系统繁忙」用户反复点击流量再翻一倍形成雪崩。所以这类系统一定是分层的越靠前的层越轻、越快、越早把无效流量挡掉越靠后的层越重、越慢、只处理真正有效的请求。2.1 选型数据库行锁、Redis 锁、队列各自该站哪个位置常见做法是把这三样当成互相替代的方案其实它们是串联的。数据库行锁的作用是兜底保证最终写库那一刻不出错它的代价是持有连接、阻塞其他事务撑不住高并发Redis 锁的作用是在内存里把「同一件商品的库存判断」串行化单机十万级 QPS 的量级一条SET NX PX就能完成互斥成本极低队列的作用是削峰把「扣减成功」和「真正生成订单」这两件事拆开让请求线程快速返回、让消费线程按自己的能力匀速落库。我在这个项目里的分工是这样的限流在本地做用令牌桶把明显超量的请求挡在业务逻辑之外去重和资格校验放在 Redis用SETNX或者集合结构防止一个用户重复提交互斥交给分布式锁或者 Lua 脚本落库交给队列。四层各管一件事任何一层出问题都不会直接击穿数据库。2.2 请求链路从进入到返回经过哪几层一个完整的抢单请求走完大概是下面这几个步骤。把这些步骤先在纸上画清楚代码写起来会顺很多也才知道每一层失败时该返回什么给前端。步骤执行位置主要动作失败时的处理1过滤器 / 拦截器参数校验、活动时间校验、本地令牌桶限流直接返回「活动未开始」或「请求过于频繁」2业务层Redis 去重判断该用户是否已参与返回「每人限购一件」3业务层按activityId skuId抢分布式锁抢锁失败快速返回「排队中请稍后重试」4Redis Lua校验库存并原子扣减、写入已购集合返回「已售罄」5业务层生成订单号写入队列扣减回滚返回「下单失败」6消费端拉取队列、幂等落库、更新订单状态重试超过次数进死信表注意第 5 步扣减成功但入队失败库存必须加回去否则就是「少卖」。这类补偿逻辑在秒杀里比正常路径更重要因为异常路径才是事故的高发区。2.3 工程结构与依赖一个能跑的最小骨架这个项目的结构不复杂按职责切成 controller、service、lock、queue、consumer 几块好处是锁和队列都能单独替换。依赖上只需要 Redis 客户端和 Web如果不想额外引入消息中间件队列直接用 Redis 的 List 结构就够用。!-- pom.xml 关键依赖 -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 提供 StringRedisTemplate执行 Lua 脚本用它 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 可选需要看门狗续期、可重入锁时再引 -- dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.x/version /dependency /dependenciesspring-boot-starter-data-redis默认用 Lettuce 做连接工厂它是共享连接的异步客户端适合秒杀这种高并发读写的场景但要注意连接池不是越大越好后面避坑章节会讲。Redisson 是可选项如果锁的持有时间不确定业务里有远程调用、有慢 SQL用它的看门狗比手写续期稳如果业务执行时间是确定的几百毫秒手写SET NX PX反而更轻、更可控。目录划分大致如下方便对照后面的代码目录 / 文件职责controller/SeckillController接收抢单请求返回排队结果service/SeckillService抢锁、执行 Lua、生成订单号、入队service/StockWarmUpService活动前把数据库库存预热进 Redislock/RedisLockHelper加锁、释放锁的封装含 Lua 释放脚本queue/OrderQueueRedis List 的入队、出队封装consumer/OrderConsumer消费队列、幂等落库、失败回滚resources/lua/seckill.lua库存判断与扣减的原子脚本2.4 配置项先定死锁超时、等待时间、库存 key配置项在秒杀里不是随便填的锁超时时间和业务最大执行时间之间的关系直接决定会不会超卖。我一般会先估一个业务执行时间的 P99然后把锁超时设成它的 2 到 3 倍并开启续期作为保险。# application.yml seckill: lock: # 锁的持有时间单位毫秒要大于业务 P99 执行时间的 2 倍 lease-time: 3000 # 抢锁的最长等待时间秒杀场景一般设得很短抢不到就快速失败 wait-time: 200 key-prefix: seckill:lock: stock: # 库存 key 前缀最终形态 seckill:stock:{activityId}:{skuId} key-prefix: seckill:stock: # 已购用户集合前缀用于一人一单 user-key-prefix: seckill:users: queue: order-key: seckill:order:queue # 消费线程数按落库能力调不是越大越好 consumer-threads: 4 spring: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 200 max-wait: 200mswait-time设成 200 毫秒是个有意的取舍秒杀请求等不起锁被占用说明前面有人正在处理同一件商品与其让线程挂在那里占资源不如直接告诉前端「排队中」让它稍后查结果。consumer-threads也不要拍脑袋调大消费端最终要写数据库线程数超过数据库连接池承受能力只会把压力转移成连接等待。3. 分布式锁实现SET NX PX、Lua 释放、看门狗续期三段式锁这块是整个项目的核心也是最容易写出 bug 的地方。我见过太多代码用setnx加expire两条命令中间服务一重启锁就永远留在 Redis 里只能靠人工删 key 救场。正确的写法必须满足三个条件加锁是原子的、释放是原子的、超时是可控的。3.1 加锁SET NX PX 一行命令里的三个参数Redis 的SET key value NX PX milliseconds一条命令同时完成「不存在才设置」和「设置过期时间」Spring Data Redis 里对应setIfAbsent的重载方法。value 不能随便写个1必须是一个能唯一标识当前持有者的字符串通常用UUID 线程 id这是后面释放锁时判断「这把锁是不是我加的」的依据。Component public class RedisLockHelper { private final StringRedisTemplate redisTemplate; public RedisLockHelper(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } /** * 尝试加锁 * param lockKey 锁的 key形如 seckill:lock:1001:2001 * param requestId 持有者标识释放锁时用它比对 * param leaseMs 锁的过期时间毫秒 * return true 表示加锁成功 */ public boolean tryLock(String lockKey, String requestId, long leaseMs) { // setIfAbsent 过期时间底层等价于 SET key value NX PX leaseMs Boolean ok redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(leaseMs)); return Boolean.TRUE.equals(ok); } /** 生成持有者标识线程级别唯一 */ public String buildRequestId() { return UUID.randomUUID().toString().replace(-, ) : Thread.currentThread().getId(); } }第一个参数lockKey决定锁的粒度这个后面单独讲。第二个参数requestId是安全底线没有它就无法区分「我的锁」和「别人的锁」。第三个参数leaseMs是最容易踩坑的地方设短了业务没跑完锁就自动释放别的请求进来直接超卖设长了服务宕机后锁要等很久才释放期间这件商品谁都抢不了。我的习惯是先量业务耗时再定值绝不拍脑袋写个 30 秒。3.2 释放锁为什么必须用 Lua 比对 value释放锁看起来只要del一下实际上藏着两个陷阱。一是「删错锁」线程 A 的锁因为超时自动释放了线程 B 加锁成功这时候 A 执行完业务去del删掉的是 B 的锁。二是「判断和删除不原子」先get判断再del两步之间锁可能刚好过期并被别人加上判断通过但删的是新锁。解决办法是把「比对 value」和「删除 key」写进同一段 LuaRedis 执行脚本是单线程的中间不会插入其他命令。-- resources/lua/unlock.lua -- KEYS[1] 锁的 key -- ARGV[1] 当前持有者的 requestId -- 返回 1 表示删除成功0 表示锁不属于当前持有者 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧加载脚本并执行注意DefaultRedisScript要指定返回类型否则反序列化会拿到奇怪的字节数组private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript(); static { UNLOCK_SCRIPT.setLocation(new ClassPathResource(lua/unlock.lua)); UNLOCK_SCRIPT.setResultType(Long.class); } public boolean unlock(String lockKey, String requestId) { Long result redisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(lockKey), // KEYS[1] requestId); // ARGV[1] return result ! null result 1L; }脚本里的KEYS和ARGV是 Redis 规定的两个数组KEYS放 keyARGV放其他参数。之所以强调「key 要通过 KEYS 传」是因为集群模式下 Redis 需要根据 key 计算槽位写死在脚本文本里会导致路由错误。返回值的含义也要约定清楚1是正常释放0说明锁已经不属于自己大概率是超时后被别人拿走了这时候要打日志告警因为这往往意味着业务执行时间超过了锁的租期。3.3 锁续期看门狗和「业务超时」这对矛盾手写锁的痛点是租期难定定短了会超卖定长了宕机后恢复慢。Redisson 的看门狗解决的就是这个问题——加锁时不指定leaseTime它会给锁一个默认 30 秒的过期时间然后起一个后台任务每隔三分之一租期检查一次只要当前线程还持有锁就续到 30 秒直到显式unlock或者服务下线。Resource private RedissonClient redissonClient; public void seckillWithRedisson(String lockKey, Runnable business) { RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // leaseTime 传 -1 才会触发看门狗传具体值则不会续期 locked lock.tryLock(200, -1, TimeUnit.MILLISECONDS); if (!locked) { throw new IllegalStateException(抢锁失败稍后重试); } business.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 必须判断锁是否还被当前线程持有否则会抛 IllegalMonitorStateException if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里的坑在于leaseTime传了具体数值看门狗就不生效了锁到点自动释放续期逻辑压根不会启动。另一个坑是unlock没加isHeldByCurrentThread判断业务抛异常或者抢锁失败时走到 finallyRedisson 会直接抛异常把原始异常盖掉排查时看到的堆栈和真实问题毫无关系。看门狗也不是万能的它依赖 JVM 活着服务被强杀或者发生长时间 Full GC续期线程同样会停锁照样会过期。所以真正稳的方案是「锁 最终校验」双保险锁只管把并发收敛落库前再用数据库的唯一索引或者乐观锁版本号确认一次别把正确性全押在锁上。3.4 锁粒度别拿一把全局锁锁住整个活动锁的 key 设计直接决定吞吐量。写成seckill:lock一把全局锁所有活动、所有商品、所有用户全部串行QPS 会被压到个位数压测时能看到请求排成长队。正确的做法是把锁拆到「活动 商品」这一级不同 SKU 之间互不影响key 形态粒度适用场景seckill:lock:{activityId}:{skuId}单个活动下的单个商品绝大多数秒杀推荐seckill:lock:{activityId}:{skuId}:{slot}再拆成 N 个槽位单品热点极高单 key 打满时seckill:lock全局只适合压测或极小流量如果单个 SKU 的并发高到一把锁扛不住可以把它拆成若干槽位每个槽位存一份库存比如 100 件拆成 10 份各 10 件请求按用户 id 取模落到某个槽位锁粒度跟着槽位走。代价是可能出现「某个槽位卖完了但别的槽位还有货」用户体验上表现为「明明还有库存却提示售罄」要不要接受这个代价取决于业务不是纯技术决策。4. 库存扣减与队列削峰Lua 原子脚本加异步落单的完整闭环锁解决的是「同一时刻只有一个请求在处理这件商品」但光有锁还不够——如果锁住的业务里藏着「读库存、判断、减库存」三次 Redis 往返中间依然可能被其他路径改写。真正干净的做法是把判断和扣减塞进一段 Lua让它们在一次 Redis 调用里完成。4.1 库存预热活动开始前把数据库数字灌进 Redis秒杀不能等请求来了再查数据库库存必须在活动开始前预热到 Rediskey 按「活动 商品」拆分value 就是剩余件数。预热一般在活动配置生效时触发或者由定时任务在开始前几分钟跑一遍同时给 key 设置一个比活动结束时间略长的过期时间避免活动结束后残留一堆垃圾数据。public void warmUp(Long activityId, Long skuId, int stock) { String stockKey stockKeyPrefix activityId : skuId; String userKey userKeyPrefix activityId : skuId; // 库存写入注意这里直接覆盖重复预热不会叠加 redisTemplate.opsForValue().set(stockKey, String.valueOf(stock)); // 清空该商品的已购集合防止上一轮活动的数据串味 redisTemplate.delete(userKey); // 活动结束后自动清理时间按活动实际时长加缓冲 redisTemplate.expire(stockKey, Duration.ofHours(2)); redisTemplate.expire(userKey, Duration.ofHours(2)); }重复预热不会叠加这点很关键如果预热方法里写成increment运维手抖点两次库存就凭空多出一倍。另外已购集合必须跟着清否则用户「上一场买过」的记录会让他在新一场里被判定为重复下单。4.2 Lua 原子扣减判断和扣减不能分两步库存判断、一人一单校验、扣减这三件事必须在同一个脚本里完成。脚本里如果库存不足要返回明确的错误码方便前端区分「售罄」和「你已买过」别统一返回一个false让业务层去猜。-- resources/lua/seckill.lua -- KEYS[1] 库存 keyseckill:stock:{activityId}:{skuId} -- KEYS[2] 已购集合 keyseckill:users:{activityId}:{skuId} -- ARGV[1] 用户 id -- 返回1 抢单成功 0 已售罄 2 重复下单 -1 库存未初始化 local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -1 end if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 2 end if stock 0 then return 0 end redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], ARGV[1]) return 1Java 侧执行并处理返回码。注意两个 key 要一起传进KEYS集群模式下它们必须落在同一个槽位稳妥的做法是用{activityId}这种 hash tag 让相关 key 绑到一起。private static final DefaultRedisScriptLong SECKILL_SCRIPT; static { SECKILL_SCRIPT new DefaultRedisScript(); SECKILL_SCRIPT.setLocation(new ClassPathResource(lua/seckill.lua)); SECKILL_SCRIPT.setResultType(Long.class); } public int tryDeduct(Long activityId, Long skuId, Long userId) { String stockKey stockPrefix activityId : skuId; String userKey userPrefix activityId : skuId; Long code redisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(stockKey, userKey), // KEYS[1]、KEYS[2] String.valueOf(userId)); // ARGV[1] return code null ? -1 : code.intValue(); }返回码要和前端约定好0提示「已抢完」2提示「您已参与过」-1是配置问题应该走告警而不是直接展示给用户。这段脚本执行时间在毫秒以内不会成为瓶颈真正的瓶颈在后面的落库。4.3 入队与削峰先建订单号再慢慢落库扣减成功之后不要在请求线程里直接写数据库那样前面所有的削峰努力都白费了。正确顺序是生成全局唯一的订单号把订单信息序列化后LPUSH进队列然后立刻返回「排队中」让前端拿着订单号去轮询结果。public String submit(Long activityId, Long skuId, Long userId) { int code tryDeduct(activityId, skuId, userId); if (code ! 1) { throw new BizException(code 0 ? 已售罄 : 您已参与过本场活动); } // 订单号用 Redis 自增生成保证全局唯一且有序 String orderId SK activityId redisTemplate.opsForValue() .increment(seckill:order:seq: activityId); SeckillOrder order new SeckillOrder(orderId, activityId, skuId, userId, System.currentTimeMillis()); try { // 入队失败要回滚库存否则就是少卖 orderQueue.push(order); } catch (Exception e) { rollback(activityId, skuId, userId); throw new BizException(下单失败请重试); } return orderId; } private void rollback(Long activityId, Long skuId, Long userId) { String stockKey stockPrefix activityId : skuId; String userKey userPrefix activityId : skuId; redisTemplate.opsForValue().increment(stockKey); redisTemplate.opsForSet().remove(userKey, String.valueOf(userId)); }队列用 Redis 的 List 就够了LPUSH进、BRPOP出天然是 FIFO还带阻塞等待消费端不用写轮询。订单号用INCR生成比 UUID 好排查出问题时看一眼订单号就知道属于哪场活动。回滚顺序要注意——先加库存再删用户标记反过来的话中间有一个窗口期用户能重复抢。4.4 消费端幂等落单与失败回滚消费端是整个链路里唯一碰数据库的地方也是最需要幂等的地方。Redis 的 List 不保证「只消费一次」消费线程崩溃重启、BRPOP之后业务没执行完就宕机都会导致同一条消息被处理两次。幂等靠数据库唯一索引兜底最可靠Redis 的SETNX只能作为前置过滤不能当唯一依据。Component public class OrderConsumer { Resource private OrderQueue orderQueue; Resource private OrderMapper orderMapper; PostConstruct public void start() { for (int i 0; i consumerThreads; i) { Thread t new Thread(this::loop, order-consumer- i); t.setDaemon(true); t.start(); } } private void loop() { while (!Thread.currentThread().isInterrupted()) { SeckillOrder order orderQueue.take(); // 内部是 BRPOP阻塞 1 秒 if (order null) { continue; } try { // 数据库对 (activity_id, user_id) 建唯一索引重复插入直接抛异常 orderMapper.insertIgnore(order); } catch (Exception e) { // 落库失败库存加回去用户标记移除交给人工或重试队列 log.error(落单失败 orderId{}, order.getOrderId(), e); rollback(order); } } } }insertIgnore依赖数据库的唯一索引这是幂等的最后一道闸门比任何应用层判断都可靠。落库失败时要把 Redis 里的库存和用户标记都还原否则这笔库存就永久消失了。生产上还会加一层重试队列把失败消息转存到另一个 List 或者死信表由定时任务补偿而不是在消费循环里原地重试——原地重试很容易把线程堵死。5. 避坑与排查五个真实翻车现场的现象、原因和补救这套东西写出来能跑通不难难的是高并发下不出岔子。下面这几条都是我在秒杀场景里真遇到或者见别人踩过的按「现象 → 原因 → 解决」列出来对着排查能省不少时间。5.1 现象库存变成负数订单数超过库存数原因基本只有一个锁在业务执行完之前就过期了第二个请求拿到锁进来读到的还是扣减前的库存。日志里的特征很明显——同一件商品的两条扣减记录时间间隔小于锁的租期。解决分两步。第一步把leaseTime调到业务 P99 的 2 到 3 倍或者改用看门狗续期第二步在 Redis 扣减之外加数据库兜底用UPDATE stock SET num num - 1 WHERE sku_id ? AND num 0配合影响行数判断就算 Redis 层出了问题数据库也不会写成负数。别指望单靠锁就百分百安全锁是性能优化不是正确性保证。5.2 现象日志里出现IllegalMonitorStateException或者锁莫名被删前者的原因是unlock之前没判断isHeldByCurrentThread线程压根没持有锁就去释放。后者是手写锁时释放逻辑没比对requestId把别人的锁删了表现为「A 明明还在跑B 却已经拿到锁」。解决手写锁一律用 Lua 比对 value 再删Redisson 一律加isHeldByCurrentThread判断。另外requestId一定要线程级别唯一见过有人用固定的常量字符串当 value等于没做校验。5.3 现象主从切换后同一件商品被卖出两份这是 Redis 主从异步复制的固有问题不是代码 bug。锁在主节点加成功还没同步到从节点主节点就挂了从节点升主之后锁就丢了。秒杀这种场景一旦丢锁后果就是超卖。解决思路有三条一是接受它用数据库唯一索引兜底把损失控制在一两单而不是灾难性超卖二是对一致性要求极高的活动改用 Redlock 或者基于共识算法的协调服务代价是延迟上升三是把「锁 库存扣减」合并成一次 Lua 执行让关键判断尽量短缩短暴露窗口。5.4 现象用户收到两条一模一样的下单成功通知队列消息重复消费消费端没做幂等。BRPOP取出消息后消费线程崩溃消息其实已经出队但业务没提交或者重试逻辑把同一条消息又塞回队列都会触发这个问题。解决数据库对(activity_id, user_id)建唯一索引插入用insert ignore或者捕获唯一键冲突异常同时给每条订单消息带一个业务唯一 id消费前用 RedisSETNX做一次前置过滤把绝大部分重复挡在前面。记住顺序Redis 过滤是优化数据库唯一索引才是最终保证。5.5 现象压测时请求大量超时但 Redis CPU 和数据库 CPU 都不高这种「哪儿都不忙但就是慢」的情况最玄学八成是连接池或者线程池卡住了。Lettuce 默认共享一个连接如果业务里有阻塞式调用所有请求会排队consumer-threads调太大把数据库连接池占满消费线程全在等连接队列越积越长。排查顺序先看lettuce.pool.max-wait是不是设得太小导致获取连接直接超时再看max-active和实际并发是否匹配最后看消费端线程数和数据库连接池上限的关系。我的经验值是消费线程数与数据库连接池上限保持在 1:2 以内别让消费端自己把自己堵死。6. 二次开发把锁粒度和队列消费速率调到能扛住压测的位置拿到这套代码之后大多数人第一件想改的事是「把商品 id 换成自己的业务字段」这很简单真正决定它能不能上生产的是锁粒度和消费速率这两个参数。我一般在预发环境先跑一轮全链路压测用并发请求打满库存然后看三个数字Redis 扣减数、队列长度峰值、数据库订单数。三个数字必须能对上对不上就说明中间有丢单或者超卖。# 2000 个请求、200 并发打同一个 sku观察 HTTP 状态码分布 seq 1 2000 | xargs -P 200 -I{} curl -s -o /dev/null -w %{http_code}\n \ -X POST http://127.0.0.1:8080/api/seckill/order \ -H Content-Type: application/json \ -d {activityId:1001,skuId:2001,userId:{}} | sort | uniq -c-P 200控制并发度userId用序号占位保证每个请求是不同用户跑完把成功的数量和各返回码数量统计出来。如果成功数正好等于库存数说明主链路没问题如果成功数小于库存数去查是不是有请求在抢锁阶段被拒绝得太狠适当把wait-time放宽一点如果大于库存数立刻停手查锁的租期和 Lua 脚本。常见改造方向我列在下面按投入产出排序改造点做法验证方式锁粒度变细从activityId:skuId拆到槽位级别每槽独立库存压测看同一 SKU 的 QPS 是否线性上升队列换中间件List 换成消息队列获得重试、死信、削峰三重能力消费端宕机重启后消息是否还在库存预热自动化活动配置生效时发事件触发预热替代定时任务改活动库存后 Redis 值是否同步更新结果查询接口按订单号查状态替代前端轮询库存前端能区分「排队中」「成功」「失败」最后说个习惯。早些年我做秒杀压测只看 QPS 曲线好不好看结果上线当天库存对不上运营手工补了半天单。从那以后我每次上线秒杀活动前都强制走一遍「预热 → 压测 → 对账」的流程先用脚本把库存和已购集合清空重灌再打满并发最后拿数据库订单数和活动库存做减法两个数字对不上就不发版。听着笨但真能挡住大部分事故。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询