Redisson分布式锁机制

Redisson分布式锁

一、基于Redis的setnx实现的分布式锁存在的问题

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次,失败就返回 false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  • 主从一致性:如果 Redis 提供了主从集群,主从同步存在延迟,此时某个线程从主节点中获取到了锁,但是尚未同步给从节点,而恰巧主节点在这个时候发生宕机。就会从从机中选择出一个节点成为新的主节点,这时候新的主节点上没有锁的信息,那么其他线程就有可能趁虚而入,从新的主节点中获取到锁,这样就出现多个线程拿到多把锁,在极端情况下,可能会出现安全问题

二、Redisson的可重入锁原理

核心:基于 Redis 中的Hash结构实现的分布式锁,利用 key 来锁定资源(锁名称),对于 field 来标识唯一成功获取锁的对象(持有该锁的对象),而对于 value 来累计同一个线程成功获取相同的锁的次数(锁被获取的次数)。

具体实现思路:

  • 尝试获取锁:先判断缓存中是否存在 key 字段,如果存在,则说明锁已经被成功获取,这时候需要继续判断成功获取锁的对象是否为当前线程,如果根据 key field 来判断是当前线程,则 value += 1 且还需要重置锁的超时时间;如果根据 key field 判断不是当前线程,则直接返回 null。如果缓存中不存在 key 字段,则说明锁还没有被其他线程获取,则获取锁成功。
  • 释放锁:当业务完成之后,在释放锁之前,先判断获取锁的对象是不是当前线程,如果不是当前线程,则说明可能由于超时,锁已经被自动释放了,这时候直接返回 null;如果是当前线程,则进行 value -= 1 ,最后再来判断 value 是否大于 0 ,当大于 0 时,则不能直接释放锁,需要重置锁的超时时间;当 value = 0 时,则可以真正的释放锁。

在这里插入图片描述

又因为使用 Java 实现不能保证原子性,所以需要借助 Lua 脚本实现多条 Redis 命令来保证原则性。

尝试获取锁的Lua脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local key = KEYS[1];  -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间

-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在,获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;

-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败

释放锁的Lua脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local key = KEYS[1];  -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间

-- 判断当前锁是否还是被自己持有
if(redis.call('hexists', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end
-- 是自己的锁,则重入次数-1
local count = redis.call('hincrby', key, threadId, -1);
-- 判断重入次数是否已经为0
if(count > 0) then
-- 大于0说明不能是释放锁,重置有效期然后返回
redis.call('expire', threadId, releaseTime);
return nil;
else
redis.call('del', key); -- 等于 0 说明可以释放锁,直接删除
return nil;
end

三、Redisson的锁重试和WatchDog机制

在这里插入图片描述

  • 锁重试:利用信号量、发布消息publish、订阅消息subscribe功能,实现获取锁失败后的一段时间(ttl)内重新尝试获取锁。而重新尝试获取锁并不是立刻重新尝试,而是通过订阅释放锁的消息,接收到锁释放的消息后去重试,减轻了cpu的负担,因此在线程释放锁后需要向外发布释放锁的消息。当然,重试并不是无限次的,会有一个等待时间,如果超过等待时间,就结束重试。
  • WatchDog机制:给锁添加过期时间,虽然能够解决死锁的问题,但是如果事务发生了阻塞导致超时释放锁,还是会出现多个线程同时执行业务的情况,失去了锁的作用,造成了一人多单的情况。因此,关键点就是不要让事务阻塞导致超时释放锁,超时释放只应该在redis服务宕机、或持有锁的线程挂掉时起作用,于是就引出了WatchDog机制。
    • WatchDog就是持有锁的线程给锁加了一条看门狗,只要这个线程存在,狗就会不断给锁续期不让它过期直到线程执行完事务并亲自释放锁
    • 既然WatchDog会给锁不断续期,那么锁设置过期时间还有意义吗?答案是有的。因为这个过期时间主要是为了防止线程挂掉、redis宕机导致的死锁,过期时间只应在这些情况下释放锁,如果过期时间是因为线程事务发生阻塞超时释放锁,就会产生上面的并发问题,而WatchDog就是引进来不让这种情况发生的。

四、Redisson的MultiLock原理

  • 为了提高Redis的可用性,我们会搭建集群或者主从,现在以主从为例
  • 此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设主机还没来得及把数据写入到从机去的时候,主机宕机了
  • 哨兵会发现主机宕机了,于是选举一个slave(从机)变成master(主机),而此时新的master(主机)上并没有锁的信息,那么其他线程就可以获取锁,又会引发安全问题
  • 为了解决这个问题。Redisson提出来了MutiLock锁,使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机,那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性

五、Redisson小结

  1. 不可重入Redis分布式锁
    • 原理:利用SETNX的互斥性;利用EX避免死锁;释放锁时判断线程标识
    • 缺陷:不可重入、无法重试、锁超时失效
  2. 可重入Redis分布式锁
    • 原理:利用Hash结构,记录线程标识与重入次数;利用WatchDog延续锁时间;利用信号量控制锁重试等待
    • 缺陷:Redis宕机引起锁失效问题
  3. Redisson的multiLock
    • 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
    • 缺陷:运维成本高、实现复杂


Redisson分布式锁机制
http://example.com/2025/05/13/Redisson分布式锁机制/
作者
Kon4tsu
发布于
2025年5月13日
许可协议