资讯动态

Spring Boot整合Redisson实现高并发分布式锁实战指南

发布时间:2026/10/18 1:24:56 来源:尧图企业网站定制
在互联网业务里高并发抢购、秒杀、库存扣减、防重复提交这些场景几乎都会碰到“同一份资源被多个节点同时操作”的问题。线程安全已经解决不了跨进程的竞争了这时候就需要分布式锁。我最早用的是 Redis SETNX 手写锁踩了一堆坑后换成了 Spring Boot 整合 Redisson算是把分布式锁这块彻底做踏实了。这篇文章把我从选型到落地、再到排障的完整过程整理出来供做后端的朋友参考。1. 先搞清楚为什么需要分布式锁 以及 SETNX 方案的痛处1.1 单体锁和分布式锁的分界线很多新人第一次写并发代码用的都是 synchronized 或者 JUC 的 Lock。在单机部署下这种 JVM 级别的锁确实能保证线程互斥。可一旦服务水平扩展成多个实例比如部署了三个同样的 Java 服务前面挂负载均衡同一个用户的请求可能落到不同的机器上。JVM 锁只对自己进程内的线程生效A 实例拿到锁B 实例根本不知道照样能进入临界区最终就会出问题。分布式锁的本质就是在一个所有服务实例都能访问到的外部存储上建立一个“公共的占用标记”谁拿到了这个标记谁才有资格对共享资源进行操作。Redis 因为性能高、自带过期能力、操作简单成了最常见的锁存储载体。1.2 我最初用 SETNX 手写锁时踩过的四个坑网上有很多教程教你用SETNX lock_key unique_value EX 30来实现锁。我最初也这么干后来觉得这做法只能应付期末考试生产环境根本不够用。它的痛处非常明显第一个坑锁误删。如果进程 A 加锁后执行时间过长锁自动过期了这时候进程 B 加锁成功。等 A 执行完执行 DEL 释放锁结果把 B 的锁给删了。要避免这个问题必须用唯一值校验删除前先 GET 对比一下但对比和 DEL 又两个操作不是原子的还是有窗口。第二个坑自旋获取锁效率低。拿不到锁的线程如果在一个 while 循环里不停调用 SETNX不仅拿锁瞬间压力大还会白白消耗 CPU。我之前遇到过一个极限压测客户端直接把 Redis 的连接池给打满了原因就是大量自旋等待。第三个坑没有自动续期。锁设置了 30 秒过期但业务方法因为调外部接口阻塞了 40 秒锁已经过期其他线程进入临界区业务就错乱了。如果业务慢锁应该自动延长有效期如果不延就只能把过期时间设得特别大可那样万一持有者挂了锁又要等很久才能被释放。这是一个两难问题。第四个坑不可重入。同一个线程如果方法里又调用另一个加锁方法SETNX 模式下第二次加锁会失败直接把自己锁死。真实业务中嵌套锁是很常见的情况。注意单纯用 StringRedisTemplate 手写锁本质上不是不能用而是要自己实现“原子读写 续期 重入”这一堆细节。我当时写完发现比业务代码还长而且容易出 bug才决定引入 Redisson。它把这些细节内置了。2. Redisson 分布式锁的核心机制2.1 看门狗是怎么做到自动续期的Redisson 解决“锁过期但业务没结束”的方案叫看门狗Watchdog。正常调用lock()加锁时默认锁的有效期为 30 秒。如果拿到锁的线程在 30 秒内还没释放锁Redisson 的定时任务会每隔 10 秒检查一次只要锁还存在就把锁的有效期重置为 30 秒。一直持续到业务执行完主动释放锁定时任务才会取消。这个机制听起来简单可贵在它是自动的。业务方不需要自己维护“续期线程”也不会因为某个节点宕机导致锁永远不释放。假如拿到锁的服务突然崩溃看门狗线程也没了锁最多 30 秒自动过期不会形成死锁。看门狗默认只在lock()无参方法下生效。如果你用lock(30, TimeUnit.SECONDS)这种指定有效期的写法Redisson 就不会启动看门狗续期了到期直接释放。2.2 Lua 脚本保证“判断与操作”的原子性分布式锁的加锁、解锁都不能只是一条命令而是“先判断是不是持有者再执行删除”这种多步操作。多步操作在分布式环境下很容易被其他客户端插入导致状态错乱。Redisson 把所有逻辑封装在一个 Lua 脚本里通过 Redis 单线程执行 Lua 的特性来保证原子性。比如核心加锁逻辑是检查 key 是否存在不存在则设置 hash 并设置过期时间同时检查 hash 中的线程标识是否是当前客户端是则重入次数加一。解锁逻辑是检查线程标识匹配则减一减到零就删除 key。整个流程由 Redis 脚本引擎一口气执行中途不会被其他命令打断。2.3 底层为什么要用 Hash 结构而不是 StringRedisson 锁的 key 底层是一个 Hash 类型field 是“UUID 线程ID”的客户端标识value 是锁的重入次数。这么做的好处有两个一是天然支持可重入。同一个线程再次加锁时不需要走创建流程只要判断 field 存在并且是自己就对 value 做加一操作。释放锁时对应做减一减到零才真正删除这个 key。这样嵌套调用完全不会产生死锁。二是能清晰地知道当前锁被哪个客户端持有、被重入了多少次。这样排查问题时用HGETALL lock_key一看便知而不是只能看到一个模糊的字符串值。2.4 Redisson 除了普通锁还提供哪些上层锁Redisson 的分布式锁家族里除了最常见的RLock还有RReadWriteLock读写锁读读共享、读写互斥、RSemaphore信号量、RCountDownLatch倒计时门闩。另外值得一提的还有公平锁RLock(fairLock)它会按照请求顺序排队获取避免部分线程饿死。普通非公平锁每个线程争抢时随机性强在极高并发下可能出现某些线程一直拿不到锁。如果业务对公平性有要求可以选用公平锁。但公平锁需要额外的排队队列管理性能上比非公平锁要低一些。绝大多数场景库存扣减、订单防重都不需要严格公平所以默认的非公平锁已经够用。3. Spring Boot 整合 Redisson 的完整落地步骤3.1 引入依赖与版本选择我用的环境是 Spring Boot 2.7.xRedisson 对应的版本是 3.17.x。如果你用 Spring Boot 3.x建议用 Redisson 3.22当然这只是一个基准具体以官网 Maven 坐标的最新版本为准。这里要注意一点redisson-spring-boot-starter这个依赖它会自动装配 RedisTemplate 和 RedissonClient但不同 Spring Boot 版本之间适配性有可能不一样。如果是老项目我更推荐只引入redisson核心包自己写一个配置类可控性更高也少踩版本坑。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.17.5/version /dependency引入 starter 之后RedissonAutoConfiguration 会自动读取 spring.redis 的相关配置。但生产环境里我基本都是用自定义配置类因为业务里往往需要同时连接多个 Redis 环境自动装配反而不好处理。3.2 编写 YAML 配置使用 starter 时最省事的做法是在application.yml里直接配置spring: redis: redisson: file: classpath:redisson.yaml然后在resources目录创建redisson.yamlsingleServerConfig: address: redis://127.0.0.1:6379 password: null database: 0 connectionPoolSize: 64 connectionMinimumIdleSize: 16 idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 3000 retryAttempts: 3 retryInterval: 1500 threads: 16 nettyThreads: 32这里解释几个关键参数connectionPoolSize和connectionMinimumIdleSize连接池大一点能扛高并发但别盲目设成几百Redis 能承受的连接数有限建议结合压测调整。timeout是等待响应的超时设置太短在 GC 停顿或网络抖动时容易误判失败太长又会拖慢调用方。retryAttempts和retryInterval命令失败重试次数和重试间隔。注意拿锁失败的重试逻辑不在这里这里控制的是客户端发送命令失败后的重试。如果不想用外部 yaml 文件也可以直接用 Java 配置类这样能够从配置中心动态读取连接信息。我本人更喜欢 Java Configuration 的方式因为很多公司的 Redis 地址是放在 Nacos 或 Apollo 里的YAML 文件方式不够灵活。3.3 用 Java Config 手动装配 RedissonClient下面是我生产环境常用的一个配置类直接用 Java 构建 ConfigConfiguration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setPassword(xxx) .setDatabase(0) .setConnectionPoolSize(64) .setConnectionMinimumIdleSize(16) .setRetryAttempts(3) .setRetryInterval(1500); return Redisson.create(config); } }如果是集群模式只需换成config.useClusterServers() .addNodeAddress(redis://192.168.1.100:7000, redis://192.168.1.101:7000) .setScanInterval(2000) .setMasterConnectionPoolSize(64) .setSlaveConnectionPoolSize(64);单点和集群之间切换只改这一处即可业务代码完全不受影响。这也是为什么值得把它封装成独立 Bean 的原因。3.4 代码实操库存扣减的完整示例场景商品 SKU 扣库存多服务并发下只能有一个线程能扣减成功。用 Redisson 实现的核心代码如下Service public class StockService { Autowired private RedissonClient redissonClient; Autowired private StringRedisTemplate stringRedisTemplate; public boolean deductStock(Long skuId, Integer quantity) { RLock lock redissonClient.getLock(stock:lock: skuId); boolean locked false; try { // 等待 2 秒拿不到锁就直接失败锁有效期 30 秒不启动看门狗 locked lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { // 记录一下拿锁失败次数方便监控 return false; } // 业务操作先查库存再判断是否充足最后扣减 String stockKey stock:sku: skuId; String stockStr stringRedisTemplate.opsForValue().get(stockKey); int stock stockStr null ? 0 : Integer.parseInt(stockStr); if (stock quantity) { return false; } stringRedisTemplate.opsForValue().set(stockKey, String.valueOf(stock - quantity)); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这里有几个细节要说明tryLock(2, 30, TimeUnit.SECONDS)第 1 个参数是“等待锁的时间”第 2 个是“锁自动释放时间”第 3 个是时间单位。如果业务方法执行时间不可控就别传第 2 个参数用lock.tryLock(2, TimeUnit.SECONDS)此时看门狗会接管续期。解锁前必须判断isHeldByCurrentThread()避免因为网络超时等原因把已经被迫自动释放并且被别人持有的锁误删。如果使用了tryLock返回 false后面 finally 里不能再 unlock否则会抛IllegalMonitorStateException。库存扣减这里为了演示简洁用了 StringRedisTemplate 的 get/set。实际生产上推荐使用 Lua 脚本原子扣减或者在事务里做“乐观锁”这样就算锁失效也还有兜底。3.5 业务里最常用的三种锁包装方式很多人拿 Redisson 之后第一反应就是把锁写在 controller 里这是不对的。锁应该放在 Service 层而且要尽量缩小锁的范围只锁住“检查 操作共享资源”的临界区。我一般在编写时会做三件事第一抽出一个RedisLockUtils工具类提供一个带回调函数的加锁模板方法public T T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit, SupplierT action) { RLock lock redissonClient.getLock(lockKey); boolean locked false; try { if (leaseTime 0) { locked lock.tryLock(waitTime, leaseTime, unit); } else { locked lock.tryLock(waitTime, unit); } if (!locked) { throw new BizException(系统繁忙请稍后重试); } return action.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(加锁过程被打断); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }这样业务方法里一行调用即可锁的逻辑高度复用不容易出错。第二把锁 key 的生成规则统一。比如订单类的锁用order:pay:{orderNo}库存类用stock:sku:{skuId}。不要直接在代码里拼接字符串建议做一个常量工具类避免多个地方 key 离散导致锁失去互斥效果。第三注意锁 key 必须包含业务维度。如果多个不同业务共用同一个 lockKey性能和互斥范围都会出大问题。最典型的是不同 SKU 之间互相阻塞买了一双鞋和一件衣服抢同一个全局锁并发能力直接掉一个量级。4. 从工程角度锁模式的选择与性能调优4.1 lock() 与 tryLock() 到底怎么选Redisson 的lock()是阻塞式加锁拿不到锁会一直等待。这个适合对实时性要求极高、且竞争不激烈的场景但如果在高竞争场景里线程会大量堆积在锁上还可能造成调用方超时。我个人非常不建议在 Web 请求线程里用lock()因为这种线程阻塞等待占用的昂贵资源很容易拖垮 Tomcat 的连接池。tryLock(waitTime, leaseTime, timeUnit)则是“先等一段时间等不到就放弃”。这个模式更适合外部请求处理如果 1 秒内拿不到锁直接返回“系统繁忙”客户端可以走重试或重定向。它把阻塞时间控制了上限不会被热点 key 拖死。另外还要注意 leaseTime 的语义。如果你不传 leaseTimeRedisson 默认开启看门狗 30 秒过期如果你传了 leaseTime看门狗不会启动。所以当业务执行时间不稳定时我只传 waitTime不传 leaseTime实际上省略了手动续期。当业务执行时间稳定时比如最多 3 秒一定结束我传 leaseTime5 秒用略大于最大耗时的值这样即使节点被 kill -9 也不会等太久。4.2 拿不到锁之后的降级策略高并发下尤其是秒杀这种瞬时流量很多线程拿不到锁是常态。这里需要设计好拿锁失败后的行为而不是千篇一律抛异常。我常用的策略有三种快速失败直接返回失败响应让前端提示“当前购买人数过多请稍后再试”。异步重试把任务丢进本地线程池用退让算法重试几次但要注意别把线程池占满。降级到本地限流如果分布式锁不可用可以用本地限流先挡住一部分流量保护下游。具体选哪种要看业务可容忍的时延。秒杀这种用户能接受“稍后再试”的就用快速失败内部定时任务这种获取锁失败则跳过本轮等下一轮调度即可无需额外处理。4.3 锁粒度与热点 key 拆分技巧锁的粒度直接影响吞吐量。一个常见的失误是整个商品类目共用一把锁比如stock:lock:all那么所有 SKU 的扣减在同一时刻只能有一个线程执行并发能力几乎没有。正确做法是锁粒度尽量细化到最小业务操作单元比如 SKU 维度的stock:lock:{skuId}、用户维度的order:submit:{userId}、账单维度的pay:settle:{billNo}。热点 key 是另一个难题。比如某款手机限量抢购整个流量都集中在一个 skuId 上那么stock:lock:{skuId}会奇热无比单个 Redis 节点的瓶颈会在锁上体现出来要么 CPU 飙升要么连接数打满。如果库存量很大、且允许稍微牺牲公平性可以在锁 key 上做分段int segment ThreadLocalRandom.current().nextInt(16); String lockKey stock:lock: skuId :segment: segment;然后把库存也分成 16 段用 Redis 的原子操作对对应分段扣减。这样压力分散到 16 个 key 上单点竞争大大降低。不过要注意分段后不能简单依赖一把锁来做全局原子性必须配合库存分段的原子扣减。如果业务严格要求“最终库存唯一”分段通常用于高价值大库存场景小库存且精确性优先时不要分段。4.4 与本地缓存、数据库事务的协作注意点分布式锁保护的是“跨服务的互斥”但不要忽略数据库层本身的事务控制。我在项目里见过一些人把Transactional和 Redisson 锁混用结果事务还没提交就释放了锁导致下一个线程读到的是旧数据。这里的先后顺序非常重要。正确做法应该是先获取分布式锁再开启事务。也就是说在没有拿到锁之前不要进入事务方法。比如下面的写法public boolean orderPay(String orderNo) { return lockUtil.executeWithLock(pay:lock: orderNo, 3, 10, TimeUnit.SECONDS, () - doPayTransaction(orderNo)); } Transactional(rollbackFor Exception.class) public boolean doPayTransaction(String orderNo) { // 事务内的业务操作 }这样锁释放的时机在事务提交并方法返回之后不会出现“锁没了但连数据还没提交”的问题。如果项目里使用了本地缓存也要注意本地缓存是每个 JVM 自己一份多个节点之间并不一致。分布式锁不能解决缓存一致性只能保证共享数据在 Redis / DB 层面不被同时修改。如果业务要求缓存强一致那就不要使用本地缓存或者通过 Redis 自带的 cache-aside 策略兜底。5. 高并发场景常见问题排查实录5.1 Redis 宕机、网络超时导致锁不可用分布式锁强依赖 Redis 的可用性。一旦 Redis 主节点宕机或网络分区客户端会报RedisTimeoutException或RedisConnectionException。此时线程拿不到锁业务直接失败。如果没有快速的降级方案整个接口都会挂掉。我的处理思路分三层在 Redisson 连接配置里开启高可用单点时用主从加哨兵集群场景用 Cluster避免单点 Redis 直接挂掉。对 Redisson 的操作结果做熔断。比如连续 N 次加锁失败直接开启一个本地谓词开关让系统在短暂的几秒内退化到“极简模式”。这个模式可以只允许部分请求通过或者返回降级提示。给 Redis 加监控和告警。锁的HGETALL、内存增长、连接池活跃数这些指标建议都接到监控上。5.2 锁误删除引发的事故有一次生产环境出了个严重问题现象是 A 服务持有锁执行一个耗时 40 秒的任务但有人把 Redisson 的默认配置改了设置了锁过期时间为 10 秒。结果锁到期后B 服务获取到锁并开始写同一个敏感数据。A 服务任务完成后执行unlock()把 B 服务持有的锁给删了C 服务又可以获取锁进入临界区。最终三个服务同时写数据非常危险。Redisson 的 unlock 本身有 Lua 原子校验但只能在它还认为锁是自己持有的时候。如果过期时间太短锁早就易主它在 unlock 时通过线程标识判断确实不同就会抛出异常不会误删。但问题在于业务代码里有人用了lock.isHeldByCurrentThread()判断发现 false 就不释放这本身没问题可如果用的是老版本并且没有判断直接 forceUnlock就会误删别人的锁。规避措施一定不要传过短的 leaseTime除非你对业务耗时非常有把握。解锁前一定要通过isHeldByCurrentThread()判断。永远不要让锁的过期时间小于业务最大耗时的 1.5 倍更稳妥的方案是直接不传 leaseTime启用看门狗。5.3 看门狗失效的隐蔽场景看门狗看起来美好但它只在当前线程还存活、且 JVM 未被 GC 长时间暂停、Netty 事件循环组状态正常时才生效。有两个隐蔽场景会导致失效业务方法里发起阻塞调用比如 Thread.sleep 配合回调看门狗定时任务仍然在工作因为这个任务在独立的线程里。但如果 JVM 发生长时间 Full GC所有用户线程都停顿Redis 连接也变成空闲状态看门狗可能无法及时续期。使用tryLock(waitTime, leaseTime, timeUnit)时如果你显式传了 leaseTime续期任务根本不会启动。我之前见过同事以为锁会自动续期结果实际超时后被人抢锁查了很久才发现是这个原因。所以排查锁失效问题时第一个问题永远是你调用的到底是哪个重载方法有没有传 leaseTime传了多久5.4 主从切换带来的锁丢失问题普通的单节点 Redis 主从模式下存在一个天然窗口客户端 A 在主节点加锁成功主节点还没来得及把数据同步到从节点主节点就宕机了哨兵提升从节点为新主但新主上没这把锁客户端 B 加锁成功与 A 同时进入临界区。Redisson 对这种情况提供了RedissonMultiLock也就是红锁思路。它的做法是同时对多个独立的 Redis 节点加锁必须超过一半节点加锁成功才算真正拿到锁。由于这些节点相互独立即使某个主节点数据丢失其他节点上仍然能保留锁信息其他客户端无法获得同一把锁。不过红锁的代价是性能和复杂度都上去了。只有在业务对极限一致性有硬性要求时比如资金相关才建议用 MultiLock。普通库存扣减、防重复提交这类业务用单点 Redis Redisson 已经足够。6. 经验和心得6.1 我在实际项目中踩过的几个坑第一个坑是版本问题。Spring Boot 2.2 和 Redisson 3.11 组合时自动装配会覆盖 RedisTemplate 的序列化方式导致原本存入的 String 数据反序列化变成乱码。后来我把 Redisson starter 拆掉改用redisson核心包 独立 Bean问题彻底消失。所以如果你发现 Redis 里的数据出现奇怪的二进制前缀基本是序列化器冲突。第二个坑是锁的等待时间设置过短。有一次压测时我把 tryLock 的 waitTime 设置成了 100 毫秒结果大量请求直接拿锁失败报“系统繁忙”。后来看了监控其实单次业务耗时也就 30 毫秒但峰值并发高排队时间超过 100 毫秒很常见。最后我把 waitTime 调到 1000 毫秒成功率立刻恢复到 99.9% 以上。第三个坑是锁 key 不统一。团队里有个人特别爱用String.valueOf(System.currentTimeMillis())拼锁 key导致每次请求锁都不同等于没有加锁。这个问题的根子是同事不了解锁 key 的语义。后来我在代码评审里增加了规则锁 key 必须由业务唯一标识组成如订单号、用户ID、SKU ID并写了检查清单。6.2 压测数据给我的启示我做过一个模拟抢购的压测8 个服务实例并发扣减同一个 skuId库存共 10000 件。最开始用手写 SETNX 自旋锁同样 2000 并发下异常量在 3% 左右CPU 消耗很高Redis 平均响应时间从 1ms 飙到 15ms。换用 Redisson 的 tryLock 之后把等待时间设为 500msRedis 响应时间稳定在 2ms 以内异常量降到 0.1% 以下。这说明锁的实现方式对并发保护至关重要不在于 Redis 本身慢而在于无序自旋导致了大量无效命令。调优上我发现几个关键参数影响很大connectionMinimumIdleSize默认值是 32对大多数场景已经够用。但在 Redis 重启后连接会在请求时慢慢创建会导致初期访问变慢。把最小空闲连接数调大到 64 可以缓解。nettyThreads默认值是 0表示由 Redisson 自动计算。如果机器核数多可以显式指定 32P99 延迟会有小幅改善。retryInterval设太短会加大 Redis 瞬时压力太短容易在 failover 时造成大量命令堆积。我一般设 1000~1500ms。6.3 几个可以直接抄的小技巧锁的自动释放时间我通常按“业务最大耗时 30%”设置并且用RedissonClient.getLock的默认锁利用看门狗规避长任务。只有那些确定不会超过几百毫秒的短操作才会用显式 leaseTime。锁的预热和监控也值得做。可以写一个定时任务在业务低峰期提前建立 Redis 连接降低冷启动的影响。同时在tryLock返回 false 时用指标采集器记录计数当失败率超过阈值就发出告警这说明锁竞争过于激烈或者锁粒度过大需要找优化点。最后一个小技巧如果你的业务对“可重入”需求很强最好直接用getLock而不是getFairLock。getFairLock的队列机制额外需要 Redis 的列表结构压力更大。只有真正需要严格公平的时候才用。我在使用 Redisson 的这段时间里最大的体会是分布式锁不是越复杂越可靠而是要看懂底层机制后选择最匹配业务场景的那一种。把看门狗、Lua 脚本、锁粒度这些关键点搞清楚再去写业务代码基本可以少走我当年那些弯路。后续如果你的系统里出现了更复杂的多节点一致性需求再往红锁方向演进也不迟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