news 2026/10/6 19:18:17

Redis分布式锁在秒杀场景下的实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis分布式锁在秒杀场景下的实现与避坑指南

简介:这份资源围绕高并发场景下的抢单秒杀需求,给出基于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 end

Java 里调用:

// 加锁 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:seckill3200所有商品共用一把锁,性能最差
商品级锁lock:goods:100118500每个商品独立锁,推荐
用户级锁lock:user:88842000同一用户不能重复抢,但不同用户可并行

结论很明确:锁粒度越细,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% 才是玄学。

希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 19:16:14

HarmonyOS ArkUI焦点事件实战:TV端遥控器导航从原理到踩坑

做TV端应用的人都知道&#xff0c;遥控器方向键按下去&#xff0c;焦点能不能落到用户预期的那块组件上&#xff0c;基本决定了这个应用好不好用。我在HarmonyOS NEXT 5.0.0(12)也就是API 12的环境上&#xff0c;用ArkUI重构了一个视频应用的遥控器导航模块&#xff0c;整个过程…

作者头像 李华
网站建设 2026/10/6 19:15:54

ANC降噪从原理到调音:一次搞懂主动降噪耳机选型与测试

ANC降噪学习&#xff1a;从原理到调音&#xff0c;一次把主动降噪讲透很久没这么认真研究过一项技术了&#xff0c;最近因为想挑一款通勤用的耳机&#xff0c;加上自己平时录视频总被空调声和键盘声折磨&#xff0c;就一头扎进了ANC降噪的学习里。ANC全称Active Noise Cancella…

作者头像 李华
网站建设 2026/10/6 19:12:09

云服务器内存怎么选?2G到64G全档位解析与场景对照

最近身边好几个朋友都在问同一件事&#xff1a;到底该买多大内存的云服务器&#xff1f;有人上来就要64G&#xff0c;理由是"怕以后不够用"&#xff1b;也有人买了个2G的轻量机&#xff0c;结果网站刚上线就被MySQL挤爆内存。这两个极端其实都有问题。这篇文章我把2G…

作者头像 李华
网站建设 2026/10/6 19:06:55

SpringBoot+Vue前后端分离图书管理系统源码解析与搭建指南

做毕设或课设的时候&#xff0c;“图书管理系统”绝对是最常见的选题之一&#xff0c;我几乎每年都会帮人看几套这类源码。但说实话&#xff0c;市面上的同类项目很多&#xff0c;质量却参差不齐&#xff0c;有的代码乱到没法看&#xff0c;有的文档几乎没有&#xff0c;还有的…

作者头像 李华
网站建设 2026/10/6 19:06:37

JavaScript性能优化实战:从度量、定位到取舍的系统工程

看到“JavaScript性能优化”这个标题&#xff0c;很多人的第一反应是找几个代码片段抄一抄&#xff0c;比如把 for 循环改成 while &#xff0c;或者给事件监听加个节流。但真正在项目里趟过坑的人都知道&#xff0c;性能问题从来不是单点技术能解决的&#xff0c;它是一个…

作者头像 李华
网站建设 2026/10/6 19:03:52

CSS分页控制:page-break-inside解决表格卡片跨页断裂的实战指南

打印预览里表格被拦腰截断、卡片从中间裂开、列表项跨页断行——这些几乎每个做过打印需求的前端都踩过。你翻遍整个样式表&#xff0c;最后往往发现解决问题的关键&#xff0c;就藏在一个叫 page-break-inside 的 CSS 属性里。 这个属性属于 CSS 分页控制体系的一员&#x…

作者头像 李华