☰
Redis分布式锁实现抢单秒杀:从SETNX到Redisson的选型与压测避坑
2026/10/6 19:18:16 网站建设 项目流程

简介:这份资源面向Java后端开发者与高并发场景学习者,聚焦电商秒杀抢单中的库存超卖与并发控制难题,提供一套基于Redis分布式锁的完整实现方案。压缩包共13个文件,约59KB,以5个Java源码文件为核心,配合properties配置、pom.xml依赖描述、mvnw与cmd构建脚本及README说明文档,结构精简,便于直接导入SpringBoot工程运行调试。内容围绕分布式锁加锁与自动释放、Redis原子操作扣减库存、消息队列削峰、抢单结果状态返回等关键环节展开,并涉及幂等性与事务一致性等进阶考量。目前已有4053人学习下载,适合希望理解秒杀系统设计思路、对照代码梳理锁与队列协作流程的开发者参考,也可作为课程设计或面试准备的实践素材。

1. 抢单秒杀场景下,Redis 分布式锁到底锁住了什么

618 或整点秒杀开始的那一秒,同一件商品可能被几千个请求同时命中。数据库行锁扛不住这个量级,本地synchronized只在单机 JVM 内有效,多实例部署下形同虚设。这时候大家都会想到用 Redis 分布式锁来兜底:让所有实例去抢同一把锁,抢到的那个才有资格扣库存。听起来简单,但真正上线后你会发现,锁没锁住、锁提前释放、锁被别人删掉,这些玄学问题一个接一个。

这篇笔记就围绕「redis 分布式锁实现抢单秒杀」这条主线,把锁的选型、加锁解锁的原子性、锁续期、库存扣减的落库时机,以及压测时最容易翻车的几个点讲透。适合已经会用 Redis 做缓存、但还没在秒杀链路里真正压过分布式锁的后端同学,也适合正在准备分布式锁面试题、想把答案落到代码上的人。读完你应该能自己搭一套可压测的抢单 Demo,并知道每个参数为什么这么设。

2. 从 SETNX 到 Redisson:抢单锁的三种实现与选型理由

2.1 为什么抢单场景不能只用 SETNX

最早大家用SETNX key value加锁,配合EXPIRE设过期时间。问题在于这两条命令不是原子的:如果SETNX成功之后、EXPIRE执行之前服务挂了,这把锁就永远不会过期,后续所有抢单请求全部阻塞。后来 Redis 2.6.12 之后SET命令支持NX和EX参数,一条命令搞定加锁和过期:

SET lock:order:1001 uuid-value NX EX 10

这条命令的含义是:只有当lock:order:1001不存在时才设置成功,同时 10 秒后自动过期。返回值是OK表示抢到锁,nil表示没抢到。uuid-value必须是每个请求唯一的,后面解锁时要用它来判断「这把锁是不是我加的」,否则可能误删别人的锁。

但只用SET NX EX还不够。抢单业务里,扣库存、生成订单、写流水可能超过 10 秒,锁提前过期后另一个请求进来,就会出现两个请求同时扣同一件商品。这就是锁续期要解决的问题。

2.2 Redisson 的看门狗机制与抢单锁参数

生产环境我一般直接用 Redisson,它把加锁、续期、可重入、解锁的 Lua 脚本都封装好了。核心是看门狗(watchdog):默认锁过期时间 30 秒,后台线程每 10 秒检查一次,如果业务还没执行完就自动续到 30 秒。注意看门狗只在「不指定 leaseTime」时才生效,一旦你手动传了leaseTime,Redisson 就不会自动续期。

// 获取锁对象,key 按商品维度隔离,避免不同商品互相阻塞 RLock lock = redissonClient.getLock("lock:seckill:sku:" + skuId); try { // 尝试加锁,最多等 3 秒,不指定 leaseTime 以启用看门狗 boolean locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { // 没抢到锁直接返回,不要在这里自旋重试,会把 Redis 打满 return Result.fail("抢购太火爆,请重试"); } // 临界区:查库存、扣减、写订单 return seckillService.doSeckill(userId, skuId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail("系统繁忙"); } finally { // 只有当前线程持有锁时才释放,避免误删 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

参数上,tryLock的等待时间设 3 秒比较稳:太短会导致大量请求直接失败,太长会让线程堆积。锁 key 一定要带skuId,如果所有商品共用一把锁,秒杀时不同商品之间会互相排队,吞吐直接掉一个数量级。isHeldByCurrentThread()这个判断不能省,它对应 Redisson 内部用 Hash 结构记录持有线程 ID 的机制,是解锁安全的关键。

