【Redisson】公平锁源码剖析 之 加锁

518 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第23天,点击查看活动详情

公平锁:保证客户端取锁的顺序跟请求获取锁的顺序一致。对应官网:文档

排队:先申请锁,先获取锁。

RedissonFairLockRedissonLock 的子类,整体的锁的技术框架的实现,都是跟 RedissonLock 是一样的,无非就是重载了一些方法,加锁和释放锁的 lua 脚本的逻辑稍微复杂了一些。

对比公平和非公平的优缺点:

优势缺陷
公平锁各线程公平平等,每个线程在等待一段时间后,总有执行的机会更慢,吞吐量小
不公平锁更快,吞吐量更大有可能产生线程饥饿,也就是某些线程在长时间内,始终得不到执行

公平锁与不公平锁的区别在于:获取锁时,队列中是否已有等待的,有则去排队。

分布式锁的公平锁逻辑上更为复杂些。

如果让凡凡来实现公平锁,凡凡能想到什么?

  • 获取锁:为了公平,就需要队列:来了就先排队
  • 释放锁:通知队列中的线程

举个栗子:

@Test
public void test() {
​
    RLock lock = redisson.getFairLock("fairLock");
    lock.lock();
    lock.unlock();
}

(1)加锁

实现加锁的 lua 脚本定位: RedissonFairLock#tryLockInnerAsync

对应参数如下:

  • KEYS[1]:锁的名称 "fairLock"

  • KEYS[2]threadsQueueName = redisson_lock_queue:{fairLock}, 基于 Redis 实现的队列,作为等待队列

    • lindex redisson_lock_queue:{fairLock} 0:从 redisson_lock_queue:{fairLock} 这个队列中弹出来第一个元素
    • lpop redisson_lock_queue:{fairLock}:弹出队列的第一个元素
    • zrem redisson_lock_timeout:{fairLock} UUID:threadId:从 Set 集合中删除 threadId 对应的元素
  • KEYS[3]timeoutSetName = redisson_lock_timeout:{fairLock}, 基于 Redis 实现的 Set 集合,作为超时集合

    有序集合,可以自动按照给每个数据指定的分数score来进行排序。

  • ARGV[1]:30000毫秒

  • ARGV[2]key 名称,UUID:threadId

  • ARGV[3]:线程等待时间,默认 5000毫秒

  • ARGV[4]:时间戳,当前时间(10:00:00)的时间戳

-- #1
-- 无限循环:移除过时无用的线程
while true do
    -- 从队列中弹出第一个值
    local firstThreadId2 = redis.call('lindex', KEYS[2], 0);
    -- 如果队列为空,则退出循环
    if firstThreadId2 == false then
        break;
    end;
    
    -- 移除超时的线程
    -- 从 set 集合(redisson_lock_timeout:{fairLock}) 中获取第一个元素的分数,即时间戳
    local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));
    if timeout <= tonumber(ARGV[3]) then             -- 如果小于了超时时间了
        redis.call('zrem', KEYS[3], firstThreadId2); -- 从 set 中移除
        redis.call('lpop', KEYS[2]);                 -- 从 队列 中移除
    else
        break;
    end;
end;
​
-- #2 .1
-- 锁不存在 且 (队列不存在 或者 队列存在,但是队头第一个元素是当前这个线程 UUID:threadId)
if (redis.call('exists', KEYS[1]) == 0)
        and ((redis.call('exists', KEYS[2]) == 0)
        or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then
    
    -- 从等待队列和超时集合中删除当前线程
    redis.call('lpop', KEYS[2]);          -- 弹出第一个元素
    redis.call('zrem', KEYS[3], ARGV[2]); -- 从 set 集合中删除 threadId对应的元素
​
    -- 减少队列中所有等待线程的超时时间
    local keys = redis.call('zrange', KEYS[3], 0, -1); -- 获取所有的 keys:线程
    for i = 1, #keys, 1 do
        redis.call('zincrby', KEYS[3], -tonumber(ARGV[4]), keys[i]);
    end;
    
    -- 当前线程获得锁
    -- 存储结构:fairLock: {"UUID:threadId": 1}
    redis.call('hset', KEYS[1], ARGV[2], 1); -- 加锁
    redis.call('pexpire', KEYS[1], ARGV[1]); -- 将这锁的生存时间设置为 30000 毫秒
    return nil; -- 认为加锁成功,并会开启一个 watchdog 看门狗定时调度
end;
​
-- #2 .2
-- 这里是可重入的代码
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;
​
-- #2 .3
-- 说明当前线程在队列中
local timeout = redis.call('zscore', KEYS[3], ARGV[2]); -- 获取当前线程的超时时间戳
if timeout ~= false then -- 当前线程超时时间不为空
    -- 计算当前线程,还需要等待多久
    -- 需要等待时间 = 超时时间 - 线程需要等待的时间 - 线程
    return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);
end;
​
-- #3
-- 取出队列中最后一个线程
local lastThreadId = redis.call('lindex', KEYS[2], -1);
local ttl;
-- 最后线程不等于当前线程
if lastThreadId ~= false and lastThreadId ~= ARGV[2] then 
    -- 剩余生存时间 = 最后线程时间戳 - 当前时间的时间戳
    ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);
else 
    -- 获取当前锁 key 的生存时间
    ttl = redis.call('pttl', KEYS[1]);
end;
​
-- #4 
-- 计算,再入队
local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);
if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then 
    redis.call('rpush', KEYS[2], ARGV[2]);
end;
​
-- 这块如何理解:
-- 1. 如果当前线程是持有锁的,则返回锁的生存时间
-- 2. 如果当前线程是不持有锁,则返回需要等待的时间,即下次再来执行此脚本的时间
return ttl;

概括下大致流程:

  1. 清理队列中过期线程:无限循环

  2. 锁是否存在:

    1. 锁不存在且(队列为空或者队首线程就是当前线程):获得锁
    2. 锁存在且持有锁的线程是当前线程:重入锁,重入值 +1
    3. 锁存在,持有锁的线程不是当前线程且在队列中:直接返回还要等待多久
  3. 计算生存时间

  4. 新来的或者过期被清理的:重新入队,延长等待时间

这里有个问题:内鬼问题、信任问题

  1. 锁处理严重依赖时间戳:倘若某台机器时钟出现问题
  2. 内鬼问题:如果不同版本实现的锁处理不同,这块是不是就会出现问题。

举个栗子:3个客户端顺序加锁

  • 客户端A:UUID_00:threadId_0010:00:00 去请求锁
  • 客户端B:UUID_01:threadId_0110:00:10 去请求锁
  • 客户端C:UUID_02:threadId_02,与客户端B同时去请求锁,差 5毫秒
  1. 客户端A在 10:00:00 请求获取锁 fairLock(这时还未有锁)

2022-06-2019-58-42.png

  1. 大概 10秒后 10:00:10,客户端B也去请求获取锁

    • ttl = 锁生存时间,20000毫秒
    • timeout = ttl + 10:00:10 + 5000毫秒(默认)= 10:00:35

2022-06-2020-14-56.png

  1. 同时客户端C也去请求获取锁,差 5毫秒

    • ttl = 10:00:35(队列最后一个线程的时间,即客户端B)- 10:00:15 (当前时间) = 20000毫秒
    • timeout = 20000毫秒 + 10:00:15 + 5000毫秒 = 10:00:40

2022-06-2020-15-32.png