Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

分布式锁的实现及原理 #47

Open
jinhailang opened this issue Mar 25, 2019 · 2 comments
Open

分布式锁的实现及原理 #47

jinhailang opened this issue Mar 25, 2019 · 2 comments

Comments

@jinhailang
Copy link
Owner

jinhailang commented Mar 25, 2019

分布式锁的实现及原理

概述

锁是在执行多线程时用于强行限制资源访问的同步机制,在分布式系统场景下,为了使多个进程(实例)对共享资源的读写同步,保证数据的最终一致性,而引入了分布式锁。

分布式锁应具备以下特点:

  • 互斥性:任意时刻,同一个锁,只有一个进程能持有
  • 安全性:避免死锁,当进程没有主动释放锁(进程崩溃退出),保证其他进程能够加锁
  • 可用性:当提供锁的服务节点故障(宕机)时,“热备” 节点能够接替故障的节点继续提供服务,并保证自身持有的数据与故障节点一致。
  • 对称性:对同一个锁,加锁和解锁必须是同一个进程,即不能把其他进程持有的锁给释放了

可以基于数据库,缓存,中间件实现分布式锁,比较主流的是使用 Redis 或 Etcd (java 可能更多的是用 ZooKeeper) 来实现,当然也可以基于数据库等支持事务的中间件实现,但相对不够健壮,也不够安全,一般不推荐,这里就不展开说明了。结合以上的四个特点,下面将深入讨论这两种方案的实现方式与原理。

实现方案

基于 Etcd

Ectd 是一个高可用的键值存储系统,具体以下特点:

  • 简单:使用 Go 语言编写,部署简单;
  • 安全:可选 SSL 证书认证;
  • 快速:在保证强一致性的同时,读写性能优秀;
  • 可靠:采用 Raft 算法实现分布式系统数据的高可用性和强一致性。

重要的是,etcd 支持以下功能,正是依赖这些功能来实现分布式锁的:

  • Lease 机制:即租约机制(TTL,Time To Live),Etcd 可以为存储的 KV 对设置租约,当租约到期,KV 将失效删除;同时也支持续约,即 KeepAlive
  • Revision 机制:每个 key 带有一个 Revision 属性值,etcd 每进行一次事务对应的全局 Revision 值都会加一,因此每个 key 对应的 Revision 属性值都是全局唯一的。通过比较 Revision 的大小就可以知道进行写操作的顺序
    在实现分布式锁时,多个程序同时抢锁,根据 Revision 值大小依次获得锁,可以避免 “羊群效应” (也称 “惊群效应”),实现公平锁。
  • Prefix 机制:即前缀机制,也称目录机制。可以根据前缀(目录)获取该目录下所有的 key 及对应的属性(包括 key, value 以及 revision 等)。
  • Watch 机制:即监听机制,Watch 机制支持 Watch 某个固定的 key,也支持 Watch 一个目录(前缀机制),当被 Watch 的 key 或目录发生变化,客户端将收到通知。

实现过程

就实现过程来说,跟“买房摇号”很相似。

1、定义一个 key 目录(如:/xxx/lock/)用于存放客户端(进程)的操作 ID。类似申请买房的号码牌;
2、客户端先 put key /xxx/lock/id,id 是全局唯一的,可以使用 UUID,并设置过期时间 TTL,防止死锁。记下返回的 RevisionR。类似你拿到一个选房序号,并规定了进去选房时间,超时还没有选中,就失效了;
3、get 目录 /xxx/lock/ 下所有的 key 及对应的 Revision 值,与上一步返回的 Revision 值进行比较:

  • 如果当前返回的 Revision 值 R 小于或等于目录下所有的 key 对应的 Revision,则当前客户端获取到了锁。类似你是排在第一个选房的,不用等了,直接选房就是;
  • 否则,记下所有比 R 小的 Revision 对应的 key,Watch /xxx/lock/。盯紧大屏幕,等待排你前面的人选房;

4、当所有靠前的 key 都被删除之后,则意味着的客户端获取到了锁。类似前面的人都选好房或者弃权了,终于轮到你选房了!

但是,这里有两个问题,也是分布式锁实现方案之间的重要区别

  • 客户端拿到锁后,在合法时间内(过期时间前)没有释放锁(工作没有做完),会导致不同客户端同时拿到或释放同一个锁的情况;
  • 当锁依赖的中间件服务是多节点集群部署时,怎么保证新节点与故障节点的数据一致性?

Etcd 和 Redis 给出了不同的答案,后面将会对比阐述。

基于 Redis

Redis 可以使用 SET 命令

SET KEY VALUE NX PX 100

这里的 KEY 是同一个,VALUE 最好是全局唯一的(原因后面会知道),如果执行成功,则意味着获取到了锁;如果失败则循环尝试,类似自旋锁的获取过程,但这里不需要太频繁,可以 Sleep 一段时间,还可以对续约次数进行限制。

看起来,这个实现方案比 Etcd 实现要简单很多,区别就是,Etcd 实现的是公平锁。但是,结合上面提的两个问题,就会发现,这只是一个简单的实现,并没有给出问题的答案。

方案对比

对于那两个问题,Etcd 与 Redis 给出了不同的答案。

1)问题一,租约(比工作完成时间)提前到期的问题。

Etcd

本身支持 KeepAlive 机制,来进行租约续期,在 put 操作成功之后,对 KEY 设置 KeepAlive 即可。Etcd 的租约是与 KV 单独分开的,有自己的租约 ID,所以实现起来并不复杂。

