Redis实现分布式锁的实例讲解
在一个分布式系统中,会遇到一些需要对多个节点共享的资源加锁的情况,这个时候需要用到分布式锁。分布式锁通常保存在一个共享的存储系统中,可以被多个节点共享和访问。
锁的本质
简单来讲,锁可以用一个变量来表示。比如,在一个单机多线程的程序来说,某个资源的锁用一个 bit 的数据就可以表示。即 0 表示没有资源可以访问,1 表示资源的锁已被别的线程获取,不能访问。
获取和释放特定资源的锁,本质上就是为获取和修改这个变量的值。如果值是 0 则将其修改为 1,就完成了获取的过程,如果访问到的值不是 0,则获取锁失败;如果之前获取了锁,将表示锁的变量的值修改为 0 的操作,其实就是释放锁的操作。
在一个分布式场景中,实现锁的方式也是一样的,只不过这个表示资源锁的变量,需要保存在一个共享的存储系统中。这个共享的存储系统,可以是 Redis,也可以是其他的任何可以提供数据存储的系统。
基于 Redis 的分布式锁实现
第一步:初步实现功能
对于将 Redis 作为这个共享存储系统的情况来说,代表某个资源的锁的变量,就是 Redis 中的一个键值对。假如,需要添加分布式锁的资源叫 resource_a,我们可以将 Redis 中 resource_a 的锁变量的 key 叫做 lock_a。
例如,节点一需要获取锁,它会访问 Redis 中 lock_a 的值,假设获取到的值为 0,则节点一将这个值设置为 1 后,就完成了加锁操作。此时,节点二也需要获取 resource_a 的锁,它去访问 Redis 中 lock_a 的值,发现值是 1,说明锁已经被别的节点获取,并且还没有释放,因此,节点二对资源 resource_a 加锁失败。
当节点一需要释放锁的时候,只需要将 Redis 中的 lock_a 的值设置为 0 就完成了锁的释放,之后,其他的节点就可以再次获取资源的锁。
第二步:加锁操作原子化
以上的描述中,加锁的并不是一个单一的操作,而是包含了多个步骤:读取锁变量、判断变量的值、修改锁变量。这三个操作需要原子化。
在 Redis 中,有一个 SETNX 命令,用于设置键值对的值,与 SET 命令不同,它在是先事会判断键值对是否存在,只有当指定的 KEY 不存在的时候,才会执行值的设定,否则什么都不执行。SETNX 就是「SET if Not eXist」的意思。它的用法与 SET 相同:
SETNX lock_a 1
这样,当需要获取锁的时候,使用 SETNX 命令为 lock_a 设置一个值,如果设置成功则获取到了锁,如果失败则没有获取到锁;当需要释放锁的时候,使用 DEL 操作删除键值对即可。
这样就实现了获取和释放锁的原子化操作。
第三步:防止加锁后不释放
接下来早考虑一个问题,如果节点一获取到锁之后,由于程序异常等原因,导致一直么有释放锁,此时,锁会一直被它持有,无法释放,其他节点也无法访问资源。
为了避免这种情况的发生,我们必须给锁变量设置过期时间,当锁变量过期后,就可以重新请求加锁,这样就可以避免这个问题。
SETNX 的命令并没有设置过期时间的选项,所幸的是,Redis 为 SET 命令提供了模拟 SETNX 的 NX 选项,我们可以这样设置过期时间:
SET lock_a 1 NX PX 10000
以上命令代表,如果 lock_a 不存在,则将它的值设置为 1,并且在 10 秒后过期。
第四步:谁加锁谁释放
最后一个问题是,如果节点一获取了锁,而由于某种原因,节点二执行了 DEL 操作,那么,其他节点又可以获取锁了。
为了解决这个问题,我们可以修改一下锁变量保存的内容。在前面的逻辑中,我们申请锁的时候,是去判断锁变量是否存在,而与其中保存的值关系不大,因此,我们可以把这个值利用起来。
在加锁的时候,如果把值保存为每个节点唯一的标识,那么,在释放锁执行 DEL 之前再对这个值进行判断,那么,就可以先判断锁是否是当前节点加上的,是的话再进行释放,这样就实现了「谁加锁谁是放」。
这一部分,没有一个单一的指令可以完成读取锁变量、判断、删除的操作,因此,可以使用 Lua 脚本实现。在脚本中获取到当前锁变量的值,与给定的节点标识进行比对,符合的话才进行删除操作,否则不操作。
在释放锁时,执行 Lua 脚本即可。
第五步:实现高可用
完善了功能之后,最后再来实现高可用。如果我们使用单一的 Redis 作为分布式锁的共享存储系统,那么,如果这个 Redis 不可用了,那涉及到分布式锁的部分都不可用了,这样是很脆锁的,这是高可用非常有必要的原因。
此时,需要搬出 Redis 的作者 Antirez 提出的分布式锁算法 Redlock。简而言之,是让锁的申请者,想多个独立的 Redis 实例请求加锁,如果能在半数以上的 Redis 完成枷锁操作,那么就成功的获取了锁,反之获取失败。
在释放锁的操作时,同样只要在超过半数的实例上执行成功删除锁变量的 Lua 脚本,即可视为成功。