1、为什么要使用分布式锁

当Redis的客户端只有一个时,可以通过在客户端加锁来控制并发写操作对共享数据的修改,也可以使用原子操作。

但是一个Redis往往不仅连接一个客户端,当有多个客户端需要并发修改数据时,这把锁加在客户端已经不起作用了。假如有3个客户端,在客户端加锁可以保证该客户端所处理的请求在同一时间内只有一个可以修改Redis数据,但是3个客户端意味着有3把锁,会出现同一时间内有3个客户端可以修改Redis数据。

所以,在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。

2、简单锁的设计

先看单机上的锁。

对锁进行简化,我们可以用一个变量来表示。变量值为0,表示没有线程获取锁,变量值为1,表示已经有线程获取到了锁。

平时所说的加锁,解锁,其实就是该线程去检查这个变量,如果是0,就可以获取该锁,然后把变量值改为1。如果变量值本身就是1,那么就返回获取锁失败。除此之外,我们还需要知道哪一个线程获取了锁,所以还需要一个id来标识。一个最简单的锁伪代码可以设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyLock {
int lock;
int id;

int acquireLock() {
if (lock == 0) {
lock = 1;
id = Thread.id;
return 1;
}

return 0;
}

int releaseLock() {
if (lock == 1 && id = Thread.id) {
lock = 0;
return 1;
}

return 0;
}
}

3、单Redis实例的分布式锁

和单机的锁类似,分布式的锁可以用一个变量来表示。而且加锁和释放锁的逻辑也和上边类似,但是锁变量需要由一个共享存储系统来维护,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值

分布式锁的两个要求:

1、上述代码的逻辑需要原子的进行。例如,在判断完lock=0进入修改lock的阶段,但是被中断,然后另一个线程此时也进入判断,lock此时还是0,就会导致两个线程都获取了锁。所以需要原子性。

2、共享存储系统需要保证可靠。如果不可靠,如果存储系统崩溃,会导致获取锁的客户端无法释放锁,而其他线程又一直等待锁。

使用Redis作为存储可以用下图表示:

image-20230317095916845

在上图中,客户端A和C同时请求加锁,但是Redis是使用单线程处理请求,所以即使客户端A和C同时把加锁请求发给Redis,Redis也会串行处理。首先是客户端A,他发现lock_key的value为0,说明没有客户端获取锁,所以他可以把value置为1。而客户端C来加锁时,会法师value为1,就返回加锁失败。

释放锁的过程,判断改锁是否是该线程加的,如果是,则可以将value设置为0,然后其他客户端就可以加锁。

但是加锁包含了三个操作,读取变量,判断value是否为0,以及设置value为1,我们需要保证这三个操作是原子进行的。在Redis中可以使用Redis 的单命令操作和使用 Lua 脚本

Redis单命令:

首先是 SETNX 命令,它用于设置键值对的值。具体来说,就是这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。可以用该命令来加锁。

对于释放锁操作来说,我们可以在执行完业务逻辑后,使用 DEL 命令删除锁变量。

伪代码如下:

1
2
3
4
5
6
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

这样设计的两个风险:

1、假如某个客户端执行SETNX命令,加锁之后却在操作共享数据时发生了异常,没有执行DEL命令释放锁,导致其他客户端无法访问共享数据。针对这个问题,一个有效的解决办法是给锁变量设置一个过期时间。这样即使出了异常,也不会导致一直持有锁。

2、一个客户端加锁,却被另一个客户端释放了。这种情况的解决办法就是加id,用于表示该锁是哪一个客户端添加的,不是添加的客户端就无法释放锁。

如果使用Lua脚本,则只需要考虑逻辑即可。因为Lua脚本本身就具有原子性,伪代码如下:

1
2
3
4
5
6
//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

其中,KEYS[1]表示 lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行 Lua 脚本时作为参数传入的。

4、基于多个 Redis 节点实现高可靠的分布式锁

为了避免Redis实例故障而导致锁无法工作,Redis开发者提出了分布式锁Redlock。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。

具体执行步骤如下(假如有N个Redis实例):

1、获取客户端当前时间。

2、客户端按顺序依次向N个Redis实例执行加锁操作。

这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识,并设置过期时间。

如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。

3、一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

​ 1):客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;

​ 2):客户端获取锁的总耗时没有超过锁的有效时间。

在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

在 Redlock 算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

5、Redis 分布式锁可靠性的问题

使用单个 Redis 节点(只有一个master)使用分布锁,如果实例宕机,那么无法进行锁操作了。那么采用主从集群模式部署是否可以保证锁的可靠性?

答案是也很难保证。如果在 master 上加锁成功,此时 master 宕机,由于主从复制是异步的,加锁操作的命令还未同步到 slave,此时主从切换,新 master 节点依旧会丢失该锁,对业务来说相当于锁失效了。

6、基于 Redis 使用分布锁的注意点

1、使用 SET $lock_key $unique_val EX $second NX 命令保证加锁原子性,并为锁设置过期时间

2、锁的过期时间要提前评估好,要大于操作共享资源的时间

3、每个线程加锁时设置随机值,释放锁时判断是否和加锁设置的值一致,防止自己的锁被别人释放

4、释放锁时使用 Lua 脚本,保证操作的原子性

5、基于多个节点的 Redlock,加锁时超过半数节点操作成功,并且获取锁的耗时没有超过锁的有效时间才算加锁成功

6、Redlock 释放锁时,要对所有节点释放(即使某个节点加锁失败了),因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况,所以需要把所有节点可能存的锁都释放掉

7、使用 Redlock 时要避免机器时钟发生跳跃,需要运维来保证,对运维有一定要求,否则可能会导致 Redlock 失效。例如共 3 个节点,线程 A 操作 2 个节点加锁成功,但其中 1 个节点机器时钟发生跳跃,锁提前过期,线程 B 正好在另外 2 个节点也加锁成功,此时 Redlock 相当于失效了(Redis 作者和分布式系统专家争论的重要点就在这)

8、如果为了效率,使用基于单个 Redis 节点的分布式锁即可,此方案缺点是允许锁偶尔失效,优点是简单效率高

9、如果是为了正确性,业务对于结果要求非常严格,建议使用 Redlock,但缺点是使用比较重,部署成本高

第6点参考文章:http://zhangtielei.com/posts/blog-redlock-reasoning.html

参考

《Redis核心技术实战》