服务注册组件学习--zookeeper、eureka、ETCD
导语:什么是服务注册中心?为什么需要服务注册中心?本文从服务发现的必要性入手,并对几款应用比较广泛的服务发现组件进行学习总结,分析每个组件使用的协议算法即原理,最后总结如果我们自己搭建一个服务发现组件需要实现什么基本功能?并且在实战项目中如何选择合适的服务发现组件?
1. 为什么需要服务发现?
单服务架构向微服务架构转变时,服务间的互相发现是一个必要环节。早期微服务拆分时,可以将服务所在的ip写死在配置文件中来进行服务调用,但随着节点的增多,维护ip配置文件会很耗精力,并且当某台机器挂掉后,不能及时将ip剔除,这个时候服务发现的必要性就体现出来了,他能够自动的发现所服务所在的ip,同时也能及时剔除不可用的节点。
目前服务发现有很多优秀的开源项目可用,例如zookeeper,Eureka,Consul,Etcd等。
2. 服务发现的基本步骤
一个服务注册中心,以下基本功能要满足:
- 服务注册:服务主动向服务器提交注册请求
- 服务下线:服务主动向服务器提交下线请求
- 服务获取:调用方从注册中心获取服务信息
- 服务续约:通过心跳告知注册中心该服务可用
- 服务删除:注册中心剔除无心跳的服务
3. zookeeper讲解
3.1. zookeepeer简介
zookeeper是一个分布式应用程序协调服务,是hadoop下的一个子项目,很多分布式服务都采用zookeeper作为组件。zookeeper可以干这些事情:配置管理、名字服务、分布式同步以及集群管理。
本文将针对zookeeper的名字服务进行讲解。
3.1.1. zookeeper的基本数据结构
在了解zookeeper的基本原理前,一些基本概念需要清楚。
znode是zookeeper的基本数据结构,使用类似文件系统的树状结构进行znode管理,其中根路径以 / 开头,如下图1:
watch具有以下特性:
- 一次性。watcher一旦被触发,zk会将它移除,如果还需要监听的话,则需要反复注册,这样的设计有效的减少了服务器的压力。
- 顺序性。zk的回调是一个串行同步过程,通过zxid判断执行的先后顺序。zxid里有时间戳。
- 轻量。
watchedEvent是zk中watcher通知的最小单元,该数据结构中只包含三部分:通知状态,事件类型和节点路径。他只会告诉客户端发生了事件,但不会告诉事件的具体内容。
- 关于回调
zk的通知机制是不可靠的,因为回调过程中有可能失败,且失败后该回调消息并不会重写,具体原因可参考下面这篇文章。
https://cloud.tencent.com/developer/article/1158972
总结:zk不会提供单独的api给你让你获取服务地址,而是采用了监听机制,服务在启动时需要告诉zk我需要监听哪些服务的地址,当被监听的服务有变动时,zk进行通知。
3.4. zk的一致性保证
在介绍zk的一致性之前,需要先介绍一下zk的灵魂算法Paxos。
3.4.1. zk灵魂算法之Paxos
zk的一致性是有zab协议做的,zab协议是基于paxos算法的。
Paxos算法Lamport是1998年提出,用于解决分布式中消息传递的一致性,并拿了2013年的图领奖。该算法一经问世持续性垄断分布式中的一致性算法。
- basic paxos算法
三个角色:
Proposer: 提出提案 (Proposal)。Proposal信息包括提案编号 (Proposal ID) 和提议的值 (Value)。
Acceptor:参与决策,回应Proposers的提案。收到Proposal后可以接受提案,若Proposal获得多数Acceptors的接受,则称该Proposal被批准。
Learner:不参与决策,从Proposers/Acceptors学习最新达成一致的提案(Value)。
该算法通过一个决议需要两个阶段:
- 第一阶段:Prepare阶段。Proposer向Acceptors发出Prepare请求,Acceptors针对收到的Prepare请求进行Promise承诺。该阶段生成全局唯一的递增ID,如服务器的id+时间戳。
- 第二阶段:Accept阶段。Proposer收到多数Acceptors承诺的Promise后,向Acceptors发出Propose请求,Acceptors针对收到的Propose请求进行Accept处理。
- 第三阶段:Learn阶段。Proposer在收到多数Acceptors的Accept之后,标志着本次Accept成功,决议形成,将形成的决议发送给所有Learners。这里的多数指一半
Accept阶段做承诺时,遵循两个原则:
- 不接受比当前提议早的提案;
- 不接受比当前准备阶段提议小的提案。
请求量小的时候没什么问题,超半数同意则接受提案,但请求量上来时改算法可能会形成活锁,导致没有结果,如图:
在选举过程中,也有可能出现消息延迟,如下:
虽然S2因为消息延迟导致选错了leader,但不影响最终的leader选举,因为leader选举已经达到了法定的仲裁数量,S2认为S3是leader,因此在后续过程中发消息给S3,但S3是错误的leader,所以不会返回,达到超时时间后,S2会重新找寻leader。
注:一组服务器达到仲裁法定数量是必须条件,如果过多服务器退出,无法得到仲裁法定数量,zk也就启动不起来。
为了解决延时问题,zk会延长选举时间,例如让s2进行群首选举的时候多等待一会,那就会选出正确的leader,默认延长时间200ms,这个时间已经比预计的消息延迟时间(1ms或几ms)要长得多,相比因为选错leader最后进行重新选举,消息延迟是值得的。
3.4.4. zookeeper的一致性保证
收到一个写请求后,follower会将请求发给leader,由leader执行该事务,接下来一个服务器如何确认一个事务是否已经提交,由此引出了Zab:zookeeper原子广播协议。
通过该协议提交一个事务非常简单:
- leader收到一个事务,为该事务生成一个zxid,并向所有follower发送一个消息PROPOSAL消息p;
- follower收到提议,将提议写到自己本地磁盘中,并向leader发送ack消息表示接受该提案;
- leader收到过半的提案则提交该事务,再通知所有follower提交,follower收到后也提交事务,再对客户端进行分发。
第二步的follower应答过程前,需要做一些检查操作,检查消息是否来自群首?确认群首广播的笑嘻嘻是按照顺序执行。
zab保障了以下几个重要属性:
- 群首顺序广播了T1和T2,那么每个服务器在提交T2前保证事务事务T1已提交完成;
- 如果某个服务器按照T1,T2的顺序提交事务,所有其他的服务器也会在提交事务T2前提交事务T1。
zk的群首有可能崩溃,因此需要选举新的群首,其中zxid的高32位epoch时间错代表了管理权的变化时间,每个时间戳代表每个群首统治的时间,因此可以很容易根据epoch整理出事务的顺序,这样就算群首崩溃可能很快恢复。
当群首崩溃后,为了保证所有的事务能够继续提交,zab有以下保证:
- 一个被选举的群首必须确保提交完之前的事务才能开始广播新的事务;
- 在任何时间点,都不会出现两个被仲裁支持的群首。
为了保证第一点,老群首崩溃后,选出的新群首不会马上处于活动状态,而是先确认仲裁数量的服务器认可当前这个群首的时间戳,即新群首的事务时间戳一定是最新的。
在群首选举的时候,我们会选zxid最大的作为群首,因此不用出现follower将提议发送给leader,而是leader将提议发送给follower。
时间戳更新后,有两种方式同步follower之后的提议:
- DIFF:追随者之后群首不多,那么群首会将之后的提议打包发给追随者,追随者会按照时间戳严格接收事务点;
- SNAP:follower滞后太多,leader会将完整的快照发送给follower,虽然会增加恢复耗时,但没办法,谁让他滞后那么多。
3.5. 为什么放弃zookeeper?
在CAP(C-数据一致性;A-服务可用性;P-服务对网络分区故障的容错性,这三个特性在任何分布式系统中不能同时满足,最多同时满足两个)定理中,zk是cp的,但是他不一定能保证每次服务都是可用的,例如上面提到的监听回调失败问题,另外zk不是为高可用设计的,现在很多项目都是多地部署,或者在云上有好几个分区,但zk只能有一个leader,在一致性上会变慢,另外如果遇到网络抖动,会话会丢失,leader选举过程也很慢,30~120s左右,期间zk不可用。即没有保证服务的可用性。
4. 后起之秀Eureka&ETCD
基于zk满足不了高可用场景,因此很多团队用了其他的服务注册组件。
4.1. Eureka简介
Eureka是netfix开源的,主要用于服务注册和服务发现,由两部分组成:Eureka服务器&Eureka客户端。特点如下:
- 开源
- 可靠
- 功能齐全
- 基于 Java
- Spring Cloud 集成
4.1.1. Eureka服务注册基本流程
- 服务注册
客户端向服务器注册时,需要提供自身源数据,例如ip,port,运行状况等
- 服务续约
客户端30秒发送一次心跳保活,正常情况下,Eureka服务器90s内没有收到客户端的消息,就会从注册表中剔除
- 获取注册表
client向server获取注册表信息,并缓存到本地
- 服务下线
client在程序关闭时会发送取消请求,Eureka服务器收到后从注册表中剔除
4.1.2. Eureka架构简介
- Eureka Server: 通过 Register、Get、Renew 等接口提供服务注册和发现功能
- Application Service: 服务提供方,把自身服务实例注册到 Eureka Server
- Application Client: 服务调用方,通过 Eureka Server 获取服务实例,并调用 Application Service
eureka没有leader,他采用p2p这种无中心网络架构,各点之间互相连接,互相通知,如下:
处理流程:
针对多地部署,每个分区都会至少有一个Eureka server,各个server之间进行数据同步
- 每个区域有一个 Eureka Cluster,每个区域的可用区中至少有一个 Eureka Server。
- Application Service 作为 Eureka Client,通过 register 注册到 Eureka Server,并通过发送心跳的方式进行续约,如果 90 秒内没有进行续约,Eureka Server 将会移除该 service 实例。
- 当一个 Eureka Server 收到数据或数据发生改变后,会将自己的数据同步到其它 Eureka Server 中。
- Application Client 也作为 Eureka Client 通过 get 接口从 Eureka Server 中获取 Application Service 实例信息。
- Application Client 通过模块内的负载均衡机制调用 Application Service,同时可以跨区域调用。
问题:
- server一致性如何保障&节点故障如何处理?
- 集群内的负载均衡策略?
4.1.3. Eureka的一致性
Eureka采用对等通信(p2p),所以没有master/slave之分。Eureka是弱一致性的,即CAP中,Eureka采用了AP。
Eureka的p2p模式,任何server都可以接受数据并进行写操作,然后点对点进行数据互相更新。
- 数据冲突
节点中相互复制和更新,如何保证数据不被写乱呢?先看一下注册表中服务实例的信息:
@JsonProperty("instanceId") String instanceId, |
---|
其中,lastDirtyTimestamp表示该server数据最近的更新时间
- if server1.lastUpdateTimestamp>server2.lastUpdateTimestamp,server2同步server1的数据;
- if server1.lastUpdateTimestamp<server2.lastUpdateTimestamp,server1同步server2的数据;
但server1和server2中的脏数据如何解决?
心跳保活,Eureka会每30秒发送一次心跳,发送心跳的时候就会知道是否要增加节点,没心跳90s内也会剔除掉无用节点,所以Eureka的数据一致性无法做到强一致。
4.1.4. Eureka负载均衡
Eureka使用Ribbon进行负载均衡,Ribbon 是一个服务调用的组件,并且是一个客户端实现负载均衡处理的组件。
策略 |
说明 |
---|---|
RoundRobinRule 轮询策略 |
默认值,启动的服务被循环访问 |
RandomRule 随机选择 |
随机从服务器列表中选择一个访问 |
BestAvailableRule 最大可用策略 |
先过滤出故障服务器,再选择一个当前并发请求数最小的服务 |
WeightedResponseTimeRule 带有加权的轮询策略 |
对各个服务器响应时间进行加权处理,再采用轮询的方式获取相应的服务器 |
AvailabilityFilteringRule 可用过滤策略 |
先过滤出故障的或并发请求大于阈值的服务实例,再以线性轮询的方式从过滤后的实例清单中选出一个 |
ZoneAvoidanceRule 区域感知策略 |
先使用主过滤条件(区域负载器,选择最优区域)对所有实例过滤并返回过滤后的实例清单,依次使用次过滤条件列表中的过滤条件对主过滤条件的结果进行过滤,判断最小过滤数(默认1)和最小过滤百分比(默认0),最后对满足条件的服务器则使用RoundRobinRule(轮询方式)选择一个服务器实例 |
策略均比较简单,源码在ribbon的ribbon-loadbalancer下,可以自行查看。具体使用哪个策略可以在配置文件中进行配置。
4.2. etcd简介
etcd是CoreOS2013年6月的开源项目,使用go语言编写,是一个高度一致的key-value存储,本质就是一个key-value存储组件。其应用都是针对键值存储的功能展开,应用场景较多的是服务注册和发现。
特点如下:
- 采用kv型数据存储
- 支持动态存储(内存)及静态存储(硬盘)
- 分布式存储
- 存储方式跟zk类似,是目录结构,只有叶子节点才能存储数据,且是文件,父节点一定是目录
能力:
- 提供存储&获取数据的api,通过raft协议保证节点之间的强一致行,用于共享信息;
- 提供监听机制,客户端可以监听某个或多个key的变更;
- 提供key的过期及续约,及服务的注册及下线,以及心跳的监听;
4.2.1. etcd的leader选举
类似zk,服务器也有状态:
- leader:执行客户端的写,一个任期只有一个
- follower:完全被动的选民,只读
- candidate:候选人,可以被选举为新leader
状态流转如下:
- etcd启动时,节点默认进入follower状态,follower只响应其他服务,如果未收到任何请求,则变为candidate参加选举,candidate获得及群众的大多数选票就变为leader
- leader故障则会结束任期,重新进行leader选举,即如果集群不故障,leader的任期将一直下去
- leader会定期给所有follower发心跳证明自己还活着
- 某个节点满足以下3个中的任一个条件即结束candidate的状态:
- 该节点赢得选举,变为leader;
- 其他节点变为leader;
- 本次选举没有leader产生;
投票过程:
- 选candidate的过程
如果follower节点没有收到任何通知消息,则该节点会投自己一票,让自己变为candidate。
- 选leader的过程
每个follower节点在某个任期内只能向candidate投出一张选票,当该candidate获得过半选票则成为leader。一个candidate节点在等待选举的过程中可能出现以下情况:
- 在candidate等待选票的时候,他有可能收到某个leader发来的消息,若candidate的任期号小于leader的任期号,则承认该leader,并且状态变为follower;
- 等待选票时,leader发来消息,若candidate的任期号大于leader任期号,则拒绝leader请求,且维持candidate状态不变
- 在candidate跟leader pk的过程中,不输不赢,许多follower变为candidate,瓜分了选票,以至于没有任何一个candidate赢得选举,那么此时任期号加一,重新进行新一轮选举;
针对candidate的第三种情况,处理办法如下:
raft设置了一个选举随机超时区间,比如说150ms~300ms,当follower成为candidate后,在该区间内随机设置一个超时时间,该时间段内未赢得选举,则切换成为follower状态重新开始选举,这样就避免了选票被瓜分的情况,并且在实际应用中,该算法也能够快速的选举出一个leader。
该随机算法的可用性官方是有做保障的。起初该团队设计的方案是为每个candidate设置一个唯一的排名值,以便在竞争候选人时进行抉择。如果一个候选人发现另一个候选人的排名比自己高,那么该候选人就变为follower,但在实验过程中,这种方法的可用性不好,怎么调整都有问题,所以最后用了这种随机算法。
4.2.2. 日志复制
日志复制中的名词解释:
- log entry:一个日志条目,该条目中包含以下信息:任期号、条目值
- log index:每个条目会有一个索引值,记录被更新的顺序。
leader选举出来后,整个系统开始进入工作状态,客户端的写请求都会给到leader,由leader进行请求的写入和请求的提交,这里的请求即是日志,流程如下:
- client要求写入一个log entry;
- leader通过心跳,并行告诉所有服务器这条log entry
- leader等待过半的服务器相应
- 收到响应后,leader将该日志应用(apply)到自己(即条目写入日志)
- leader通知client success
- leader通知所有follower应用该日志。
以上其实就是数据同步的过程,当然第6步的应用有可能失败,所以raft并不是强一致性,但他会保证最终一致性。因为leader会持续的发送心跳包让follower同步自己的状态机。从这里就能看到etcd的心跳是整个系统的基石。
另外再说下log replication中的安全性,首先要知道raft中commit和apply的区别,commit是上述第4步之前,apply才是将条目真正的写入日志,raft确保在apply后,日志不会回滚,这是该协议所保障的安全性,为了这个安全性,raft有以下保证:
- 在不同log中的两个条目,如果log index相同且log term相同,那么存储了相同的命令;
- 在不同log中的两个条目,如果log index相同且log term相同,那么该index前所有的条目都一致。
第一个特性基于以下进行保证:leader在每个任期内,保证一个log index只创建一个log entry,可以理解为联合主键
第二个特性由每次收到心跳包后的一致性检查所保证,leader将新的条目附加在日志中发给follower,如果follower该term下index的条目,即不一致,就会拒绝leader新添加的这个entry。在不出现异常的情况下,一致性会很容易保障,我们看下异常的情况。
异常产生:leader crash或者follower crash,都会造成日志的不同步,不一致会加速一系列的崩溃,如下图:
a~表示了follower6中可能的状态,a和b是少了条目,c和d是多了条目,e和f出现了条目跟leader不一致,e和f可能是因为以下情况出现:server是一个leader,在commit前宕机,重启后又重新选为leader,加了些条目,commit前又crash。
针对不一致的情况,leader会强行让follower跟自己一致,即follower的数据会被覆盖。如何强行一致呢?leader会使用一个nextIndex来跟follower维持同步,举个栗子:
- server成为leader,且该server中已经有log,初始化nextIndex为自己日志log index+1;
- 心跳包发送给follower,follower比较leader_term[nextIndex-1]是否等于myself[nextIndex-1];
- 如果不等于,follower返回false
- leader的nextIndex-1,重复步骤2,直到出现相同的term
- leader同步nextIndex后的数据给该follower
以上是保证最终一致性的过程,raft演示动画可以更好的帮助理解http://www.kailing.pub/raft/index.html
,不过我还是建议直接读raft的论文,这样理解的更清楚。
5. 总结
介绍完三个组件,总结下如果我们设计一个服务注册中心,该如何设计?
1. 该系统须实现基本的服务注册、服务下线、心跳保活、服务剔除,服务信息修改功能;
2. 需要选择合适的一致性算法,保证集群内部数据的一致性;
3. 需要选择一个负载均衡算法来将请求均衡到服务注册中心的集群上。
再升华一下,对比几个服务注册组件,服务发现与注册的流程其实就是如何协调一个分布式系统,在分布式集群中如何保持数据的一致性,如何在分布式集群中做负载均衡,至于服务注册组件的选型,主要还是看业务方的需求,例如:
- 是否要求数据的强一致性?如果只是为了最终一致性,eureka和etcd都是很好的选择;
- cap定理中,希望满足哪两部分?cp就是zk,ap则eureka和etcd都可;
- 业务方服务开发的生态,如果是spring,那么eureka可能是个好选择,go的话etcd就是首选。
跳出服务注册功能来看这三个组件,其实就是分布式系统中,如何做数据的一致性,如何节点保活等。一致性主要还是看用什么协议,目前分布式中主流的一致性协议无非就是paxos的一系列变种和raft,如果想要一致性强的,那就用paxos,想要可用性搞高的就选raft。
参考:
zookeeper:
《zookeeper分布式过程协同技术详解》
https://www.jianshu.com/p/c68b6b241943
https://juejin.cn/post/6844903684610981895
https://cloud.tencent.com/developer/article/1158972
https://zhuanlan.zhihu.com/p/31780743
Eureka:
整体架构:https://www.infoq.cn/article/emmw80joe8l0v4qaaizt
一致性:https://cloud.tencent.com/developer/article/1083131
负载均衡:https://www.jianshu.com/p/c15e80deb5e8
etcd:
etcd系列:https://cloud.tencent.com/developer/article/1630711
raft协议论文:https://raft.github.io/raft.pdf