目录
一、分布式协调技术
在学习Zookeeper之前,我们需要先了解什么是分布式协调技术,以及该技术产生的背景是什么。
分布是协调技术主要用来解决分布式环境中多个进程(节点)之前的同步控制,让他们 有序 的区访问某种 “临界资源” ,防止出现 “脏数据” 的情况。
什么是脏数据?
例如事务A修改了某个数据X,但是由于某种原因,事务A出现了问题导致发生了回滚,但是在事务A回滚之前,事务B读取到了数据X,接着A回滚了事务,数据项恢复了原值,而事务B读取到的数据X“临时”的值则被称为 “脏数据”。
如下图所示,图中有三台机器,每台机器各自跑一个应用程序。
然后我们将这三台机器通过网络将其连接起来,构成一个系统来为用户提供服务,但是对于用户来说,他并不知道我们的系统是由多台机器构成的,这种系统我们可以称作为 “分布式系统架构”。
在分布式系统中,如何对进程(节点)进行调度?
我们假设在第一台机器上挂载了一个资源,然后这三个物理分布的进程(节点)都要竞争这个资源,但我们又不希望他们同时访问(同时访问可能会造成脏数据的问题),这时我们就需要一个 "协调器" ,来让他们有序的来访问这个资源。
协调器就是我们经常提到的 “锁” ,例如 “进程1” 在使用该资源时,会先去获得锁, 并且会对该资源保持 “独占” ,这样其他没有获得锁的进程就无法访问该资源,在 “进程1” 用完该资源后会将 “锁” 释放掉,让其他进程来获取,通过这种锁机制 我们就能保证分布式系统中多个进程能够有序的访问该 “临界资源”。
在分布式的环境下(多个节点提供服务)的锁机制,我们称作为 “分布式锁” ,这个分布式锁也是我们 “分布式协调技术” 实现的核心内容。
0x01:分布式锁
分布式技术的核心:实现分布式锁。
为了防止分布式系统中的多个进程之间相互干扰,正如前面的章节所述,我们需要一种 分布式协调技术 来对这些进程进行调度。而这个分布式协调技术的核心就是 “分布式锁”。
为什么使用分布式锁?
使用分布式锁的目的是为了保证 同一时间内 只有一个客户端可以对共享资源进行操作。
根据锁的用途还可以细分为以下两类:
1、基于锁实现 允许多个客户端操作共享资源
这种情况下,对共享资源的操作一定是 “幂等性” 的操作,无论你操作多少次,都不会出现不同的结果。在这里使用锁,是为了避免重复操作共享资源从而提高效率。
2、基于锁实现 只允许一个客户端操作共享资源
这种情况下,对共享资源的操作一般是 “非幂等性” 的。在这种情况下,如果出现多个客户端操作同一个共享资源,就可能意味着出现数据不一致的情况(脏数据),导致数据丢失。
分布式锁的实现有哪些?
1、Memcached
利用 Memcached 的 add
命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,这也就意味着线程获得了锁。
2、Redis
和 Memcached 的方式类似,利用 Redis 的 setnx
命令。此命令同样是原子操作,只有在 key
不存在的情况下,才能 set
成功。
3、Zookeeper
利用 Zookeeper 的顺序临时节点,来实现分布式锁 和 等待队列。
4、Chubby
Google 公司实现的粗粒度分布式锁服务,底层利用了 Paxos 一致性算法。
0x02:基于 Redis 实现分布式锁
分布式锁的三个核心要素
- 加锁
- 解锁
- 锁超时
1、加锁
最简单的方法就是使用 setnx
命令。key
是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给 key
明明为 “lock_sale_product_id” 。 而 value
我们暂时设置为1。
setnx(lock_sale_product_id,1)
当一个线程执行 setnx
返回 1 ,说明 key
根本不存在,该线程成功获得了锁;反之如果返回 0 时,则说明 key 已经存在,则该线程抢锁失败。
2、解锁
有加锁就得有 "解锁"。当得到锁的线程执行完任务之后,就需要将锁释放,以其他线程可以进入。
释放锁的最简单的方式是执行 del
命令,伪代码如下
del(lock_sale_product_id)
锁释放之后,其他线程就可以执行 setnx
命令来获得锁。
3、锁超时
如果一个得到锁的线程在执行任务的过程中挂掉了,来不及执行释放锁的操作,那么这块锁将会被永远的锁住(死锁),则导致别的线程将无法再获取到该资源。
所以 setnx
的 key
必须设置一个 超时时间,以保证及时锁没有被显式的释放,这把锁也会在一定的时间内自动释放。而 setnx
不支持超时参数,可以用 expire
来设置指定key的超时时间,如下
expire(lock_sale_product_id, 30)
综合的伪代码如下
if(setnx(lock_sale_product_id,1) == 1){
expire(lock_sale_product_id,30)
try {
do something ......
} finally {
del(lock_sale_product_id)
}
}
以上伪代码中存在三个致命问题
1、问题一:setnx
和 expire
的非原子性
设想一个极端场景,当某线程执行 setnx
后成功获得了锁,如下图
setnx
刚执行成功,还未来得及执行 expire
指令,节点 1 挂掉了。
这样一来,这把锁就没有设置过期时间,变成 死锁,别的线程再也无法获得锁了。
怎么解决呢?setnx
指令本身是不支持传入超时时间的,但是 set
指令增加了可选参数,伪代码如下:
set(lock_sale_商品ID,1,30,NX)
这样就可以取代 setnx
指令。
2、问题二:del
导致误删
又是一个极端的场景,假如某线程成功得到了锁,并且设置的超时时间是30秒。
如果某些原因导致线程 A 执行的很慢很慢,过了 30 秒都没执行完,这时候锁过期自动释放,线程 B 得到了锁。如下图
随后,线程 A 执行完了任务, 现场 A 接着执行 del
命令来释放锁。但是这时的线程 B
还没执行完,线程 A
实际上删除的是 线程 B
加的锁。
那么如何避免这种情况的发生?可以在 del
释放锁之前做一个判断,验证当前的锁是否为自己加的锁。可以在加锁的时候把当前的线程 ID
作为 value ,并在删除之前验证 key
对应的 value
是不是自己的ID。
加锁时:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
解锁时:
if(threadId .equals(redisClient.get(key))){
del(key)
}
但是,这样做又隐含了一个新的问题,判断和释放锁是两个独立的操作,不能保证原子性。
3、问题三:出现并发的可能性
还是刚才第二点所描述的场景,虽然我们避免了线程A误删锁的情况,但是同一时间有 A , B 两个线程在访问代码块,仍然是不完美的。根本原因是目前无法保证锁在线程操作的过程中一直持有锁。
那么如何解决? 我们可以通过让获得锁的线程再开启一个新的线程,用来给快过期的锁 "续航"
假如我们设置的过期时间为30秒,在第29秒时,线程A还没执行完成,这时 守护线程 就会执行 expire
指令,为当前 主线程 持有的锁 “续命” 20秒,守护线程从第29秒开始,每20秒都会执行一次。
当线程 A 执行完任务,会显式关掉守护线程。如下图
另一种情况,如何线程 A 所在的节点 1 突然断电了,这时由于 线程A 和 守护线程 处于同一个进程内,所以会同时结束,在锁到达超时时间后就会自动释放,避免了 死锁的情况发生。
总结:分布式锁要解决的三个问题
- 要保证原子性操作,"加锁" 和 "锁超时" 的操作都要一次性执行
- 防止误删锁
- 再防误删的基础上,增加一个守护线程,为锁 “续命”
二、什么是Zookeeper?
在对分布式技术有了初步的了解后,我们来进入正题,对 Zookeeper
进一步的了解。
Zookeeper 是一种分布式协调服务(分布式协调技术的一种实现)用于管理大型主机。在分布式环境中 协调 和 管理 服务是一个复杂的过程。Zookeeper 通过其简单的架构和提供丰富的API 来解决这个问题。
Zookeeper 允许开发人员专注于核心应用程序的逻辑,而不必担心应用程序的分布式特性。
0x01:Zookeeper 的数据模型
Zookeeper 的数据模型很像我们数据结构中的 “树” 结构,也与 Linux 的文件系统类似。如下图
树是由节点所组成,Zookeeper 的数据储存也同样是基于节点。 这种节点叫做 Znode
但是 不同于树的节点,Znode 的引用方式是路径引用,类似于文件路径。
/动物/猫
Znode
包含了以下的元素
- data:Znode储存的数据信息
- ACL:记录 Znode 的访问权限,即哪些人或者哪些 IP 可以访问本节点。
- stat:包含了 Znode 的各种元数据,比如事务ID、版本号、时间戳、数据大小等。
Zookeeper 是为 “读多写少” 的场景所设计的,Znode
并不适合储存大规模的业务数据,而是用于储存少量的状态和配置信息,所以每个节点的数据最大不能超过 1MB
0x02:Zookeeper 的基本操作
参考 ZooKeeper - 客户端命令行操作 - 简书 (jianshu.com)
0x03:Zookeeper 的事件通知
我们可以把 Watch
理解成是注册在特定 Znode
上的触发器。
当 Znode
发生改变,也就是调用了 create、delete 、setData 方法时,将会触发 Znode 上注册的对应的事件,请求 Watch
的客户端会接收到异步通知。
具体的交互过程如下:
1、客户端调用 getData 方法,watch 参数是 true 。服务端接收到请求,返回节点数据,并且在对应的哈希表里插入被 Watch
的 Znode
路径,以及 Watcher
列表内。
2、当 Watch
的 Znode 已删除,服务端会查找哈希表,找到该 Znode 对应的所有 Watcher(客户端),并且异步的通知这些客户端,并删除哈希表中对应的 Key-Value
。
0x04:Zookeeper 的一致性
Zookeeper 身为分布式系统协调服务,如果自身挂了如何处理呢? 为了防止单机挂掉的情况,Zookeeper 可以使用集群模式,如下图所示
从上图可以看出,Zookeeper Service 集群是一主多从结构。
在更新数据时,首先更新到主节点,再同步到从节点。
这里的节点是指服务器,不是Znode
在读取数据时,直接读取任意从节点中读取。
为了保证主从节点的数据一致性,Zookeeper 采用了 ZAB协议 ,这种协议非常类似于一致性算法 Paxos
和 Raft
。
什么是 ZAB ?
Zookeeper Atomic Broadcast ,有效的解决了 Zookeeper 集群崩溃恢复、主从同步数据的问题。
ZAB
协议定义的三种状态:
- Looking:选举状态
- Follwing:Follower 节点(从节点)所处的状态。
- Leading:Leader 节点(主节点)所处的状态。
最大 ZXID
最大 ZXID 也就是节点本地的最新事务编号,包含 epoch 和 计数 两部分。
epoch 是纪元的意思,相当于 Raft
算法选主时候的 term
。
ZAB 的崩溃恢复
假如 Zookeeper 当前的主节点挂掉了,集群会进行崩溃恢复(重新选举主节点)。
ZAB 的崩溃恢复分成三个阶段:
1、Leader election(选举阶段)
选举阶段,此时集群中的节点处于 Looking 状态。
它们会各自向其他节点发起投票,投票当中包含了自己的 服务器ID 和 最新的事务ID(ZXID),如下图
接下来,节点会用自身的 ZXID
和从其他节点接收到的 ZXID
做比较,如果发现别人家的 ZXID
比自己的大,也就是数据比自己的要新,那么久重新发起投票,投给目前已知最大的 ZXID 所属节点。
每次投票后,服务器都会统计投票数量,判断是否有某个节点得到 半数 以上的投票。如果存在这样的节点,此节点将会成为 准 Leader ,状态变为 Leading
。其他节点的状态变为 Following 。
2、Discovery(发现阶段)
用于在从节点中发现最新的 ZXID
和 事务日志。
这是为了防止某些意外的情况发生,例如因网络原因在上个阶段产生了多个 Leader
的情况。
所以这个阶段,Leader 集思广益,接收所有 Follower 发来各自最新 epoch 值。
Leader 从中选出最大的 epoch ,基于此值加1,生成新的 epoch 分发给各个 Follwer。
当各个 Follower
收到全新的 epoch 后,返回给 ACK
给 Leader ,并带上各自最大的 ZXID 和 历史事务日志。Leader 选出最大的 ZXID ,并更新自身的历史日志。
3、Synchronization(同步阶段)
同步阶段,把 Leader 刚才收集得到的最新历史事务日志,同步给集群中所有的 Follower。只有当半数 Follower 同步成功,这个准 Leader 才能成为正式的 Leader。
自此,故障恢复正式完成。
ZAB 的数据写入
ZAB 的数据写入涉及到 Broadcast
阶段,简单来说,就是 Zookeeper 常规情况下更新数据的时候,由 Leader 广播到所有的 Follower。其过程如下:
- 客户端发出写入数据请求给任意 Follower。
- Follower 把写入数据请求转发给 Leader。
- Leader 采用二阶段提交方式,先发送 Propose 广播给 Follower。
- Follower 接到 Propose 消息,写入日志成功后,返回 ACK 消息给 Leader。
- Leader 接到半数以上ACK消息,返回成功给客户端,并且广播 Commit 请求给 Follower
ZAB 协议既不是强一致性,也不是弱一致性,而是处于两者之间的单调一致性(顺序一致性)。它依靠事务 ID 和版本号,保证了数据的更新和读取是有序的。
弱一致性:当数据更新后,后续对该数据的读取操作可能得到更新后的值,也可能是更改前的值。
强一致性:当更新操作完成之后,在任何时刻所有的用户或者进程查询到的都是最近一次成功更新的数据。强一致性是程度最高一致性要求,也是最难实现的。关系型数据库更新操作就是这个案例。
最终一致性:在某一时刻用户或者进程查询到的数据可能都不同,但是最终成功更新的数据都会被所有用户或者进程查询到。简单理解为,就是在一段时间后,数据会最终达到一致状态。
顺序一致性:简单理解为,就是程序的执行顺序和它编写的顺序一致。