zookeeper 分布式锁原理
本篇博客参考论文ZooKeeper: Wait-free coordination for Internet-scale system (ZooKeeper 互联网级别的无等待协调系统)
前言
为了读者更好的理解zookeeper 分布式实现原理,现对zookeeper 一些机制和client Api 进行介绍。
- Watch 机制: 通过指定watch,可以监听对应文件的变化,zookeeper 可以确保如果文件有任何变更,都会通知到客户端。
- zookeeper 提供了两个基本的顺序保障。 1: Linearizable writes 写请求是线性一致的。 2: FIFO client order 任何一个客户端请求,都会按照该客户端发送顺序来执行。 总结一下就是: 读请求不需要经过leader,只有写请求经过leader,读请求只会到达某个副本。考虑到FIFO客户端序列,客户端会以某种顺序读某个数据,之后读第二个数据。
- 有三种类型的zonde,分别是Regular znode,Ephemeral znode,Sequential znode。Regular znode 一旦创建就一直存在,除非你显式删除了它。 Ephemeral znode(短命节点),zookeeper 认为创建它的客户端挂了或会话结束,它就会删除该node。Sequential znode: 当你想用特定的名字创建一个文件,zookeeper 实际上创建的文件名是你指定的文件名再加上一个数字
- 每一个znode 都有一个表示当前版本号的version,当znode 有更新的时候,version 也会随之增加。
zookeeper 中的client api
-
1 create(path,data,flags)。入参分别是文件的全路径名PATH,数据DATA,和表明znode类型的flags。
eg: create(“f”,data,sequential=True,ephemeral=True) -
2 delete(path,version )。入参分别是文件的全路径名path,和版本号versoin。当存在多个客户端同时要做相同的操作时,这里的参数version会非常有帮助(并发操作不会被覆盖)。所以,对于delete,你可以传入一个version表明,只有当znode版本匹配时才删除。
-
3 exist(path,watch )。入参分别是文件的全路径名path,和一个有趣的额外参数watch。通过指定watch,你可以监听对应文件的变化。不论文件是否存在,你都可以设置watch为true,这样Zookeeper可以确保如果文件有任何变更,例如创建,删除,修改,都会通知到客户端。此外,判断文件是否存在和watch文件的变化,在Zookeeper内是原子操作。所以,当调用exist并传入watch为true时,不可能在Zookeeper实际判断文件是否存在,和建立watch通道之间,插入任何的创建文件的操作,这对于正确性来说非常重要。
-
4 getData(path,watch)。入参分别是文件的全路径名path,和watch标志位。这里的watch监听的是文件的内容的变化。返回 data以及相关的 meta-data比如说version 信息。
-
5 setData(path,data,version )。入参分别是文件的全路径名path,数据data,和版本号version 。如果你传入了version,那么Zookeeper当且仅当文件的版本号与传入的version一致时,才会更新文件。
-
6 getChildren (path)。入参是目录的路径名,返回的是路径下的所有znode。
-
7 sync( path ) 把所有在sync 之前的更新操作都进行同步,达到每个请求在半数以上的zookeeper server 上生效。
为什么要实现分布式锁
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用并发处理相关的功能进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的应用并不能提供分布式锁的能力。为了解决这个问题就需要一种跨机器的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
如上图所示,系统A,B中的某一程序同时请求系统D去取某一相同的资源。取到后进行修改又写到系统D 中,这时候可能会产生数据与预期不符的情况。比如说在系统D中存放库存100,系统A,B同时读到100,A减5 写到D,B减10 写到D,最终为90,但实际情况应该为85。这时候就需要分布式锁。
单体中的加锁只能保证单体中只有一个线程去处理资源。java 中可以通过 Synchronize 和 Reentranck 加锁方式实现。
分布式锁: 将进程之间的并行操作转化为串行的操作。
常见分布式锁解决方案
1 数据库实现分布式锁
利用mysql 的唯一索引 或行锁(排他锁)
创建一张锁表, name 表示锁定的方法或者资源。
当要锁住某个方法或资源时,就要往该表中插入一条该资源的一条记录,当释放锁的时候,删除这条记录。
缺点:
1 使用数据库实现的分布式锁,强依赖与数据库,存在单点故障的问题。解决: 配置数据库主从机器,防止单点故障。
2 超时无法失效, 一旦解锁失败,会导致锁记录一直在数据库中,其他线程无法再获得锁。 解决: 可以添加独立的定时任务,通过时间戳对比等方式,删除超时数据。
2 利用redis 实现分布式锁,
使用 redis 中的 setNx(key,value) 命令, 命令在指定的 key 不存在时,为 key 设置指定的值。多个客户端请求的时候,如果key 不存在就添加成功,并获得锁,如果key 存在就添加失败。 需要不断轮询等待锁的释放。释放锁 del key 。
setnx + expire 设置过期时间。 但这两指令不具备原子性。
setex 支持setnx 和 expire 指令组合的原子操作。 解决了加锁过程中失败的问题。
生产环境下的分布式锁需要满足以下条件。
集群下分布式锁存在的问题:
3 利用zookeeper 实现分布式锁
zookeeper分布式锁实现
zookeeper分布式锁的实现就是调用client api,再利用zookeeper 上的各种机制来实现的。
Non-Scalable Lock 非扩展锁:
如果有多个客户端请求zookeeper 的服务器,非扩展锁伪代码:
while True:
if create("f",data,ephemeral=TRUE):
return
if exist("f",watch=True)
wait
释放锁
delete(path)
解析:因为zokeeper 有写请求的线性一致性,多个客户端创建锁文件,zookeeper leader 会以某种顺序只执行一个请求。只有一个客户端获得了锁,并返回。其他客户端就无法创建锁文件就使用watch 机制并监视 f 的删除。若删除,剩下的客户端继续尝试create(由快的获取锁)。
缺点: 虽然这种简单的锁定协议能工作,但确实存在问题。 首先,它受到羊群效应的影响。 如果有许多客户端等待获取锁,即使只有一个客户端可以获取锁,它们都会在释放锁的时候争夺锁。 第二,它只实现了排他锁。
Scalable Lock 扩展锁
伪代码
create("f",data,sequential=True, ephemeral=True)
while true:
getChildren("f*")
if no lower:
return
if exist(next lower,watch=true)
wait
释放锁
delete(path)
解析: 这时候每个客户端在f 下会生成与之对应znode,通过create +sequential=True生成一个为文件名+序列号的znode。eg ff125 注意125这个序列号是全局唯一的,并且zookeeper 生成的序列号是自增的。if no lower
表示让f 下序列号最小的客户端获得锁,.if exist(next lower,watch=true) 表示等待比自己序列号更小的下一个锁文件删除。通过在释放锁时只唤醒一个进程来避免羊群效应。
这种锁定方案具有以下优点 :
- Znode 的移除仅导致一个客户端醒来,因为每个 Znode 被恰好另一个客户端监视,所以我们没有羊群效应;
- 没有轮询或超时;
- 由于我们实现锁定的方式,我们可以通过浏览 Zookeeper 数据来查看锁争用、中断锁和调试锁定问题。
非扩展锁vs 扩展锁
拿火车站买票的例子做个比喻,比如1000个人去火车站买票但只有一个窗口。非扩展锁就像1000个人等在窗口前,谁离得近谁先买到,每次一个人买票的时候,其他全部人都要等着,以便下次离窗口更近。扩展锁更像每个人都领一个号,快轮到自己的时候进行排队,而其他人可以干其他事,快轮到自己的时候才进行排队。