Redisson中分布式锁的实现原理


theme: channing-cyan

redisson版本:3.27.2

简介

锁归根结底就是对同一资源的竞争抢夺,不管是在单体的应用亦或者集群的服务中,上锁都是对同一资源进行修改的操作。
至于分布式锁,那就是多个服务器或资源,同时抢占某一单体应用的同个资源了。在本篇文章中,抢占的资源就是Redis中的某个Key了。

原理

上锁

RLock lock = RedissonClient.getLock("test-lock");
lock.lock();

执行lock.lock()后,最终会在Redis中执行一段lua脚本。来判断锁是否已经被占用:

if ((redis.call('exists', KEYS[1]) == 0) 
    or (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]);

参数:

名称 内容
KEY[1] 锁名称
ARGV[1] 锁过期时间,毫秒
ARGV[2] 锁对象ID+当前线程ID

注意,在lua脚本中,上锁时并非设置一个key-value,而是使用了hash结构。

redis.call('hincrby', KEYS[1], ARGV[2], 1);

这样做的目的是Redisson不光实现了分布式锁,还增加了一个特性:可重入。因为单独的键值对无法存储上锁次数,就使用了hash结构。


上锁时redis日志:

10:40:50.064 [0 192.168.65.1:34743] "EVAL" "if ((redis.call('exists', KEYS[1]) == 0) or (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]);" "1" "test-lock" "30000" "fcce544e-09e1-48bb-9c90-2c77c75d673f:1"
10:40:50.064 [0 lua] "exists" "test-lock"
10:40:50.064 [0 lua] "hincrby" "test-lock" "fcce544e-09e1-48bb-9c90-2c77c75d673f:1" "1"
10:40:50.064 [0 lua] "pexpire" "test-lock" "30000"

可以很清晰的从日志分析出来,Redisson在给分布式锁上锁时所做的操作。

判断锁是否被占->没有被占,抢占并设置过期时间

那么在第一次抢占不到锁时,Redisson在等待时,会不会做些其他事情呢?
的确,Redisson在等待锁时,还会做一些其他事情,免得在傻傻等待。

等待锁释放

在抢不到锁的时候,Redisson会监听redisson_lock__channel开头的Channel
锁释放时,抢占锁的应用会向这个Channel发布一个消息(消息内容为:0)。向正在等待锁释放的应用通知此时锁已经释放了,可以尝试抢占锁了。
在上面的这个例子中,对应的Channel名称为:redisson_lock__channel:{test-method},消息内容为:0。

解锁

在抢到锁并且本地逻辑运行完成后,此时就需要解锁让其他应用运行下去了。

RLock lock = RedissonClient.getLock("test-lock");
lock.lock();

执行的lua脚本

local val = redis.call('get', KEYS[3]); 
if val ~= false then 
  return tonumber(val);
end;
-- 看hash中是否还存在这个键(RLock对象的名称以及线程名称)
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
  return nil;
end;
-- counter:hash中减一后的值
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then 
  redis.call('pexpire', KEYS[1], ARGV[2]); 
  redis.call('set', KEYS[3], 0, 'px', ARGV[5]); 
  return 0; 
-- 为0了,说明重入锁的次数都删掉了
else 
  -- 删除锁对应的redis hash表
  redis.call('del', KEYS[1]);
  -- 发布当前锁释放的通知
  redis.call(ARGV[4], KEYS[2], ARGV[1]); 
  -- 设置对象对应的 值为1 
  redis.call('set', KEYS[3], 1, 'px', ARGV[5]); 
  return 1; 
end;

参数:

参数名 说明
KEY[1] 分布式锁名称 test-lock
KEY[2] redis pub/sub 通道名称 redisson_lock__channel:{test-lock}
KEY[3] 正在解锁操作标识 redisson_unlock_latch:{test-lock}:96ca7c366fa0a6bda6d39931f2092eb1
ARGV[1] pub/sub 通道值-解锁消息(0)
ARGV[2] 锁过期时间
ARGV[3] Lock对象对应的锁名称 d5804b0b-50e4-4d61-a91a-319c2ddb5b1d:1
ARGV[4] PUBLISH
ARGV[5] 正在解锁操作标识KEY对应过期时间 13500

解锁时Redis日志:

10:40:50.073 [0 192.168.65.1:34745] "EVAL" "local val = redis.call('get', KEYS[3]); if val ~= false then return tonumber(val);end; 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]); redis.call('set', KEYS[3], 0, 'px', ARGV[5]); return 0; else redis.call('del', KEYS[1]); redis.call(ARGV[4], KEYS[2], ARGV[1]); redis.call('set', KEYS[3], 1, 'px', ARGV[5]); return 1; end; " "3" "test-lock" "Redisson_lock__channel:{test-lock}" "Redisson_unlock_latch:{test-lock}:4673449b09ccae99bad2a89c9f0122de" "0" "30000" "fcce544e-09e1-48bb-9c90-2c77c75d673f:1" "PUBLISH" "13500"
10:40:50.073 [0 lua] "get" "Redisson_unlock_latch:{test-lock}:4673449b09ccae99bad2a89c9f0122de"
10:40:50.073 [0 lua] "hexists" "test-lock" "fcce544e-09e1-48bb-9c90-2c77c75d673f:1"
10:40:50.073 [0 lua] "hincrby" "test-lock" "fcce544e-09e1-48bb-9c90-2c77c75d673f:1" "-1"
10:40:50.073 [0 lua] "del" "test-lock"
10:40:50.073 [0 lua] "PUBLISH" "Redisson_lock__channel:{test-lock}" "0"
10:40:50.073 [0 lua] "set" "Redisson_unlock_latch:{test-lock}:4673449b09ccae99bad2a89c9f0122de" "1" "px" "13500"
10:40:50.076 [0 192.168.65.1:34746] "DEL" "Redisson_unlock_latch:{test-lock}:4673449b09ccae99bad2a89c9f0122de"

解锁的lua脚本比上锁时的脚本有太多的逻辑了,不过还是分为了三块:

  1. 判断是否有其他线程在解锁,如果有其他线程在同时释放锁时,忽略本次操作
local val = redis.call('get', KEYS[3]); 
if val ~= false then 
  return tonumber(val);
end;
  1. 判断锁是否已经释放,锁已经释放了,无需操作
-- 看hash中是否还存在这个键(RLock对象的名称以及线程名称)
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
  return nil;
end;
  1. 给锁的hash结构减一,根据减一后的结果做进一步处理。
-- counter:hash中减一后的值
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then 
  redis.call('pexpire', KEYS[1], ARGV[2]); 
  redis.call('set', KEYS[3], 0, 'px', ARGV[5]); 
  return 0; 
-- 为0了,说明重入锁的次数都删掉了
else 
  -- 删除锁对应的redis hash表
  redis.call('del', KEYS[1]);
  -- 发布当前锁释放的通知
  redis.call(ARGV[4], KEYS[2], ARGV[1]); 
  -- 设置对象对应的 值为1 
  redis.call('set', KEYS[3], 1, 'px', ARGV[5]); 
  return 1; 
end;

解锁时,会发布一条消息,通知锁已经释放。

10:40:50.073 [0 lua] "PUBLISH" "redisson_lock__channel:{test-lock}" "0"

方便其他正在等待锁的Redisson应用及时唤醒抢占锁。

其他隐藏配置

Redisson在默认上锁时设置的锁过期时间为30S,与其他Java Redis库不设置过期时间的逻辑相反。
由于Redisson显示声明了锁过期时间,那么他一定会在别的地方去一直延长该时间,否则锁在用着用着就被别人抢占了,
于是Redisson中一个特殊机制就出现了:看门狗机制
至于为什么Redisson要这么做,在他对于这个看门狗过期时间配置项可以得知:

This prevents against infinity locked locks due to Redisson client crash or any other reason when lock can't be released in proper way.
这可以防止由于Redisson客户端崩溃或任何其他原因导致无法以适当的方式解锁而导致的无限锁定。


看门狗续期脚本:
如果锁还在使用中,那么重置锁的过期时间,否则不做任何操作。

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
return 0;
名称 内容
KEY[1] 锁名称 test-lock
ARGV[1] 锁过期时间,毫秒 30000
ARGV[2] 锁对象ID+当前线程ID d5804b0b-50e4-4d61-a91a-319c2ddb5b1d:1

引用文章

https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers
https://github.com/redisson/redisson/wiki/2.-Configuration/


这是一个从 https://juejin.cn/post/7368375416928780340 下的原始话题分离的讨论话题