ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis分布式锁实现抢单秒杀:从SETNX到Redisson的选型与压测避坑

Redis分布式锁实现抢单秒杀:从SETNX到Redisson的选型与压测避坑 简介这份资源面向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」时才生效一旦你手动传了leaseTimeRedisson 就不会自动续期。// 获取锁对象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] 库存 keyARGV[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、跨系统的约束。我一般的顺序是先用分布式锁按userId skuId加锁防止同一用户并发重复提交锁内执行 Lua 脚本扣 Redis 库存扣减成功后发消息到 MQ异步落库生成订单释放锁。这里锁的粒度是「用户 商品」比按商品加锁更细能减少锁竞争。如果按商品加锁同一商品的所有用户都要排队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 end4.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调用时只传常量不写裸数字。希望帮到你。本文还有配套的精品资源点击获取
返回列表