SpringBoot集成Redisson实现分布式锁:原理、实战与避坑指南

发布时间:2026/10/7 3:28:18
SpringBoot集成Redisson实现分布式锁:原理、实战与避坑指南
折腾SpringBoot分布式锁绕不开Redisson网上很多人习惯把它搜成Redission其实正确拼写是Redisson多一个o。它是Redis官方推荐的Java客户端但真正让它出名的是内置了一套完整的分布式锁和同步器实现——你不需要自己拼Lua脚本不用手工处理过期时间调用几行API就能把单机锁升级成跨进程、跨节点的分布式锁。这篇文章适合人群是后端Java开发尤其是微服务多实例部署后还在用synchronized、或者自己用Redis SETNX拼锁的人。如果你正被库存超卖、定时任务重复执行、热点数据同时重建这类问题困扰那这篇文章正好覆盖你需要的全部内容分布式锁到底解决什么问题、Redisson读写锁和可重入锁的原理、SpringBoot里的完整落地代码以及我踩过的坑和排障经验。1. 什么是Redisson先搞清楚它和Redis的关系1.1 Redisson到底是什么先明确一个基础概念。Redisson是架设在Redis基础上的Java客户端和Jedis、Lettuce这类偏底层的客户端不同Redisson把Redis里的数据结构封装成了Java风格的接口你操作的不是byte[]而是RBucket、RMap、RSet这些高级集合还有RLock这一类分布式锁对象。拿我自己的项目经验来说最早用Jedis的时候想要一个锁只能自己写SETNX加上EXPIRE还要小心释放锁的时候别误删别人的key非常容易踩坑。换成Redisson之后getLock()返回一个RLock接口长得跟JDK的java.util.concurrent.locks.Lock几乎一模一样加锁、解锁、tryLock、锁超时这些语义全都自带了几乎没有学习成本。有一点要提醒一下Redisson的定位不只是客户端它更像个基于Redis的Java中间件除了分布式锁还提供分布式对象、分布式集合、分布式队列、分布式信号量等功能。不过大多数人接触它是从分布式锁开始的这篇文章也聚焦在锁这一块其他功能以后有机会再单独写。1.2 为什么原生Redis命令做锁不够用很多人一开始是这么写锁的先SET一个带过期时间的key然后业务执行完再DEL。单看一条命令确实没问题但放在真实场景里有几个绕不过去的坑。第一个坑加锁和设置过期时间不原子。早期写法是SETNX之后单独EXPIRE一旦在两步之间进程重启锁就成了没有过期时间的死锁。后来SET命令支持了NX和EX参数才解决。现在也有团队继续用这条命令做简单锁但只适合业务极简的场景。SET order:lock unique-value NX EX 30第二个坑误删别人的锁。线程A的锁因为业务执行太久自动释放了线程B拿到同一把锁开始干活这时线程A执行完业务抬手一个DEL把B的锁删了。正确做法是用唯一value标识这把锁是谁的删除之前先比对这个逻辑看起来简单自己实现时总会漏掉边界情况。第三个坑锁过期了业务还没干完。如果业务执行超过30秒锁到点自动释放后面的线程又进来了这把锁等于形同虚设。Redisson用看门狗机制自动续期专门解决这个问题后面我会详细展开。2. 为什么要用分布式锁单机锁与多实例的本质差异2.1 从synchronized到分布式锁的演进先问一个问题单机部署的时候用synchronized做并发控制为什么没问题答案很简单synchronized和ReentrantLock锁到的是JVM内部的Monitor和AQS它们只对当前进程内的线程生效。当一个服务从单机拆成两个实例同样的代码在节点A和节点B上各自跑一份两个进程之间没有任何共享的锁状态原本的互斥保护就完全失效了。举个真实例子。我之前负责一个对账任务每天晚上定时扫描前一天的订单这个任务本身逻辑没问题单机部署时一直正常。后来服务扩容成两台机器定时任务在每台机器上都会执行一次结果同一批订单被对账了两次产生了重复的结算数据。排查了很久发现不是代码逻辑的问题而是两个进程同时跑了一遍。这就是典型的需要分布式锁的场景。再说库存扣减。单机时用乐观锁、CAS甚至synchronized都能控制但多实例之后呢每个实例的synchronized都只锁自己进程内的线程两个实例同时读到库存10同时减到9数据库里最终可能只减了一次。分布式锁就是把这些分散的进程拉到同一个互斥域里让同一时刻只有一个进程能执行临界区代码。2.2 分布式锁的三个核心特性一个合格的分布式锁至少要满足三个条件。第一互斥性。任意时刻这把锁只能被一个客户端持有这是锁存在的根本意义没有互斥就谈不上锁。第二安全性。客户端崩溃或者网络异常时锁必须能被释放不能出现死锁所以锁一定要有超时时间作为兜底。第三可用性。加锁和解锁这两个操作本身要足够快、足够可靠不能因为存储节点抖动导致整个业务链路被拖垮。如果要求更严格还会考虑锁的可重入性也就是同一个线程能否重复获取同一把锁以及公平性多个请求等待时是否按先来后到的顺序获取。Redisson默认的锁是非公平的也提供了FairLock实现公平锁。大多数场景下非公平锁性能更好够用就行真需要公平锁的场合并不多。3. 分布式锁的典型应用场景盘点3.1 库存扣减与秒杀场景锁粒度怎么设计分布式锁最经典的应用就是库存扣减。想象一个秒杀接口100件库存1000个人同时抢。如果不加并发控制多个请求同时把库存减1最后库存数据就对不上了。传统做法是用数据库行锁UPDATE ... WHERE stock 0但在高并发下连接会打满数据库压力非常大。把库存扣减放到Redis里用分布式锁保证同一时刻只有一个请求在操作某个SKU的库存能有效降低数据库冲击。经验之谈库存热点加分布式锁锁的粒度一定要设计到SKU维度而不是全局一把锁。比如 stock:lock:1001 这种不同商品之间互相不干扰。如果图省事用一个全局锁相当于把所有商品的扣库存操作串行化并发能力大打折扣秒杀场景下直接就体现成QPS上不去。3.2 定时任务防重复tryLock优于lock还有一个非常高频的场景是分布式定时任务。很多项目用Scheduled注解实现定时任务单机没问题多实例部署后每个节点都会触发。最简单的做法是给任务加一个分布式锁让同一时刻只有一个节点能执行比如每天凌晨的清结算任务拿到锁的节点执行其他节点跳过。这里有个小坑直接用lock()会让没拿到锁的节点阻塞等待如果任务执行时间很长其他节点会一直阻塞到锁超时浪费线程资源。更适合的是tryLock()配合一个很短的等待时间拿不到锁就直接放弃不阻塞业务线程。我习惯写成tryLock(0, 5, TimeUnit.SECONDS)waitTime设为0表示立即返回失败非常干脆。3.3 缓存重建与防止缓存击穿缓存击穿是指一个热点key过期后大量请求同时回源数据库重建缓存瞬间打垮数据库。用分布式锁也能解决当缓存不存在时不是所有请求都去查库而是先去竞争锁抢到锁的线程查库并回填缓存其他线程短暂等待后从缓存读取数据。这个方案的思路是让重建缓存的动作只发生一次而不是并发发生很多次。我实际开发中常把这个方案和双检锁结合先查缓存没有则尝试加锁加锁成功后再查一次缓存还是没有才回源数据库回填后释放锁。之所以要二次检查是因为可能在等待锁的过程中已经有别的线程把缓存重建好了再查一次可以省掉一次无谓的数据库访问。4. Redisson的读写锁读锁、写锁到底怎么用4.1 读写锁的基本语义ReentrantReadWriteLock大家应该不陌生Redisson同样提供了分布式版本的读写锁接口是RReadWriteLock通过getReadWriteLock(lockName)获取。读写锁的核心语义是读锁和读锁之间共享不互斥写锁和写锁互斥读锁和写锁也互斥。换句话说多个线程可以同时读一份数据但任何线程要写的时候必须等所有读线程结束并且写线程在写的时候其他线程既不能读也不能写。这个语义适合什么场景最典型的就是读多写少的共享资源。比如一个配置缓存在Redis里多个实例都在读取偶尔有后台任务要刷新配置。如果没有读写锁刷新的时候直接替换key可能出现某个正在读的线程读到一半的数据如果加读写锁刷新线程持有写锁所有读线程要么等它写完要么读旧值不会读到中间状态。4.2 读锁和写锁的互斥关系把四种情况列成一张表看起来最直观。当前持有锁请求获取是否允许说明读锁读锁允许多个读锁可以共存并行读读锁写锁不允许写必须等待所有读线程释放写锁读锁不允许写期间其他线程不能读写锁写锁不允许一个写锁只能被一个线程持有从实现角度想想为什么读锁和写锁要互斥因为要保证读的一致性。如果读和写可以同时进行读线程获取到的数据可能是写线程写到一半的状态。在读多写少且数据一致性要求高的场景里这个取舍是值得的。4.3 读写锁的使用示例与注意事项在SpringBoot里用Redisson读写锁非常简单先注入RedissonClient然后这样调用Autowired private RedissonClient redissonClient; public void refreshConfig() { RReadWriteLock rwLock redissonClient.getReadWriteLock(config:lock); RLock writeLock rwLock.writeLock(); try { writeLock.lock(); // 读取数据库、重建缓存 } finally { writeLock.unlock(); } } public String getConfig() { RReadWriteLock rwLock redissonClient.getReadWriteLock(config:lock); RLock readLock rwLock.readLock(); try { readLock.lock(); return cacheService.getConfig(); } finally { readLock.unlock(); } }几个细节要注意。第一读写锁底层在Redis里也是用hash结构存储持有者信息和状态计数是分开记录的读锁和写锁的状态可以同时存在但写锁存在时读锁计数必须为0。第二Redisson的读写锁同样支持可重入同一个线程可以多次获取读锁或写锁。第三读写锁虽然有并发优势但极端高并发的写场景下不要指望它能替代互斥锁写操作本身就是串行的读写锁对写场景没有加速作用。5. 可重入锁的原理hash计数与看门狗续期5.1 可重入锁到底是什么可重入锁很多人第一次听这个词会觉得玄乎。用大白话解释同一个线程可以多次获取同一把锁不需要先释放再获取。比如一个方法加锁方法内部又调用另一个加锁的方法只要锁标识相同线程可以直接进去而不是把自己卡死。为什么需要可重入因为真实业务代码往往存在嵌套调用。假设外层方法A获取了订单锁方法A内部调用方法B方法B也尝试获取同一把订单锁。如果锁不可重入方法B就会一直等待自己持有的锁释放直接死锁。所以可重入是分布式锁能不能在复杂业务里真正落地的关键特性。5.2 Redisson可重入锁的实现机制Redisson可重入锁的核心实现依赖Redis的hash结构。普通锁存的是String类型的value一个key对应一个值而Redisson的锁key对应一个hashhash里的field存放的是客户端唯一标识由UUID和线程ID拼接而成hash里的value存放的是加锁计数。加锁时执行Lua脚本逻辑可以简化为三步先判断锁key是否存在不存在则说明当前没人持有锁直接在hash里写入当前线程标识计数值设为1设置过期时间如果锁key已经存在再判断当前线程标识是否已在hash里在的话直接计数值加1同时刷新过期时间如果锁被其他线程持有则返回剩余过期时间告诉调用者当前还在等待期。解锁时的逻辑与之对称先判断hash里有没有当前线程标识没有说明锁不是自己的不能删有则计数值减1减完还大于0说明还有外层调用没有退出只刷新过期时间不删除锁只有计数值减到0才真正删除这个key。理解了这套hash计数机制就明白了为什么叫可重入重入一次计数加一每解锁一次计数减一只有计数归零锁才真正释放。5.3 看门狗机制与锁续期接下来说分布式锁最容易翻车的地方锁过期时间。假设手动设置锁10秒自动过期但业务执行了15秒锁到期自动释放后面的请求又进来了临界区被并发访问。Redisson的解法是看门狗机制。默认情况下通过lock()方法加锁且没有显式指定leaseTime时Redisson会给锁设一个30秒过期时间并启动一个后台定时任务每隔锁租期三分之一的时间也就是约10秒一次自动把过期时间续到30秒。只要业务线程还在运行、锁还在持有中看门狗就会一直续期直到业务结束unlock才取消续期任务。这个机制保证了业务没跑完之前锁不会被自动回收。但要注意两点第一手动指定leaseTime后比如lock(10, TimeUnit.SECONDS)看门狗不会介入锁到10秒一定被释放哪怕业务没跑完这种模式适合对执行时长有严格上限的任务。第二看门狗续期的前提是Redisson客户端还活着如果客户端进程崩溃续期任务自然停止锁会在30秒后自动过期这恰恰是设计上期望的行为避免了死锁。6. SpringBoot整合Redisson的完整实操6.1 引入依赖与配置RedissonClient现在进入实操部分。SpringBoot项目整合Redisson最舒服的方式是引入官方starter。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency引入依赖后需要在项目里确认RedissonClient的Bean是否被正确创建。用starter方式时如果你在application.yml里配好了spring.redis相关参数Redisson会尝试复用连接信息。但我个人更喜欢手动配置类因为可以明确指定集群模式、连接池参数和序列化方式排查问题也方便。手动创建的配置类如下Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(32); return Redisson.create(config); } }把这个配置类放进项目之后在业务代码里直接注入RedissonClient就能使用各种分布式锁了。注意Bean的destroyMethod设为shutdown这样Spring容器关闭时会自动优雅关闭Redisson连接池释放资源。6.2 加锁代码的正确写法很多新手会在业务代码里这样写RLock lock redissonClient.getLock(order:lock); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }这个写法能用但有几个隐患。首先默认锁没有指定leaseTime依赖看门狗自动续期如果业务方法的锁忘记释放虽然最终会过期但30秒窗口里其他线程会一直阻塞。其次lock()是阻塞式拿不到锁会一直等下去如果持有锁的线程卡死其他线程也会跟着挂起。我推荐优先使用tryLock并设置合理的等待时间和租期RLock lock redissonClient.getLock(order:lock); boolean acquired false; try { acquired lock.tryLock(2, 10, TimeUnit.SECONDS); if (!acquired) { // 没拿到锁直接返回失败或者走降级逻辑 return Result.busy(); } // 业务逻辑 } finally { if (acquired) { lock.unlock(); } }这里的参数含义等待2秒2秒内拿不到锁就放弃锁租期10秒即使业务异常导致忘记解锁锁也会自动释放。注意指定leaseTime后看门狗不会介入租期一定要大于业务预估的最大执行时间否则业务没跑完锁就没了。6.3 业务中如何优雅释放锁解锁环节看起来简单却是线上事故高发区。核心原则就一条谁加的锁谁来释放没拿到锁绝不释放。上面的acquired标志位就是为了保证这一点。另一个细节是锁对象的匹配。Redisson解锁时要求锁对象和加锁时是同一个你在一处getLock(order:lock)加锁在另一处必须也用getLock(order:lock)解锁锁的标识依赖key本身不能写错。还有一点是关于锁释放顺序。嵌套获取多把锁时释放顺序要和加锁顺序相反类似栈结构。先拿订单锁、再拿用户锁释放时先释放用户锁、再释放订单锁。如果顺序反了可能出现两个线程互相等待对方的锁形成死锁。7. 实战中的坑与排障经验7.1 锁超时时间怎么设置这是最容易被低估的点。leaseTime设得太短业务没执行完锁就自动释放后面的请求闯进临界区设得太长万一持有锁的节点宕机其他节点要等很久才能拿回锁。我的经验是两条原则。第一优先用默认的看门狗机制让锁租期跟着业务执行时间动态续期。第二如果一定要指定leaseTime建议设置为普通业务耗时的10倍以上或者按接口的P99耗时来估算宁可设置大一点也别让锁提前失效。锁提前失效造成的并发问题远比锁晚释放几秒钟更严重。另外如果业务中有远程调用或者网络等待一定要把这些阻塞时间算进租期里这是最容易漏掉的部分。7.2 锁粒度与锁key的设计分布式锁的另一个常见问题是锁的粒度设计不合理。锁粒度太粗比如整个系统用一把global lock所有需要互斥的操作全部串行并发能力急剧下降。锁粒度太细比如对每一行数据都加锁锁的数量会爆炸Redis的内存和性能都受影响。我通常按业务维度拆分锁key。秒杀场景用skuId订单创建用orderId用户操作可以用userId。如果一个业务同时涉及多个资源才考虑用组合key例如order:create:10001。锁key的命名规律建议统一前缀加业务ID像module:resource:id这样出了问题也好排查。好的锁key设计要能一眼看出防的是什么同时具备足够的区分度。7.3 主从架构下锁会丢失吗这是分布式锁原理层面绕不开的话题。Redisson默认的锁写入Redis单节点如果使用主从架构写锁时只写主节点主节点还没同步到从节点就发生故障切换从节点晋升为主节点后锁数据就丢了新的客户端可能拿到同一把锁造成互斥失效。Redisson官方提供了Redlock红锁方案解决这个问题思路是向多个独立节点依次加锁超过半数节点加锁成功才算成功。但红锁实现复杂、性能开销大而且理论上对它的安全性也有争论。我的个人建议是绝大多数业务场景下单机Redis或者哨兵Redis的分布式锁已经够用没必要为了极端一致性上红锁如果业务真的无法接受锁失效的影响优先考虑引入强一致性的协调组件而不是在Redis上硬磕。7.4 常见问题速查表问题现象排查方向加了锁但请求还是并发进入锁失效或过期时间太短检查leaseTime是否小于业务耗时、锁key是否一致、是否误用了不同RedissonClient解锁报IllegalMonitorStateException在没持有锁的情况下调用了unlock检查是否拿锁失败后仍执行了解锁、锁对象是否被重新创建锁一直不释放导致阻塞忘记调用unlock或进程被强杀检查finally块、确认看门狗是否正常、租期是否设置过长一个线程反复重入后计数异常同一线程加锁多次未释放检查嵌套方法是否重复加锁控制重入层级表格里的问题都是我在项目里真实遇到过的。有一次同事在循环里反复获取同一把锁而忘记在每次业务结束后释放计数只增不减最终把Redis内存打高。这个案例给我的教训是可重入锁的计数机制虽然方便但滥用会导致锁对象膨胀一定要在业务层面控制重入层级并且养成finally里解锁的习惯。最后分享一点个人心得。分布式锁听起来是高大上的并发原语但落地的时候它解决的是最朴素的工程问题多个人抢一份资源怎么办。Redisson把细节封装得很好可这不代表使用者可以完全不去理解底层机制。我第一次在生产环境遇到锁失效问题就是因为只看文档、不理解hash计数和看门狗漏配了leaseTime导致线上频繁出现并发重复执行排查了很久才定位到是锁提前过期。如果你正要开始用Redis分布式锁建议把hash计数实现可重入、看门狗自动续期、读写锁互斥语义这三个机制亲手敲一遍源码或者至少把Lua脚本读一遍。理解它们远比背下API更有价值。