1. 从单体到分布式:为什么锁突然不够用了?
如果你是从单体应用时代一路走过来的Java开发者,对synchronized和ReentrantLock这两个词一定不会陌生。在单个JVM进程里,它们就是守护共享资源的“门神”,简单、直接、有效。一个synchronized关键字,或者一把ReentrantLock锁,就能保证同一时刻只有一个线程能访问临界区,数据一致性稳如泰山。
但当你开始接触微服务、分布式系统,情况就完全变了。想象一下,你的订单服务部署了三个实例,分别在三台不同的机器上跑。这时有一个“秒杀扣库存”的请求,通过负载均衡同时打到了这三个实例上。每个实例内部的synchronized锁都只能管好自己JVM里的线程,对于另外两个实例的请求,它们完全无能为力。结果就是,三个线程可能同时认为库存充足,都执行了扣减操作,最终导致库存超卖。这就是典型的分布式环境下的并发问题,单机锁瞬间失效。
分布式锁就是为了解决这个问题而生的。它的核心目标是在分布式系统这个“多JVM”甚至“多机器”的集群环境中,提供一个全局唯一的、排他的“信号”,让所有节点对某个共享资源的访问能够串行化。实现分布式锁的方案有很多,比如基于数据库唯一索引、基于ZooKeeper的临时顺序节点,而基于Redis的实现,因其高性能、高可用和丰富的客户端支持,成为了目前最主流的选择之一。
在Java的Redis客户端生态里,Redisson是一个绕不开的名字。它不仅仅是一个Redis的Java客户端,更是一个在Redis基础上实现的分布式对象和服务框架。而Redisson提供的分布式锁(RLock),可以说是其最核心、使用最广泛的功能之一。它封装了Redis实现分布式锁的诸多细节,比如自动续期、可重入、锁等待等,让开发者能够像使用JDK中的锁一样简单、自然地使用分布式锁。今天,我们就抛开那些简单的“SETNX”示例,从头开始,深入Redisson分布式锁的内部,看看一个生产级的分布式锁到底应该如何构建和使用。
2. 超越SETNX:Redisson分布式锁的核心设计哲学
很多初学者接触Redis分布式锁,第一个学会的命令就是SETNX(SET if Not eXists)。思路很直观:用一个固定的Key(比如lock:order:123)去占坑,谁先SETNX成功返回1,谁就拿到了锁;用完了再DEL掉释放锁。这个模型听起来完美,但在生产环境中却漏洞百出,Redisson的设计正是为了系统性地填补这些漏洞。
2.1 单命令原子性:从SETNX到SET NX PX
最初的SETNX方案有一个致命问题:它只负责设值,不负责设置过期时间。如果拿到锁的客户端在执行业务逻辑时崩溃,没有执行DEL命令,那么这个锁就会永远存在于Redis中,其他客户端再也无法获得锁,导致“死锁”。于是,改进方案是SETNX之后立刻用EXPIRE设置一个过期时间。但这又引入了新的风险:SETNX和EXPIRE是两个独立的命令,如果客户端在SETNX成功之后、执行EXPIRE之前崩溃,锁依然不会自动释放。
Redisson解决这个问题的办法是使用Redis 2.6.12版本后提供的扩展参数的原生SET命令:SET key value NX PX milliseconds。这个命令将“设置值”(如果不存在)和“设置过期时间”两个操作原子性地结合在一起,要么一起成功,要么一起失败,从根本上避免了因客户端崩溃导致的死锁。这是构建可靠分布式锁的基石。
2.2 可重入性:像ReentrantLock一样工作
JDK中的ReentrantLock是可重入的,意味着同一个线程可以多次获取同一把锁而不会阻塞自己。这个特性在复杂的业务逻辑中非常有用,比如一个加锁的方法内部调用了另一个也需要同一把锁的方法。
原生的Redis命令无法直接实现可重入性。Redisson通过在锁对应的Redis Key中存储一个HashMap结构来实现。HashMap的field是客户端唯一标识(通常由UUID和线程ID组成),value是重入次数。当线程第一次获取锁时,设置值为1;同一线程再次请求锁时,会将值递增为2;释放锁时递减,只有当值减到0时,才会真正执行删除Key的操作。这样,就完美模拟了可重入锁的行为。
2.3 看门狗机制:告别业务超时导致的锁失效
设置了过期时间(比如30秒)的锁,如果业务逻辑执行时间超过了30秒怎么办?锁会自动释放,其他客户端就能获取到锁,导致临界区被多个客户端同时进入,数据一致性被破坏。
一种简单的想法是:把过期时间设置得足够长,比如10分钟。但这又带来了新的问题:如果客户端真的崩溃了,其他客户端需要白白等待10分钟才能重新竞争锁,系统的容错性和响应速度会变差。
Redisson引入了经典的“看门狗”(Watchdog)机制来优雅地解决这个问题。它的工作原理是:
- 默认情况下,Redisson加锁时设置的过期时间是30秒。
- 加锁成功后,Redisson会启动一个后台定时任务(看门狗),这个任务每隔一段时间(默认是锁过期时间的1/3,即10秒)就去检查一下客户端是否还持有这把锁(通过判断Redis中对应Key是否存在且客户端标识匹配)。
- 如果客户端依然持有锁,看门狗就会自动将锁的过期时间重新刷新为初始的30秒。
- 只要客户端进程没有挂掉,并且没有主动释放锁,这个续期操作就会一直进行下去,相当于实现了一个“租约”,保证了业务逻辑执行期间锁不会意外失效。
- 一旦业务逻辑执行完毕,客户端调用
unlock()方法,在释放锁的同时,也会取消这个看门狗定时任务。
这个机制的精妙之处在于,它只在客户端正常工作时才续期。如果客户端进程崩溃,看门狗线程也会随之停止,锁在30秒后会自动过期释放,不会造成永久死锁。它完美平衡了“防止业务超时丢锁”和“客户端崩溃后快速释放”这两个矛盾的需求。
注意:看门狗机制是Redisson的默认行为,但它也提供了
lock()方法的重载,允许你指定一个固定的leaseTime(租约时间)。如果你传入了leaseTime,Redisson就不会启动看门狗,锁会在你指定的时间后自动释放。这适用于你能够准确预估业务执行时间的场景。
2.4 锁等待与公平性:避免无休止的轮询
当锁被其他客户端持有时,当前客户端该怎么办?最简单的做法是循环重试(自旋),但这会空耗CPU和网络资源,给Redis服务器带来压力。
Redisson实现了基于Redis Pub/Sub的锁等待机制。当客户端尝试加锁失败后,它不会立即返回或盲目重试,而是会订阅一个与锁Key相关的Channel。当持有锁的客户端释放锁时(执行DEL命令),它会向这个Channel发布一条消息。所有订阅了这个Channel的等待客户端都会收到通知,然后同时去尝试抢锁。这种方式比简单的轮询要高效得多,也减少了Redis的压力。
关于公平性,Redisson也提供了两种锁:非公平锁(默认)和公平锁(RFairLock)。非公平锁就是上面描述的机制,所有被唤醒的客户端一起竞争,谁抢到算谁的。而公平锁则通过Redis的列表(List)结构维护了一个等待队列,严格按照先来后到的顺序授予锁,避免了“线程饥饿”问题,但性能开销会稍大一些。
3. 手把手实战:从环境搭建到代码落地
理解了核心原理,我们来看看如何在实际项目中使用Redisson分布式锁。我会以一个模拟“商品库存扣减”的场景为例,带你走完全流程。
3.1 环境准备与依赖引入
首先,你需要一个Redis服务器。本地开发可以用Docker快速启动一个:
docker run -d --name my-redis -p 6379:6379 redis:7-alpine在你的Spring Boot项目中,引入Redisson的Spring Boot Starter是最方便的方式。以Maven为例:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.0</version> <!-- 请使用最新稳定版本 --> </dependency>然后在application.yml中配置Redis连接:
spring: redis: host: localhost port: 6379 # 如果有密码 # password: yourpassword # 数据库索引,默认0 database: 0Redisson Starter会自动读取这些配置并创建RedissonClient实例注入到Spring容器中。
3.2 核心API使用与代码示例
Redisson的分布式锁对象是RLock,它实现了java.util.concurrent.locks.Lock接口,因此用法和ReentrantLock非常相似。
场景:我们有一个ProductService,其中有一个扣减库存的方法deductStock。
基础加锁与释放:
@Service public class ProductService { @Autowired private RedissonClient redissonClient; @Autowired private ProductRepository productRepository; // 假设的数据库访问层 public void deductStock(Long productId, Integer quantity) { // 1. 构造锁的Key。通常格式为`业务前缀:资源标识`,清晰且避免冲突。 String lockKey = "lock:product:stock:" + productId; // 2. 获取锁对象 RLock lock = redissonClient.getLock(lockKey); try { // 3. 尝试加锁。最常用的方法是lock(),它会阻塞直到获取到锁。 lock.lock(); // lock()方法默认启用看门狗,锁超时时间为30秒,会自动续期。 // 4. 进入临界区,执行业务逻辑 Product product = productRepository.findById(productId).orElseThrow(); if (product.getStock() < quantity) { throw new RuntimeException("库存不足"); } // 模拟一个可能耗时的操作 Thread.sleep(10000); product.setStock(product.getStock() - quantity); productRepository.save(product); System.out.println("扣减成功,剩余库存:" + product.getStock()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态 throw new RuntimeException("操作被中断", e); } finally { // 5. 必须在finally块中释放锁,确保锁一定会被释放 if (lock.isLocked() && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这段代码是标准范式。lock()是阻塞式调用,会一直等待。finally块中的释放逻辑是必须的,并且先判断了锁是否被当前线程持有,这是一个好习惯。
尝试加锁与等待超时: 在实际场景中,我们通常不愿意无限期等待。tryLock方法提供了更灵活的控制。
public boolean deductStockWithTryLock(Long productId, Integer quantity) { String lockKey = "lock:product:stock:" + productId; RLock lock = redissonClient.getLock(lockKey); // 尝试获取锁,最多等待10秒,获取后锁的持有时间(租约时间)为30秒 boolean isLocked = false; try { isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS); if (isLocked) { // 成功获取锁,执行业务逻辑 // ... 业务代码 ... return true; } else { // 在指定时间内未获取到锁 System.out.println("获取锁失败,可能系统繁忙,请稍后重试或提示用户"); // 这里可以返回false,或抛出特定的业务异常 return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取锁时被中断", e); } finally { if (isLocked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }tryLock(long waitTime, long leaseTime, TimeUnit unit)是最常用的重载方法:
waitTime: 获取锁的最大等待时间。如果设置为0,则立即返回结果。leaseTime: 锁的持有时间。如果设置了此参数,看门狗机制将不会启动,锁在指定时间后自动过期。这要求你必须能准确评估业务执行时间。- 返回值:
true表示成功获取锁,false表示超时未获取。
可重入性验证:
public void reentrantMethod(Long productId) { String lockKey = "lock:product:stock:" + productId; RLock lock = redissonClient.getLock(lockKey); lock.lock(); try { System.out.println("外层方法获取锁,重入次数预估: 1"); // 在锁内调用另一个也需要同一把锁的方法 innerMethod(productId); } finally { lock.unlock(); } } private void innerMethod(Long productId) { String lockKey = "lock:product:stock:" + productId; // 相同的Key RLock lock = redissonClient.getLock(lockKey); lock.lock(); // 同一线程,这里不会阻塞 try { System.out.println("内层方法再次获取锁,重入次数预估: 2"); } finally { lock.unlock(); // 释放一次,重入次数减为1 } } // 最终外层方法unlock时,重入次数减为0,锁被真正释放。运行这段代码,你会发现线程可以顺利进入innerMethod,而不会发生死锁,这就是可重入锁的价值。
4. 生产环境避坑指南与高阶考量
把Demo跑通只是第一步,要把Redisson分布式锁用到生产环境,还有一大堆坑等着你。下面是我在实际项目中总结的一些关键点和避坑经验。
4.1 Key的设计与命名空间
锁的Key设计至关重要,它直接关系到锁的粒度和系统性能。
- 粒度要合适:锁的粒度越细,并发度越高。比如用
lock:order:{orderId}就比lock:order:all好得多,后者会让所有订单操作串行化。但也不是越细越好,太细会导致Redis中Key数量爆炸。 - 含义要清晰:遵循
业务:子业务:资源标识的命名规范,例如inventory:deduct:sku_001。这便于后续监控和排查问题。 - 避免随机值:除非特殊场景,否则不要用UUID作为Key的一部分,这会导致锁无法被复用,失去意义。
4.2 锁的过期时间与业务执行时间评估
这是最容易出问题的地方。
- 使用看门狗(默认):对于执行时间不确定的长任务,这是最安全的选择。但你要确保业务逻辑中没有长时间的阻塞操作(如同步HTTP调用),否则看门狗线程可能无法正常工作。
- 指定leaseTime:如果你能100%确定业务逻辑的最大执行时间(比如一个简单的数据库更新),那么指定一个合理的
leaseTime(比如业务最长时间+5秒缓冲)是更高效的选择,因为它节省了看门狗续期的开销。绝对不要拍脑袋写一个很大的值,比如10分钟,这会在客户端崩溃时严重拖慢故障恢复。 - 一个真实的坑:我们曾有一个报表生成任务,预估最多2分钟,就设置了
leaseTime为130秒。结果某次数据量激增,任务执行了3分钟。锁在130秒后自动释放,另一个节点上的相同任务开始执行,导致同一份报表被生成了两次,且第二次覆盖了第一次的结果。教训是:对于时间预估没把握的任务,老老实实用看门狗。
4.3 释放锁的严格判断
在finally块中释放锁时,务必进行双重判断:
finally { if (lock.isLocked() && lock.isHeldByCurrentThread()) { lock.unlock(); } }lock.isLocked():检查锁是否还存在。可能锁已经因为超时被自动释放了。lock.isHeldByCurrentThread():检查当前线程是否还持有这把锁。这是为了防止“释放了其他线程的锁”这种严重错误。 如果缺少isHeldByCurrentThread()判断,考虑以下场景:线程A持有锁,但业务执行时间超过了看门狗续期间隔(比如发生了Full GC),锁因超时被释放。线程B获得了锁。此时线程A从GC中恢复,继续执行到finally块,如果直接调用unlock(),就会把线程B的锁给释放掉!这会导致数据混乱。Redisson在unlock()内部其实有类似的检查,但显式地写出这个判断是一个非常好的编程习惯。
4.4 在Spring事务中使用分布式锁
这是一个经典的陷阱。看下面这段代码:
@Transactional public void deductStockInTransaction(Long productId) { RLock lock = redissonClient.getLock("lock:stock:" + productId); lock.lock(); try { // 1. 查询库存 Product product = productRepository.findById(productId).orElseThrow(); // 2. 检查并扣减(内存中计算) if (product.getStock() > 0) { product.setStock(product.getStock() - 1); } // 3. 保存(此时UPDATE语句还未发送到数据库!) productRepository.save(product); // 事务提交后,数据库才真正更新 } finally { lock.unlock(); } }问题在于:@Transactional使得数据库更新操作在方法结束时(finally块之后)才真正提交。锁在事务提交前就被释放了!另一个线程可能在事务提交前就拿到了锁,并读到了未扣减的旧库存,同样造成超卖。
解决方案:让锁的范围覆盖整个事务。有两种常见做法:
- 编程式事务:在加锁后,再开始事务。
public void deductStockSafe(Long productId) { RLock lock = redissonClient.getLock("lock:stock:" + productId); lock.lock(); try { // 在锁内开启事务 transactionTemplate.execute(status -> { // ... 业务逻辑 ... productRepository.save(product); return null; }); } finally { lock.unlock(); } } - 将加锁操作放在事务方法的外层(更推荐):这是更清晰的做法,由调用方负责加锁,Service方法只负责事务内的业务。
// Controller 或 外层Service public void businessProcess(Long productId) { RLock lock = redissonClient.getLock("lock:stock:" + productId); lock.lock(); try { // 调用带有@Transactional注解的Service方法 productService.deductStockInTransaction(productId); } finally { lock.unlock(); } } // ProductService内部方法 @Transactional public void deductStockInTransaction(Long productId) { // ... 纯数据库业务逻辑 ... }
4.5 Redis集群模式与红锁(RedLock)的争议
在Redis单节点或主从哨兵模式下,上述讨论是成立的。但在Redis Cluster集群模式下,由于数据分片,锁信息只存在于某一个主节点上。如果这个主节点宕机,而哨兵选举从节点为新主的过程存在延迟,可能会导致锁状态丢失,出现多个客户端同时持有锁的情况。
为了解决这个问题,Redis作者提出了RedLock算法。其核心思想是:客户端向Redis集群中的大多数(N/2+1)个独立节点依次申请锁,只有当从大多数节点都获取到锁时,才算加锁成功。Redisson也实现了RedissonRedLock。
然而,RedLock在分布式系统社区(如Martin Kleppmann)中引发了巨大争议。反对者认为,它依赖于一个“所有节点时钟同步”的不可靠假设,并且在网络分区、GC暂停等复杂故障场景下,依然无法保证绝对安全。社区目前的共识是:
- 对于绝大多数业务场景,使用单Redis节点(配合哨兵高可用)的分布式锁已经足够。其可靠性高于基于数据库的方案,性能也更好。你需要权衡的是“极端情况下锁失效的风险”与“引入RedLock带来的复杂性和性能损耗”。
- 如果你认为业务完全不能承受锁在极端情况下失效(比如金融核心交易),那么你不应该依赖任何基于CP(一致性优先)组件(如ZooKeeper、etcd)的分布式锁。你需要重新审视业务架构,是否可以通过串行化队列、乐观锁(如版本号)、或者业务本身的幂等性设计来避免对强一致锁的依赖。
因此,我的建议是:优先使用单节点/主从模式的Redisson锁,并配合良好的业务幂等和补偿机制。除非有非常充分的理由和全面的测试,否则不要轻易引入RedLock。
4.6 监控与运维
线上系统离不开监控。你需要关注:
- Redis内存和Key数量:监控带有特定前缀(如
lock:)的Key数量,防止因程序Bug导致锁未释放而堆积。 - 锁的等待时间:通过Redisson的
RLock对象的getHoldTime()、remainTimeToLive()等方法,或在业务日志中打印加锁耗时,可以评估锁竞争是否激烈。长时间等待可能意味着临界区业务过重或锁粒度太粗。 - 看门狗续期日志:Redisson可以配置日志级别来输出看门狗的活动,这有助于诊断锁的持有状态是否健康。
5. 不仅仅是锁:Redisson的其他分布式同步器
当你熟练使用RLock后,你会发现Redisson还提供了一系列其他JDK标准接口的分布式实现,它们能解决更复杂的同步问题。
5.1 分布式信号量(RSemaphore)类似于java.util.concurrent.Semaphore,用于控制同时访问某个特定资源的线程数量。比如,你可以用它来限制同时处理某个任务的客户端数量不超过10个。
RSemaphore semaphore = redissonClient.getSemaphore("mySemaphore"); semaphore.trySetPermits(10); // 设置总许可数为10 // 在某个客户端 if (semaphore.tryAcquire()) { // 尝试获取一个许可 try { // 执行业务,最多10个客户端同时进入 } finally { semaphore.release(); } }5.2 分布式闭锁(RCountDownLatch)类似于CountDownLatch,用于让一个或多个线程等待其他一组线程完成操作。在分布式部署中,可以用来协调多个服务实例同时开始某个任务,或者等待所有实例准备就绪。
// 在协调者服务 RCountDownLatch latch = redissonClient.getCountDownLatch("startLatch"); latch.trySetCount(3); // 等待3个实例 // 在各个工作实例启动时 RCountDownLatch latch = redissonClient.getCountDownLatch("startLatch"); // ... 实例完成初始化 ... latch.countDown(); // 计数减1 latch.await(); // 等待计数归零,然后所有实例同时开始工作5.3 分布式可重入读写锁(RReadWriteLock)RReadWriteLock实现了ReadWriteLock接口,支持“读-读不互斥,读-写互斥,写-写互斥”的语义。这在“读多写少”的场景下可以大幅提升并发性能,比如缓存热点数据的更新。
RReadWriteLock rwLock = redissonClient.getReadWriteLock("myRwLock"); RLock readLock = rwLock.readLock(); RLock writeLock = rwLock.writeLock(); // 读操作 readLock.lock(); try { // 多个线程可以同时进入这里读数据 } finally { readLock.unlock(); } // 写操作 writeLock.lock(); try { // 同一时间只有一个线程能进入这里写数据,且会阻塞所有读锁 } finally { writeLock.unlock(); }掌握这些同步器,能让你在设计和实现分布式系统时拥有更多、更合适的工具,而不仅仅是依赖一把简单的互斥锁。从一把可靠的分布式锁开始,逐步深入其原理、陷阱和最佳实践,再扩展到更丰富的分布式并发原语,这才是“从头开始学Redisson分布式锁”的完整路径。