资讯详情

资讯详情

SpringBoot+Redis分布式锁与队列实现秒杀防超卖

简介围绕 Redis 分布式锁实现抢单秒杀的实战型学习包面向已掌握 Java 基础、希望理解高并发场景下并发控制与库存一致性设计的中级开发者。内容以 SpringBoot 工程为骨架覆盖分布式锁加锁与释放、库存原子扣减、请求排队削峰、抢单结果状态返回等关键环节可作为秒杀类业务的参考实现与调试底稿。压缩包共 13 个文件、约 59KB以 5 个 java 源码为主体承载锁与业务逻辑2 个 properties 与 xml、jar 支撑配置和依赖另有 mvnw、mvnw.cmd 便于快速启动md 与 gitignore 用于说明与版本管理结构精简、便于逐层阅读。已有 4057 人学习下载说明该场景具备较强通用性。读者可从中获得一套可运行的最小工程、锁超时与幂等处理的落地思路以及并发扣减与队列协同时的排错参照适合本地跑通后改写为自己的业务模型。1. 从一次超卖事故说起库存 100 件开抢 3 秒后订单表里躺着 137 条记录——这是我第一次做 Redis 分布式锁抢单秒杀时遇到的场面。问题不在前端按钮也不在数据库性能而在于「判断还有没有库存」和「真正扣掉库存」之间那段没人管的空隙两个线程同时读到 stock1都认为自己能卖于是都写了订单。用 Redis 分布式锁把这段空隙焊死是这套方案的第一层再往后接一条队列削峰把瞬时几万请求摊到几百毫秒里慢慢落库超卖和重复下单才会同时消失。这套结构适合两类人正在做秒杀、抢单类毕设课设需要一个既能跑通又能讲清楚技术取舍的学生以及要给营销活动临时搭一套限购链路的开发者。下面按需求边界、方案取舍、锁实现、队列落地、避坑、验收的顺序走一遍代码可以直接改成自己的包名。2. 需求拆解与技术选型三条硬指标定架构四种方案做取舍2.1 秒杀抢单的真实需求边界很多人一上来就问「QPS 能到多少」但秒杀场景的难点从来不是单纯的 QPS而是极短时间窗口内的并发写冲突。一场活动 10 点开抢前 2 秒可能涌进来 5 万请求2 秒之后流量断崖式回落平时这套系统可能一天只有几百次访问。拿常态流量去设计这种脉冲流量本身就是错的方向。我把需求拆成三条硬指标后面所有设计都围绕它们转不超卖库存 100 件最终成功订单数必须小于等于 100多一条都算事故。这条是红线没有「尽量」的说法。不重复同一个用户对同一件秒杀商品只能有一单重复提交、网络重试、连点器都不能突破。可降级Redis 挂了、队列堆了、下单服务超时系统要能优雅拒绝而不是全线雪崩。秒杀活动宁可少卖也不能把主站拖垮。除开这三条还有两个软指标值得写进方案的验收章节一是响应时间用户在页面上点「立即抢购」后 200ms 内必须拿到明确的「成功 / 已售罄 / 排队中」三种结果之一不能转圈二是数据最终一致Redis 里的库存和 MySQL 里的订单量在活动结束后必须对得上差一条都要能查出来是谁。提示把三条硬指标写进需求文档的第一页后面每次技术选型有争议时回来对一遍能省掉大量无谓的讨论。2.2 防超卖四种方案对比数据库行锁、乐观锁、Redis 锁、队列串行同一个「防止并发写坏库存」的问题至少有四种解法很多课设的失分点就在选了不合适的那一种也不解释为什么不选别的。方案核心做法单机吞吐超卖风险主要问题数据库悲观锁select ... for update锁行低几百 TPS无行锁持有期间连接占死高并发下大量请求排队超时数据库乐观锁update ... where stock 0或 version 版本号中无大量失败重试打回数据库热点行竞争激烈Redis 分布式锁SETNX 抢锁抢到的才允许扣减高无锁正确时锁过期、误删、续期都要处理实现复杂度高队列串行化请求入队单消费者顺序扣减高无前端拿不到同步结果要做「排队中」状态和结果查询实际项目里我一般选后两者组合Redis 锁负责「守住最后一道库存判断」队列负责「把下单的写操作拉出请求链路」。原因很直接——锁解决的是正确性队列解决的是吞吐和削峰两个问题不该指望一个组件全包。纯数据库乐观锁的方案在很多教程里被推荐理由是「不用引入中间件」。但热点商品的库存行在开抢瞬间会被上万次 update 打到InnoDB 行锁排队 大量失败重试数据库连接池很快就满了。这种方案适合库存充足、并发几百的场景套到秒杀上就是典型的选型翻车。2.3 技术栈与工程分层SpringBoot 里五个包各管什么技术栈按输入定SpringBoot 做服务框架Redis 做锁和库存缓存MySQL 落订单队列用 Redis 自带的数据结构先扛住后面第 4 章会讲为什么前期不必上专业消息队列。工程按职责切成五层避免所有逻辑堆在 Service 里seckill-demo ├── controller # 只做参数校验和结果包装不写业务 ├── service # 编排抢锁 - 扣库存 - 入队 - 返回排队状态 ├── lock # 分布式锁工具类 Lua 脚本 ├── mq # 队列生产与消费 └── mapper # MySQL 读写分层的判断标准很简单任何一层改动时不需要同时改另外两层的代码。比如把 Redis List 换成专业消息队列只动mq包service里的编排逻辑一行不改。这个约束在课设答辩时特别好用老师问「你怎么保证可扩展」直接指给他看接口边界。配置上至少要在application.yml里显式声明连接池和超时别用默认值spring: redis: host: 127.0.0.1 port: 6379 timeout: 2000ms # 命令超时避免锁请求把线程挂死 lettuce: pool: max-active: 64 # 按压测结果调不是越大越好 max-wait: 500mstimeout这一项最容易被忽略。默认无超时的情况下Redis 一旦网络抖动抢锁的线程会一直挂着Web 容器线程池几十秒就被打满表现是整个服务无响应——而日志里什么错都没有排查起来相当玄学。把超时压到 2 秒宁可这次抢购失败也不能拖垮线程池。3. 分布式锁落地SETNX 加锁、Lua 释放、看门狗续期与分段锁3.1 一把合格的锁要满足的四个条件写锁之前先立标准否则代码写完自己都说不清哪里可能出问题。我判断一把分布式锁合格与否看四条互斥同一时刻只有一个客户端持有锁。这是底线靠 Redis 单线程执行命令保证。不死锁持锁进程崩了锁必须能自动释放。靠给 key 设过期时间保证。不误删A 的锁过期后 B 拿到了锁A 执行完不能把 B 的锁删掉。靠 value 存唯一标识 释放时比对保证。可续期业务执行时间超过锁过期时间时要么续期要么业务必须能安全中断。这条最容易被漏掉。前三条是「不加就会立刻出问题」第四条是「并发一上来才出问题」。很多课设的锁只做了前两条本地跑几百并发一切正常一上压测就出现超卖原因就在这里。3.2 RedisTemplate 手写加锁setIfAbsent 的三个参数不能省SpringBoot 里加锁的核心就是一条命令SET key value NX PX timeout。RedisTemplate的setIfAbsent重载方法正好对应关键是四个参数一个都不能少。Component public class RedisLock { private static final String LOCK_PREFIX seckill:lock:goods:; /** 锁过期时间必须大于业务 P99 耗时否则锁会提前失效 */ private static final long EXPIRE_MS 10_000L; Resource private StringRedisTemplate redisTemplate; /** * 尝试加锁不阻塞。返回 token 表示加锁成功null 表示失败。 * token 必须回传给 unlock用于校验锁的归属。 */ public String tryLock(Long goodsId) { String key LOCK_PREFIX goodsId; // UUID 保证每个客户端的 token 唯一避免误删别人的锁 String token UUID.randomUUID().toString(); Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, token, EXPIRE_MS, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok) ? token : null; } }三个参数各自的含义key是锁的粒度这里按商品维度加锁token是持有者凭证用 UUID 而不是线程 ID因为跨 JVM 后线程 ID 会重复EXPIRE_MS是兜底过期时间防止持锁进程挂掉后锁永远不释放。这里最容易犯的错是先setIfAbsent(key, value)再单独expire(key, 10)。两条命令之间如果服务重启锁就永久留在 Redis 里后续所有请求全部失败。setIfAbsent带过期时间的重载是单条命令原子执行没有这个窗口。EXPIRE_MS设多少我的经验是取业务 P99 耗时的 3 倍左右。扣库存 写订单这套逻辑正常 20ms 内完成10 秒的过期时间留了足够余量。设成 500ms 看着「更安全」实际业务稍有抖动锁就提前释放超卖立刻出现。3.3 释放锁为什么必须走 Lua一次 get 加一次 del 的坑释放锁的逻辑只有一句「删掉 key」但直接delete(key)会出事线程 A 的锁到期自动释放线程 B 抢到了同一把锁此时 A 执行完毕去删 key删掉的是 B 的锁。B 以为自己还持锁实际锁已经没了第三个线程 C 也能进来两个线程同时扣库存。正确做法是「比对 token 后再删」而比对和删除必须是原子的所以要写 Lua 脚本-- unlock.lua只有 value 与传入 token 一致时才删除避免误删他人锁 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧用DefaultRedisScript加载并执行注意泛型返回类型要写成Long写成Boolean会得到反序列化异常private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript(unlock.lua 的内容, Long.class); public boolean unlock(Long goodsId, String token) { if (token null) { return false; } Long result redisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(LOCK_PREFIX goodsId), token); return Long.valueOf(1L).equals(result); }参数说明KEYS[1]是锁 keyARGV[1]是加锁时写入的 token。返回 1 表示确实删掉了自己的锁返回 0 表示锁已经不属于自己过期后被别人拿走这种情况必须当作业务失败处理不能继续往下写订单否则就是在没有锁保护的情况下改库存。很多教程把返回 0 当成功忽略掉理由是「反正业务快结束了」。这个习惯在低并发下看不出问题压测一上 1000 并发立刻见血。我踩过这个坑改法就是老老实实判断返回值返回 0 直接回滚事务并把订单标记成失败。3.4 续期、锁粒度与 Redisson 的取舍业务执行时间不可控时比如扣库存之后还要调第三方接口10 秒的锁可能不够用。两种处理方式一是自己起一个续期线程每 3 秒检查锁是否还在、token 是否还是自己的是就重新expire到 10 秒业务结束后停掉线程。代码不复杂但要处理好线程回收否则线程池会被慢慢吃满。二是直接上 Redisson它的看门狗机制默认 30 秒过期每 10 秒自动续期一次业务结束调用unlock()时停止。代价是引入一个额外依赖加锁流程对课设来说成了黑匣子答辩时容易被追问「它内部怎么实现的」。我的建议课设和毕设用手写版本因为整个链路的每一步你都能讲清楚答辩问「锁过期了怎么办」你能答出续期线程的检查周期和停止条件生产项目用 Redisson社区验证过的续期逻辑比自己写的可靠省下来的时间去做幂等和监控更值。还有一个常被忽略的点是锁粒度。按商品加锁意味着同一件商品的所有请求串行库存 100 件、1 万请求锁竞争会非常激烈。优化手段是分段锁把库存拆成 10 份key 变成seckill:stock:{goodsId}:{0..9}请求按userId % 10路由到对应分段锁也按分段加。这样锁竞争降到原来的十分之一代价是要处理「某个分段卖完了但其他分段还有货」的边界通常允许一定概率的提前售罄或者做一次跨分段再平衡。4. 库存与队列预热模型、异步下单与幂等兜底4.1 库存预热与 Redis Key 设计秒杀开始前必须把库存从 MySQL 搬到 Redis否则开抢瞬间所有请求都打到数据库锁还没起作用数据库先挂了。预热在活动开始前 5 分钟由一个定时任务触发public void warmUp(Long goodsId) { // 以数据库为准避免重复预热把库存刷大 Integer stock goodsMapper.selectStock(goodsId); String key seckill:stock: goodsId; redisTemplate.opsForValue().set(key, String.valueOf(stock), 2, TimeUnit.HOURS); // 标记活动已预热防止任务重跑 redisTemplate.opsForValue().set(seckill:warm: goodsId, 1, 2, TimeUnit.HOURS); }Key 设计上我习惯按业务:对象:标识三段命名seckill:stock:{goodsId}表示秒杀库存seckill:lock:goods:{goodsId}表示商品锁seckill:idem:{userId}:{goodsId}表示幂等标记。好处是排查时能用SCAN seckill:*一次看全运维交接也不容易看漏。过期时间设 2 小时是有意的活动结束后 key 自动清理不用写额外的清理逻辑同时避免万一预热任务被重复调用用seckill:warm:标记做幂等防止把库存从 100 刷回 100 导致超卖。库存扣减必须用 Lua 保证「判断 扣减」原子完成这是整条链路最核心的一段脚本-- deduct_stock.lua库存大于 0 才扣减返回 1 成功 / 0 售罄 / -1 key 不存在 local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -1 end if stock 0 then return 0 end redis.call(decr, KEYS[1]) return 1三种返回值要分别处理1继续走下单0返回「已售罄」-1说明库存没预热或已过期属于配置错误要打告警日志而不是当成售罄。把-1和0混为一谈活动当天会出现「明明有库存却全卖不出去」的故障而且前端只显示售罄没有任何错误线索。4.2 订单表与秒杀商品表的字段设计库里至少两张表。商品表记录活动配置和真实库存订单表记录每一笔成功下单两张表都要有能支撑幂等的唯一约束。CREATE TABLE seckill_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL COMMENT 商品ID, stock INT NOT NULL COMMENT 剩余库存, start_time DATETIME NOT NULL COMMENT 活动开始时间, end_time DATETIME NOT NULL COMMENT 活动结束时间, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seckill_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_user_goods (user_id, goods_id) -- 幂等的最后一道防线 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_user_goods这个唯一索引是整套方案里最便宜的保险。Redis 锁失效、幂等 key 过期、消费者重复消费任何一种情况导致重复插入数据库会直接抛DuplicateKeyException捕获后当成功处理即可。加了它之后即使上层全部出问题订单表也不会出现一个用户两单的情况。version字段在纯 Redis 方案里其实用不上但保留它有意义活动结束做库存对账时可以用乐观锁的方式把 Redis 的最终库存回写 MySQL避免覆盖掉中间的手工调整。这种「留一个不用的字段」在方案设计里不算坏习惯前提是你得说清它为什么留着。4.3 队列选型与消费端下单同步下单的问题是用户请求要等锁、等扣库存、等写库整条链路 50ms 起步1 万并发就是 500 秒的排队。改成异步后请求只做「抢锁 扣 Redis 库存 入队」三件事10ms 内返回「排队中」真正的写库交给消费者慢慢做。队列选型上前期我一般用Redis ListleftPush入队消费者用rightPop阻塞取天然 FIFO且不用额外部署中间件。数据量大到需要多消费者分组和消息确认时再换 Redis Stream 或专业消息队列接口不变。Service public class OrderConsumer { private static final String QUEUE_KEY seckill:queue:order; Resource private StringRedisTemplate redisTemplate; Resource private SeckillOrderService orderService; /** 消费循环单线程串行写库避免并发插入撞唯一索引 */ Scheduled(fixedDelay 20) public void consume() { while (true) { // 阻塞 1 秒取一条取不到就退出本轮把线程让出去 String payload redisTemplate.opsForList() .rightPop(QUEUE_KEY, 1, TimeUnit.SECONDS); if (payload null) { return; } try { orderService.createOrder(payload); // 内部靠唯一索引兜底幂等 } catch (DuplicateKeyException e) { // 重复下单视为已成功直接丢弃消息 } catch (Exception e) { // 其他异常进重试队列配合死信做人工介入 redisTemplate.opsForList().leftPush(seckill:queue:order:retry, payload); } } } }fixedDelay 20表示上一轮消费结束后再等 20ms 开始下一轮避免空转把 CPU 打满。rightPop的 1 秒超时是关键参数不设超时线程会一直阻塞在 Redis 上容器关闭时无法优雅退出设太长一批消息处理完要等很久才进入下一轮。1 秒是个折中值。消费端单线程串行是有意为之。多线程消费虽然吞吐更高但会重新引入并发写库的竞争还得处理消息乱序。秒杀的瓶颈在入口的锁不在消费端串行足够跑满几千 TPS简单可靠比性能重要。4.4 幂等键设计与对账补偿异步链路上有三个地方会重复用户连点、网关重试、消费者重启后重放未确认的消息。幂等要在多个层次都做不能只靠一处。第一层是请求入口的幂等 keyseckill:idem:{userId}:{goodsId}用setIfAbsent抢抢不到直接返回「请勿重复提交」过期时间设成活动时长。第二层是订单表的uk_user_goods唯一索引兜住所有漏网的重复。第三层是对账任务活动结束后每小时跑一次比对 Redis 剩余库存和MySQL 初始库存 - 有效订单数差值超过阈值就告警。对账逻辑不复杂但它是唯一能发现「悄悄错账」的手段。锁有问题、队列丢消息、消费者异常退出这些情况都不会报错只会让数字慢慢偏掉不对账就永远发现不了。5. 避坑清单抢单链路上最容易翻车的五个点5.1 锁相关的三个坑坑一锁过期了业务还没跑完。现象是压测时偶发超卖日志里完全看不出异常复现也复现不了。原因是EXPIRE_MS设得比业务实际耗时短锁自动释放后另一个线程进来同时扣库存。 解决方式是压测时打日志记录持锁时长取 P99 的 3 倍作为过期时间业务确实可能超时的加续期线程或者把远程调用移出锁的范围。我一般会在锁工具类里加一行耗时打点超过过期时间一半就 warning提前暴露而不是等事故发生。坑二删了别人的锁。现象是低并发正常高并发偶发超卖而且同一批请求里有的成功有的失败毫无规律。 原因是释放锁时直接delete(key)没有比对 token。A 的锁过期 → B 拿到锁 → A 执行完删掉 B 的锁 → C 也能进来。解决方式就是第 3 章那段 Lua 脚本比对 value 再删并且把返回 0 当失败处理。坑三锁的粒度选错。现象有两种极端按用户加锁同一件商品下不同用户并发扣库存照样超卖按全局加了一个「下单锁」所有商品所有用户串行吞吐掉到几百。 正确的粒度是「竞争资源的维度」库存挂在商品上就按商品加锁。商品 SKU 多、单商品并发极高时再上分段锁。判断标准很简单锁保护的资源是哪一个锁就加到哪一个维度上。5.2 队列与数据一致性的坑坑四消息进了队列但消费失败订单凭空消失。现象是 Redis 库存扣了用户页面显示排队中但订单表里查不到用户投诉「抢到了没订单」。 原因是消费者抛异常后消息被直接丢弃没有重试也没有死信。解决方式是消费失败先进 retry 队列重试 3 次仍失败转入死信队列并告警由人工或定时任务核对补偿。同时「排队中」状态要有超时机制超过 30 秒没有变成成功就主动查一次订单查不到就回滚 Redis 库存。坑五先扣库存再判断扣减结果或者反过来。现象是偶尔出现库存变成负数或者明明还有库存却提示售罄。 根因是「读库存」和「扣库存」分成了两条命令中间被其他线程插了队。解决办法只有一个用 4.1 的 Lua 脚本把判断和扣减合成一次 Redis 调用。任何「先 get 再 decr」的写法都是错的不管中间加了多少次检查。5.3 环境与压测的坑坑六本地单机 Redis 压测通过上线集群就出事。现象是本地 500 并发压测零超卖部署到多节点环境后开始出现重复订单。 原因是本地只有一个应用实例、一个 Redis 节点锁的互斥天然成立上线后多实例共享 Redis如果锁工具类里用了ThreadLocal存 token 或用了实例级变量做标记跨实例就完全失效。解决方式是所有锁状态只能存在 Redis 里应用侧保持无状态压测环境至少起两个应用实例。注意压测脚本和正式环境用同一套并发模型别用单线程循环调接口冒充并发——那不叫压测那叫顺序执行。6. 验收与答辩用三组数据证明这套锁真的不超卖验收不是「跑通了就算完」而是要拿出可复现的证据。我一般准备三组数据正好对应第 2 章那三条硬指标。第一组是超卖验证库存设 100用并发工具打 2000 个请求统计订单表里goods_id对应的有效订单数。合格的输出是「订单数 100Redis 剩余库存 0失败请求 1900」。这里要特别强调统计的是数据库里的真实订单数不是接口返回的成功数——接口返回成功但库里没单那是另一种故障。第二组是幂等验证同一用户对同一商品连发 50 次请求最终订单表里该用户该商品只能有 1 条记录。这组最容易被跳过但答辩时老师很爱问「用户连点怎么办」直接展示uk_user_goods唯一索引和这组数据比讲十句设计思路都有用。第三组是性能与降级验证记录不同并发下的 P99 响应时间和吞吐同时模拟 Redis 不可用确认系统返回「活动太火爆请稍后再试」而不是 500 错误页。指标大致如下指标目标值说明成功订单数等于库存数多一条少一条都不合格重复订单数0以唯一索引为最终判据P99 响应时间 200ms从点击到拿到「排队中」峰值吞吐 3000 TPS单实例4 核 8GRedis 故障时返回友好提示不能出现 500 或白屏压测脚本我用 Java 写CountDownLatch让所有线程同时起跑这样才是真并发int threads 2000; CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threads); ExecutorService pool Executors.newFixedThreadPool(200); for (int i 0; i threads; i) { long userId 10000 i; pool.submit(() - { try { start.await(); // 全部就位后统一放行 seckillService.seckill(userId, 1L); // 商品 ID 固定为 1 } catch (Exception ignored) { } finally { done.countDown(); } }); } start.countDown(); done.await(60, TimeUnit.SECONDS); // 最多等 60 秒防止卡死 pool.shutdown();线程数 2000、线程池 200、超时 60 秒这三个参数要按自己机器调。线程池小于线程数是为了制造真实的排队全部同时打进 Redis 反而不符合真实场景——真实用户也是排队进来的。跑完立刻查库select count(*) from seckill_order where goods_id 1数字必须精确等于 100。答辩环节通常集中在三个问题上提前把答案压成两三句话说清楚「为什么不用数据库乐观锁」——热点行在开抢瞬间会被上万次 update 打到失败重试把连接池打满锁方案把竞争挡在数据库前面。「锁过期了业务没跑完怎么办」——过期时间取业务 P99 的三倍同时有续期线程兜底超时打点提前告警。「Redis 挂了怎么办」——降级返回友好提示活动库存以数据库为准做对账宁可少卖不出错账。这里面最值钱的不是哪段代码而是「先定指标、再压测、最后拿数据说话」这条线。我早期做课设时是写完代码直接演示老师问一句「你怎么证明不超卖」就卡住了后来每次做这类项目我都会在写第一行业务代码之前把验收表和压测脚本先建好跑不出那三组数据就不算做完。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →