课程回顾
微服务架构需要解决的问题
分布式协调框架Zookeeper
什么是分布式协调技术
分布式协调技术主要用来解决分布式环境当中多个进程之间的同步控制,让他们有序的去访问某种临界资源,防止造成“脏数据”的后果。
在这图中有三台机器,每台机器各跑一个应用程序。 然后我们将这三台机器通过网络将其连接起来,构成个系统来为用户提供服务,对用户来说这个系统的架构是透明的,他感觉不到我这个系统是一个什么样的架构。那么我们就可以把这种系统称作一个分布式系统。
在这个分布式系统中如何对进程进行调度,我假设在第一台机器上挂载了一个资源, 然后这三个物理分布的进程都要竞争这个资源,但我们又不希望他们同时进行访问,这时候我们就需要一个协调器, 来让他们有序的来访问这个资源。这个协调器就是我们经常提到的那个锁,比如说进程-1在使用该资源的时候,会先去获得锁,“进程1"获得锁以后会对该资源保持独占,这样其他进程就无法访问该资源,”进程1”用完该资源以后就将锁释放掉,让其他进程来获得锁,那么通过这个锁机制,我们就能保证了分布式系统中多个进程能够有序的访问该临界资源。那么我们把这个分布式环境下的这个锁叫作分布式锁。这个分布式锁也就是我们分布式协调技术实现的核心内容。
什么是分布式锁
为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁。
为什么要用分布式锁
- 成员变量A存在JVM1、 JVM2、 JVM3三个JVM内存中
- 成员变量A同时都会在JVM分配一 块内存,三个请求发过来同时对这个变量操作,显然结果是不对的
- 不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的
注:该成员变量A是一个有状态的对象
如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题,这就是分布式锁要解决的问题。
分布式锁应该具备哪些条件
- 在分布式系统环境下,一个方法在同一时间只能被一 个机器的一个线程执行
- 高可用的获取锁与释放锁
- 高性能的获取锁与释放锁
- 具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
- 具备锁失效机制,防止死锁
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
分布式锁的实现有哪些
- Memcached: 利用Memcached的add命令。此命令是原子性操作,只有在key不存在的情况下,才能add成功,也就意味着线程得到了锁。
- Redis: 和Memcached的方式类似,利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。
- Zookeeper: 利用Zookeeper的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。
- Chubby: Google 公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法。
通过Redis分布式锁的实现理解基本概念
分布式锁实现的三个核心要素:
加锁
最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为“lock_sale_商品ID”。而value设置成什么呢?我们可以姑且设置成1。加锁的伪代码如下:
setnx(lock_sale_商品ID,1)
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。
解锁
有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令,伪代码如下:
del(lock_sale_商品ID)
释放锁之后,其他线程就可以继续执行setnx命令来获得锁。
锁超时
锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显示地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来。所以,setnx的key必须设置一个超时时间,以保证即使没有被显示释放,这把锁也要在一定时间后自动释放。setnx不支持超时参数,所以需要额外的指令,伪代码如下:
expire(lock_sale_商品ID, 30)
综合伪代码如下:
if(setnx(lock_sale_商品ID, 1) == 1){
exprie(lock_sale_商品ID, 30)
try{
do something ...
} finally {
del(lock_sale_商品ID)
}
}
存在什么问题
以上伪代码存在三个致命问题
setnx和exprie的非原子性
设想一个极端场景,当某线程执行setnx,成功得到了锁:
setnx刚执行成功,还未来得及执行expire指令,节点1挂掉了。
这样一来,这把锁就没有设置过期时间,变成死锁,别的线程再也无法获得锁了。
怎么解决呢?setnx指令本身是不支持传入超时时间的,set指令增加了可选参数,伪代码如下:
set(lock_sale_商品ID, 1, 30 , NX)
这样就可以取代setnx指令。
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)
}
但是,这样做又隐含了一个新的问题,判断和释放锁是两个独立操作,不是原子性。
出现并发的可能性
还是刚才第二点所描述的场景,虽然我们避免了线程A误删掉key的情况,但是同一时间有A,B两个线程在访问代码块,仍然是不完美的。怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续命”。
当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,没20秒执行一次。
当线程A执行完任务,会显式关掉守护线程。
另一种情况,如果节点1忽然断电,由于线程A和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。
什么是Zookeeper
ZooKeeper是一种分布式协调服务, 用于管理大型主机。在分布式环境中协调和管理服务是一个复杂的过程。ZooKeeper通过其简单的架构和API解决了这个问题。ZooKeeper 允许开发人员专注于核心应用程序逻辑,而不必担心应用程序的分布式特性。
以下为Zookeeper的基本概念。
Zookeeper的数据模型
Zookeeper的数据模型是什么样子呢?它很像数据结构当中的树,也很像文件系统的目录。
树是由节点所组成,Zookeeper 的数据存储也同样是基于节点,这种节点叫做Znode
但是,不同于树的节点,Znode 的引|用方式是路径引用,类似于文件路径:
/动物/猫
/汽车/宝马
这样的层级结构,让每一个Znode节点拥有唯一的路径, 就像命名空间一样对不同信息作出清晰的隔离。
Znode包含哪些元素
- data: Znode 存储的数据信息。
- ACL: 记录Znode的访问权限,即哪些人或哪些IP可以访问本节点。
- stat: 包含Znode的各种元数据,比如事务ID、 版本号、时间戳、大小等等。
- child: 当前节点的子节点引用
这里需要注意一点, Zookeeper 是为读多写少的场景所设计。Znode 并不是用来存储大规模业务数据,而是用于存储少量的状态和配置信息,每个节点的数据最大不能超过1MB。
Zookeeper的基本操作
创建节点:
create
删除节点:
delete
判断节点是否存在:
exists
获得一个节点的数据:
getData
设置一个节点的数据:
setData
获取节点下的所有子节点:
getChildren
这其中,exists,getData,getChildren属于读操作。Zookeeper客户端在请求读操作的时候,可以选择是否设置Watch。
Zookeeper的事件通知
我们可以把Watch理解成是注册在特定Znode上的触发器。当这个Znode发生改变,也就是调用了create,delete,setData方法的时候,将会触发Znode上注册的对应事件,请求Watch的客户端会接收到异步通知。
具体交互过程如下:
- 客户端调用getData方法,watch参数是true。服务端接到请求,返回节点数据,并且在对应的哈希表里插入被Watch的Znode路径,以及Watch列表。
- 当被Watch的Znode已删除,服务端会查找哈希表,找到该Znode对应的所有Watcher,异步通知客户端,并且删除哈希表中对应的Key-Value。
Zookeeper的一致性
Zookeeper身为分布式系统协调服务,如果自身挂了如何处理呢?为了防止单机挂掉的情况,Zookeeper维护了一个集群。如下图:
Zookeeper Service集群是一主多从结构。
在更新数据时,首先更新到主节点(这里的节点是指服务器,不是Znode),再同步到从节点。
在读取数据时,直接读取任意从节点。
为了保证主从节点的数据一致性,Zookeeper 采用了ZAB协议,这种协议非常类似于一致性算法Paxos和Raft。
什么是ZAB
Zookeeper Atomic Broadcast,有效解决了Zookeeper集群崩溃恢复,以及主从同步数据的问题。
ZAB协议定义的三种节点状态
- Looking: 选举状态。
- Following: Follower 节点(从节点)所处的状态。
- Leading: Leader节点(主节点)所处状态。
最大ZXID
最大ZXID也就是节点本地的最新事务编号,包含epoch和计数两部分。epoch 是纪元的意思,相当于Raft算法选主时候的term。
ZAB的崩溃恢复
假如Zookeeper当前的主节点挂掉了,集群会进行崩溃恢复。ZAB的崩溃恢复分成三个阶段:
Leader election
选举阶段,此时集群中的节点出于Looking状态。它们会各自向其他节点发起投票,投票当中包含自己的服务器ID和最新事务ID(ZXID)。
接下来,节点会用自身的ZXID和从其他节点接收到的ZXID作比较,如果发现别人家的ZXID比自己大,也就是数据比自己新,那么就重新发起投票,投票给目前已知最大的ZXID所属节点。
每次投票后,服务器都会统计投票数量,判断是否有某个节点得到半数以上的投票。如果存在这样的节点,该节点将会成为准Leader,状态变为Leading。其他节点的状态变为Following。
Discovery
发现阶段,用于在从节点中发现最新的ZXID和事务日志。或许有人会问:既然Leader被选为主节点,已经是集群里数据最新的了,为什么还要从节点中寻找最新事务呢?
这是为了防止某些意外情况,比如因网络原因在上一阶段产生多个Leader的情况。
所以这一阶段,Leader 集思广益,接收所有Follower发来各自的最新epoch值。Leader 从中选出最大的epoch,基于此值加1,生成新的epoch分发给各个Follower。
各个Follower收到全新的epoch后,返回ACK给Leader,带上各自最大的ZXID和历史事务日志。Leader 选出最大的ZXID,并更新自身历史日志。
Synchronization
同步阶段,把Leader刚才收集得到的最新历史事务日志,同步给集群中所有的Follower。只有当半数Follower同步成功,这个准Leader才能成为正式的Leader。
自此,故障恢复正式完成。
ZAB的数据写入
Broadcast
ZAB的数据写入涉及到Broadcast阶段,简单来说,就是Zookeeper常规情况下更新数据的时候,由Leader广播到所有的Follower.其过程如下:
- 客户端发出写入数据请求给任意Follower。
- Follower 把写入数据请求转发给Leader。
- Leader采用二阶段提交方式,先发送Propose广播给Follower.
- Follower 接到Propose消息,写入日志成功后,返回ACK消息给Leader.
- Leader接到半数以上ACK消息,返回成功给客户端,并且广播Commit请求给Follower
ZAB协议既不是强一致性,也不是弱一致性,而是处于两者之间的单调一致性(顺序一致性)。它依靠事务ID和版本号,保证了数据的更新和读取是有序的。
Zookeeper的应用场景
分布式锁
这是雅虎研究院设计Zookeeper的初衷。利用Zookeeper的临时顺序节点,可以轻松实现分布式锁。
服务注册和发现
利用Znode和Watch,可以实现分布式服务的注册和发现。最著名的应用就是阿里的分布式RPC框架Dubbo。
共享配置和状态信息
Redis的分布式解决方案Codis,就利用了Zookeeper来存放数据路由表和codis-proxy节点的元信息。同时codis-config发起的命令都会通过ZooKeeper同步到各个存活的codis-proxy.
此外,Kafka、 HBase、 Hadoop, 也都依靠Zookeeper同步节点信息,实现高可用。
Zookeeper如何实现分布式锁
什么是临时顺序节点?
Zookeeper的数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做Znode。
Znode分为四种类型:
持久节点(PERSISTENT)
默认的节点类型。创建节点的客户端与Zookeeper断开连接后,该节点依旧存在。
持久节点顺序节点(PERSISTENT_SEQUENTIAL)
所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号:
临时节点(EPHEMERAL)
和持久节点相反,当创建节点的客户端与Zookeeper断开连接后,临时节点会被删除:
临时顺序节点(EPHEMERAL_SEQUENTIAL)
顾名思义,临时顺序节点结合临时节点和顺序节点的特点:在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与Zookeeper断开连接后,临时节点会被删除。
Zookeeper分布式锁的原理
Zookeeper分布式锁恰恰应用了临时顺序节点。具体如何实现呢?详细步骤如下:
获取锁
首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLcok这个节点下面创建一个临时顺序节点Lock1。
之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
这时候,如果再有一个客户端Client2前来获取锁,则在ParentLock下面再创建一个临时顺序节点Lock2。
Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。
于是,Client2向排序仅比它靠前的节点Lock1注册Watch,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。
这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下面再创建一个临时顺序节点Lock3。
Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。
于是,Client3箱排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。
这样一来,Client 1 得到了锁,Client 2 监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列。
释放锁
释放锁分为两种情况:
任务完成,客户端显式释放
当任务完成时,Client1 会显示调用删除节点Lock1的指令。
任务执行过程中,客户端崩溃
获得锁的Client1 在任务执行过程中,如果崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。
Zookeeper和Redis分布式锁的比较
基于Docker安装Zookeeper
Zookeeper部署有三种方式,单机模式、集群模式、伪集群模式,以下采用Docker的方式部署
注意: 集群为大于等于3的奇数,如3、5、7,不宜太多,集群机器多了选举和数据同步耗时长,不稳定。
单机模式
docker-compose.yml
version:'3.1'
service:
zoo1:
image:zookeeper
restart:always
hostname:zoo1
ports:
- 2181:2181
environment:
ZOO_MY_ID:1
ZOO_SERVERS:server.1=zoo1:2888:3888
验证是否安装成功
- 以交互的方式进入容器
docker exec -it zookeeper_zoo1 /bin/bash
- 使用客户端连接服务端
- 使用服务端工具检查服务器状态
集群模式
准备3台Ubuntu Server系统,并分别配置Zookeeper
第一台主机
docker-compose.yml
version:'3.1'
services:
zoo1:
image:zookeeper
restart:always
environment:
ZOO_MY_ID:1
ZOO_SERVERS:server.1=192.168.75.130:2888:3888 server.2=192.168.75.134:2888:3888 server.3=192.168.75.135:2888:3888
network_mode:host
验证测试
第二台主机
docker-compose.yml
version:'3.1'
services:
zoo2:
image:zookeeper
restart:always
environment:
ZOO_MY_ID:2
ZOO_SERVERS:server.1=192.168.75.130:2888:3888 server.2=192.168.75.134:2888:3888 server.3=192.168.75.135:2888:3888
network_mode:host
验证测试
第二台主机
docker-compose.yml
version:'3.1'
services:
zoo1:
image:zookeeper
restart:always
environment:
ZOO_MY_ID:3
ZOO_SERVERS:server.1=192.168.75.130:2888:3888server.2=192.168.75.134:2888:3888 server.3=192.168.75.135:2888:3888
network_mode:host
验证测试
伪集群模式
docker-compose.yml
version:'3.1'
services:
zoo1:
image:zookeeper
restart:always
hostname:zoo1
ports:
- 2181:2181
environment:
ZOO_MY_ID:1
ZOO_SERVERS:server.1=zoo1:2888:3888 server.2=zoo2:2888:3888 server.3=zoo3:2888:3888
zoo2:
image:zookeeper
restart:always
hostname:zoo2
ports:
- 2181:2181
environment:
ZOO_MY_ID:2
ZOO_SERVERS:server.1=zoo1:2888:3888 server.2=zoo2:2888:3888 server.3=zoo3:2888:3888
zoo3:
image:zookeeper
restart:always
hostname:zoo3
ports:
- 2181:2181
environment:
ZOO_MY_ID:3
ZOO_SERVERS:server.1=zoo1:2888:3888 server.2=zoo2:2888:3888 server.3=zoo3:2888:3888
验证是否安装成功
- 分别以交互方式进入容器查看
docker exec -it zookeeper_zoo1_1 /bin/bash
docker exec -it zookeeper_zoo2_1 /bin/bash
docker exec -it zookeeper_zoo3_1 /bin/bash
Zookeeper配置说明
Zookeeper的三种工作模式
- 单机模式:存在单点故障
- 集群模式:在多台机器上部署Zookeeper集群,适合线上环境使用
- 伪集群模式:在一台机器同时运行多个Zookeeper实例,仍然有单点故障问题,当然,其中配置的端口号要错开,适合实验环境模拟集群使用。
Zookeeper的三种端口号
- 2181:客户端连接Zookeeper集群使用的监听端口号
- 3888:选举leader使用
- 2888:集群内机器通讯使用(Leader和Follower之间数据同步使用的端口号,Leader监听此端口)
Zookeeper单机模式配置文件
配置文件路径:/conf/zoo.cfg
clientPort=2181
dataDir=/data
dataLogDir=/datalog
tickTime=2000
- clientPort:这个端口就是客户端连接Zookeeper服务器的端口,Zookeeper会监听这个端口,接受客户端的访问请求。
- dataDir:Zookeeper保存数据的目录。
- dataLogDir:Zookeeper保存日志的目录。
- tickTime:这个时间是作为Zookeeper服务器之间或客户端与服务器之间维持心跳的时间间隔,也就是每隔tickTime时间就会发送一个心跳。
Zookeeper集群模式配置文件
配置文件路径:/conf/zoo.cfg
clientPort=2181
dataDir=/data
dataLogDir=/datalog
tickTime=2000
initLimit=5
syncLimit=2
autopurge.snapRetainCount=3
autopurge.purgeInterval=0
maxClientCnxns=60
server.1=192.168.0.1: 2888:3888
server.2=192.168.0.2: 2888:3888
server.3=192.168.0.3: 2888:3888
- initLimit:配置Zookeeper接受客户端(这里所说的客户端不是用户连接Zookeeper服务器的客户端,而是Zookeeper服务集群中连接到Leader的Folloer服务器)初始化连接时最长能忍受多少个心跳时间间隔数。当已经超过initLimit(默认为10)个心跳的时间(也就是tickTime)长度后Zookeeper服务器还没有收到客户端的返回信息,那么表明这个客户端连接失败。总的时间长度就是5*2000=10秒
- syncLimit: 配置Leader与Follower之间发送消息,请求和应答时间长度,最长不能超过多少个tickTime的时间长度,总的时间长度就是2 * 2000 = 4秒
- 定时清理(Zookeeper从3.4.0开始提供了自动清理快照和事务日志的功能)以下两个参数配合使用:
- autopurge.purgeInterval: 指定了清理频率,单位是小时,需要填写一个 1或更大的整数,默认是0,表示不开启自己清理功能。
- autopurge.snapRetainCount:指定了需要保留的文件数目。默认是保留3个。
- maxClientCnxns:限制连接到Zookeeper的客户端的数量,限制并发连接的数量,它通过IP来区分不同的客户端。此配置选项可以用来阻止某些类别的Dos攻击。将它设置为0或者忽略而不进行设置将会取消对并发连接的限制。
- server.A=B: C: D:其中A是一个数字,表示这个是第几号服务器。B是这个服务器的IP地址。C表示的是这个服务器与集群中的Leader服务器交换信息的端口(== 2888==); D表示的是万一集群中的Leader服务器挂了,需要一个端口来 重新进行选举,选出一个新的 Leader,而这个端口就是用来执行选举时服务器相互通信的端口( 3888 )。如果是伪集群的配置方式,由于B都是一样, 所以不同的Zookeeper实例通信端口号不能一样, 所以要给它们分配不同的端口号。
注意: server.A中的A是在dataDir配置的目录中创建一个名为myid的文件里的值(如: 1)
Zookeeper常用命令
zkServer
- 启动服务
./zkServer.sh start
- 停止服务
./zkServer.sh stop
- 重启服务
./zkServer.sh restart
- 执行状态
./zkServer.sh status
zkClient
-
客户端连接服务器并进入Bash模式
./zkCli.sh -server <ip>:<port>
-
创建节点(Bash模式)
create /test "hello zookeeper"
-
查询节点(Bash模式)
get /test
-
删除节点(Bash模式)
delete /test