简介:在电商秒杀、限时抢购等场景下,库存超卖和并发一致性问题一直是后端开发的重点。一个基于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 30- NX:只有 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 接进 SpringBoot
SpringBoot 接入 Redisson 主要是两步:pom 引入依赖,配置类里声明 RedissonClient 或者直接读 application.yml。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.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 Map<String, 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) // 扣库存(Redis,0.1ms) // 写订单(MySQL,3ms) 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 阻塞读取,没有消息时线程挂起等待,而不是空轮询消耗 CPU:
@Component public class OrderConsumer { @Resource private StringRedisTemplate stringRedisTemplate; @Scheduled(fixedDelay = 100, initialDelay = 1000) public void consumeOrder() { while (!Thread.currentThread().isInterrupted()) { // 第二个参数 0 表示无限等待,有消息立即返回 List<String> 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 里的结果缓存。消费者处理完订单,把处理结果写入 key:
stringRedisTemplate.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 分布式锁落地得更稳,希望帮到你。
本文还有配套的精品资源,点击获取