尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Redis分布式锁实现秒杀抢单:从SETNX到Redisson的完整实践

发布时间:2026/9/26 5:03:49

资讯中心
01
ARTICLE

Redis分布式锁实现秒杀抢单:从SETNX到Redisson的完整实践

Redis分布式锁实现秒杀抢单:从SETNX到Redisson的完整实践
简介面向Java开发者与电商系统后端工程师这份基于SpringBoot的Redis分布式锁秒杀抢单项目完整演示了高并发秒杀场景下如何借助Redis原子操作、分布式锁与队列机制避免超卖、保证库存一致与抢单公平。项目共13个文件5个Java源文件构成核心业务逻辑覆盖锁的获取与释放、库存实时扣减、请求入队及结果返回等环节2个properties文件负责Redis连接与项目参数配置另有Maven构建配置与依赖jar包解压后可直接导入IDE运行。资源压缩包仅59KB结构精简适合初中级开发者学习分布式锁原理、秒杀接口幂等处理与高并发设计思路也能为实际项目中的库存扣减方案提供参考。已有4053人学习项目附带README文档说明便于快速梳理代码结构掌握后可进一步扩展消息队列、限流和事务一致性等进阶优化方案。1. Redis 分布式锁实现抢单秒杀你锁住的到底是什么“redis分布式锁实现抢单秒杀”这个标题我拆过很多次先说结论这套方案适合 Spring Boot 集群部署下每秒几百到几千的中等并发场景用 Redis 的分布式锁把“扣库存 写订单”这段临界区串行化再用预减库存和异步队列削峰。很多人以为锁是核心难点实际写起来最难的是锁过期、锁误删、主从切换丢锁这些边角问题。手写一把锁不难难的是把它放到抢单链路里还能稳定跑住。这篇文章按我从零手写锁、踩坑、换 Redisson、落到完整秒杀链路的顺序来写步骤都能直接复现坑位我也会一个个标出来。2. 准备阶段Redis 单线程模型、安装部署与可视化客户端2.1 单线程模型锁的原子性缘何成立分布式环境里多个 JVM 实例同时操作共享资源需要一把所有实例都认的锁。数据库悲观锁SELECT ... FOR UPDATE能用但秒杀场景下高并发全部打在同一行记录上行锁竞争激烈数据库连接池很快被打满整个服务跟着雪崩。Redis 能当分布式锁的基础是它的单线程命令队列所有命令串行执行一个命令从开始到返回中间不会被其他命令插入。这个特性决定了 Redis 锁的原子性不是靠代码拼出来的而是底层模型天然保证的。Redis 提供的SETNX命令全称是 set if not exists只在 key 不存在时写入天然满足互斥条件。加上EX参数后可以同时设置过期时间整个操作是原子的。下面用 redis-cli 演示加锁与竞争失败的过程127.0.0.1:6379 SET lock:order:1001 6f9a2c1e NX EX 10 OK 127.0.0.1:6379 SET lock:order:1001 3b7d8f2a NX EX 10 (nil)第一条命令返回OK说明当前实例拿到了锁第二条命令返回(nil)说明 key 已存在加锁失败。NX参数保证只有 key 不存在时才写入EX 10给锁设置 10 秒过期时间防止持有锁的实例宕机后锁永远不释放。这两个参数必须放在同一条 SET 命令里如果先 SETNX 再单独 EXPIRE中间一旦宕机会留下没有过期时间的死锁。2.2 安装部署Linux 与 Windows 两套命令一把过生产环境一般跑在 Linux 上推荐源码编译方式版本可控、参数能按需调整。常见做法是下载官方 release 包编译安装后改配置启动wget https://download.redis.io/releases/redis-7.0.14.tar.gz tar xzf redis-7.0.14.tar.gz cd redis-7.0.14 make -j4 make install redis-server /etc/redis/6379.conf redis-cli pingmake install之后 redis-server 和 redis-cli 会安装到/usr/local/bin下。生产环境配置里至少要把daemonize改成yes、设置requirepass访问密码有条件的再开appendonly yes做持久化避免实例重启后锁和库存数据全部丢失。验证服务是否正常一条redis-cli ping返回PONG就说明进程已经起来了。Windows 上做本地开发调试可以直接用 Docker 跑一个单机实例比去 GitHub 找 Windows 移植版省事得多docker run -d --name redis-local \ -p 6379:6379 \ -e REDIS_PASSWORD123456 \ redis:7.0这个命令把容器内的 6379 端口映射到宿主机REDIS_PASSWORD指定连接密码。如果本地没有 Docker也可以用社区维护的 Windows 版本但要注意这类移植版通常滞后于官方版本只建议开发调试用别拿到生产环境跑。2.3 可视化客户端与数据类型选择排查现场的第三只手锁的问题经常是“看不到”的。锁 key 有没有残留、过期时间还剩多少、value 是不是当前线程的标识这些光靠代码日志很难快速定位所以可视化客户端是排查现场的第三只手。我常用 redis desktop manager 连接实例直接看 key 的 value 和 TTL判断锁是不是卡死在某个节点上。现在 RDM 新版有部分收费功能社区里也有不少人改用 another redis desktop manager功能相差不大连接配置都一样host、port、password 三项填对就能连上。连上之后你会看到不同数据类型在客户端里的呈现方式完全不同——手写锁的 value 是普通字符串Redisson 的锁 value 是一个 hash 结构。这里要提前说清楚 Redis 序列化的选择这是很多人第一次看到乱码的根源。Spring Boot 里如果直接用默认的 JdkSerializationRedisSerializer存进去的 value 在 RDM 里全是\xAC\xED开头的二进制乱码根本没法人工核对。我一般直接用 StringRedisTemplate对象统一转 JSON 字符串再写入。整个抢单场景只依赖 String、Hash、List 三种数据类型不要为了炫技引入复杂结构排查成本会成倍增加。3. 手写分布式锁的三个避坑点SETNX 从翻车到稳定3.1 SETNX DEL最原始的锁也是最容易埋雷的锁先用最直观的方式实现一把锁setIfAbsent加锁业务结束后delete释放。Java 代码长这样public boolean tryLock(String key, String token, long expireSeconds) { return redisTemplate.opsForValue() .setIfAbsent(key, token, expireSeconds, TimeUnit.SECONDS); } public void executeOrder(String orderId) { String lockKey lock:order: orderId; String token UUID.randomUUID().toString(); boolean locked tryLock(lockKey, token, 10); if (!locked) { throw new BizException(手慢了请重试); } try { doDeductStock(orderId); } finally { redisTemplate.delete(lockKey); } }setIfAbsent对应 Redis 的SET NX EX成功返回 true 才进入业务代码finally块里释放锁保证业务抛异常时锁也能被删掉。这里要说一个很多人忽略的点token必须每次请求生成一个唯一值用来标识“这把锁是谁持有的”后面防误删全靠它。这个版本看起来逻辑完整但放到高并发抢单场景里三个坑会轮着出现。3.2 坑一解锁时把别人的锁删了现象服务 A 拿到锁执行下单但业务链路里包含远程调用耗时超过了锁的 10 秒过期时间。锁到期自动释放服务 B 立刻抢到锁并开始扣库存。此时服务 A 的 finally 块执行了delete(lockKey)把 B 的锁直接删掉。紧接着服务 C 也抢到了锁进入临界区扣同一批库存最终超卖。原因delete没有校验 value 是否还是自己的 token。锁过期后key 已经被新持有者重新写入原持有者无脑删除删的是别人的锁。解决解锁前先 GET 比较 value一致才删。但“比较 删除”是两个命令中间有缝隙必须用 Lua 脚本把它们合并成一个原子操作if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本的 KEYS[1] 是锁 keyARGV[1] 是当前线程的 token。Redis 单线程执行 Lua 脚本整个脚本运行期间不会有其他命令插入从机制上堵死了误删缝隙。执行后返回 1 表示删除成功返回 0 表示 token 不匹配锁不是自己的不做任何操作。3.3 坑二业务没跑完锁先过期现象锁过期时间设为 10 秒但下单链路里包含库存远程预占、风控校验、订单号生成某次调用超时拖到 15 秒。第 10 秒锁释放第二个线程进来重复扣减同一库存数据库里出现两条相同商品的订单。原因过期时间拍脑袋设定无法匹配业务时长的长尾。任何业务接口都有慢请求设置一个固定过期时间本质上是在赌“业务一定能在时间内跑完”。解决两个方向。方向一是把过期时间放大到最坏情况比如 30 秒牺牲的是持有者宕机后的锁恢复速度方向二是引入自动续期机制让锁的过期时间跟随业务执行动态延长这就是下一章 Redisson 看门狗做的事。无论选哪个都不能不设置过期时间——没有过期时间的锁持有者一旦宕机所有请求永久阻塞。3.4 坑三自旋重试把自己锁死现象同一个线程内先抢了一次锁执行到一半又调用了另一个加锁方法第二次setIfAbsent必然失败。如果代码里用了自旋等待重试获取锁这个线程会一直循环等到自己的锁超时释放白白空转局部接口响应时间拉满。原因Redis 原生的 String 锁不可重入。同一线程第二次加锁时key 已经存在且属于自己但命令不认识“属于自己”这件事直接返回失败。解决要么手写可重入逻辑用 Hash 结构记录持有线程和重入次数要么直接换 Redisson 的可重入锁底层自己维护重入计数。手写重入要考虑线程标识、计数增减、释放时清零代码量不大但细节多我后来全部切换到了 Redisson不再自己造这个轮子。4. Redisson 锁接入看门狗续期与可重入的正确姿势4.1 为什么弃用手写锁把黑匣子交给 Redisson上面的三个坑说明一个事实手写分布式锁不是不能用而是所有边界情况都要自己兜底。当前线程标识、原子释放、可重入、续期每个点都得写代码写完还要压测验证成本不低。Redisson 把这些能力全部封装在 RLock 里底层用 Hash 结构和 Lua 脚本实现对外暴露的 API 非常窄业务代码只需要关注加锁、解锁、等待时间三个动作。对比项手写 SETNX 锁Redisson RLock可重入不支持需自己实现计数原生支持自动续期无过期时间固定看门狗默认续期释放锁校验需自己写 Lua内置持有者校验等待锁超时需自己写循环tryLock 原生支持实现成本高边界多低API 简单这里也顺带回答一个面试高频问题“分布式锁使用场景有哪些”。简单说就是跨 JVM 实例的互斥资源访问抢单、秒杀、定时任务防重、库存扣减、缓存击穿重建保护本质都是让多个进程对同一资源的操作串行化。面试官想听的不是背概念而是你能说出锁误删和锁过期这两个坑的解决方案。4.2 Maven 引入与客户端配置序列化是第一个坎引入 Redisson 推荐用 Spring Boot Starter版本选择与你的 Spring Boot 主版本匹配的稳定版即可dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.17.7/version /dependency配置文件里加 Redisson 的连接参数spring: data: redis: host: 127.0.0.1 port: 6379 password: 123456如果项目里已经有 RedisTemplate 在跑这里要重点检查 Redis 序列化配置。默认的 JdkSerializationRedisSerializer 会产生乱码而且多个服务实例之间如果序列化方式不一致会出现“A 实例写进去的锁 B 实例读出来是乱码”这种诡异问题。我一般直接声明一个 StringRedisTemplate锁的 key 用业务前缀拼接value 用 JSON 序列化保证所有实例看到的都是可读的纯字符串。4.3 RLock 加锁解锁的代码形态与参数设定RLock 的代码形态和手写锁接近但参数语义完全不同RLock lock redissonClient.getLock(lock:order: orderId); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(手慢了请重试); } try { doDeductStock(orderId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里三个参数要理解透。第一个参数 3 是 waitTime表示抢锁时最多等待 3 秒3 秒内拿不到锁就直接返回 false而不是无限阻塞这个值决定了请求的最长响应时间。第二个参数 30 是 leaseTime锁的最长持有时间一旦传入 leaseTimeRedisson 会关闭看门狗到期强制释放所以传了它就得保证业务能在 30 秒内跑完。isHeldByCurrentThread检查当前线程是否还持有锁避免业务执行时间过长导致锁自动释放后finally 块又去解锁别人新持有的锁。很多没有读过 Redisson 源码的人会被一个问题问住Redis 分布式锁如果不带 leaseTime 会怎样答案锁默认 30 秒过期但看门狗每 10 秒续期一次只要持有锁的线程还活着锁就不会过期。续期的本质是 Redisson 在底层起了一个定时任务定期执行 Lua 脚本把锁的过期时间重置。4.4 看门狗机制锁过期时间与业务时长的和解看门狗是 Redisson 相对手写锁最大的优势。用lock()或tryLock(waitTime, TimeUnit.SECONDS)不传 leaseTime 时锁使用默认的 30 秒过期时间然后 Redisson 启动一个后台任务每 10 秒检查一次锁是否还在当前线程手里在的话就把过期时间重新刷回 30 秒。业务执行 5 分钟锁就续期到 5 分钟线程挂了后台任务感知到锁已释放不再续期锁在下一次过期后自动消失。参数默认值含义使用建议waitTime0最多等待抢锁时长秒杀场景设 1~3 秒避免请求堆积leaseTime30 秒锁的固定持有时长业务时长波动大就不传交给看门狗watchdogTimeout30 秒看门狗续期基准值一般不用改默认即可看门狗适合锁保护长任务但有一个隐患如果持有锁的线程因为死循环没有退出锁会被不断续期故障恢复时间被拉长到“任务结束”。所以我一般会在锁的代码块内部加上执行时长的监控日志超过阈值就告警而不是完全依赖看门狗兜底。5. 秒杀链路完整实现Redis 预减库存 Lua 原子扣减 异步队列落库5.1 库存模型取舍缓存预减与数据库兜底秒杀场景先想清楚一件事库存的最终一致性放在哪。直接在 MySQL 里扣库存最准确但 1000 并发同时 UPDATE 一行行锁竞争导致大量事务排队数据库连接池最先被打满。常见做法是用 Redis 预减库存挡住绝大多数流量只有扣减成功的请求才继续往下走数据库只承接已经“预占成功”的小流量。Redis 库存初始化要在系统启动时做预热不能懒加载。秒杀开始第一波请求同时打过来如果库存 key 不存在Redis 里全是空值请求会全部穿透到数据库这就是经典的缓存击穿。预热代码很简单redisTemplate.opsForValue().set(stock:sku: skuId, 500);把库存 500 写进 Redis后续每次成功下单前对 key 做 DECR。DECR 本身是 Redis 单命令原子性有保障但“检查库存是否足够 扣减库存 记录用户已购买”这三个操作不能分开执行否则并发下会出现先把库存扣成负数、再判断“不够了”的缝隙。这个问题的标准解法是 Lua 脚本。5.2 Lua 脚本扣库存、限购一次在一个事务里完成把库存判断、库存扣减、用户限购三个动作合并成一个 Lua 脚本Redis 单线程执行整个脚本期间不会有其他命令插入。脚本如下local stock redis.call(get, KEYS[1]) if not stock or tonumber(stock) 0 then return 0 end if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 2 end redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], ARGV[1]) return 1KEYS[1] 是库存 keyKEYS[2] 是已购买用户集合ARGV[1] 是用户 ID。返回值 0 表示库存不足2 表示该用户重复下单1 表示扣减成功。Java 侧用 DefaultRedisScript 执行DefaultRedisScriptLong script new DefaultRedisScript(luaText, Long.class); Long result redisTemplate.execute(script, Arrays.asList(stock:sku: skuId, user:buy: skuId), userId); if (result ! null result 1L) { // 预占成功进入异步下单流程 }先判断库存再扣减加上用户限购判断全部在一个 Redis 网络往返内完成。对比一下不用 Lua 的写法先 GET 库存、再判断、再 DECR三次网络请求期间库存可能被其他实例扣掉超卖就在这个缝隙里发生。Redis Lua 是这个场景的必选项不是可选项。5.3 异步队列落库从 Redis 到 MySQL 的一致性问题Redis 预占库存成功后订单不能直接在请求线程里写入 MySQL。秒杀请求高峰期如果每个请求都同步 INSERT 订单数据库瞬间压力还是很大。标准做法是预占成功后把订单消息丢进队列由消费者批量落库// 扣减成功后入队 redisTemplate.opsForList().leftPush(order:queue, orderMessageJson); // 消费者线程拉取 String message redisTemplate.opsForList().rightPop(order:queue, 5, TimeUnit.SECONDS); saveOrderWithTransaction(message);这里 Redis 库存已扣但 MySQL 还没写入时宕机会产生不一致Redis 里库存少了数据库里没有订单。兜底方案是建一张库存预占流水表预占成功后先记录一条流水消费者落库成功后更新流水状态。再写一个定时对账任务扫描 Redis 已扣减但流水状态未完成的记录超过阈值就人工介入或自动补单。数据库侧还要加上订单唯一索引做最后一道防线比如(user_id, sku_id)联合唯一即使消费者重复拉取消息第二次插入会被唯一索引拦住。所有层级的幂等都做了才算闭环。6. 压测验证与边界测试信任锁之前先证明它秒杀功能开发完成后上线前的压测是最关键的一步。我用 JMeter 模拟 1000 个用户同时抢 500 件库存重点看三个指标数据库订单数是否恰好等于 500、Redis 库存最终是否归零、有没有用户重复下单。JMeter 参数设置值说明线程数1000模拟用户数Ramp-Up 时间1 秒1 秒内全部发起循环次数1每人只抢一次用户标识CSV 配置每个线程携带不同 user_id压测结果里如果数据库订单数大于 500说明超卖优先查锁释放逻辑如果订单数少于 500 但 Redis 库存已经归零说明预占成功但落库丢失查消费者和队列如果同一 user_id 出现两条订单限购脚本有问题。这三类问题分别对应锁、链路一致性、幂等三个层面排查方向完全不同压测前先想清楚。边界测试我会故意把业务代码 Sleep 拉长到超过锁过期时间观察是不是有两个线程同时进入临界区。如果 Redisson 看门狗配置正常Sleep 期间锁会被续期第二个线程进不来如果关了看门狗锁到期后第二个线程会立刻进入日志里能看到两个线程的执行时间重叠。这套用例可以直接沉淀成回归测试每次迭代都跑一遍。那次正式上线前我用 1000 并发打出一堆 500 错误回去查日志发现是 RedisTemplate 的 value 序列化不一致——一个实例写入的锁标识是 JSON 字符串另一个实例读出来是 JDK 二进制数组isHeldByCurrentThread判断全乱了。从那以后我每次上线抢单需求都强制走一遍“锁边界测试 压测验证”的组合流程先证明锁是可靠的再放流量进来。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。