分布式协调服务 ( 服务治理 ).
标签(空格分隔): Java
1. 问题所在
- 主要用于解决分布式环境中多个进程之间的同步控制, 让他们有序的去访问某种临界资源, 防止造成脏数据的后果.
订单服务JVM1->商品服务(库存五个): 我要五个
订单服务JVM2->商品服务(库存五个): 我要五个
订单服务JVM3->商品服务(库存五个): 我要五个
商品服务(库存五个)-->订单服务JVM1:给你五个
商品服务(库存五个)-->订单服务JVM2:给你五个
商品服务(库存五个)-->订单服务JVM3:给你五个
三个JVM 同时发送清空库存,这个时候就造成了脏数据的问题, 库存变成了 \(-10\)个
2. 解决方案
- 分布式锁: 在第一个订单服务访问到商品服务的时候, 我们将商品服务加锁. 这个时候 第二个订单服务去访问 商品服务的时候会被拒绝.
- 分布式协调的核心就是 实现分布式锁, 而Zookeeper就是分布式锁的实现框架.
分布式锁
1. 目的
- 为了防止分布式系统中的多个进程之间的相互干扰, 我们需要一种分布式协调技术去对这些进程进行调度, 而这个分布式协调技术的核心就是来实现这个分布式锁, 而Zookeeper 就是分布式锁的实现框架 .
2. 完备条件
- 在分布式系统环境下, 一个方法在同一时间只能被一个机器的一个线程执行.
- 高可用的获取锁和释放锁.
- 高性能的获取锁和释放锁.
- 具备非阻塞特性 , 即没有获取到锁将直接放回获取锁失败.
- 具备失效机制, 防止死锁. ( 在加锁以后因为发生某些意外, 这个时候可以让锁失效, 而不是一直持有锁, 造成死锁. )
- 具备可重入特征(可以理解为重新进入, 由于多于一个任务并发适用, 而不必担心数据错误).
3. 常用方案
- Memcached: 利用Memcached的add 命令. 此命令是原子性操作, 只有在key不存在的情况下, 才能add 成功, 也就意味着线程得到了锁.
- Redis: 和Memcached的方法类似, 利用Redis的setnx命令. 此命令同样是原子性操作, 只有在key不为空的情况下,才能set成功.
- Zookeeper: 利用Zookeeper的顺序临时节点, 来实现分布式锁和等待队列, Zookeeper设计的初衷,就是为了实现分布式锁.
- Chubby: Google公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法.
分布式锁实现的三个核心要素:
1. 加锁
- 最简单的方式是使用
setnx
命令. key是锁的唯一标识, 按业务来决定命名. 比如想要给一种商品的秒杀活动加锁, 可以给key
命名为lock_sale_商品ID
. 而value可以姑且设置为1
. 加锁的伪代码如下:
setnx(lock_sale_商品ID,1);
- 当一个线程执行
setnx
返回1
, 说明key
原本不存在, 则该线程成功得到了锁; 当一个线程执行setnx
返回0
, 说明key
已经存在, 该线程抢锁失败.
2. 解锁
- 有加锁就有解锁. 当得到锁的线程执行完任务之后,需要释放锁, 以便其他线程可以进入. 释放锁的最简单方式是执行
del
指令, 伪代码如下:
del(lock_sale_商品ID);
3. 锁超时
- 锁超时是什么意思呢? 如果一个得到锁的线程在执行任务的过程中挂掉, 来不及显示的释放锁, 这块资源将会被永远的锁住(死锁), 别的线程再也别想进来. 所以
setnx
的key
必须设置一个超时时间, 以保证及时没有被显式的释放, 这把锁也要在一定的时间后自动释放.setnx
不支持超时参数, 所以需要额外的指令,伪代码如下:
expire(lock_sale_商品ID, 30)
综合伪代码如下: 如果可以获得锁的话, 先设置自动释放的时间, 然后去do something
if(setnx(lock_sale_商品ID,1) == 1){
expire(lock_sale_商品ID,30)
try {
do something ......
} finally {
del(lock_sale_商品ID)
}
}
以上代码存在三个致命问题
1. setnx
和expire
的非原子性
假设一个极端的场景, 上述setnx执行完毕得到了锁, 但是在没有执行expire的时候服务器宕机了, 这个时候依然是没有过期时间的死锁, 别的线程再也无法获得锁了. setnx本身是不支持传入操作时间的, 但是set指令增加了可选参数, 其伪代码如下:
set(lock_sale_商品ID,1,30,NX);
2. del
误删
不确定到底expire到底设置为多长的时间, 如果是30s的话 ,那么万一30s内A任务没有将 something执行完毕, 这个时候 依然将锁释放掉了, 此时B任务进程成果或得到了锁. 然后A进程执行完毕, 按照
del
来释放锁, 这个时候就出问题了.
3. 第一种问题已经有解决方案了, 现在是第二个问题的解决方案.
为了避免这种情况的发生我们可以在
del
释放锁之前做一个判断, 验证当前的锁是不是自己加的锁. 具体的实现: 我们在加锁的时候把当前的线程ID作为锁的value
,并且在删除之前验证key
对应的value
是不是自己的线程ID.
// 加锁
String threadId = Thread.currentThread().getId();
set(key,threadId,30,NX);
// 解锁
if(threadId .equals(redisClient.get(key))){
del(key)
}
4. 但是这样又出现 第二点的问题. 解锁的代码不是原子性操作.
如果判断结束之后, 发现当前线程的ID, 当时在没有执行
del
的时候,expire
了, 这样就又回到了第二种方案的致命问题.
5. 致命大杀器
现在可以确定的是目前的问题解决思路是存在问题的, 应该换一种思路. 应该从第二种del误删这里向下继续解决这个问题.
第二点问题描述: 可能存在多个线程同时执行该代码块.
第二点问题原因分析: 因为不确定代码的执行时间, 可能设置30S的话 大家都会疯狂超时.
第二点问题解决思路: 设置一个可以动态变化,可以满足代码块运行时间的, 且可以应对宕机情况的守护进程.
方案: 给锁开启一个守护进程, 用来给锁进行续航操作, 当时间到29S , 发现还没有执行完毕的时候, 守护进程执行expire 给锁续命, 如果宕机的话 没人给锁续命, 时间到了之后 也会自动释放锁.
什么是Zookeeper
主要有两个功能, 分布式锁和服务注册与发现. 以下主要说明服务注册与发现部分.
Zookeeper是一种分布式协调服务,用于管理大型主机. 在分布式环境中协调和管理服务是一个复杂的过程. Zookeeper通过其简单的架构和API解决了这个问题, Zookeeper允许开发人员专注于核心应用程序逻辑, 而不必担心应用程序的分布式特性.
Zookeeper的数据模型是一个标准的二叉树结构. 树是由节点Znode组成的, 但是不同于树的节点, Znode的饮用方式是路径引用, 类似于文件路径.
/动物/猫
/汽车/宝马
Znode的数据结构
// 元数据: 数据的数据, 例如数据的创建,修改时间, 大小等.
Znode{
data; // Znode存储的信息
ACL; // 记录Znode的访问权限
stat; // 包含Znode的各种元数据, 比如事务的ID,版本号,时间戳,大小
child;// 当前节点的子节点引用
}
Zookeeper这样的数据结构是为了读多写少的场景所设计的. Znode并不是用来存储大规模业务数据,而是用于存储少量的状态和配置信息, 每个节点的数据最大不能超过1MB.
1. Zookeeper的基本操作
- 创建节点
create
- 删除节点
delete
- 判断节点是否存在
exists
- 获得一个节点的数据
getData
- 设置一个节点的数据
setData
- 获取节点下的所有子节点
getChildren
其中exists,getData,getChildren属于读操作. Zookeeper客户端在请求读操作的时候,可以选择是否设置
Watch
.
Zookeeper的事件通知
根据事件通知机制, 如果某个服务下线,
Zookeeper
会及时发现并且将消息异步传送至客户端A(API GateWay
), 这个时候网关发现服务下线会及时启用该服务的备用服务, 从而达到高可用的特性.
整个服务注册与发现 也是基于事件通知机制.
我们可以把Watch理解成是注册在特定Znode上的触发器, 当这个Znode发生改变, 也就是调用了该节点的
create, delete, setData
方法的时候, 将会出发Znode上注册的对应事件, 请求Watch的客户端会接收到异步通知.
设置
watch
示例: 客户端调用getData
方法,watch
参数是true
. 服务端接收到请求, 返回节点数据, 并且在对应的哈希表里插入被Watch
的Znode
的路径,以及Watcher
列表.
异步获取反馈信息
watch
示例: 根据上述操作WatchTable
只用已经有了/动物/猫
节点的信息, 这个时候我们对其进行delete
操作. 服务端会查找HashTable
发现该节点的信息, 然后异步通知客户端A
,并且删除哈希表中对应的Key-Value
.
Zookeeper的一致性
为了防止服务注册与发现(Zookeeper)挂掉的情况, 我们需要对Zookeeper的自身实现高可用, 这个时候我们需要维护一个Zookeeper集群, 假设目前集群中有 ZkA,ZkB,ZkC , 三台机器. 该项目下存在多个项目, 每个项目将自身链接到 ZkA,ZkB,ZkC中某个服务注册与发现中心. 在更新数据(包括服务注册)的时候, 先将数据更新到主节点(Leader), 然后同步到从节点(Follwer) .
Zookeeper Atomic Broadcast
1. ZAB协议定义的三种状态
- Looking: 选举状态
- Following: Follower节点所处的状态
- Leading: Lead接待所处的状态
最大ZXID
最大ZXID也就是节点本地的最新事务编号, 包含epoch和计数两部分. epoch是纪元的意思, 相当于Raft算法选主时候的term.
ZXID是一个64位的数字, 低32位代表一个单调递增计数器, 高32位代表Leader的周期. 当有新的Leader产生的时候,Leader的epoch+1, 计数器从0开始; 每当处理一个新的请求的时候, 计数器+1.
Epoch | 计数器 |
---|---|
Leader周期 | 单调递增,从0开始 |
高32位 | 低32位 |
崩溃恢复
1. Leader Selection
- 选举阶段,此时集群中的节点处于Looking状态( Zookeeper刚开启的时候也是这个状态 ), 他们会向其它节点发起投票, 投票中包含自己的服务器ID和最新事务ID.
- 将自己的ZXID和其它机器的ZXID比较, 如果发现别人的ZXID比自己的大, 也就是数据比自己的新, 那么重新发起投票, 投票给目前最大的ZXID所属节点. (比较ZXID的大小的时候,前32位是一致的. 只能从后32位比较, 这样就是处理请求越多的节点的ZXID的越大)
- 每次投票结束之后,服务器都会统计投票数量, 判断是否有某个节点得到半数以上的投票. 如果存在这样的节点, 该节点会成为准
Leader
,状态变为Leading
. 其他节点的状态变为```Following.
2. Discovery
- 发现阶段, 用于在从节点中发现最新的ZXID和事务日志. 或许有人会问: 既然
Leader
被选为主节点, 已经是集群里面数据最新的了, 为什么还要从节点中寻找最新的事务呢?
- 为了防止某些意外情况, 比如因为网络原因在上一个阶段产生多个
Leader
的情况.
Leader
接收所有Follower
发送过来各自的epoch
值, Leader从中选出最大的epoch
,基于此值+1, 生成新的epoch分发给各个Follower
.
- 各个
Follower
收到全新的epoch
之后返回ACK
给Leader
,带上各自最大的ZXID
和历史事务事务日志,Leader
从中选出最大的ZXID
, 并更新自身的历史日志.
3. Synchronization
- 同步阶段, 把
Leader
刚才收集到的最新历史事务日志, 同步给集群中所有的Follower
, 只有当半数Follower
同步成功, 这个准Leader
才能成为正式的Leader
.
- 自此故障恢复完成, 其大约需 30-120S , 期间服务注册与发现 集群是无法正常工作的.
ZAB数据写入(Broadcast)
- ZAB的数据写入涉及到
Broadcast
阶段, 简单来说, 就是Zookeeper
常规情况下更新数据的时候, 有Leader
广播到所有的Follower
. 其过程如下. (Zookeeper 数据一致性的更新方式)
- 客户端发出写入数据请求给任意的
Follower
. Follower
把写入数据请求转发给Leader
.Leader
采用二阶段提交方式, 先发送Propose
广播给Follower
.Follower
接收到Propose
消息,写入日志成功后, 返回ACK
消息给Leader
.( 类似数据库的insert
操作 )Leader
接收到半数以上的ACK
(类似Http状态码)消息, 返回成功给客户端, 并且广播Commit
请求给Follower
. (在第四步,insert
之后, 执行commit
操作,进行数据持久化)
ZAB 协议既不是强一致性也不是弱一致性, 而是处于两者之间的
单调一致性(顺序一致性)
. 它依靠事务的ID和版本号, 保证了数据的更新和读取时有序的.
Zookeeper的应用场景
1. 分布式锁
这里雅虎研究院设计Zookeeper的初衷, 利用Zookeeper的临时顺序节点可以轻松的实现 分布式锁.
2. 服务注册与发现
利用
Znode
和Watch
, 可以实现分布式服务的注册与发现. 最著名的应用就是阿里的分布式RPC
框架Dubbo .
3. 共享配置和状态信息
Redis的分布式解决方案Codis, 就利用了Zookeeper来存放数据路由表和codis-proxy节点的元信息. 同时codis-config发起的命令都会通过Zookeeper同步到各个存活的codis-proxy.
此外, kafka,Hbase,Hadoop也都依靠Zookeeper同步节点信息,实现高可用.