redis实现分布式锁 单实例情况 多实例情况 RedLock

背景

多个实例抢一个任务,需要分布式锁协调,只有一个实例能抢到锁并执行任务。
如何实现一个分布式锁?

基于redis的实现

版本1:单实例情况

使用setnx命令获取锁,再加上简单的Lua脚本来释放锁。

例如,要获取key foo的锁,客户端可以尝试:

SETNX lock.foo <current unix time + lock timeout + 1>

如果命令返回1,客户端获取了锁,lock.foo的值就是一个锁失效时间。后面客户端应该使用 DEL lock.foo 来释放锁。
如果命令返回0,说明已经有别的客户端set成功了,key已经被其他客户端锁上了,我们可以直接返回获取锁失败

处理死锁

如果客户端挂了,会不会无法释放锁?

这个key对应的value包含了unix时间戳。如果当前unix时间 大于 这个value,那么这个锁就无效了。

当我们发现超时的时候,不能直接调用DEL方法然后SETNX。因为这又有竞争(多个客户端检测到超时锁并且尝试去释放他):

  • C1和C2读到lock.foo,检查到时间戳已经超时;
  • C1 DEL 锁
  • C1 SETNX 成功 获得了锁
  • C2 DEL 锁
  • C2 SETNX 成功 获得了锁

于是C1 C2都获得了锁,不符合要求。

那么正确的算法是啥?

  • GET 获取锁,看看时间戳超了没
  • 如果超时: GETSET lock.foo <current unix timestamp + lock timeout + 1> (设置key,并返回旧的值)
  • 如果获取到了时间戳还是过期的,说明获得了锁;如果获取到的时间戳没有过期,说明别人设置成功了,认为还是获取不到锁

为了使得算法更robust,客户端获取了锁之后,需要在解锁前检查时间戳超时没。因为客户端出问题的情况有很多,可能是挂掉,也可能是因为出现某些问题阻塞了好久之后才del,这时候会删掉别人获得的锁。

代码实现以及分析

根据上面的算法描述,我们可以看到正确和比较robust的算法是这样的:(获取失败不重试)

int lockVal = current unix time + lock timeout + 1;
int ret = `SETNX lock.foo <val>`
if (ret == 1) {
	// 成功获取锁, 可以执行任务。。。
	doSomethingAndReleaseLock(lockVal);
} else {
	// 获取锁失败,需要检查超时没
	int ts = `GET lock.foo`;
	if (ts > System.currentTimeMillis()) {
		// 发现超时了,可以尝试再去获取锁
		int lockVal = current unix time + lock timeout + 1;
		int retTs = `GETSET lock.foo <lockVal>`
		if (retTs == ts) {
			// 再次获取发现时间戳没被改变,说明确实是我抢到了,那就ok
			doSomethingAndReleaseLock(lockVal);
		} else {
			// 再次获取发现时间戳被别人改了,说明别的客户端抢在前面set好了,我还是获取失败,返回失败
			return false;
		}
	} else {
		// 还没超时,确实获取锁失败了,返回失败
		return false;
	}
}

private void doSomethingAndReleaseLock(int lockVal) {
	try {
		doSomething();
	} finally {
		int ts = `GET lock.foo`;
		if (ts == lockVal) {
			// 确实还没超时,不是别人新设置的时间戳,可以释放锁
			`DEL lock.foo`;
		}
	}
}

上面的方法的问题:
1、看着还是比较复杂
2、而且最后释放锁的时候不是原子性的

  • 客户端A GET,发现是期望的lockVal,正准备删呢,阻塞了/切换线程了😂,阻塞了好久好久,锁都已经超时了被别人获取到了
  • 阻塞结束,执行下一句DEL,还是把别人的锁给删了😂

版本2:改进

加锁:

SET resource_name my_random_value NX EX 3

这个命令,只有在key不存在的时候(NX),会设置key,3s超时(EX)。

释放锁:

为了保证删不了别的客户端的锁:将value设置为一个随机值,这个值是用来安全的释放锁的。删除(释放锁)的时候利用lua脚本告诉Redis:只有当存在这个key而且保存的value真的等于我期望的值的时候,才删除key。脚本如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这个设计是为了避免删除别的客户端设置的锁。比如,一个客户端获取锁,然后阻塞了好久,key超时了,然后删除lock,可是这个lock已经被别的客户端获取了。直接用DEL是不安全的,因为客户端会删除别的客户端的锁。但是我们用上面的脚本,每个lock都用一个随机字符串”签名“了,就只有自己能删自己的了。

