Redisson实现公平锁原理

1,464 阅读4分钟

参考资料:

  1. 使用Redisson实现公平锁原理

总结

Redisson 实现公平锁的原理,需要借助 List 作为 先进先出的队列,和 SortedSet,用来保存线程获取到锁的预计时间,如果在这个时间前还没有获取到锁,那么这个线程会被移出队列。

Redisson 实现公平锁的原理,需要借助 List 和 SortedSet

  • List 作为一个FIFO的队列,右边进,左边出,按照请求顺序保存线程
  • SortedSet 是保存预计获取到锁的时间点,如果在这个时间点前还没有获取到锁,那么会被移出队列,这次获取锁请求失败

公平锁使用

public static void getFairLock () {
    // 获取一个公平锁
    RLock fairLock = redissonClient.getFairLock("fairLockName");
    fairLock.lock();
    fairLock.unlock();
}

参数说明

要分析核心lua脚本前,先了解下相关参数。

KEYS = Arrays.asList(getName(), threadsQueueName, timeoutSetName)

  • KEYS[1]: 锁的名称,即我们设置的 "fairLockName"
  • KEYS[2]: threadsQueueName,线程队列名称,格式是redisson_lock_queue:{锁名称}
  • KEYS[3]: timeoutSetName,超时集合名称,格式是redisson_lock_timeout:{锁名称}

为了实现公平锁,Redisson 使用队列按照顺序存储需要获取锁的线程。另外,为了可以清除失效的请求,使用SortedSet记录获取锁的超时时间

ARGV = internalLockLeaseTime, getLockName(threadId), threadWaitTime, currentTime

  • ARGV[1]:锁的释放时间,默认是30s
  • ARGV[2]:线程名称,格式是UUID:threadId
  • ARGV[3]:线程等待时间(5s)
  • ARGV[4]:当前时间

核心lua脚本分析

if (command == RedisCommands.EVAL_LONG) {
	return 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
			// 获取并遍历集合中所有的线程,每个线程的超时时间都减去5s
			"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
			// 当前线程获取到锁,并设置过期时间,默认是30s
			"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
		// 如果当前线程已经获取到锁,则value+1(可重入)。同时重新设置过期时间
		"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
		// 获取不到锁
		// 如果当前线程已经在队列里面,返回 超时时间-等待时间(5s)-当前时间
		"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);" +
		// 计算ttl
		// 如果最后一个线程存在,且不是当前线程的话,ttl就等于最后一个线程的超时时间 减去 当前时间
		// 否则的话,那么就是队列为空,看下锁剩余多少时间
		"if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
		"local ttl;" +
			"ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
		"else " +
			"ttl = redis.call('pttl', KEYS[1]);" +
		"end;" +
		// 计算超时时间 并 当前线程入队
		// 超时时间 等于 ttl + 线程等待时间 + 当前时间
		"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;" +
		
		// 如果返回的ttl为数字,会重复执行这段lua脚本
		"return ttl;",
		
		Arrays.asList(getName(), threadsQueueName, timeoutSetName), 
			internalLockLeaseTime, getLockName(threadId), wait, currentTime);
}

补充:

  1. 返回的是nil,在外层代码中,就会认为是加锁成功。此时就会开启一个watchdog看门狗定时调度的程序,每隔10秒判断一下,当前这个线程是否还持有锁,如果是,则刷新锁key的生存时间为30000毫秒

  2. 返回的是ttl,是一个数字的话,那么此时客户端就会进入一个while true的死循环,每隔一段时间都尝试去进行加锁,重新执行这段lua脚本

具体的加锁过程可以看参考文档,比较详细。

疑问

看完这个lua脚本最大的疑问是,当某个线程获取到锁,为什么要扣减等待时间?