Redis

Redis 本身没有 KeepAlive 的机制,所以,只能客户端自己模拟实现:

1、首先客户端 SET 时,VALUE 要是全局唯一的,也可以使用 UUID,并记下这个 VALUE 值;
2、使用单独的线(协程)程 GET KEY,并对比 VALUE 值是否与前面的记录的值相同,如果相同,说明当前客户端仍然持有锁,通过 EXPIRE 更新 KEY 失效时间;
3、当工作完程,释放锁(删除 KEY)之前,先关闭这个续约线程,并且删除 KEY 之前也要比较 VALUE 是否与本客户端设置的一样,防止释放别的客户端持有的锁;

两种续约方式,基本原理,效果都类似,Etcd 更优雅一些。

2)问题二,保证节点数据一致性的问题。

这是分布式架构中的基础也是经典问题,一般分布式系统中为了保证分区容错性,节点(数据)都是主备的。
对 etcd 主节点写入时,要保证所有主从节点都写入成功,才会返回写入完成,也即是主从同步复制,这样就可以保证主从节点的数据强一致性。Redis 由于历史原因,刚开始都是单机部署的,后面才支持集群部署,为了保证性能,主从使用异步复制,因此,并不保证节点间数据的强一致性。

Redis 集群一般有多个 Master 节点,数据负载到不同的 Master 节点上(数据分片)。这种场景下,实现分布式锁时更加麻烦,因为,为了保证当前只会出现一把锁,就必须要设置 KV 到所有 Master 节点才行(实际只要超过一半就行)。为了解决这个问题,Redis 作者基于 Redis 设计实现了 Redlock 算法,实现过程过程如下:

1、得到当前的时间,微妙单位。
2、尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 key 和 random value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间。
3、当 client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
4、如果锁申请到了,那么锁真正的 lock validity time 应该是 origin(lock validity time) - 申请锁期间流逝的时间。
5、如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态。

当然,也可以取个巧,客户端约定在同一个 Master 申请/释放 锁,但是,这样客户端处理起来又太累赘了,不够通用。

小结

通过深入分析分布式锁的实现,可以发现,由于 Redis 主要是用于数据读写缓存,需要优先保证大流量场景下读写性能,分区容错性以及服务可用性是最重要的;而 Etcd 主要用于配置分发,必须要保证数据强一致性以及分区容错性。
这也就是 CAP 理论实例,根据系统应用场景来做取舍,选择最合适的实现方案。对于分布式锁的使用场景来说,使用 Etcd 来实现分布式锁,要更加的简洁,也更加安全。

虽然 Etcd (V3) 官方已经支持了分布式锁的 API 实现,为了理解的更深刻,我自己也造了个轮子https://github.com/jinhailang/rainforest/tree/master/ivy。此外,因为支持事务(基于软件事务内存机制(STM)实现),所以,还可以使用事务来实现分布式锁,具体参考NewSTM 使用实例

PS: 事务也是非常常见而且非常重要的概念,我也在文章 #48 较详细的阐述了事务的应用及原理。

参考

@samuelyao314
Copy link

楼主, 你弄错了。 zookeeper和etcd都是强一致的(满足CAP的CP)。 CAP 理论中, 现实情况P 必然会出现。 所以系统设计只有 AP 和 CP 2个选择。 并不是3选2的情况

@jinhailang
Copy link
Owner Author

jinhailang commented Jun 28, 2019

楼主, 你弄错了。 zookeeper和etcd都是强一致的(满足CAP的CP)。 CAP 理论中, 现实情况P 必然会出现。 所以系统设计只有 AP 和 CP 2个选择。 并不是3选2的情况

@samuelyao314 感谢你的指正,我这里是对比 etcd 与 redis ,没有讨论 zookeeper。但是,我上面写的确实有问题,etcd 的主从复制是同步的,而 redis 为了提供性能,是异步复制,所以 etcd 满足 CP,redis 满足 AP。

虽然,现在设计分布式系统,都是要满足分区容错性的情况下,对 CA 进行取舍,但是在传统系统架构中,CA 系统是普遍的,比如数据库分区,不做主备,且将事务内数据放到一个数据库或者干脆允许某条数据记录查询失败。
这是因为,传统系统,节点一般都是在同一网络坏境,发生网络故障的概率很小,所以可以不用考虑分区容忍。但是,大多数场景下,网络故障或超时是很正常,很频繁的,必须要首先考虑分区容错,一般做法就是数据备份到多个节点,也即主备。

关于分区容错是个很容易搞懵的概念,知乎有个问题,感觉很清楚,供参考:

一个分布式系统里面,节点组成的网络本来应该是连通的。然而可能因为一些故障,使得有些节点之间不连通了,整个网络就分成了几块区域。数据就散布在了这些不连通的区域中。这就叫分区。当你一个数据项只在一个节点中保存,那么分区出现后,和这个节点不连通的部分就访问不到这个数据了。这时分区就是无法容忍的。提高分区容忍性的办法就是一个数据项复制到多个节点上,那么出现分区之后,这一数据项就可能分布到各个区里。容忍性就提高了。然而,要把数据复制到多个节点,就会带来一致性的问题,就是多个节点上面的数据可能是不一致的。要保证一致,每次写操作就都要等待全部节点写成功,而这等待又会带来可用性的问题。总的来说就是,数据存在的节点越多,分区容忍性越高,但要复制更新的数据就越多,一致性就越难保证。为了保证一致性,更新所有节点数据所需要的时间就越长,可用性就会降低。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants