水煮Redisson(十一)-公平锁

241 阅读6分钟

简介

上一章介绍了非公平锁,这里再来介绍一下公平锁。在特定场合下,比如开放式的名额申请,如果某一个客户端直到等待超时也获取不到锁,一直不能拿到名额走下一步业务流程,会造成平台不公平的非议。对于类似的情况,需要用公平锁来保障用户的体验和权益。
首先还是来看看代码声明

/**
 * Distributed implementation of {@link java.util.concurrent.locks.Lock}
 * Implements reentrant lock.<br>
 * Lock will be removed automatically if client disconnects.
 * <p>
 * Implements a <b>fair</b> locking so it guarantees an acquire order by threads.
 *
 * @author Nikita Koksharov
 *
 */
public class RedissonFairLock extends RedissonLock implements RLock {...}

和非公平锁类似,也是可重入的,但是可以保证各客户端的请求顺序。

锁获取

怎么保证公平

在创建资源公平锁时,redisson同时维护了两个集合,一个客户端线程队列,名称为redisson_lock_queue:{lockname},一个客户端超时有序集合,名称为redisson_lock_timeout:{lockname},其中队列用来保证请求的顺序,一般是先进先出【FIFO】。

如何实现

实现的方法是tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command),重写了RedissonLock的同名方法,主体也是lua脚本。在上一章非公平锁里讲解了EVAL_NULL_BOOLEAN和EVAL_LONG两种语义,公平锁里也对这两种语义进行区别实现。

EVAL_NULL_BOOLEAN

进入的契机:redissonClient.getFairLock("test").tryLock(),不填写等待时间,执行时获取默认的看门狗超时时间。
分为了三个独立的部分,一个while循环,两个if逻辑分支,下面来进行拆解逐个分析。
最终的效果:如果当前线程没有立即获取到锁,则立刻失败。

evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// remove stale threads
"while true do " +
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +
    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
    "if timeout <= tonumber(ARGV[3]) then " +
        // remove the item from the queue and timeout set
        // NOTE we do not alter any other timeout
        "redis.call('zrem', KEYS[3], firstThreadId2);" +
        "redis.call('lpop', KEYS[2]);" +
    "else " +
        "break;" +
    "end;" +
"end;" +

"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]);" +

    // decrease timeouts for all waiting in the queue
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        "redis.call('zincrby', KEYS[3], -tonumber(ARGV[4]), keys[i]);" +
    "end;" +

    "redis.call('hset', 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 1;",
Arrays.<Object>asList(getName(), threadsQueueName, timeoutSetName),
internalLockLeaseTime, getLockName(threadId), currentTime, threadWaitTime);
while循环

执行目标:删除已经超时的客户端线程。

  1. 判断锁等待队列中,首元素是否为空,如果没有客户端等待锁,直接跳出循环;
  2. 判断首元素是否已经等待超时,如果没有超时,执行后续IF分支;
  3. 如果超时,将这个客户端从等待队列和超时集合中删除,继续取下一个进行判断;
IF分支一

执行目标:当前申请锁的客户端线程获得锁。
前提条件:资源锁在redis中不存在,且等待队列不存在,或者等待队列第一个元素是当前申请锁的客户端线程;

  1. 将这个客户端从等待队列和超时集合中删除;
  2. 遍历超时集合中所有元素,将等待超时剩余时间减去当前客户端等待锁的时间;
  3. 将资源锁与当前客户端绑定,设定锁自动释放时间;
IF分支二

执行目标:当前申请锁的客户端线程重入锁。
前提条件:锁的持有者是当前申请锁的线程。

  1. 累加持有次数;
  2. 延迟持有过期时间;

EVAL_LONG

进入的契机:redissonClient.getFairLock("test").tryLock(2, TimeUnit.SECONDS);申请锁时,写明具体的等待时间。
分为了四个独立的部分,一个while循环,两个if逻辑分支,和等待处理环节。前面三个和EVAL_NULL_BOOLEAN里的一致,这里主要说明一下等待处理环节。
执行效果:

evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// remove stale threads
"while true do " +
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +

    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
    "if timeout <= tonumber(ARGV[4]) then " +
        // remove the item from the queue and timeout set
        // NOTE we do not alter any other timeout
        "redis.call('zrem', KEYS[3], firstThreadId2);" +
        "redis.call('lpop', KEYS[2]);" +
    "else " +
        "break;" +
    "end;" +
"end;" +

// check if the lock can be acquired now
"if (redis.call('exists', KEYS[1]) == 0) " +
    "and ((redis.call('exists', KEYS[2]) == 0) " +
        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

    // remove this thread from the queue and timeout set
    "redis.call('lpop', KEYS[2]);" +
    "redis.call('zrem', KEYS[3], ARGV[2]);" +

    // decrease timeouts for all waiting in the queue
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        "redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]);" +
    "end;" +

    // acquire the lock and set the TTL for the lease
    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    "return nil;" +
"end;" +

// check if the lock is already held, and this is a re-entry
"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;" +

// the lock cannot be acquired
// check if the thread is already in the queue
"local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
"if timeout ~= false then " +
    // the real timeout is the timeout of the prior thread
    // in the queue, but this is approximately correct, and
    // avoids having to traverse the queue
    "return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
"end;" +

// add the thread to the queue at the end, and set its timeout in the timeout set to the timeout of
// the prior thread in the queue (or the timeout of the lock if the queue is empty) plus the
// threadWaitTime
"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 " +
    "ttl = redis.call('pttl', KEYS[1]);" +
"end;" +
"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;" +
"return ttl;",
Arrays.<Object>asList(getName(), threadsQueueName, timeoutSetName),
internalLockLeaseTime, getLockName(threadId), threadWaitTime, currentTime);
等待处理

执行目标:当前申请锁的客户端线程没有获取到锁,如果在队列中,返回超时时间;如果不在队列中,将其添加到队列末尾,并返回超时时间;

  1. 获取当前线程的超时时间,如果不为空,则返回超时时间-当前线程等待时间-当前时间;
  2. 设定ttl变量,获取队列最后一个客户端线程,如果此客户端不为当前线程,则ttl = 此线程的超时时间 - 当前时间;
  3. 否则ttl = 当前资源锁的过期时间;
  4. 设定一个超时时间变量,等于ttl + 当前线程等待时间 + 当前时间;
  5. 将当前线程添加到有序集合中,然后将当前线程放到等待队列末尾;
  6. 返回ttl变量【超时时间】;

获取失败注销

@Override
protected RFuture<Void> acquireFailedAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_VOID,
            // get the existing timeout for the thread to remove
            "local queue = redis.call('lrange', KEYS[1], 0, -1);" +
            // find the location in the queue where the thread is
            "local i = 1;" +
            "while i <= #queue and queue[i] ~= ARGV[1] do " +
                "i = i + 1;" +
            "end;" +
            // go to the next index which will exist after the current thread is removed
            "i = i + 1;" +
            // decrement the timeout for the rest of the queue after the thread being removed
            "while i <= #queue do " +
                "redis.call('zincrby', KEYS[2], -tonumber(ARGV[2]), queue[i]);" +
                "i = i + 1;" +
            "end;" +
            // remove the thread from the queue and timeouts set
            "redis.call('zrem', KEYS[2], ARGV[1]);" +
            "redis.call('lrem', KEYS[1], 0, ARGV[1]);",
            Arrays.<Object>asList(threadsQueueName, timeoutSetName),
            getLockName(threadId), threadWaitTime);
}

执行目标:调整在这个客户端之后的其他客户端的剩余等待时间,并将此客户端从队列中移除;
执行条件:等待锁超时
执行步骤:

  1. 提取在队列中等待的所有客户端线程,找到当前客户端的位置;
  2. 调整队列中,在当前客户端线程之后其他客户端的等待时间,等待时间 = 剩余等待时间 - 当前客户端线程的等待时间;
  3. 把当前客户端线程从队列和超时集合中移除;

