分布式锁的实现及原理
nove001 · 2019年05月20日 · 844 次阅读
目录
概述
锁是在执行多线程时用于强行限制资源访问的同步机制,在分布式系统场景下,为了使多个进程(实例)对共享资源的读写同步,保证数据的最终一致性,而引入了分布式锁。
分布式锁应具备以下特点:
- 互斥性:任意时刻,同一个锁,只有一个进程能持有
- 安全性:避免死锁,当进程没有主动释放锁(进程崩溃退出),保证其他进程能够加锁
- 可用性:当提供锁的服务节点故障(宕机)时,“热备” 节点能够接替故障的节点继续提供服务,并保证自身持有的数据与故障节点一致。
- 对称性:对同一个锁,加锁和解锁必须是同一个进程,即不能把其他进程持有的锁给释放了
可以基于数据库,缓存,中间件实现分布式锁,比较主流的是使用 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
,防止死锁。记下返回的 Revision
值 R
。类似你拿到一个选房序号,并规定了进去选房时间,超时还没有选中,就失效了; 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 实现了 Raft 算法,可以保证新节点与故障节点的数据一致性。Redis 由于历史原因,很早之前都是单机部署的,后面才慢慢的支持集群部署,由于分布式实现的方案选择不同,并不保证节点间数据的强一致性。而且 Redis 集群一般有多个 Master 节点,使用数据分片将数据负载到不同的 Master 节点上,一个 Master 节点有 N 个 Slave 节点,通过主从复制来保证服务可用性和数据一致性。这意味这在实际中集群在特定的条件下可能会丢失写操作,原因如下:
- 为了提高写入的性能,主从复制的过程是异步的;
- 出现网络隔离,如果某个 Master 节点 A 被隔离在集群之外,那么它的从节点 A' 可能被选举为 Master 节点,此时,连接到 A 的客户端写数据可能会丢失;
为了解决这个问题,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;。此外,因为支持事务(基于软件事务内存机制( 使用实例](https://godoc.org/go.etcd.io/etcd/clientv3/concurrency#example-STM--Apply)。
PS: 事务也是非常常见而且非常重要的概念,我也在文章 <https://github.com/jinhailang/blog/issues/48> 较详细的阐述了事务的应用及原理。