不同版本Nacos原理之临时/永久实例,注册服务,心跳保活,服务发现,责任机制
1 Nacos原理
1.1 临时实例和永久实例
临时实例
和永久实例
在Nacos
中是一个非常非常重要的概念,之所以说它重要,主要是因为在读源码的时候发现,临时实例
和永久实例
在底层的许多实现机制是完全不同的
1.1.1 临时实例
临时实例
在注册到注册中心之后仅仅只保存在服务端内部一个缓存中,不会持久化到磁盘
这个服务端内部的缓存在注册中心一般被称为服务注册表
当服务实例出现异常或者下线之后,就会把这个服务实例从服务注册表中剔除
1.1.2 永久实例
永久服务实例
不仅仅会存在服务注册表中,同时也会被持久化到磁盘文件中
当服务实例出现异常或者下线,Nacos
只会将服务实例的健康状态设置为不健康,并不会对将其从服务注册表中剔除
所以这个服务实例的信息还是可以从注册中心看到,只不过处于不健康状态
这是就是两者最最最基本的区别
1.1.3 应用场景
临时实例
就比较适合于业务服务,服务下线之后可以不需要在注册中心中查看到
永久实例
就比较适合需要运维的服务,这种服务几乎是永久存在的,比如说MySQL、Redis
等等
MySQL、Redis
等服务实例可以通过SDK手动注册
对于这些服务,我们需要一直看到服务实例的状态,即使出现异常,也需要能够查看时实的状态
所以从这可以看出Nacos
跟印象中的注册中心不太一样,不仅仅可以注册平时业务中的实例,还可以注册像MySQL、Redis
这个服务实例的信息到注册中心
在SpringCloud
环境底下,一般其实都是业务服务,所以默认注册服务实例都是临时实例
当然如果你想改成永久实例,可以通过下面这个配置项来完成
spring
cloud:
nacos:
discovery:
#ephemeral单词是临时的意思,设置成false,就是永久实例了
ephemeral: false
注意
:在1.x
版本中,一个服务中可以既有临时实例也有永久实例,服务实例是永久还是临时是由服务实例本身决定的,但是2.x
版本中,一个服务中的所有实例要么都是临时的要么都是永久的,是由服务决定的,而不是具体的服务实例,所以在2.x
可以说是临时服务和永久服务
那么为什么2.x
把临时还是永久的属性由实例本身决定改成了由服务决定
其实很简单,假设对一个
MySQL
服务来说,它的每个服务实例肯定都是永久的,不会出现一些是永久的,一些是临时的情况
所以临时还是永久的属性由服务本身决定其实就更加合理了
1.2 服务注册
作为一个服务注册中心,服务注册肯定是一个非常重要的功能
所谓的服务注册,就是通过注册中心提供的客户端 SDK
(或者是控制台)将服务本身的一些元信息,比如ip、端口等信息发送到注册中心服务端
服务端在接收到服务之后,会将服务的信息保存到前面提到的服务注册表中
1.2.1 1.x版本的实现
在Nacos
在1.x
版本的时候,服务注册是通过Http
接口实现的
代码如下
public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
NAMING_LOGGER.info("[REGISTER-SERVICE]{} registering service {} with instance: {}", namespaceId, serviceName,instance);
final Map<String,String> params = new HashMap<>( initialCapacity:16);
params.put(CommonParams.NAMESPACE_ID,namespaceld);
params.put(CommonParams.SERVICE_NAME,serviceName);
params.put(CommonParams.GROUP_NAME, groupName);
params.put(CommonParams.CLUSTER_NAME,instance.getClusterName());
params.put("ip",instance.getIp());
params .put("port",String.value0f(instance.getPort()));
params.put("weight",String.value0f(instance.getWeight()));
params.put("enable",String.value0f(instance.isEnabled()));
params.put("healthy",String.value0f(instance.isHealthy()));
params .put("ephemeral",String.value0f(instance.isEphemeral()));
params.put("metadata",JacksonUtils.toJson(instance.getMetadata()));
reqApi(UtilAndComs.nacosUrlInstance, params, HttpMethod.POST);
}
整个逻辑比较简单,因为Nacos
服务端本身就是用SpringBoot
写的
但是在2.x版本的实现就比较复杂了
1.2.2 2.x版本的实现
1.2.2.1 通信协议的改变
2.x
版本相比于1.x
版本最主要的升级就是客户端和服务端通信协议的改变,由1.x
版本的Http
改成了2.x
版本gRPC
gRPC
是谷歌公司开发的一个高性能、开源和通用的RPC
框架,Java
版本的实现底层也是基于Netty
来的
之所以改成了gRPC
,主要是因为Http
请求会频繁创建和销毁连接,白白浪费资源
所以在2.x
版本之后,为了提升性能,就将通信协议改成了gRPC
根据官网显示,整体的效果还是很明显,相比于1.x
版本,注册性能总体提升至少2倍
虽然通信方式改成了gRPC
,但是2.x
版本服务端依然保留了Http
注册的接口,所以用1.x
的Nacos SDK
依然可以注册到2.x
版本的服务端
1.2.2.2 具体的实现
Nacos
客户端在启动的时候,会通过gRPC
跟服务端建立长连接
这个连接会一直存在,之后客户端与服务端所有的通信都是基于这个长连接来的
当客户端发起注册的时候,就会通过这个长连接,将服务实例的信息发送给服务端
服务端拿到服务实例,跟1.x
一样,也会存到服务注册表
除了注册之外,当注册的是临时实例时,2.x
还会将服务实例信息存储到客户端中的一个缓存中,供Redo
操作
所谓的
Redo
操作,其实就是一个补偿机制
,本质是个定时任务,默认每3s
执行一次
这个定时任务作用是,当客户端与服务端重新建立连接时(因为一些异常原因导致连接断开),那么之前注册的服务实例肯定还要继续注册服务端(断开连接服务实例就会被剔除服务注册表)
所以这个Redo
操作一个很重要的作用就是重连之后的重新注册
的作用
除了注册之外,比如服务订阅之类的操作也需要Redo
操作,当连接重新建立,之前客户端的操作都需要Redo
一下
1.2.3 服务注册总结
总结:
1.x
:是通过Http
协议来进行服务注册的2.x
:由于客户端与服务端的通信改成了gRPC
长连接,所以改成通过gRPC
长连接来注册2.x
比1.x
多个Redo
操作,当注册的服务实例是临时实例是,出现网络异常,连接重新建立之后,客户端需要将服务注册、服务订阅之类的操作进行重做
这里你可能会有个疑问
既然2.x有Redo机制
保证客户端与服务端通信正常之后重新注册,那么1.x
有类似的这种Redo机制
么?
当然也会有,接下往下看
1.3 心跳机制
心跳机制
,也可以被称为保活机制
,它的作用就是服务实例告诉注册中心这个服务实例还活着
在正常情况下,服务关闭了,那么服务会主动向Nacos
服务端发送一个服务下线的请求,Nacos
服务端在接收到请求之后,会将这个服务实例从服务注册表中剔除
但是对于异常情况下,比如出现网络问题,可能导致这个注册的服务实例无法提供服务,处于不可用状态,也就是不健康,而此时在没有任何机制的情况下,服务端是无法知道这个服务处于不可用状态
所以为了避免这种情况,一些注册中心,就比如Nacos、Eureka
,就会用心跳机制来判断这个服务实例是否能正常
在Nacos
中,心跳机制仅仅是针对临时实例
来说的,临时实例
需要靠心跳机制
来保活
心跳机制
在1.x
和2.x
版本的实现也是不一样的
1.3.1 1.x心跳实现
在1.x
中,心跳机制实现是通过客户端和服务端各存在的一个定时任务来完成的
在服务注册时,发现是临时实例
,客户端会开启一个5s
执行一次的定时任务
这个定时任务会构建一个Http
请求,携带这个服务实例的信息,然后发送到服务端
在Nacos
服务端也会开启一个定时任务,默认也是5s
执行一次,去检查这些服务实例最后一次心跳的时间,也就是客户端最后一次发送Http
请求的时间
- 当最后一次心跳时间超过
15s
,但没有超过30s
,会把这服务实例标记成不健康 - 当最后一次心跳超过
30s
,直接把服务从服务注册表中剔除
这就是1.x
版本的心跳机制,本质就是两个定时任务,其实1.x
的这个心跳还有一个作用,就是跟上面说的gRPC
时Redo
操作的作用是一样的
服务在处理心跳的时候,发现心跳携带这个服务实例的信息在注册表中没有,此时就会添加到服务注册表
所以心跳也有Redo
的类似效果
1.3.2 2.x心跳实现
在2.x
版本之后,由于通信协议改成了gRPC
,客户端与服务端保持长连接,所以2.x
版本之后它是利用这个gRPC
长连接本身的心跳来保活,一旦这个连接断开,服务端就会认为这个连接注册的服务实例不可用,之后就会将这个服务实例从服务注册表中剔除
除了连接本身的心跳之外,Nacos
还有服务端的一个主动检测机制
Nacos
服务端也会启动一个定时任务,默认每隔3s
执行一次,这个任务会去检查超过20s
没有发送请求数据的连接,一旦发现有连接已经超过20s
没发送请求,那么就会向这个连接对应的客户端发送一个请求,如果请求不通或者响应失败,此时服务端也会认为与客户端的这个连接异常,从而将这个客户端注册的服务实例从服务注册表中剔除
所以对于2.x
版本,主要是两种机制来进行保活:
- 连接本身的
心跳机制
,断开就直接剔除服务实例 Nacos
主动检查机制
,服务端会对20s
没有发送数据的连接进行检查,出现异常时也会主动断开连接,剔除服务实例
1.3.3 心跳机制总结
总结:
- 心跳机制仅仅针对
临时实例
而言 1.x
心跳机制是通过客户端和服务端两个定时任务来完成的,客户端定时上报心跳信息,服务端定时检查心跳时间,超过15s
标记不健康,超过30s
直接剔除1.x
心跳机制还有类似2.x
的Redo
作用,服务端发现心跳的服务信息不存在会,会将服务信息添加到注册表,相当于重新注册了2.x
是基于gRPC
长连接本身的心跳机制
和服务端的定时检查机制
来的,出现异常直接剔除
1.4 健康检查
上面说了,心跳机制
仅仅是临时实例
用来保护的机制,而对于永久实例
来说,一般来说无法主动上报心跳,就比如说MySQL
实例,肯定是不会主动上报心跳到Nacos
的,所以这就导致无法通过心跳机制来保活
所以针对永久实例
的情况,Nacos
通过一种叫健康检查的机制
去判断服务实例是否活着
健康检查
跟心跳机制
刚好相反,心跳机制
是服务实例
向服务端
发送请求,而所谓的健康检查就是服务端
主动向服务实例
发送请求,去探测服务实例是否活着
健康检查机制在1.x和2.x
的实现机制是一样的
Nacos
服务端在会去创建一个健康检查任务,这个任务每次执行时间间隔会在2000~7000
毫秒之间
当任务触发的时候,会根据设置的健康检查的方式执行不同的逻辑,目前主要有三种方式:TCP
,HTTP
,MySQL
TCP
的方式就是根据服务实例的ip和端口
去判断是否能连接成功,如果连接成功,就认为健康,反之就任务不健康HTTP
的方式就是向服务实例的ip和端口
发送一个Http
请求,请求路径是需要设置的,如果能正常请求,说明实例健康,反之就不健康MySQL
的方式是一种特殊的检查方式,他可以执行下面这条Sql
来判断数据库是不是主库
默认情况下,都是通过TCP
的方式来探测服务实例是否还活着
1.5 服务发现
1.5.1 简介
服务发现就是指当有服务实例注册成功之后,其它服务可以发现这些服务实例
Nacos
提供了两种发现方式:
- 主动查询:指客户端主动向服务端查询需要关注的服务实例,也就是
拉(pull)
的模式 - 服务订阅:指客户端向服务端发送一个订阅服务的请求,当被订阅的服务有信息变动就会主动将服务实例的信息推送给订阅的客户端,本质就是
推(push)
模式
在我们平时使用时,一般来说都是选择使用订阅的方式,这样一旦有服务实例数据的变动,客户端能够第一时间感知,并且Nacos
在整合SpringCloud
的时候,默认就是使用订阅的方式
对于这两种服务发现方式,1.x和2.x
版本实现也是不一样,服务查询其实两者实现都很简单
1.x
整体就是发送Http
请求去查询服务实例,2.x
只不过是将Http
请求换成了gRPC
的请求
服务端对于查询的处理过程都是一样的,从服务注册表中查出符合查询条件的服务实例进行返回,不过对于服务订阅,两者的机制就稍微复杂一点
在Nacos
客户端,不论是1.x还是2.x
都是通过SDK
中的NamingService#subscribe
方法来发起订阅的
当有服务实例数据变动的时,客户端就会回调EventListener
,就可以拿到最新的服务实例数据了
虽然1.x还是2.x都是同样的方法,但是具体的实现逻辑是不一样的
1.5.2 1.x服务订阅实现
在1.x
版本的时候,服务订阅的处理逻辑大致会有以下三步:
- 第一步,客户端在启动的时候,会去构建一个叫
PushReceiver
的类,这个类会去创建一个UDP Socket
,端口是随机的
其实通过名字就可以知道这个类的作用,就是通过UDP
的方式接收服务端推送的数据的 - 第二步,调用
NamingService#subscribe
来发起订阅时,会先去服务端查询需要订阅服务的所有实例信息,之后会将所有服务实例数据存到客户端的一个内部缓存中
并且在查询的时候,会将这个UDP Socket
的端口作为一个参数传到服务端,服务端接收到这个UDP
端口后,后续就通过这个端口给客户端推送服务实例数据 - 第三步,会为这次订阅开启一个不定时执行的任务,这个任务会去从服务端查询订阅的服务实例信息,然后更新内部缓存
之所以不定时,是因为这个当执行异常的时候,下次执行的时间间隔就会变长,但是最多不超过60s
,正常是10s
,这个10s
是查询服务实例是服务端返回的
那么既然有了服务变动推送的功能,为什么还要定时去查询更新服务实例信息呢?
其实很简单,那就是因为
UDP通信不稳定
导致的
虽然有Push
,但是由于UDP
通信自身的不确定性,有可能会导致客户端接收变动信息失败
所以这里就加了一个定时任务,弥补这种可能性,属于一个兜底的方案。
1.x
版本的服务订阅的实现
1.5.3 2.x服务订阅的实现
讲完1.x
的版本实现,接下来就讲一讲2.x
版本的实现
由于2.x
版本换成了gRPC
长连接的方式,所以2.x
版本服务数据变更推送已经完全抛弃了1.x
的UDP
做法,当有服务实例变动的时候,服务端直接通过这个长连接将服务信息发送给客户端
客户端拿到最新服务实例数据之后的处理方式就跟1.x
是一样了
除了处理方式一样,2.x
也继承了1.x
的其他的东西:
- 客户端依然会有服务实例的缓存
- 定时对比机制也保留了,只不过这个定时对比的机制默认是
关闭状态
之所以默认关闭,主要还是因为长连接还是比较稳定的原因
当客户端出现异常,接收不到请求,那么服务端会直接跟客户端断开连接,当恢复正常,由于有Redo
操作,所以还是能拿到最新的实例信息的
所以2.x
版本的服务订阅功能的实现大致如下图所示
注意
:
在1.x
版本的时候,任何服务都是可以被订阅的
但在2.x
版本中,只支持订阅临时服务,对于永久服务,已经不支持订阅了
1.5.4 服务发现总结
总结:
- 服务查询
1.x
是通过Http
请求;2.x
通过gRPC
请求 - 服务订阅
1.x
是通过UDP
来推送的;2.x
就基于gRPC
长连接来实现的 1.x
和2.x
客户端都有服务实例的缓存,也有定时对比机制,只不过1.x
会自动开启;2.x
提供了一个开关,可以手动选择是否开启,默认不开启
1.6 数据一致性
由于Nacos
是支持集群模式的,所以一定会涉及到分布式系统中不可避免的数据一致性问题
1.6.1 服务实例的责任机制
再说数据一致性问题之前,先来讨论一下服务实例的责任机制
什么是服务实例的责任机制?
上面提到的服务注册
、心跳管理
、监控检查机制
,当只有一个Nacos
服务时,那么自然而言这个服务会去检查所有的服务实例的心跳时间,执行所有服务实例的健康检查任务
但是当出现Nacos
服务出现集群时,为了平衡各Nacos
服务的压力,Nacos
会根据一定的规则让每个Nacos
服务只管理一部分服务实例的,当然每个Nacos
服务的注册表还是全部的服务实例数据
这个管理机制我给他起了一个名字,就叫做责任机制,因为在1.x和2.x
都提到了responsible
这个单词
本质就是Nacos
服务对哪些服务实例负有心跳监测,健康检查的责任。
1.6.2 CAP定理和BASE理论
1.6.3 Nacos的AP和CP
Nacos
其实目前是同时支持AP
和CP
的
具体使用AP
还是CP
得取决于Nacos
内部的具体功能,并不是有的文章说的可以通过一个配置自由切换。
- 对于
临时实例
,Nacos
会优先保证可用性,也就是AP
- 对于
永久实例
,Nacos
会优先保证数据的一致性,也就是CP
接下来我们就来讲一讲Nacos的CP和AP的实现原理
1.6.3.1 Nacos的AP实现
对于AP
来说,Nacos
使用的是阿里自研的Distro
协议
在这个协议中,每个服务端节点是一个平等的状态,每个服务端节点正常情况下数据是一样的,每个服务端节点都可以接收来自客户端的读写请求
当某个节点刚启动时,他会向集群中的某个节点发送请求,拉取所有的服务实例数据到自己的服务注册表中
这样其它客户端就可以从这个服务节点中获取到服务实例数据了
当某个服务端节点接收到注册临时服务实例的请求,不仅仅会将这个服务实例存到自身的服务注册表,同时也会向其它所有服务节点发送请求,将这个服务数据同步到其它所有节点
所以此时从任意一个节点都是可以获取到所有的服务实例数据的。
即使数据同步的过程发生异常,服务实例也成功注册到一个Nacos
服务中,对外部而言,整个Nacos
集群是可用的,也就达到了AP
的效果
同时为了满足BASE
理论,Nacos
也有下面两种机制保证最终节点间数据最终是一致的:
- 失败重试机制:指当数据同步给其它节点失败时,会每隔
3s
重试一次,直到成功 - 定时对比机制:指每个
Nacos
服务节点会定时向所有的其它服务节点发送一些认证的请求,这个请求会告诉每个服务节点自己负责的服务实例的对应的版本号,这个版本号随着服务实例的变动就会变动
如果其它服务节点的数据的版本号跟自己的对不上,那就说明其它服务节点的数据不是最新的
此时这个Nacos
服务节点就会将自己负责的服务实例数据发给不是最新数据的节点,这样就保证了每个节点的数据是一样的了。
1.6.3.2 Nacos的CP实现
Nacos
的CP
实现是基于Raft
算法来实现的
在1.x
版本早期,Nacos
是自己手动实现Raft
算法
在2.x
版本,Nacos
移除了手动实现Raft
算法,转而拥抱基于蚂蚁开源的JRaft
框架
在Raft
算法,每个节点主要有三个状态:
Leader
:负责所有的读写请求,一个集群只有一个Follower
:从节点,主要是负责复制Leader
的数据,保证数据的一致性Candidate
:候选节点,最终会变成Leader
或者Follower
集群启动时都是节点Follower
,经过一段时间会转换成Candidate
状态,再经过一系列复杂的选择算法,选出一个Leader
当有写请求时,如果请求的节点不是Leader
节点时,会将请求转给Leader
节点,由Leader
节点处理写请求
比如,有个客户端连到的上图中的Nacos
服务2节点,之后向Nacos
服务2注册服务,Nacos
服务2接收到请求之后,会判断自己是不是Leader
节点,发现自己不是,此时Nacos
服务2就会向Leader
节点发送请求,Leader
节点接收到请求之后,会处理服务注册的过程
为什么说Raft
是保证CP
的呢?
主要是因为Raft
在处理写的时候有一个判断过程
- 首先,
Leader
在处理写请求时,不会直接把数据应用到自己的系统,而是先向所有的Follower
发送请求,让他们先处理这个请求 - 当超过半数的
Follower
成功处理了这个写请求之后,Leader
才会写数据,并返回给客户端请求处理成功
如果超过一定时间未收到超过半数处理成功Follower
的信号,此时Leader
认为这次写数据是失败的,就不会处理写请求,直接返回给客户端请求失败
所以,一旦发生故障,导致接收不到半数的Follower
写成功的响应,整个集群就直接写失败,这就很符合CP
的概念了。
注意
:Nacos
在处理查询服务实例的直接请求时,并不会将请求转发给Leader
节点处理,而是直接查当前Nacos
服务实例的注册表
如果客户端查询的Follower
节点没有及时处理Leader
同步过来的写请求(过半响应的节点中不包括这个节点),此时在这个Follower
其实是查不到最新的数据的,这就会导致数据的不一致
所以说,虽然Raft
协议规定要求从Leader
节点查最新的数据,但是Nacos
至少在读服务实例数据时并没有遵守这个协议
当然对于其它的一些数据的读写请求有的还是遵守了这个协议。
JRaft
对于读请求其实是做了很多优化的,其实从Follower
节点通过一定的机制也是能够保证读到最新的数据