ZooKeeper 分布式锁实践(下篇)读写锁

ZooKeeper 分布式锁实践(上篇)排它锁 中我们通过代码实践了如何使用 ZooKeeper 组件来实现排他锁。

排他锁简单易用,但是缺点也很明显:

  • 竞争压力大:当锁被占用之后,其他获取锁的操作只能阻塞等待;当锁释放后,所有等待锁的进程会在同一时刻争抢锁的使用权。
  • 羊群响应:锁释放后,会通知所有等待锁的进程,如果等待者特别多,一时间锁的竞争压力将会特别大。

简单来说就是:通知范围太广、锁的粒度太大。我们可以分别从这两个层面去寻找解决方案:

  • 缩小通知范围:等待锁的小伙伴们按先来后到的顺序排队吧,排好队了,接下来我只需要关心我前面一个节点的状态,当前一个节点被释放,我再去抢锁。

  • 缩小锁的粒度:锁不关心业务,但是可以简单地通过操作的读、写性质来二分锁的粒度:

    • 读锁:又称共享锁,如果前面没有写节点,可以直接上锁;当前面有写节点时,则等待距离自己最近的写节点释放( 删除 )。

    • 写锁:如果前面没有节点,可以直接上锁;如果前面有节点,则等待前一个节点释放( 删除 )。

      思考:为什么不是关注前面距离自己最近的写节点?

      如果两个写节点之间有读节点,必需等待读节点释放之后再进行写节点请求,否则会有不可重复读的问题。

数据结构

和排他锁一样,我们通过 ZooKeeper 的节点来表示一个读写锁的父节点,如 /SHARE_LOCK,通过父节点下的临时自增子节点来表示一个读写操作请求,如 /SHARE_LOCK/R_0000000001 。整体数据结构如下图所示。

算法

获取锁

获取锁的算法步骤:

  1. 开始尝试获取锁
  2. 如果持久化父节点不存在,则创建父节点
  3. 如果当前临时自增子节点不存在,则创建子节点
  4. 获取父节点下的所有子节点
  5. 在所有子节点中,查找序号比当前子节点小的前置子节点( 最近的兄节点 )。有两种情况:
    • 读请求:查找比自己小的前置子节点 ( 最近的兄节点 )
    • 写请求:查找比自己小的前置子节点 ( 最近的兄节点 )
  6. 如果没有更小的前置子节点,则持有锁
  7. 如果有更小的前置子节点,则监听该子节点被释放( 删除 )的事件
  8. 释放 ( 删除 )子节点事件被触发后,重复第 1 步

释放锁

释放锁的算法与排他锁部分的释放锁算法相似,这里不再赘述。

加锁、解锁流程

加锁、解锁完整的流程图。

代码实现

子节点定义

子节点属性

  • lockName 读写锁的名称,即父节点的名称
  • name 子节点的名称,格式为 :{请求类型:R/W}_{自增序号} ( 子节点的路径为:{lockName}/{name}
  • seq 子节点的自增序号,通过解析 name 属性 _ 下划线分隔符后面的数字字符串来获取序号( ZooKeeper 创建临时自增节点时会自动分配 Int 范围内的序号 )
  • isWrite 子节点是否为写请求,通过解析 name 属性 _ 下划线分隔符前面的英文字符来判断请求类型 :
    • R :读请求
    • W :写请求

zk_lock_share_childnode

读写锁定义和初始设置

读写锁的属性

  • lockName 读写锁的名称,即父节点的路径
  • locker 获取锁的请求方,即锁的持有者,释放锁时需要验证请求者与锁的持有者是否一致
  • isWrite 请求类型:
    • 读:false
    • 写:true

读写锁的初始设置

  • 连接到 ZooKeeper 实例
  • 连接后,如果父节点不存在,则创建父节点

zk_lock_share_zksharelock_setup

尝试获取锁

尝试获取锁的算法实现

  1. 获取或创建 ZooKeeper 子节点
  2. 获取当前子节点后,遍历所有的子节点,查找:
    • front 离当前子节点最近的兄节点:序号比当前子节点的序号小、且在小于当前序号的子节点中序号是最大的
    • fontWrite 离当前子节点最近的写、兄节点:序号比当前子节点的序号小、且在小于当前序号的子节点中序号是最大的、且为写子节点
  3. 查找后,返回序号更小的兄节点:
    • 读请求:返回最近的写、兄节点,用于 Watch 监听释放( 删除 )事件
    • 写请求:返回最近的兄节点,用于 Watch 监听释放( 删除 )事件
  4. 如果没有更小的子节点,返回 None ,表示成功地获取了锁

zk_lock_share_zksharelock_trylock

zk_lock_share_zksharelock_createchildnode

同步获取锁

同步获取锁的算法实现

  1. 尝试获取锁
  2. 如果没有兄节点,则成功持有锁
  3. 如果得到更小的兄节点,则监听该兄节点的释放( 删除 )事件
  4. 收到兄节点的释放( 删除 )事件通知后,重复第 1 步

zk_lock_share_zksharelock_lock

测试验证

最后,通过一个简单的测试方法来验证读写锁的加、解锁过程:

zk_lock_share_zksharelock_test

测试结果

zk_lock_share_zksharelock_test_out

测试结果、分析

# 请求方 操作 输出
1 LOCK1_读 加锁:成功 [LOCK1] : Lock
2 LOCK2_写 加锁:等待,因为有未释放的兄节点 LOCK1
3 LOCK3_写 加锁:等待,因为有未释放的兄节点 LOCK2
4 LOCK4_读 加锁:等待,因为有未释放的兄、写节点 LOCK3
5 LOCK5_读 加锁:等待,因为有未释放的兄、写节点 LOCK3
6 LOCK1_读 解锁:成功,通知到 LOCK2 [LOCK1] : Unlock
7 LOCK2_写 收到通知,尝试加锁:成功 [LOCK2] : Lock
8 LOCK2_写 解锁:成功,通知到 LOCK3 [LOCK2] : Unlock
9 LOCK3_写 收到通知,加锁成功 [LOCK3] : Lock
10 LOCK3_写 解锁成功,通知到 LOCK4、LOCK5 [LOCK3] : Unlock
11 LOCK4_读 收到通知,尝试加锁:成功 [LOCK4] : Lock
12 LOCK5_读 收到通知,尝试加锁:成功 [LOCK5] : Lock
13 LOCK4_读 解锁:成功 [LOCK4] : Unlock
14 LOCK5_读 解锁:成功 [LOCK5] : Unlock

尾声

通过 ZooKeeper 分布式锁实践,对它的接口最直观的感受就是简单。虽然它没有直接提供加锁、解锁这样的原语,但是当你了解了它的数据结构、接口和事件设计之后,加锁、解锁功能简直呼之欲出,实现起来毫无障碍,一切都是那么地合理、妥当。

而 ZooKeeper 的能力远不止于此,就像前面提到的它能够十分轻松地实现诸如:数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、分布式锁、分布式队列这些小菜,不得不佩服 ZooKeeper 设计者的抽象能力。本篇只是浅尝了 ZooKeeper 的基本能力,有关它的设计思路、实现细节仍待进一步发掘和探索。

0

我们正在招聘Java工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com

发表评论

电子邮件地址不会被公开。 必填项已用*标注