锁释放

强制释放

强行释放是不带参数的,不指定客户端线程。

@Override
public RFuture<Boolean> forceUnlockAsync() {
    cancelExpirationRenewal(null);
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // remove stale threads
            "while true do "
            + "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);"
            + "if firstThreadId2 == false then "
                + "break;"
            + "end; "
            + "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));"
            + "if timeout <= tonumber(ARGV[2]) then "
                + "redis.call('zrem', KEYS[3], firstThreadId2); "
                + "redis.call('lpop', KEYS[2]); "
            + "else "
                + "break;"
            + "end; "
          + "end;"
            + 
            
            "if (redis.call('del', KEYS[1]) == 1) then " + 
                "local nextThreadId = redis.call('lindex', KEYS[2], 0); " + 
                "if nextThreadId ~= false then " +
                    "redis.call('publish', KEYS[4] .. ':' .. nextThreadId, ARGV[1]); " +
                "end; " + 
                "return 1; " + 
            "end; " + 
            "return 0;",
            Arrays.<Object>asList(getName(), threadsQueueName, timeoutSetName, getChannelName()), 
            LockPubSub.UNLOCK_MESSAGE, System.currentTimeMillis());
}

执行目标:释放锁,通知其他客户端继续尝试。
执行步骤:

  1. 第一步的while循环和之前获取锁的目的一致,删除过期的客户端线程;
  2. 如果锁存在,强行删除资源锁,然后取出等待队列中的第一个客户端,如果不为空,则发布此客户端是否锁的消息,通知其他等待线程继续尝试,返回true;
  3. 如果锁不存在,直接返回false;

手动释放

非公平锁的手动释放比较简单,删除资源占用锁即可,公平锁则稍微复杂一点,需要做一下前置判断。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // remove stale threads
            "while true do "
            + "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);"
            + "if firstThreadId2 == false then "
                + "break;"
            + "end; "
            + "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));"
            + "if timeout <= tonumber(ARGV[4]) then "
                + "redis.call('zrem', KEYS[3], firstThreadId2); "
                + "redis.call('lpop', KEYS[2]); "
            + "else "
                + "break;"
            + "end; "
          + "end;"
            
          + "if (redis.call('exists', KEYS[1]) == 0) then " + 
                "local nextThreadId = redis.call('lindex', KEYS[2], 0); " + 
                "if nextThreadId ~= false then " +
                    "redis.call('publish', KEYS[4] .. ':' .. nextThreadId, ARGV[1]); " +
                "end; " +
                "return 1; " +
            "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]); " +
                "return 0; " +
            "end; " +
                
            "redis.call('del', KEYS[1]); " +
            "local nextThreadId = redis.call('lindex', KEYS[2], 0); " + 
            "if nextThreadId ~= false then " +
                "redis.call('publish', KEYS[4] .. ':' .. nextThreadId, ARGV[1]); " +
            "end; " +
            "return 1; ",
            Arrays.<Object>asList(getName(), threadsQueueName, timeoutSetName, getChannelName()), 
            LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId), System.currentTimeMillis());
}

执行目的:
执行步骤:

  1. 第一步的while循环和之前获取锁的目的一致,删除过期的客户端线程;
  2. 如果锁不存在,取出等待队列中的第一个客户端,如果不为空,则发布此客户端是否锁的消息,通知其他等待线程继续尝试,返回true;
  3. 如果持有锁的不是当前参数中的客户端线程,则返回null;
  4. 如果持有锁的是当前线程,持有次数减一之后如果大于零,重置过期时间,返回false;
  5. 如果持有次数减一之后等于0,删除资源锁,取出等待队列中的第一个客户端,如果不为空,则发布此客户端是否锁的消息,通知其他等待线程继续尝试,返回true;

自动释放

与非公平锁类似,锁到期之后,资源被redis自动回收,其他客户端线程继续尝试获取锁。