简介在电商秒杀、限时抢购等场景下库存超卖和并发一致性问题一直是后端开发的重点。一个基于SpringBoot的Redis分布式锁抢单秒杀项目面向具备Java基础、希望掌握高并发处理与分布式锁原理的开发者完整演示了从加锁扣库存到返回抢单结果的实现流程。压缩包共13个文件大小仅59KB包含5个Java源码涵盖锁操作与库存扣减等核心逻辑、2个properties配置文件Redis连接及应用参数、相关jar依赖、Maven构建脚本、启动命令及说明文档目录结构简洁便于快速对照学习尤其适合初学者快速定位核心代码与配置。已有4056人学习下载。通过源码可直接理解Redis的SETNX与原子递减实现防超卖以及结合队列削峰填谷的思路配合项目骨架可迁移到真实抢单业务中也可作为课程设计与毕业设计的参考是练习分布式锁应用的实用范例。1. Redis分布式锁与抢单秒杀从超卖到削峰的一次落地复盘Redis 分布式锁是抢单秒杀场景里被讨论最多的组件之一但真正把它落地用稳的团队并不多。我参与的模拟项目X商品库存 100 件活动页刚上线瞬间涌入 6000 多个请求服务没挂数据库先顶不住——超卖订单刷了几十条同一个商品被扣成负数页面还在提示“抢购成功”。后来整套方案改成 Redis 分布式锁 Redis 队列 异步落库才把成功率、库存一致性、数据库压力三个指标同时拉下来。这篇不是源码逐行注释是我把可以直接照着改的代码、参数设置和踩坑记录整理在一起。方案基于 SpringBoot Redis Redisson覆盖分布式锁的原理和选型、抢单流程怎么用锁、队列怎么削峰、压测怎么验证。适合刚接手秒杀类需求的后端开发也适合想从“锁能跑”升级到“锁用得稳”的工程师。2. 锁的选型与原理自研SETNX锁为何会翻车2.1 单机锁到分布式锁synchronized管不到第二台机器先说清楚为什么必须上分布式锁。单体应用里库存扣减是一个方法内的同步块synchronized 或者 ReentrantLock 能保证同进程内只有一个线程进入扣减逻辑。但抢单系统不会只部署一台机器至少两台起步前面挂负载均衡。一个请求打到 A 实例另一个请求打到 B 实例两个 JVM 各自拿了一把本地锁互相不知道对方存在同步块形同虚设。我见过某开发者坚持用 synchronized 扛活动压测到 200 并发就出现负数库存。原因不是 synchronized 效率低而是在多实例部署下它根本没有跨进程互斥的语义。分布式锁的核心是在多个进程之间共享一个“谁持有锁”的状态。Redis 天然适合承担这个状态单线程执行命令、响应快、操作具备原子性、还有键过期机制。用 Redis 做分布式锁本质就是在 Redis 里写一个只有当前请求能删除的标记其他请求看到标记就等待或者直接失败。2.2 SETNX EXPIRE 的老写法三个坑让你从入门到放弃早期那套写法是用 SETNX 命令占一个 key 当锁抢到 key 就认为是抢到锁SETNX lock_key 1 # 返回 1 表示抢到锁返回 0 表示锁已存在 EXPIRE lock_key 30 # 给锁加过期时间防止客户端崩溃导致死锁单看没问题组合起来全是坑。SETNX 和 EXPIRE 是两条命令如果 SETNX 成功之后、EXPIRE 还没执行时进程崩溃或网络断开锁就变成永不过期的孤儿锁后续所有请求全部阻塞。这是第一个坑命令非原子。第二个坑是锁的释放。新手写法是抢完锁、执行业务后直接 DEL。假设请求 A 持锁后业务执行时间过长锁超时自动释放请求 B 立刻抢到锁开始执行这时请求 A 跑完了执行 DEL把请求 B 的锁删掉。B 和后续的 C 就可能同时进入临界区超卖问题又回来了。这是锁被误删。第三个坑是过期时间拍脑袋。30 秒是随手值业务实际耗时不稳定库存扣减加远程调用一旦超过 30 秒锁提前释放设置成 300 秒又会导致服务崩溃后其他请求长时间拿不到锁。过期时间应该在系统能接受的极限响应时间内反复压测得出而不是拍脑袋定一个值。2.3 SET with NX EX 原子命令 唯一标识自研锁的正确姿势上面三个坑不是不能绕开。先解决原子性用 SET 命令的扩展参数SET lock_key request_id NX EX 30NX只有 key 不存在时才写入相当于 SETNX 的原子版EX 30过期时间 30 秒request_id唯一定位符释放锁的时候校验是不是自己持有释放锁的代码就要带回校验了用 Lua 脚本在 Redis 端一次完成if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本先 GET 判断 value 是否是当前请求的 ID是才 DEL。整个过程在 Redis 服务端原子执行不会有并发插入的机会解决了误删锁问题。这是自研锁能用的底线加锁用原子命令value 存唯一 ID释放用 Lua 脚本校验身份。2.4 Redisson 的看门狗机制为什么我推荐直接用现成的自研锁绕开了原子性和误删还有一个痛点业务耗时超过锁过期时间怎么办。Redisson 处理这个问题的方案是看门狗。当锁没有指定 leaseTime 时Redisson 会启动一个后台任务每隔 10 秒检查一次锁还在就自动把过期时间续到 30 秒。业务执行多久锁就续多久不用人工估算。Redisson 的锁还有个明显优势是可重入。自研锁要自己维护计数器记录进入了几次Redisson 的 RLock 内部用 Hash 结构每次加锁对同一个 key 的 field 计数释放时减计数计数归零才真正删除。嵌套调用时不用反复抢锁业务代码省心很多。我整理了一张选型对比表维度自研 SETNX 锁Redisson原子加锁SET NX EX 一条命令内置防误删Lua 校验 value内置过期时间手动硬编码看门狗自动续期可重入自实现原生支持运维成本依赖自己的代码质量依赖 Redisson 文档和版本管理如果只想快速上线、Redis 是单机部署自研 SETNX Lua 也够用。但只要涉及主从、集群部署或者锁住的代码内部还会嵌套调用直接用 Redisson 更稳。后面所有代码我都是基于 Redisson 展开。3. SpringBoot 集成 Redis 分布式锁抢单扣库存的核心链路3.1 工程依赖与基础配置把 Redisson 接进 SpringBootSpringBoot 接入 Redisson 主要是两步pom 引入依赖配置类里声明 RedissonClient 或者直接读 application.yml。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency注意版本兼容性。SpringBoot 2.7 配 Redisson 3.2x 是常见组合SpringBoot 3.x 需要用 Redisson 3.3x 以上否则自动装配会因为序列化版本冲突直接启动报错。我在公司项目里遇到过这个坑每次升级 SpringBoot 都要连带升级 Redisson最好在 pom 里把版本写成属性引用方便统一升级。配置文件 application.yml 里加 Redis 地址spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0如果用的是 Redisson starter它默认读取 spring.data.redis 配置并自动创建 RedissonClient。如果是单服务部署且不想引入 starter可以自己写配置类手动创建连接池Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(8) .setConnectionPoolSize(24); return Redisson.create(config); } }setConnectionMinimumIdleSize 控制空闲连接保持数适合每秒几百次的抢锁频率。setConnectionPoolSize 是最大连接数默认值偏低高并发压测下很容易出现连接池满、获取 RedissonClient 连接超时的报错。常见做法是先设 24压测时观察连接等待时间再往上调。3.2 抢单核心tryLock 拿到锁再扣库存秒杀场景的核心代码是先抢锁再查库存、扣库存最后放锁。我用一个具体商品号的兑换业务来演示Service public class SeckillService { Resource private RedissonClient redissonClient; Resource private StringRedisTemplate stringRedisTemplate; private static final String STOCK_KEY stock:product:1001; public MapString, Object grabOrder(Long userId) { RLock lock redissonClient.getLock(lock:product:1001); boolean locked false; try { locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return failResult(抢购太火爆请稍后重试); } // 1. 查库存 String stockStr stringRedisTemplate.opsForValue().get(STOCK_KEY); int stock Integer.parseInt(stockStr); if (stock 0) { return failResult(商品已售罄); } // 2. 扣库存 stringRedisTemplate.opsForValue().decrement(STOCK_KEY); // 3. 写入用户抢单记录异步消费 stringRedisTemplate.opsForList().leftPush( order:queue:1001, userId :1001: System.currentTimeMillis()); return successResult(stock - 1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return failResult(系统繁忙); } finally { if (locked) { lock.unlock(); } } } }代码逻辑分四段。第一段抢锁tryLock 在 3 秒内没抢到就直接返回失败避免请求无限排队。第二段查库存判断剩余量这里先用 GET 读一次是为了给用户返回“还有几件”的直观提示。第三段用 DECR 扣库存扣减动作放在 Redis 内存中执行比先查后回写快一个数量级。第四段把抢单记录写入 Redis List后续异步消费。finally 里判断 locked 再 unlock防止没抢到锁也去释放触发 Redis 的释放异常。tryLock 的两个参数需要精调。第一个参数 waitingTime 是等待锁的时间我一般设 2 到 3 秒超过这个时间说明系统确实忙不过来直接返回提示比让用户无限等待体验更好。第二个参数 leaseTime 是锁的租期Redisson 里如果不指定用 30 秒默认值加看门狗续期。注意手动指定 leaseTime 后看门狗就失效了锁在固定时间后必然释放业务没跑完就可能被其它线程抢占。除非业务逻辑非常短且稳定否则不推荐手动指定。3.3 库存不是只在 Redis 扣完就完事缓存与数据库的一致性很多人把 Redis 当数据库用DECR 扣完库存就写订单完全不走数据库。这个做法压测时好看对账时全是黑洞。正确姿势是 Redis 负责承接瞬时流量最终库存和订单要异步落库。库存扣减建议用 DECR 的返回值做二次判断Long remain stringRedisTemplate.opsForValue().decrement(STOCK_KEY); if (remain 0) { // 补偿把超卖的那一个加回来同时标记当前请求失败 stringRedisTemplate.opsForValue().increment(STOCK_KEY); return failResult(商品已售罄); }这段代码是秒杀场景里库存控制的最后一道防线。DECR 是原子操作返回的 remain 小于 0说明这一笔已经把库存扣成负数了立刻加回来并返回失败。这比先 get 再判断再 decrement 可靠因为 get 和 decrement 之间其他线程可能已经清空库存。数据库那边库存扣减语句要带乐观判断UPDATE product SET stock stock - 1 WHERE id #{productId} AND stock 0;这条 SQL 影响行数为 0 说明没有库存了事务回滚订单不落库。Redis 的 DECR 和 MySQL 的 UPDATE两层都在做同一件事防止超卖。Redis 是挡在最前面的闸门MySQL 是最终对账保底。上线前把这条 SQL 的执行计划也看一遍确认走了主键索引否则高并发下全表扫描会直接把数据库拖死。3.4 锁粒度与业务耗时锁内只放最小必要操作抢单代码的耗时直接影响锁的竞争强度。我见过某开发者的实现把远程查询用户等级也写在锁里面lock.tryLock(3, TimeUnit.SECONDS); // 检查会员等级远程调用偶尔阻塞 500ms // 检查风控远程调用平均 200ms // 扣库存Redis0.1ms // 写订单MySQL3ms lock.unlock();锁的持有时长被最慢的远程调用牵着走。会员等级和风控接口一抖动锁被占了几百毫秒同一时刻后面的抢单请求全部被堵在 tryLock 等待或者直接失败吞吐量断崖式下跌。我的调整习惯是把锁内代码压缩到“查 Redis 库存 DECR 写 Redis 队列”三步远程服务全部放到锁外检查或者放到拿到锁之后异步补验。用户等级不满足可以在异步阶段取消订单。锁内最小操作原则比调大连接池和超时时间都管用。4. 队列削峰Redis List 与 Streams 的落地区别4.1 为什么抢单要锁和队列两层锁管互斥队列管削峰分布式锁解决了并发下库存扣减的互斥问题但它没有解决“请求瞬时全部打到后端”的问题。假设有 3000 个请求同时进来锁让它们逐个通过后一个请求要等前一个释放锁。如果每个请求的业务处理时间是 5 毫秒3000 个请求串行处理就是 15 秒的尾延迟。数据库侧呢前面 1000 个请求拿到锁并落库数据库压力还是瞬时爆发。队列的作用是把“同步处理”变成“异步消化”。客户端请求到达后只做两件事抢锁扣 Redis 库存然后把订单消息丢进队列立刻返回“正在处理”。真正写数据库的消费者在后台按固定速率拉取消息每秒消费 200 条还是 500 条完全由消费者线程池控制。前端再通过轮询或者回调获取最终结果。这个设计的价值是不管秒杀瞬间来多少请求数据库看到的永远是一条平稳的写入流不会出现连接打满导致的雪崩。Redis 本身就能当队列用最简单的是 List 类型。LPUSH 生产BRPOP 消费天然支持 FIFO操作延迟在毫秒级。如果项目不想额外引入消息中间件用 Redis List 搭一个简易队列完全够用。4.2 Redis List 队列的生产和消费LPUSH BRPOP 闭环生产者侧的代码在秒杀服务里已经出现过// 秒杀接口里锁内扣完库存后把 userId:商品id:时间戳 写入队列 stringRedisTemplate.opsForList().leftPush( order:queue:1001, userId :1001: System.currentTimeMillis() );消费者这边用 BRPOP 阻塞读取没有消息时线程挂起等待而不是空轮询消耗 CPUComponent public class OrderConsumer { Resource private StringRedisTemplate stringRedisTemplate; Scheduled(fixedDelay 100, initialDelay 1000) public void consumeOrder() { while (!Thread.currentThread().isInterrupted()) { // 第二个参数 0 表示无限等待有消息立即返回 ListString msg stringRedisTemplate.opsForList().rightPop( order:queue:1001, 1, TimeUnit.SECONDS); if (msg null || msg.isEmpty()) { break; } String body msg.get(0); processOrder(body); } } private void processOrder(String body) { // body 格式userId:productId:timestamp String[] parts body.split(:); Long userId Long.valueOf(parts[0]); Long productId Long.valueOf(parts[1]); System.out.println(处理订单 userId userId productId productId); } }BRPOP 的第三个参数是阻塞超时时间我一般设 1 秒或 3 秒。设成 0 表示永久阻塞调度线程会长期挂起如果消费逻辑断掉很难定位。设成 1 秒的好处是即使队列暂时没消息也能及时感知线程是否还活着。消费者用 Scheduled 定时拉取是轻量做法缺点是消费逻辑一旦处理慢下一次调度触达时会出现两个线程同时消费。更稳的做法是启动一个线程池在后台循环消费。我平时写的就是 while 循环 手动线程池方式避免 Scheduled 的重叠执行。消息幂等是 List 队列方案必须考虑的点。Redis List 的 rightPop 是删除式读取消息被取走后消费者宕机这条消息就丢了。消费端的落库逻辑必须保证重复处理不产生重复订单。常见做法是消费前先查订单表用 userId productId 做唯一索引存在就跳过不存在才插入。4.3 Redis Streams比 List 更完整的队列能力项目量级再往上走可以看 Redis Streams。Streams 是 Redis 5.0 引入的队列数据结构相比 List 多了消息持久化、消费组、确认机制、消息回溯能力。List 模式下消息丢了就丢了很难查证Streams 可以记录消费游标和未确认消息列表对生产环境更友好。Streams 的生产代码String messageId stringRedisTemplate.opsForStream().add( stream:order, Map.of( userId, userId.toString(), productId, productId.toString(), orderTime, String.valueOf(System.currentTimeMillis()) ) );消费组 API 比 List 复杂spring-data-redis 对 Streams 的封装完整度不如 List部分操作需要直接调用原生命令。如果只是秒杀场景我会先评估 Redis 版本和运维能力。上了 Streams就要接受 Redis 承担更多持久化职责内存和磁盘的监控要跟上。4.4 前端拿结果基于 ticket 的轮询比长连接更省心用户提交抢单后接口立刻返回一个 ticket。前端把 ticket 存下来每秒轮询一次查询最终结果比直接 HTTP 长连接或者 WebSocket 可靠得多。原因很简单HTTP 长连接在网关层默认 60 秒就会断开秒杀场景里异步落库可能要 30 秒以上连接断开后前端要重新建立链路非常容易丢状态。轮询接口的职责是查 Redis 里的结果缓存。消费者处理完订单把处理结果写入 keystringRedisTemplate.opsForValue().set( order:result: ticket, orderVO.toJson(), 5, TimeUnit.MINUTES );前端拿着 ticket 轮询5 分钟内查不到结果就提示稍后查询。key 设置 5 分钟过期时间是防止 Redis 内存被无效结果占满的兜底。5. 避坑Redis 锁与秒杀链路里的 5 个常见故障5.1 锁被提前释放业务没跑完后面的人已经进厂了现象日志里同一个商品出现两个用户都抢购成功库存 Redis 扣减正确但一个用户订单被另一个用户取消或覆盖。原因理解这个现象的关键在于 tryLock 的 leaseTime 手动设置了固定值。业务里调用了第三方优惠券接口网络抖动时业务耗时超过锁的租期锁被 Redis 自动清掉。新的请求拿到锁开始执行旧请求 finally 里的 unlock 把新请求的锁释放了。解决改成不指定 leaseTime 的 tryLock只传 waitingTime交给 Redisson 看门狗续期。同时检查 finally 里的 unlock 是否真的被执行锁释放前打一条日志。后来我在所有抢单接口里统一加了个监控指标lock wait time 和 lock hold time 全部上报出现锁异常立刻告警。5.2 Redis 主从切换锁丢了一次现象主节点故障切换到从节点后两个请求同时拿到了锁压测结果出现超卖但数据库乐观锁拦截了部分异常损失小于 Redis 里的超卖数。原因Redisson 单节点模式在主从切换场景下存在锁丢失的窗口期。主节点写入锁 key 后还没同步到从节点就宕机从节点提升为主节点锁 key 不存在新的加锁请求成功。这是分布式锁的理论问题。解决单节点 Redis 加主从架构锁丢失风险存在。能接受业务层加数据库乐观锁保底MySQL 的 stock 0 条件会拦住超卖不能接受引入 RedLock 多节点锁或者把 Redis 升级为 Cluster 模式配合 Redisson 的集群锁实现。我的取舍是秒杀场景允许极少比例的理论失败用数据库兜底解决实际影响不引入 RedLock 的复杂度。5.3 锁内做事务操作Transactional 和 unlock 顺序搞反现象订单表偶尔出现多次扣减但 Redis 库存是正常的两边数据对不上。原因方法上加了 Transactional锁释放写在前事务提交写在后。锁一释放下一个线程进来时以为库存已经扣完了事务还没提交MySQL 里库存是旧值扣减动作又执行了一次。Redis 侧库存不回退数据库侧扣了两次。解决把锁的释放挪到事务提交之后。Spring 的 Transactional 默认在方法结束时提交如果 unlock 写在方法最后提交动作其实还在之后执行。处理方式是手动管理事务边界用 TransactionTemplate 把数据库操作包在一个独立方法里unlock 放在事务提交代码之后。5.4 队列消费异常消息丢了或者被重复消费现象用户明明抢单成功订单表里却没有记录或者同一用户出现两条订单。原因消息丢失是因为用 List 的 rightPop 是删除式读取消费逻辑异常导致消息已出队但没落库。重复消费是因为消费逻辑没有幂等消费者线程重启后重复处理同一条消息。解决队列消费必须做到“消费前查重、消费落库、落库后再确认”。List 方案做不到可靠确认只能靠消费方幂等。订单表加唯一索引 (user_id, product_id) 是最后防线重复插入直接报错跳过。日志里发现 duplicate key 异常不用慌张说明重复消息被拦截了。5.5 压测结果异常加了分布式锁反而吞吐量更差现象上线前压测加了分布式锁之后TPS 从 2000 掉到 300数据库压力反而更大。原因锁的 waitingTime 设置了 10 秒。请求到达后全部阻塞在 tryLock 上Redis 连接池被打满后续请求排队等待连接压测线程数越高阻塞越严重数据库被无效连接穿透。解决压测时先测 Redis 单命令延迟再测带锁的业务延迟最后把 TPS 和锁等待时间放在同一张统计图里看。waitingTime 不是越大越好秒杀场景我一般设 2 到 3 秒。锁等待超时的请求直接返回失败把用户的体验交给重试按钮而不是让请求无限排队拖垮系统。6. 进阶压测验证与锁的最后一公里调优写完代码只是第一步锁到底稳不稳压测会告诉你。我的固定流程是先用 JMeter 搭一个线程组线程数 1000、持续时间 60 秒、Ramp-Up 周期 10 秒请求直接打抢单接口。压测时重点盯三个指标TPS、P95 响应时间、Redis 连接池监控里的活跃连接数。第一轮压测跑完如果 TPS 和 P95 都符合预期再模拟一个慢业务场景在扣库存代码后面加一段 Thread.sleep(500)看锁等待曲线是否平稳。这一步是为了验证锁的 leaseTime 和看门狗设置是否合理很多问题只有在人为拖慢业务时才暴露出来。调优参数上连接池大小我习惯从 24 起步压测时观察连接等待时间超过 10ms 就往上调但不要超过 Redis 实例的 maxclients 限制。waitingTime 保持在 3 秒以内如果 3 秒内拿不到锁说明系统容量已经到极限扩容或者限流才是正路继续调大等待时间是掩盖问题。锁粒度是最后一公里。同一个商品一个锁高热度商品锁竞争依旧激烈可以拆成分段锁把库存分成 10 段每段分配部分库存用商品 ID 加段号做锁 key请求先随机选段再抢锁。代价是代码复杂度上升需要对段内库存做汇总统计。我的习惯是库存超过 500 才考虑分段库存低时分段反而增加管理成本。从那以后我每次写抢单场景都会强制走一遍先跑基准压测再人为制造慢调用看锁行为最后检查数据库兜底是否生效三关都过才敢上线。分布式锁能解决并发一致性问题但解决不了容量瓶颈流量洪峰到来时最终还是要靠异步化和限流配合。希望这些参数和坑能帮你把 Redis 分布式锁落地得更稳希望帮到你。本文还有配套的精品资源点击获取