2.3 三种方案对比与选型建议

方案原子加锁自动续期可重入适用场景
SETNX + EXPIRE否否否仅学习,不推荐生产
SET NX EX + Lua 解锁是否否逻辑简单、耗时可控的短任务
Redisson RLock是是是抢单秒杀、长事务临界区

选型逻辑很直接:抢单链路里临界区包含数据库操作,耗时不确定,必须要有续期能力,所以 Redisson 是默认选择。如果你不想引入 Redisson,至少也要用SET NX EX加 Lua 解锁脚本,并且把过期时间设得足够覆盖 P99 耗时。

3. 把锁落到抢单链路:库存扣减与 Lua 原子脚本

3.1 库存预热与扣减的原子性

秒杀开始前,先把库存从数据库加载到 Redis,用 String 类型存seckill:stock:skuId。扣减时不能先GET再DECR,这两步之间会有并发窗口。正确做法是用 Lua 脚本把「判断库存是否大于 0」和「扣减」合成一个原子操作:

-- KEYS[1] = 库存 key,ARGV[1] = 扣减数量 local stock = redis.call('GET', KEYS[1]) if not stock then return -1 -- 库存未预热 end if tonumber(stock) < tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 -- 扣减成功

返回值约定:-1表示 key 不存在,0表示库存不够,1表示扣减成功。Java 侧用DefaultRedisScript加载这段脚本,通过redisTemplate.execute(script, keys, args)调用。Lua 脚本在 Redis 单线程模型里执行,天然不会被其他命令打断,这是它比多条命令组合更可靠的根本原因。

3.2 分布式锁与 Lua 扣减的配合顺序

有了原子扣减脚本,是不是就不需要分布式锁了?不是。Lua 保证的是「扣库存」这一步的原子性,但抢单链路还有「同一用户不能重复下单」「扣完库存要写订单」这些跨 key、跨系统的约束。我一般的顺序是:

  1. 先用分布式锁按userId + skuId加锁,防止同一用户并发重复提交;
  2. 锁内执行 Lua 脚本扣 Redis 库存;
  3. 扣减成功后发消息到 MQ,异步落库生成订单;
  4. 释放锁。

这里锁的粒度是「用户 + 商品」,比按商品加锁更细,能减少锁竞争。如果按商品加锁,同一商品的所有用户都要排队,QPS 上不去。按用户维度加锁后,只有同一用户的并发请求会互斥,不同用户之间靠 Lua 脚本的原子性保证库存不超卖。

