分布式锁要求
在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。
分布式锁可以用一个变量来实现。客户端加锁和释放锁的操作逻辑:加锁时,先获取锁变量的值,再根据锁变量值是否为0来判断能否加锁成功;释放锁时需要把锁变量值设置为 0,表明客户端不再持有锁。在分布式场景下,锁变量需要由一个共享存储系统来维护,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值。
从上述过程可以看出来,分布式锁有以下两个要求:
- 分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;
- 共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性
作为分布式锁实现过程中的共享存储系统,Redis 可以使用键值对来保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求。我们用lock_key作为键,来设置锁变量的值。
首先我们来看加锁操作,包含了三个动作:读取锁变量,判断锁变量和将锁变量的值修改为1。而这三个操作在执行时需要保证原子性。那怎么保证原子性呢?
Redis提供的原子操作
为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:
- 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
- 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。
Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。当然,Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。
可以看到redis的单个命令操作可以原子性地执行,但是在实际应用中,数据的修改可能需要多个操作,对于这种情况redis提供了INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。
但是,如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了。所以,这个时候,我们需要使用第二个方法,也就是 Lua 脚本。 Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入,因此在脚本运行过程中无需担心会出现竞态条件。我们可以使用 Redis 的 EVAL 命令来执行脚本。假设我们编写的脚本名称为 lua.script,我们可以使用 Redis 客户端,带上 eval 选项,来执行该脚本。脚本所需的参数将通过以下命令中的 keys 和 args 进行传递。
redis-cli --eval lua.script keys , args
Redis实现分布式锁
我们可以用SETNX 单命令设置键值对的值。具体来说,就是这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。使用redisTemplate操作SetNx会返回一个布尔值,根据布尔值判断是否获得了锁。 对于释放锁操作来说,我们可以在执行完业务逻辑后,使用 DEL 命令删除锁变量。SETNX就包括了获取锁变量值和判断的过程。
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
不过使用SETNX和DEL命令组合的方式实现分布式锁会出现以下问题:
- 某个客户端执行了SETNX却在处理业务逻辑时出现异常,未执行DEL操作,导致其他客户端无法获取锁。
- 某个客户端A执行了SETNX,而另一个客户端B在其处理业务逻辑时执行了DEL命令,然后客户端C又执行了SETNX获取了锁,与A一起操作共享数据,导致错误。
对于第一个问题,一个有效的解决方法是,给锁变量设置一个过期时间。 对于第二个问题,我们需要能区分来自不同客户端的锁操作。具体操作是,在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。具体实现如下:
// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000
其中,unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000 (ms 相当于EX 10 s)则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁。因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识。这个时候就要用到Lua脚本了,因为,放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作。
分布式锁的可靠性保证
为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。Redlock 算法可以分为三步:
- 客户端获取当前时间
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作,与上述加锁操作不同的是会给加锁操作设置一个超时时间,如果加锁操作超时就会和下一个redis实例进行加锁
- 一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
- 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
- 条件二:客户端获取锁的总耗时没有超过锁的有效时间。
在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。释放锁的操作和在单例上释放锁一样。