这个算法的伪代码

int ret = `SET lock.foo my_random_value NX EX 3`
if (ret == 1) {
	// 成功获取锁, 执行任务。。。
	doSomethingAndReleaseLock();
} else {
	return false;
}

private void doSomethingAndReleaseLock(String my_random_value) {
	try {
		doSomething();
	} finally {
		// 调用lua脚本:
		if redis.call("get",KEYS[1]) == ARGV[1] then
    		return redis.call("del",KEYS[1])
		else
    		return 0
		end
	}
}

用lua脚本DEL保证了原子性

上面的方法在单实例的情况下就已经ok了。然而!现在我们的系统哪还有单实例的呢。在分布式系统下又会引入新的问题了,这时候就需要RedLock算法了。

RedLock

上面的方法应该还是存在问题,所以官网也推荐了RedLock

java的实现是:
Redisson (Java implementation).

为什么failover-based 的实现不足够?

现有的方法一般是创建一个key,只有一定的生存时间,使用redis expire特性。当客户端需要释放资源,删除key。

表面上works well,但是问题是:如果redis master挂了怎么办?一般我们会有个从库!当master挂了就用这个从库。很不幸这是不可行的。这么做的话我们不能实现mutual exclusion,因为Redis集群是异步的。

这个模型有明显的竞争问题:
1、客户端A从master获取锁
2、master挂了,在key同步到从库之前
3、从库升级成大佬master
4、客户端B获取到了锁。。

如果我们的应用,允许多个客户端同时持有锁,那问题不大,上面的方法是ok的,否则的话咱们再来看下RedLock:

RedLock 算法

分布式情况下,我们假设有N个redis master。这些节点是完全相互独立的,所以我们不用复制或者同步系统。

上面已经说过怎么安全地在单个实例的情况下获得和释放锁了。我们认为单实例下就用这种算法来获得和释放锁。

在我们的例子中,我们设置N=5。

获取锁

1、获取当前时间(毫秒)
2、逐个获取N个实例的锁,使用相同的key和随机value。当在每个实例设置锁,客户端使用一个小的超时时间(相对自动释放时间而言)。比如,如果自动释放锁是10s,这个超时时间可以是5-50ms。这个避免了客户端长时间阻塞于连接那些挂了的redis node:如果一个实例不可用,我们应该尽快连接别的实例。
3、客户端计算一下获得锁花了多少时间(通过减去第一步获取的时间)。只有获得大多数实例的锁(在这个例子里是3),而且花的时间在锁的有效时间内,才认为是真正的获得锁:(如有效时间是3s,获取全部实例的锁花了1s, <3s,就认为获取锁成功)
4、如果获得了锁,锁的真正有效时间应该是初始设定的有效时间减去流逝的时间(3-1=2s)
5、如果客户端由于某些原因获取锁失败了(可能是没到大多数【3个】,也可能是第4步算的实际有效时间是个负数),他会尝试解锁所有的实例(就算是他不能解锁的他也要去尝试)

算法是异步的吗?

这个算法依赖于这么一个假设:尽管整个过程中没有一个同步的时钟,但是各个实例的本地时间大约都是差不多的(误差对比起释放锁的时间相对小)这个假设是模拟了现实世界的电脑,每个电脑有本地的时钟,我们通常都可以允许不同电脑之间有一点点的时间误差。

现在我们需要更好地说一下我们的“mutual exclusion rule”:只有满足下面条件才能得到保证:获取锁的客户端在【锁有效时间内 减去一些时间(用于补偿时钟飘移,就那么几毫秒)】结束他要干的活(第3步获得的)

如果想进一步了解 时钟漂移 请参考:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency

失败重试

当客户端获取锁失败,它应该随机等一会然后重试。这个是为了“去同步”:多个客户端在同时尝试获取相同资源的锁(这个可能会导致split brain condition脑裂,没人能赢)客户端尝试去获取大多数的redis实例的锁,这个动作越快,发生脑裂的机会就越小。所以理想情况下,客户端应该使用多路复用发送SET command给N个实例

值得强调的是,客户端获取锁失败的时候,需要尽快释放锁,这样就不用等待key超时才能再次获取key。(然而如果网络分区发生了的话,客户端就不再能跟redis 实例联系了,可用性就降低了,因为需要等待key超时)

释放锁

释放锁比较简单,就是直接释放所有实例的锁,无论客户端是不是能够成功获得锁,都去尝试解锁

参考

其实redis官网直接就有介绍

这篇文章也不错

其他工具(mysql、zk)实现分布式锁

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章