【zookeeper学习笔记】| 十、Zookeeper实现分布式锁

一、为什么需要分布式锁

数据一致性是一个比较重要的话题,在单机环境中,我们可以通过Java提供的并发API来解决;而在分布式环境(会遇到网络故障、消息重复、消息丢失等各种问题)下要复杂得多,常见的解决方案是分布式事务、分布式锁等。

二、实现分布式锁需要考虑的问题

1、实现思路注意事项

锁的可重入性(递归调用不应该被阻塞、避免死锁)

锁的超时(避免死锁、死循环等意外情况)

锁的阻塞(保证原子性等)

锁的特性支持(阻塞锁、可重入锁、公平锁、联锁、信号量、读写锁)

2、具体使用注意事项

分布式锁的开销(分布式锁一般能不用就不用,有些场景可以用乐观锁代替)

加锁的粒度(控制加锁的粒度,可以优化系统的性能)

加锁的方式

三、基于数据库实现锁

1、方式一、基于数据库表

创建一张锁表,当要锁住某个方法或资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。给某字段添加唯一性约束,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

会引入数据库单点、无失效时间、阻塞、不可重入等问题。

2、基于数据库排他锁

使用的是MySql的InnoDB引擎,在查询语句后面增加for update数据库会在查询过程中(须通过唯一索引查询)给数据库表增加排他锁,可以认为获得排它锁的线程即可获得分布式锁,通过 connection.commit() 操作来释放锁。

会引入数据库单点、不可重入、无法保证一定使用行锁(部分情况下MySQL自动使用表锁而不是行锁)、排他锁长时间不提交导致占用数据库连接等问题。

3、优缺点

1)优点:直接借助数据库,容易理解

2)缺点

会引入更多的问题,使整个方案变得越来越复杂

操作数据库需要一定的开销,有一定的性能问题

使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候

四、基于缓存

1、相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。目前有很多成熟的缓存产品,包括Redis、memcached、tair等。

2、Redis几种实现方法

1)基于 redis 的 setnx()、expire() 方法做分布式锁

setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。

expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。

  1. 基于 redis 的 setnx()、get()、getset()方法做分布式锁

getset 这个命令主要有两个参数 getset(key,newValue),该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。

  1. 基于 Redlock 做分布式锁

Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)

  1. 基于 redisson 做分布式锁

redisson 是 redis 官方的分布式锁组件,GitHub 地址:redisson

3、优缺点

1)优点:性能好。

2)缺点:

实现中需要考虑的因素太多

通过超时时间来控制锁的失效时间并不是十分的靠谱(一执行时间不好控制、二、宕机引发问题更多)

五、基于Zookeeper实现分布式锁

1、思想

每个客户端对某个方法加锁时,在 Zookeeper 上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题

2、实现方式一

1)排它锁

a:排它锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。

b:排它锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。

c:Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。

  1. 实现步骤

定义锁:通过Zookeeper上的数据节点来表示一个锁

获取锁:客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况

释放锁:
a:当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除(临时节点随会话结束而删除)
b:正常执行完业务逻辑,客户端主动删除自己创建的临时节点

3)流程图

y
N
N
Y
完成事务逻辑
事务中断
A获取锁
B是否已经被其他事务获取
C等待锁
D创建lock临时节点
E是否创建成功
F占用锁
G释放锁
3、实现方式二
  1. 共享锁

共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。

共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。

  1. 实现步骤

a:定义锁:通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点

b:获取锁:客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号 节点,如果是写请求则创建 /lockpath/[hostname]-W-序号 节点

c:判断读写顺序 分个步骤

1)创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听

2)确定自己的节点序号在所有子节点中的顺序

3.1)对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待

3.2)对于写请求,如果自己不是序号最小的节点,那么等待

4)接收到Watcher通知后,重复步骤1)

d:释放锁:

1)当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除(临时节点随会话结束而删除)

2)正常执行完业务逻辑,客户端主动删除自己创建的临时节点

4、共享锁的羊群效应

1)在实现共享锁的 “判断读写顺序” 的第1个步骤是:创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。这样的话,任何一次客户端移除共享锁之后,Zookeeper将会发送子节点变更的Watcher通知给所有机器,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行,然后所有节点再判断自己是否是序号最小的节点(写请求)或者判断比自己序号小的子节点是否都是读请求(读请求),从而继续等待下一次通知。

2)这些重复操作很多都是 “无用的”,实际上每个锁竞争者只需要关注序号比自己小的那个节点是否存在即可

3)当集群规模比较大时,这些 “无用的” 操作不仅会对Zookeeper造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个客户端释放了共享锁,Zookeeper服务器就会在短时间内向其余客户端发送大量的事件通知–这就是所谓的 “羊群效应“。

5、共享锁羊群效应的改进方案

1)客户端调用 create 方法创建一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点

2)客户端调用 getChildren 方法获取所有已经创建的子节点列表(这里不注册任何Watcher)

3)如果无法获取任何共享锁,那么调用 exist 来对比自己小的那个节点注册Watcher

a:读请求:向比自己序号小的最后一个写请求节点注册Watcher监听
b:写请求:向比自己序号小的最后一个节点注册Watcher监听

4)等待Watcher监听,继续进入步骤2

【zookeeper学习笔记】| 十、Zookeeper实现分布式锁