“分布式锁”这个问题快被说烂了,奈何笔者实在没有找到一个满意的答案,故记录自己寻找答案、总结的过程。分布式锁的设计涉及了许多分布式系统相关的问题,许多地方值得推敲,非常有意思。
TL; DR太长不看?没关系,我已经做好了思维导图,点个分享再收藏,支持一下,也方便以后查阅。
分布式锁三个属性和两大类多线程编程通常使用 mutex 或信号量等方式进行同步;在同一个操作系统下的多进程也能通过共享内存等方式同步;但在分布式系统多进程之间的资源争抢,例如下单抢购,就需要额外的分布式锁。
分布式锁大多都是 Advisory lock,即在访问数据前先获取锁信息,再根据信息决定是否可以访问;相对的是 mandatory lock,未授权访问锁定的数据时会产生异常。
分布式锁属于分布式互斥问题(distributed mutual exclusion),实际上 Lamport 在那篇经典论文 "Time, clocks, and the ordering of events in a distributed system" 中早就证明了使用状态机能够去中心化解决多进程互斥问题,而共识算法就能实现这样的状态机。但大多时候我们还是会使用一个分布式锁而不是构建一个共识库,主要因为:
因此,相比于客户端实现一个共识库,使用分布式锁服务耦合更松、更易用、也更节省资源。
提起分布式锁,大家可能马上会想到各种实现方式,以及一场关于基于 Redis 实现的分布式锁是否安全的论战,这些文章可能很多地方都能搜到。但在开始讨论这些东西之前,我们首先要思考,一个分布式锁到底需要具备哪些性质?
总的来说,分布式锁服务有三个必备的性质:
值得注意的是,容错会以性能为代价,容错性取决于你的系统级别,如果你的系统可以承担分布式锁存在误差,那么单节点或者简单的主从复制也许就能满足;如果你的系统非常严格,例如金融系统或航天系统,那么就要考虑每个 corner case——本文更倾向于讨论后者。
我们会拿着这三个属性逐一分析各种分布式锁的实现。在此之前,先把分布式锁分为两大类:自旋类和监听类。
因此,本文默认读者大概了解 Redis、ZooKeeper 和 etcd 是什么。
如此,我们在头脑中已经有了一个很好的框架,现在开始往思维导图中填充知识。
基于数据库的实现最简单的,我们想到通过某个独立的数据库(或文件),当获取到数据时,往数据库中插入一条数据。之后的进程想要获取数据,会先检查数据库是否存在记录,就能够知道是否有别的进程持有锁,这便实现了分布式锁的互斥性。
数据库可以通过主从同步复制来实现容错,虽然主从复制切换时不会非常轻松,可能需要管理员参与。
为了避免死锁,我们需要增加时间戳字段和自增 id 字段,同时在后台启动一个线程定时释放和清理过期的锁。
字段 | 作用 |
id | 自增 id,唯一标识锁 |
key | 锁名称 |
value | 自定义字段 |
ttl | 存活时间,定时清理,避免死锁 |
可见基于数据库的实现较为繁琐,要自己维护锁的 TTL;除非使用分布式数据库,否则主从复制的故障切换并不轻松。
除了麻烦之外,如果经常用数据库你也知道,在高并发常见下数据库读写是非常缓慢的,会导致我们的系统性能存在瓶颈。如果采用多个独立数据库进行容错,那性能就更差了。
于是,为了分布式锁的性能,开始转向基于 Redis 或者 memcache 等内存存储系统来实现分布式锁。
基于 Redis 的实现分布式锁最多的恐怕就是基于 Redis 的实现。首先我们从单节点 Redis 开始。
基于单节点 Redis 的分布式锁总的来说就是一条命令实现写 key 设置过期时间,否则原子性无法保证可能出现死锁。于是就有了以下命令:
set key value nx px 10000
set 命令后的 5 个参数分别是:
这个方案在互斥性和避免死锁上性能良好,且非常轻量。但单节点的 Redis 存在单点故障。注意,Redis 主从复制是异步的,所以加入从节点会增加破坏互斥性的风险。为了实现容错性,就有了基于多节点 Redis 的分布式锁,即 Redlock。
基于多节点 Redis 的分布式锁Redlock 用到多个独立的 Redis 节点,其思想简而言之,是在多个 Redis 实际都获取锁,其中一个宕机了,只要还有超过半数节点可用,就可以继续提供锁服务。
如图所示,Redlock 获取锁的大致步骤如下,:
释放锁操作就是向所有实例都发送删除 key 命令。
Redlock 容错性依赖于一个时间戳的计算,这在分布式系统中并不受待见,于是有了一场著名的论战。
Redlock 论战DDIA 的作者 Martin Kleppmann 大佬发表了著名的文章《How to do distributed locking》,表示 Redlock 并不可靠,该文章主要阐述了两个观点:
第一点如下图所示,client 1 获取锁后发生了 STW GC(或缺页等问题),TTL 过期后 Client 2 获取了锁,此时两个客户端持有锁,违反了互斥性。后续写操作自然就可能存在问题。
我们在避免死锁时提到,需要另外用单调递增 id (Martin 称之为 fencing token,也叫序列号)来标识每一个锁。增加 id 后逻辑如下图所示,最后的 Client 1 的写请求因为 token 是旧的,会被存储系统拒绝。
第二点 Martin 认为,Redlock 的时间戳计算方式不可靠,每台服务器的走时并不绝对准确,例如 NTP 进行同步时系统会发生时钟漂移,即当前服务器的时间戳突然变大或变小,这都会影响 Redlock 的计算。
Martin 的这篇文章引起了大家对分布式锁广泛讨论。Redis 作者 antirez 也不甘示弱,发表文章《Is Redlock safe?》进行反驳,回应了上述两个问题,我总结了 antirez 的论点:
非常推荐阅读争论的两篇文章,但篇幅所限我只提取了观点。关于争论的详细内容张铁蕾老师的文章《基于Redis的分布式锁到底安全吗(下)?》也有着比较完整的中文回顾。
对于这两个问题,我想谈谈我的理解。
对于第一个问题,文章开头“三大属性”我们就分析过,增加 TTL 来避免死锁就会对互斥性产生影响,无论基于 Redis 还是基于 Zookeeper 实现都会存在该问题。antirez 观点是 Redlock 也可以用 value 作为唯一标识来阻断操作,这确实没问题,我也挑不出毛病。但我们可以思考下,实际编程中读者您觉得使用一个自增 id 进行判断容易还是使用 read-modify-write 操作更容易呢?(实际上,一开始我都不怎么理解什么是 read-modify-write 操作)
我认为 fencing token 是一个更好的解决方案,一个单调自增的 id 更符合我们的直觉,同时也更好进行 debug。
作为 fencing token 的一个实际参考,Hazelcast 的文章 "Distributed Locks are Dead; Long Live Distributed Locks!" 给出了一个 FencedLock 解决方案,并且通过了 Jepsen 测试。
第二个问题,时钟漂移是否应该引起注意呢?antirez 的观点是时钟确实会影响 Redlock,但可以通过合理运维避免。
Julia Evans(也是很出名的技术博主)也写了一篇后续文章 "TIL: clock skew exists",来讨论时钟漂移的问题是否真的值得引起注意。最终得出的结论是:有界的时钟漂移不是一个安全的假设。
事实上,时钟问题并不罕见,例如:
通过上述例子,时钟问题是真实存在的,如果你的系统对分布式锁的安全性要求严格,不想造成任何系统和金钱上的损失,那么你应该考虑所有的边缘情况。
Martin Kleppmann 没有回复任何 Hacker News 的评论,他觉得自己想要表达的都已经说完了,他不想参与争论,他认为实际上一个分布式系统到底该怎么做取决于你如何管理你的系统。
本文想表达的也是这样的观点,软件工程没有银弹,这些 trade-off 取决于你系统的重要级别,你怎么管理你的分布式系统。
只不过分布式系统研究人员通常会非常关注那些看似非常不可能在你的电脑上发生的事情(例如:时钟偏移),原因是:
除了 Redlock,还有哪些既能容错又能避免死锁的方式呢?
容错性当然离不开我们的老朋友共识算法,我们不再让客户端依次上多个锁,而是让锁服务器通过共识算法复制到多数派节点,然后再回复客户端。由于共识算法本身不依赖系统时间戳而是逻辑时钟(Raft 的任期或 Paxos 的 epoch),故不存在时钟漂移问题。
其次,死锁避免问题依然需要 TTL 和自增 id 等手段,我们通过锁服务给每次加锁请求标识上单调递增 id。
通过以上两种方法,我们可以得到一个更可靠的分布式锁。代价是,我们需要一个实现共识算法的第三方组件。下文主要介绍三个此类实现:ZooKeeper、etcd 和 Chubby。
ZooKeeper 是一个分布式协调服务,参见:《ZooKeeper 设计原理》。
基于 ZooKeeper 实现的分布式锁依赖以下两个节点属性:
ZooKeeper 官方文档有提供现成的分布式锁实现方法:
如上图所示,每个客户端只监听比自己小的 znode,可以避免惊群效应。
获取锁的伪代码如下:
n = create(l “/guid-lock-”, EPHEMERAL|SEQUENTIAL)
C = getChildren(l, false)
if n is lowest znode in C, exit
p = znode in C ordered just before n
goto 2
释放锁非常简单:客户端直接删除他们在步骤 1 创建的 znode 节点。
有几点需要注意:
当然,虽然 ZooKeeper 的实现看起来更为可靠,但根据你实现锁的方式,可能还是会有大量的锁逻辑调试、锁争抢等问题。
基于 ZooKeeper 的分布式锁性能介于基于 Mysql 和基于 Redis 的实现之间,性能上当然不如单节点 Redis。
ZooKeeper 的另一个缺点是需要另外维护一套 ZooKeeper 服务(已有则忽略)。
etcdEtcd 是著名的分布式 key-value 存储结构,因在 Kubernetes 中使用而闻名。etcd 同样可以用来实现分布式锁,官方也很贴心的提供了 clientv3 包给开发者快速实现分布式锁。
我们来看下 etcd 是如何解决分布式锁“三大问题”的:
为了帮助开发者快速实现分布式锁,etcd 给出了 clientv3 包,其中分布式锁在 concurrency 包中。按照官方文档给出的案例1,首先创建一个新的会话(session)并指定租约的 TTL,然后实例化一个 NewMutex() 之后就可以调用 Lock() 和 Unlock() 进行抢锁和释放锁。代码如下:
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
s, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
log.Fatal(err)
}
defer s.Close()
m := concurrency.NewMutex(s, "/my-lock/")
if err := m.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("acquired lock for s")
if err := m.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("released lock for s")
其中 Lock() 函数的源代码很容易找到,由于篇幅我就不放出来了,但源代码中可以看到的一些其他机制包括:
本质上 etcd 和 ZooKeeper 对分布式锁的实现是类似的。
选择 etcd 的原因可能有:
最后简要谈一下 Chubby。由于 Chubby 没有开源,没法直接使用,细节也难以得知,很少会有造一个 Chubby 的需求(虽然我老东家真的用 C 造了一个)。因此,Chubby 部分只是 Paper 读后感,不感兴趣的读者可以跳过。
Chubby 是 Google 研发的松耦合分布式锁服务,此外 GFS 和 BigTable 使用 Chubby 来存储一些元数据。Chubby 有以下几大特点:
Chubby 依赖于 Paxos 共识算法来实现容错,数据以 UNIX 文件系统方式进行组织(称为 Node),同样有 Ephemeral 类型的临时节点,断开连接后会被删除;支持监听某个 Node——总而言之,我们可以从 ZooKeeper 上看到许多 Chubby 的影子,ZooKeeper 和 Chubby 解决的问题是比较类似的,因此很多人认为 ZooKeeper 是 Chubby 的开源实现,不过两者的具体架构还是略有不同。
为了容错,一个 Chubby 集群包含 5 个节点,组成一个 Chubby cell。这 5 个节点只有一个是 master 其余都是 replicas,只有 Master 能够处理请求和读写文件,其它副本节点通过 Paxos 算法复制 Master 的行为来容错。
Chubby 还支持:
比较有意思的是 Chubby 的故障恢复。当 Master 发生故障,其内存的 session 和锁信息会丢失,session 中最重要的租约信息,Paxos 算法会选举出新的 Master,然后:
整个过程如图所示。其中绿色都是安全时期,橙色部分是危险时期,Chubby 集群切换到新的 Master。
Chubby 我不想太过深入,我想大家没有再造一个 Chubby 的动力了。
结语写这篇文章的时候内心是忐忑的,为了 ego 和不被大家骂我尽量不犯错,但错误实在难免,并且随着时间推移可能两三年后一些解决方案并不适用。这篇文章实在花了我许多时间和精力。
来源:https://mp.weixin.qq.com/s/35aCS_5GqLyzZS3VobL6fg
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved