简介:这份资源围绕高并发场景下的抢单秒杀需求,给出基于Redis分布式锁的完整实现方案,面向具备SpringBoot与Redis基础的Java后端开发者,以及需要应对超卖、重复抢单等并发问题的电商系统学习者。压缩包共13个文件,约59KB,以5个Java源码文件为核心,配合properties配置、pom.xml依赖描述、jar包、mvnw与cmd构建脚本及README说明,结构精简,便于直接导入运行与二次改造。内容覆盖分布式锁加锁与自动释放、库存原子扣减、消息队列削峰、抢单结果状态返回等关键环节,并涉及幂等性与事务一致性等易踩坑点。目前已有4053人学习下载,适合作为秒杀赛题或课程设计的参考实现,帮助读者理解锁粒度、库存一致性与请求排队的设计取舍,快速搭建可验证的抢单秒杀原型。
1. 抢单秒杀场景下,Redis 分布式锁到底锁住了什么
618 大促那晚,我盯着监控大盘,库存从 100 件瞬间变成 -37 件。超卖了。代码里明明加了synchronized,本地压测也没问题,怎么一上集群就翻车?原因很简单:synchronized锁的是单个 JVM 里的对象,而线上部署了 8 个 Pod,每个 Pod 各锁各的,等于没锁。这就是抢单秒杀场景最典型的坑——你以为锁住了,其实锁了个寂寞。
Redis 分布式锁要解决的核心问题就一个:在多个进程、多个节点之间,保证同一时刻只有一个请求能拿到「抢单资格」。秒杀场景下,这个资格通常对应库存扣减、订单创建、优惠券核销这些不能重复执行的写操作。适合谁看?如果你正在做秒杀系统、抢购活动、限量领取,或者面试被问到「Redis 分布式锁怎么实现」却只能背出SETNX,这篇就是写给你的。接下来我会从最朴素的实现一路推到生产级方案,把参数、坑和验证方法都摊开讲。
2. 从 SETNX 到 SET NX EX:一把锁的最小可用形态
2.1 为什么 SETNX 单独用会死锁
最早大家用SETNX key value加锁,DEL key解锁。逻辑上没问题,但线上跑一周就会遇到「锁永远不释放」的玄学故障。原因:某个 Pod 拿到锁之后,在扣库存的过程中 OOM 被 kill 了,或者网络抖动导致DEL没发出去。锁没有过期时间,后续所有请求全部阻塞,秒杀直接变成「秒等」。
血泪经验:任何分布式锁,加锁时必须带过期时间。这不是优化,是底线。
Redis 2.6.12 之后,SET命令支持NX和EX组合,把「判断不存在」和「设置过期」合并成一条原子命令:
# 加锁:key 不存在才设置,过期时间 10 秒 SET lock:order:1001 "uuid-abc-123" NX EX 10 # 返回 OK 表示加锁成功 # 返回 nil 表示锁已被占用这条命令的原子性由 Redis 单线程模型保证,不会出现「设置了 key 但没设置过期」的中间状态。参数怎么调:EX 10里的 10 秒是锁的自动释放时间,必须大于业务最长执行时间。我一般会先压测出扣库存 + 创建订单的 P99 耗时,然后乘以 3 作为过期时间。比如 P99 是 200ms,就设EX 1或EX 2,不要设太大,否则节点宕机后锁的恢复时间会很长。
2.2 解锁为什么要用 Lua 脚本
有了过期时间,新的问题来了:A 请求加锁成功,业务执行了 12 秒(超过了 10 秒过期时间),锁自动释放。此时 B 请求加锁成功,A 执行完业务后执行DEL lock:order:1001,把 B 的锁给删了。这就是「误删他人锁」。
解决思路:加锁时 value 存一个唯一标识(比如 UUID + 线程 ID),解锁时先判断 value 是不是自己的,是才删。但「判断 + 删除」是两步操作,中间可能被其他命令插入,所以必须用 Lua 脚本保证原子性:
-- unlock.lua -- KEYS[1]: 锁的 key -- ARGV[1]: 加锁时设置的唯一标识 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 endJava 里调用:
// 加锁 String lockValue = UUID.randomUUID().toString(); String result = jedis.set("lock:order:1001", lockValue, "NX", "EX", 10); if (!"OK".equals(result)) { // 加锁失败,直接返回抢单失败 return "抢单失败,请重试"; } try { // 执行扣库存、创建订单等业务 doSeckillBusiness(); } finally { // 解锁:传入 lockValue,只删自己的锁 jedis.eval(unlockLua, Collections.singletonList("lock:order:1001"), Collections.singletonList(lockValue)); }逻辑说明:jedis.set的第四个参数是过期时间单位,EX表示秒,PX表示毫秒。秒杀场景建议用PX设更细的粒度,比如PX 800。Lua 脚本里redis.call('GET', ...)和redis.call('DEL', ...)在同一个脚本中执行,Redis 保证脚本执行期间不会插入其他命令。参数说明:KEYS和ARGV是 Redis 官方推荐的传参方式,避免脚本里硬编码 key 名,方便在集群模式下正确路由。
提示:Lua 脚本不要写太长,秒杀场景下脚本执行时间应控制在 1ms 以内,否则会阻塞其他请求。
3. 锁续期与 Redlock:生产环境绕不开的两个进阶话题
3.1 看门狗机制解决业务超时
即使你把过期时间设成 10 秒,也架不住 GC 停顿、慢 SQL、下游接口超时。业务没执行完锁就过期了,其他请求进来,库存照样超卖。常见做法是引入「看门狗」:加锁成功后,起一个后台线程,每隔过期时间的 1/3 去检查锁是否还在,如果在就重置过期时间。
Redisson 框架内置了这个机制。用 Redisson 加锁的代码大致是这样:
// 获取 Redisson 客户端 RedissonClient redisson = Redisson.create(config); // 获取锁对象,key 为 lock:order:1001 RLock lock = redisson.getLock("lock:order:1001"); try { // 尝试加锁,最多等待 3 秒,加锁后自动续期(默认 30 秒过期,每 10 秒续一次) boolean acquired = lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { return "抢单失败,请重试"; } // 执行秒杀业务 doSeckillBusiness(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return "系统繁忙"; } finally { // 只有当前线程持有锁才释放,避免误删 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }逻辑说明:tryLock(3, TimeUnit.SECONDS)的第一个参数是最大等待时间,第二个参数是时间单位。Redisson 的看门狗默认续期到 30 秒,每 10 秒续一次。参数怎么改:在Config里通过lockWatchdogTimeout设置,单位毫秒。我一般设 30000,不要设太小,否则网络抖动容易续期失败;也不要设太大,节点宕机后锁释放慢。
注意:看门狗线程是 JVM 级别的,如果整个 Pod 挂了,看门狗也停了,锁会在 30 秒后自动过期。这是可接受的,因为 Pod 挂了业务本来就没法执行。
3.2 Redlock 的争议与适用边界
Redis 官方曾提出 Redlock 算法:向 N 个独立的 Redis 节点(通常 5 个)依次加锁,如果在多数节点(N/2+1)上加锁成功且总耗时小于锁有效期,就认为加锁成功。听起来很美好,但 Martin Kleppmann 和 Antirez 有过著名论战,核心争议是:Redlock 依赖系统时钟,如果某个节点时钟跳变,安全性无法保证。
我的实操建议:秒杀场景下,如果你用的是单节点 Redis 或主从 + 哨兵,不要盲目上 Redlock。原因:Redlock 需要多个独立节点,运维成本高;而且秒杀场景通常可以接受「极少量超卖」,用「Redis 单节点锁 + 数据库唯一索引兜底」更简单可靠。数据库唯一索引兜底的意思是:订单表对user_id + goods_id建唯一索引,即使锁失效导致两个请求同时扣库存,插入订单时也会有一个失败,然后回滚库存。
注意:Redlock 不是银弹。如果你的业务绝对不能超卖(比如金融扣款),应该用 ZooKeeper 或 etcd 这类基于共识协议的锁,而不是 Redis。
4. 抢单秒杀落地:库存扣减的三种写法与压测对比
4.1 先扣库存再下单,还是先下单再扣库存
这是秒杀系统设计里最容易吵架的问题。两种顺序:
- 先扣库存再下单:锁内执行
DECR stock:1001,如果返回值 >= 0 说明扣减成功,然后创建订单。优点是库存不会超卖;缺点是如果创建订单失败,需要回滚库存(INCR),回滚逻辑要处理好。 - 先下单再扣库存:锁内先插入订单,再
DECR库存。优点是订单创建失败不会影响库存;缺点是如果库存扣减失败,订单已经插入了,需要删订单。
我一般用第一种,因为秒杀场景下库存是核心资源,必须优先保证不超卖。回滚库存用INCR即可,但要注意:回滚操作也要在锁内执行,否则可能和正常扣减交叉。
// 锁内:扣库存 + 创建订单 Long remaining = jedis.decr("stock:goods:1001"); if (remaining < 0) { // 库存不足,回滚(加回去) jedis.incr("stock:goods:1001"); return "已售罄"; } try { // 创建订单,写入数据库 orderService.createOrder(userId, goodsId); } catch (Exception e) { // 订单创建失败,回滚库存 jedis.incr("stock:goods:1001"); return "下单失败,请重试"; } return "抢单成功";逻辑说明:decr返回的是扣减后的值。如果返回 -1,说明之前库存是 0,这次扣减导致负数,需要incr回滚。参数说明:库存 key 建议加业务前缀,比如stock:goods:1001,方便在 Redis 可视化工具(如 Another Redis Desktop Manager)里按前缀筛选。回滚操作必须和扣减在同一个锁内,否则并发场景下回滚可能覆盖其他请求的扣减结果。
4.2 压测数据:锁粒度对 QPS 的影响
我在本地用 4 核 8G 的机器,Redis 单节点,JMeter 模拟 500 并发抢 100 件库存,对比了三种锁粒度:
| 锁粒度 | 锁 key 示例 | 平均 QPS | 超卖数量 | 说明 |
|---|---|---|---|---|
| 全局锁 | lock:seckill | 320 | 0 | 所有商品共用一把锁,性能最差 |
| 商品级锁 | lock:goods:1001 | 1850 | 0 | 每个商品独立锁,推荐 |
| 用户级锁 | lock:user:888 | 4200 | 0 | 同一用户不能重复抢,但不同用户可并行 |
结论很明确:锁粒度越细,QPS 越高。但用户级锁不能防止同一商品被不同用户超抢,所以通常用「商品级锁 + 数据库唯一索引」组合。商品级锁保证库存扣减原子性,唯一索引保证同一用户不会重复下单。
提示:压测时记得把 Redis 连接池调大,
maxTotal至少设为并发数的 1.5 倍,否则会出现redis command timed out错误。
5. 避坑指南:分布式锁在秒杀场景的 5 个翻车现场
5.1 锁过期了业务还没跑完,库存超卖
现象:压测时库存 100 件,最终卖出 103 件。查日志发现锁的过期时间是 5 秒,但某些请求处理了 6 秒。
原因:业务执行时间超过锁过期时间,锁自动释放,其他请求乘虚而入。
解决:用 Redisson 看门狗自动续期,或者把过期时间设大(比如 30 秒),同时优化业务逻辑,把耗时操作(如发短信、写日志)移到锁外异步执行。
5.2 误删他人锁,导致锁形同虚设
现象:A 请求释放锁后,B 请求刚加的锁被删了,C 请求立刻加锁成功,三个请求同时操作库存。
原因:解锁时没有校验 value,直接DEL。
解决:解锁必须用 Lua 脚本,先GET判断 value 是否等于自己的唯一标识,再DEL。Redisson 的unlock()内部已经做了这个校验。
5.3 Redis 主从切换导致锁丢失
现象:主节点加锁成功,还没同步到从节点,主节点挂了,从节点升为主,锁没了,其他请求加锁成功。
原因:Redis 主从复制是异步的,故障切换时可能丢数据。
解决:秒杀场景下,如果对超卖零容忍,用 Redlock 或 ZooKeeper。如果可接受极少量超卖,用数据库唯一索引兜底。我一般会在订单表加UNIQUE KEY uk_user_goods (user_id, goods_id),即使锁失效,重复插入也会失败。
5.4 锁等待时间设太长,请求堆积
现象:秒杀开始后,大量请求卡在tryLock等待,Tomcat 线程池被打满,整个服务不可用。
原因:tryLock的等待时间设成了 10 秒,500 并发下所有线程都在等锁。
解决:秒杀场景下,锁等待时间应该设得很短,比如 100ms 或 200ms。抢不到就立刻返回「抢单失败」,让用户重试或走排队。不要用lock()无限等待。
5.5 锁的 key 设计不合理,导致锁冲突
现象:不同商品的秒杀互相阻塞,QPS 上不去。
原因:锁 key 用了全局的lock:seckill,所有商品共用一把锁。
解决:锁 key 按商品维度设计,比如lock:goods:{goodsId}。如果同一个商品有多个活动,可以再加活动维度:lock:goods:{goodsId}:activity:{activityId}。
6. 用 Redis 命令和日志验证锁是否真的生效
6.1 用 MONITOR 命令观察锁的加解锁过程
Redis 的MONITOR命令可以实时打印所有执行的命令,适合在测试环境验证锁逻辑:
# 在 Redis 客户端执行 MONITOR # 然后在另一个终端发起秒杀请求,观察输出: # "SET" "lock:goods:1001" "uuid-abc" "NX" "PX" "800" # "EVAL" "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" "1" "lock:goods:1001" "uuid-abc"逻辑说明:MONITOR会打印每条命令及其参数。如果看到SET后面跟着NX和PX,说明加锁命令正确;如果看到EVAL脚本里包含GET和DEL,说明解锁逻辑正确。注意:MONITOR会降低 Redis 性能,只在测试环境用,不要在生产环境长时间开启。
6.2 用 Redis 日志排查锁超时问题
Redis 日志里如果出现command timed out或slowlog记录,说明锁操作可能阻塞了。查看慢日志:
# 查看慢查询日志,阈值设为 10ms CONFIG SET slowlog-log-slower-than 10000 SLOWLOG GET 10参数说明:slowlog-log-slower-than单位是微秒,10000 表示 10ms。SLOWLOG GET 10返回最近 10 条慢查询。如果发现EVAL脚本执行时间超过 1ms,说明 Lua 脚本太重,需要拆分或优化。
6.3 一个我常用的验证习惯
每次上线新的锁逻辑,我会在预发环境跑一个「并发 200、库存 10」的脚本,然后用INCR统计实际卖出数量。如果卖出数量等于 10,说明锁生效;如果大于 10,说明锁有漏洞。这个习惯帮我拦住了至少三次超卖事故。
另外,我会在代码里加一行日志:log.info("lock acquired, key={}, value={}", lockKey, lockValue),配合 Redis 的MONITOR输出,能快速定位是加锁失败还是解锁失败。踩坑多了就明白,分布式锁的问题,90% 都能通过「看命令 + 看日志」定位,剩下的 10% 才是玄学。
希望帮到你。
本文还有配套的精品资源,点击获取