Redisson实现分布式锁的方式,与很多手写SETNX加过期时间的方案有本质区别。它把锁对象映射为Redis中的一个Hash结构,键名是业务锁标识,Hash字段是当前持有锁的线程ID,字段值则是该线程的重入次数。加锁、续期、解锁操作全部通过Lua脚本发送到Redis执行,这样能保证在多线程并发下每个操作都是原子性的。

如果不理解这套结构,很容易在使用Redisson时踩坑。比如只看过简单的setnx示例,就以为Redisson只是在setnx外面包了一层客户端API。实际上,Redisson的锁信息是放在Hash里,而不是一个普通的字符串键。Hash字段使用线程ID加上连接ID组成,避免不同JVM里的相同线程ID互相误判。只有理解了锁的存储形式和Lua脚本的作用,才能解释可重入、自动续期这些能力是怎么来的。
一、加锁与解锁的原子流程
Redisson默认通过一段Lua脚本来完成加锁。脚本会先判断目标锁对应的Hash键是否存在,如果不存在,表示当前没有线程持有锁,脚本就执行hincrby命令,将当前线程标识作为Hash字段写入,并把值设为1,同时设置锁的过期时间。如果锁已经存在,脚本会继续判断当前Hash字段是否就是请求线程自己,如果是,则说明同一个线程再次获取锁,也就是可重入场景,此时把Hash字段的值加一,并重新刷新过期时间。其他情况下脚本会直接返回锁的剩余存活时间,表示加锁失败。
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
解锁同样不是简单地删除键,而是通过Lua脚本先校验Hash中是否存在当前线程的字段。如果存在,就把对应的计数值减一。减一后如果计数值仍然大于零,说明该线程还持有锁,只需要刷新过期时间,不需要删除整个Hash。只有当计数值归零时,脚本才会真正删除锁对应的Hash键。这样可以保证解锁操作只对自己持有的锁生效,不会把别的线程刚获取到的锁误删掉。
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
把加锁和解锁都放在Lua脚本里执行,是为了利用Redis单线程执行脚本的特性。脚本中的exists、hincrby、pexpire等命令会作为一个整体连续执行,中间不会被其他客户端命令插入。如果只是先执行setnx,再单独执行expire,一旦两个命令之间客户端崩溃,就可能留下一个永不过期的锁。Redisson从设计上就规避了这种经典问题。
二、可重入锁与看门狗续期机制
可重入是Redisson分布式锁的重要特性之一。同一个线程在已经持有锁的情况下,可以再次调用lock方法而不会死锁。实现原理正是基于前面提到的Hash结构:每次加锁时,同一个线程的Hash字段值会加一;每次解锁时,字段值减一,直到零才真正删除锁。这种设计让递归调用、嵌套方法调用或者同一个线程串行执行多个加锁段成为可能。相比之下,如果使用简单的setnx方案,第二次setnx会失败,如果没有额外处理,线程就会阻塞在自己持有的锁上。
RLock lock = redisson.getLock("order:pay:1001");
try {
lock.lock();
// 第一层业务逻辑
processOrder();
// 同一个线程再次获取同一把锁,不会阻塞
lock.lock();
try {
updateStock();
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
看门狗机制解决的是锁租约与业务执行时间不匹配的问题。默认情况下,如果调用lock方法时没有显式指定leaseTime,Redisson会把锁的租约设置为30秒,同时启动一个后台任务,每隔10秒检查一次锁是否仍然被当前线程持有。如果持有,就把过期时间续期到30秒。这样即使业务逻辑执行超过30秒,锁也不会提前释放。只有当客户端进程崩溃或者连接断开时,看门狗无法继续续期,锁才会在到期后自动释放,避免死锁。
需要特别注意的是,看门狗只在没有手动指定leaseTime的情况下生效。一旦调用lock或tryLock时传入了leaseTime,Redisson会严格按照传入的时间设置过期时间,不会再启动自动续期任务。因此,如果业务执行时间无法预估,更适合使用不带leaseTime的lock方法,让看门狗动态续期。如果业务时间可以精确预估,手动指定leaseTime可以节省看门狗带来的少量Redis通信开销。
三、典型用法与参数调优
在实际项目中使用Redisson分布式锁,通常先配置RedissonClient,然后通过getLock方法获取锁对象。常见的Spring Boot集成方式是把RedissonClient交给容器管理,业务代码直接注入使用。下面的例子演示了带超时时间的tryLock用法,这种方式比无参lock更适合接口请求场景,因为可以避免线程无限等待锁释放。
@Autowired
private RedissonClient redissonClient;
public void handleRequest(String orderId) {
RLock lock = redissonClient.getLock("order:handle:" + orderId);
boolean acquired = false;
try {
acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("获取锁超时");
}
// 执行业务逻辑
doBusiness(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
tryLock的参数中,第一个参数表示等待获取锁的最长时间,第二个参数表示锁的自动释放时间,第三个参数是时间单位。上面的配置表示最多等待3秒拿锁,拿到后最多持有10秒。这里由于指定了leaseTime为10秒,所以看门狗不会参与续期,业务逻辑如果超过10秒,锁会自动释放。因此使用tryLock时,必须保证业务执行时间严格小于leaseTime,否则后面的线程可能拿到同一把锁,造成并发问题。
锁粒度和锁名称的设计也很关键。锁的粒度应该尽量小,只包住真正需要互斥的那段逻辑。例如订单支付场景下,不要对整个订单模块加一把大锁,而是针对具体订单号生成锁名,这样不同订单之间仍然可以并行处理。锁名最好带有业务前缀和唯一标识,例如order:pay:1001,这样在排查Redis键时可以快速定位。还要避免使用用户的手机号、身份证号等敏感信息作为锁名,防止敏感数据出现在Redis键空间中。
四、红锁和集群环境注意点
在Redis主从架构中,如果客户端向主节点成功加锁后,主节点还没来得及把锁数据同步给从节点就发生了宕机,此时从节点被提升为主节点,锁数据就丢失了。另一个客户端可能在新主节点上再次获取同一把锁,从而出现两个客户端同时持锁的情况。对于一致性要求极高的场景,单实例Redisson锁可能不够安全。为此Redisson提供了RedLock红锁实现,它要求客户端在多个互相独立的Redis节点上依次获取锁,只有超过半数节点加锁成功,才算真正持有锁。
RLock lock1 = redissonClient1.getLock("common:lock");
RLock lock2 = redissonClient2.getLock("common:lock");
RLock lock3 = redissonClient3.getLock("common:lock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
if (redLock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 业务逻辑
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
redLock.unlock();
}
使用红锁时需要准备至少三个独立的Redis实例,最好部署在不同物理机或不同机架上,否则某个实例宕机可能导致多个节点同时不可用。红锁的加锁过程会在每个节点上尝试获取锁,并在规定时间内完成,如果成功节点数没有超过一半,就需要在已经加锁成功的节点上执行解锁回滚。红锁的引入会增加加锁耗时和Redis压力,因此只有在真正需要高一致性保证时才使用,普通业务场景下用单实例Redisson锁配合合理的租约和续期已经足够。
此外,在集群和容器化环境中,要特别注意时钟漂移和网络分区问题。如果多个Redis节点所在的服务器时间不同步,红锁依赖的过期时间判断就可能失效。客户端进程被GC暂停、网络抖动导致续期线程延迟,也可能让锁在业务尚未结束时过期释放。生产环境应当对看门狗续期任务的执行频率进行监控,并为核心业务设置合理的降级策略,例如获取锁失败时直接快速失败或者进入队列重试,而不是让所有请求都堆积在锁等待上。