用 Redis 实现分布式锁

陈易生, notesdist-sys
Back

分布式锁

在多线程共享内存数据的场景下,一个线程为了获得对数据的排他(exclusive)访问,通常先获取(acquire)锁,随后进行对数据的操作,最后释放(release)锁。而在多客户端(clients)共享数据的场景下,一个客户端为了获得对数据的排他访问,需要进行类似的操作。由于在第二个场景中,客户端之间、客户端和数据之间都可能位于通过网络进行通信的不同节点上,具有分布式的性质,因此在该场景下进行的锁机制被称为分布式锁。

Redis 作者认为分布式锁应该同时保证 safety 和 liveness。

  1. Safety:互斥。在任一时点,只有一个客户端可以持有限制访问某一资源的锁。
  2. Liveness A:无死锁。即便持有锁的客户端宕机或发生网络分区(network partition),客户端最终(eventually)仍有可能获取锁。
  3. Liveness B:容错。只要 Redis 集群中的大多数节点仍在线,客户端就可以获取和释放锁。

Redis 文档探讨了两种分布式锁的实现,两种方案都需要客户端和服务器端 Redis 集群进行配合。

基于复制的实现

在服务器端,维护多个 Redis 节点,其中一个节点为主(leader)节点,其余为从(follower)节点。主节点接收写,并异步地将写复制到从节点上。主节点下线后,任命其中一个仍在线的从节点作为新的主节点,继续接收写。

在客户端,通过在 Redis 主节点上新建一个 key,并设置失效时间来获取锁,即 SET resource-name any-value EX 100,其中锁的名字为 resource-name,锁的时限(lock validity time)为 100 秒。持有锁的客户端通过删除该 key 来释放锁,即 DEL resource-name

正确性上,容易证明这个实现满足两个 liveness 特性。对于无死锁特性,如果一个客户端在获取锁后下线,Redis 上的 key 在指定时限过后会失效,此后客户端仍然可以成功获取该锁。对于容错,由于所有写都发生在主节点上,因此只要主节点仍在线,客户端就可以获取和释放锁。

但是,该实现在以下场景无法保证 safety。

  1. 客户端 1 获取锁。
  2. 主节点上的写还没来得及异步传输到从节点上,就下线了。
  3. 从节点被任命为主节点。
  4. 客户端 2 获取同一把锁。此时客户端 1 和客户端 2 都持有锁。

该实现存在两个漏洞。其一,客户端 2 可以轻易地获取与客户端 1 相同的锁。为了避免客户端 2 获取客户端 1 的锁,可以使用 SET 命令的 NX 参数,即 SET resource-name any-value NX EX 100。参数 NX 保证 SET 命令只在 key 不存在时生效。因此,客户端 2 在试图获取同一个锁时,如果客户端 1 获得的锁还没有失效,则 SET 命令无效,获取锁失败。演示如下,可见客户端 2 无法获取客户端 1 正持有的锁。

client1> SET resource-name any-value NX EX 100
OK

client2> SET resource-name any-value NX EX 100
(nil)

该实现的第二个漏洞在于,客户端 2 为了获取同一个锁,可能先释放客户端 1 获得的锁,然后再获取该锁。为了防止客户端 2 释放客户端 1 持有的锁,可以要求客户端在获取锁时,传入一个 token 作为 value,即 SET resource-name token NX EX 100 。在释放锁时,客户端传入 token 以示身份,Redis 首先确认传入的 token 与 Redis 记载的 token 相同,才会删除 key,即 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end。因此,只要客户端 2 猜不出客户端 1 的 token,客户端 2 就无法释放客户端 1 持有的锁。演示如下,可见客户端 2 在客户端 1 释放锁之前,无法获取客户端 1 持有的锁。

client1> SET resource-name token1 NX EX 100
OK

client2> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 resource-name token2
(integer) 0

client1> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 resource-name token1
(integer) 1

client2> SET resource-name token2 NX EX 100
OK

Redlock

在服务器端,维护 N 个 Redis 主节点。每个节点互相独立,且均不设副本。

在客户端,获取锁的步骤如下:

  1. 获得当前时间戳。
  2. 使用同样的 resource-name 和 token,逐个向 N 个 Redis 节点申请锁,并为单个锁的申请设置时限,称之为“锁申请时限”。如果锁的时限是 10 秒,则锁申请时限可以设置为 5 至 50 毫秒。这一设定确保了,如果客户端向某个节点申请锁的过程中,该节点下线,客户端不会一直等待。当锁申请时限已至,如果客户端没能成功地从某个节点获取锁,客户端就会跳过该节点。
  3. 在遍历完成后,客户端计算从开始向第一个 Redis 节点申请锁至今花费了多少时间。如果客户端获取了超过 N/2+1 个锁,且耗时小于锁的时限,则称客户端成功获取锁。
  4. 如果客户端获取锁失败,则客户端向全部 N 个 Redis 节点要求释放锁。

如果上述步骤结束,客户端未能获取锁,则可以在一个随机延时后进行重试,防止多个客户端同时重试。

客户端获取锁的过程很简单,只需向全部 N 个 Redis 节点要求释放锁即可。

文档并没有对算法的正确性给出形式化的证明,因此我只能尝试做个非形式化的证明。

总结

Redis 作者认为 Redlock 比基于复制的分布式锁实现更安全,但我有些疑惑,因为我并没有看出这一点。Martin Kleppmann 撰文 How to do distributed locking 认为 Redlock 其实并不安全,部分回答了我的疑惑,推荐大家阅读。

参考文献


If you want to give any feedback on this post, please contact me via email or Twitter.

© Yik San Chan. Built with Vercel and Nextra.