String lockKey = "lock:seckill:" + userId + ":" + skuId; RLock lock = redissonClient.getLock(lockKey); if (!lock.tryLock(2, TimeUnit.SECONDS)) { return Result.fail("请勿重复提交"); } try { Long result = redisTemplate.execute(stockScript, Collections.singletonList("seckill:stock:" + skuId), "1"); if (result == null || result <= 0) { return Result.fail("库存不足"); } // 发送 MQ 消息,异步创建订单 mqProducer.send(new SeckillMessage(userId, skuId)); return Result.success("抢购成功,订单生成中"); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

3.3 锁超时与业务耗时的匹配

锁的过期时间必须大于临界区 P99 耗时。我压测时会把临界区耗时打点,如果 P99 是 200ms,锁过期设 3 到 5 秒足够。但如果你用了 Redisson 看门狗,默认 30 秒其实偏长,一旦服务假死,锁要 30 秒才释放,期间该用户的请求全部失败。我的习惯是显式设置leaseTime为 5 秒,同时确保临界区不会超过 3 秒,用业务超时来兜底,而不是完全依赖看门狗。

提示:leaseTime一旦设置,看门狗就不再续期。设置前先确认临界区最大耗时,留出至少 2 倍余量。

4. 压测时最容易翻车的四个坑

4.1 锁被误删:现象是库存扣了两次

现象:压测时偶尔出现同一用户扣了两次库存,日志里两次请求都显示加锁成功。原因:解锁时没有校验锁的持有者,A 请求的锁过期后 B 请求拿到锁,A 执行完直接DEL把 B 的锁删了。解决:解锁必须用 Lua 脚本比较 value 再删除,或者直接用 Redisson 的unlock(),它内部已经做了持有者校验。

-- 安全解锁:只有 value 匹配才删除 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end

4.2 锁等待时间过长导致线程池打满

现象:秒杀开始后 Tomcat 线程数飙升,大量请求超时。原因:tryLock等待时间设了 10 秒,没抢到锁的线程一直挂着,线程池被占满。解决:等待时间控制在 1 到 3 秒,没抢到直接快速失败返回「抢购火爆」,让前端引导用户重试。秒杀场景下快速失败比排队等待体验更好,也能保护后端。

4.3 Redis 主从切换导致锁丢失

现象:Redis 主节点宕机,从节点升主,原本加锁的 key 还没同步过去,新请求又能加锁成功。原因:Redis 主从复制是异步的,锁数据可能丢失。解决:如果业务绝对不能超卖,用 RedLock 或者把库存扣减的最终一致性交给数据库唯一索引兜底。我的做法是 Redis 扣减成功后,数据库订单表对userId + skuId建唯一索引,即使锁失效重复扣了 Redis 库存,落库时也会被唯一约束拦住。

4.4 看门狗线程与业务线程池互相拖累

现象:服务运行一段时间后,Redisson 续期线程报RedisCommandTimeoutException,锁提前过期。原因:业务线程池和看门狗共用同一个 Redis 连接池,业务高峰时连接被占满,续期命令发不出去。解决:给 Redisson 单独配置连接池,或者把lockWatchdogTimeout调大,同时监控redis command timed out日志。连接池参数上,最小空闲连接建议不低于 10,最大连接数按 QPS 估算。

注意:redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeout这类报错在压测时很常见,先查连接池,再查网络延迟,最后才怀疑锁逻辑。

5. 用 JMeter 验证锁是否真的锁住了

5.1 压测脚本与断言设计

验证分布式锁有没有生效,不能只看「没报错」,要设计能暴露并发问题的断言。我用 JMeter 建一个 500 线程、循环 10 次的压测计划,请求体里带同一个userId和skuId,然后加两个断言:

  • 响应中「抢购成功」的出现次数不能超过库存数;
  • 数据库订单表里userId + skuId不能有重复记录。

压测前先把 Redis 库存设为 100,数据库订单表清空。跑完后用 SQL 核对:

-- 检查是否超卖 SELECT sku_id, COUNT(*) AS order_count FROM seckill_order WHERE sku_id = 1001 GROUP BY sku_id HAVING COUNT(*) > 100; -- 检查同一用户是否重复下单 SELECT user_id, sku_id, COUNT(*) AS cnt FROM seckill_order WHERE sku_id = 1001 GROUP BY user_id, sku_id HAVING COUNT(*) > 1;

两条 SQL 都返回空,才说明锁和库存扣减配合正确。如果第一条有结果,说明超卖,回去查 Lua 脚本和锁粒度;如果第二条有结果,说明用户维度锁没生效,检查 lockKey 拼接。

5.2 监控指标与日志埋点

光靠压测不够,线上要能实时看到锁的健康度。我会在加锁前后打点,记录三个指标:加锁成功率、加锁平均等待时间、锁持有时间。加锁成功率突然下降,通常是 Redis 连接池或网络问题;锁持有时间变长,说明临界区里有慢 SQL 或 MQ 发送阻塞。日志里把lockKey、userId、skuId、traceId一起打出来,出问题时能直接定位到具体请求。

long start = System.currentTimeMillis(); boolean locked = lock.tryLock(2, TimeUnit.SECONDS); long waitMs = System.currentTimeMillis() - start; metrics.recordLockWait(waitMs); if (!locked) { log.warn("lock_fail lockKey={} userId={} skuId={} waitMs={}", lockKey, userId, skuId, waitMs); return Result.fail("抢购火爆"); }

这套埋点跑一周,你就能知道当前锁参数是否合理。如果加锁等待 P99 超过 500ms,说明锁竞争太激烈,要么缩小锁粒度,要么在网关层做限流,把无效请求挡在锁外面。

5.3 一个我踩过的参数坑

最后说个血泪经验:tryLock的等待时间单位别写错。我有次把TimeUnit.SECONDS写成了TimeUnit.MILLISECONDS,结果等待时间从 3 秒变成 3 毫秒,压测时加锁成功率直接掉到 30%,排查了半天才发现是单位问题。现在我的习惯是,所有和时间相关的参数都在常量类里定义,并且带上单位后缀,比如LOCK_WAIT_SECONDS = 3,调用时只传常量,不写裸数字。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询