Bootstrap

深入浅出Nacos的原理

前言

本文来讲一讲nacos作为底层注册中心的实现原理。那么就有这几个问题?

  • 临时实例和永久实例是什么?有什么区别?

  • 服务实例是如何注册到服务端的?

  • 服务实例和服务端之间是如何保活的?

  • 服务订阅是如何实现的?

  • 集群间数据是如何同步的?CP还是AP?

  • Nacos的数据模型是什么样的?

本文就通过探讨上述问题来探秘Nacos服务注册中心核心的底层实现原理。

临时实例和永久实例

临时实例和永久实例在Nacos中是一个非常非常重要的概念

之所以说它重要,主要是因为我在读源码的时候发现,临时实例和永久实例在底层的许多实现机制是完全不同的

临时实例

临时实例在注册到注册中心之后仅仅只保存在服务端内部一个缓存中,不会持久化到磁盘

这个服务端内部的缓存在注册中心届一般被称为服务注册表

当服务实例出现异常或者下线之后,就会把这个服务实例从服务注册表中剔除

永久实例

永久服务实例不仅仅会存在服务注册表中,同时也会被持久化到磁盘文件中

当服务实例出现异常或者下线,Nacos只会将服务实例的健康状态设置为不健康,并不会对将其从服务注册表中剔除

所以这个服务实例的信息你还是可以从注册中心看到,只不过处于不健康状态

这是就是两者最最最基本的区别

当然除了上述最基本的区别之外,两者还有很多其它的区别,接下来本文还会提到

这里你可能会有一个疑问

为什么Nacos要将服务实例分为临时实例和永久实例?

主要还是因为应用场景不同

临时实例就比较适合于业务服务,服务下线之后可以不需要在注册中心中查看到

永久实例就比较适合需要运维的服务,这种服务几乎是永久存在的,比如说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服务来说,它的每个服务实例肯定都是永久的,不会出现一些是永久的,一些是临时的情况吧

所以临时还是永久的属性由服务本身决定其实就更加合理了

服务注册

作为一个服务注册中心,服务注册肯定是一个非常重要的功能

所谓的服务注册,就是通过注册中心提供的客户端SDK(或者是控制台)将服务本身的一些元信息,比如ip、端口等信息发送到注册中心服务端

服务端在接收到服务之后,会将服务的信息保存到前面提到的服务注册表中

1、1.x版本的实现

在Nacos在1.x版本的时候,服务注册是通过Http接口实现的

代码如下

整个逻辑比较简单,因为Nacos服务端本身就是用SpringBoot写的

但是在2.x版本的实现就比较复杂了

2、2.x版本的实现

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版本的服务端

2.2、具体的实现

Nacos客户端在启动的时候,会通过gRPC跟服务端建立长连接

这个连接会一直存在,之后客户端与服务端所有的通信都是基于这个长连接来的

当客户端发起注册的时候,就会通过这个长连接,将服务实例的信息发送给服务端

服务端拿到服务实例,跟1.x一样,也会存到服务注册表

除了注册之外,当注册的是临时实例时,2.x还会将服务实例信息存储到客户端中的一个缓存中,供Redo操作

所谓的Redo操作,其实就是一个补偿机制,本质是个定时任务,默认每3s执行一次

这个定时任务作用是,当客户端与服务端重新建立连接时(因为一些异常原因导致连接断开)

那么之前注册的服务实例肯定还要继续注册服务端(断开连接服务实例就会被剔除服务注册表)

所以这个Redo操作一个很重要的作用就是重连之后的重新注册的作用

除了注册之外,比如服务订阅之类的操作也需要Redo操作,当连接重新建立,之前客户端的操作都需要Redo一下

小总结

1.x版本是通过Http协议来进行服务注册的

2.x由于客户端与服务端的通信改成了gRPC长连接,所以改成通过gRPC长连接来注册

2.x比1.x多个Redo操作,当注册的服务实例是临时实例是,出现网络异常,连接重新建立之后,客户端需要将服务注册、服务订阅之类的操作进行重做

这里你可能会有个疑问

既然2.x有Redo机制保证客户端与服务端通信正常之后重新注册,那么1.x有类似的这种Redo机制么?

当然也会有,接下往下看。

心跳机制

心跳机制,也可以被称为保活机制,它的作用就是服务实例告诉注册中心我这个服务实例还活着

在正常情况下,服务关闭了,那么服务会主动向Nacos服务端发送一个服务下线的请求

Nacos服务端在接收到请求之后,会将这个服务实例从服务注册表中剔除

但是对于异常情况下,比如出现网络问题,可能导致这个注册的服务实例无法提供服务,处于不可用状态,也就是不健康

而此时在没有任何机制的情况下,服务端是无法知道这个服务处于不可用状态

所以为了避免这种情况,一些注册中心,就比如Nacos、Eureka,就会用心跳机制来判断这个服务实例是否能正常

在Nacos中,心跳机制仅仅是针对临时实例来说的,临时实例需要靠心跳机制来保活

心跳机制在1.x和2.x版本的实现也是不一样的

1.x心跳实现

在1.x中,心跳机制实现是通过客户端和服务端各存在的一个定时任务来完成的

在服务注册时,发现是临时实例,客户端会开启一个5s执行一次的定时任务

这个定时任务会构建一个Http请求,携带这个服务实例的信息,然后发送到服务端

在Nacos服务端也会开启一个定时任务,默认也是5s执行一次,去检查这些服务实例最后一次心跳的时间,也就是客户端最后一次发送Http请求的时间

  • 当最后一次心跳时间超过15s,但没有超过30s,会把这服务实例标记成不健康

  • 当最后一次心跳超过30s,直接把服务从服务注册表中剔除

这就是1.x版本的心跳机制,本质就是两个定时任务

其实1.x的这个心跳还有一个作用,就是跟上一节说的gRPC时Redo操作的作用是一样的

服务在处理心跳的时候,发现心跳携带这个服务实例的信息在注册表中没有,此时就会添加到服务注册表

所以心跳也有Redo的类似效果

2.x心跳实现

在2.x版本之后,由于通信协议改成了gRPC,客户端与服务端保持长连接,所以2.x版本之后它是利用这个gRPC长连接本身的心跳来保活

一旦这个连接断开,服务端就会认为这个连接注册的服务实例不可用,之后就会将这个服务实例从服务注册表中提出剔除

除了连接本身的心跳之外,Nacos还有服务端的一个主动检测机制

Nacos服务端也会启动一个定时任务,默认每隔3s执行一次

这个任务会去检查超过20s没有发送请求数据的连接

一旦发现有连接已经超过20s没发送请求,那么就会向这个连接对应的客户端发送一个请求

如果请求不通或者响应失败,此时服务端也会认为与客户端的这个连接异常,从而将这个客户端注册的服务实例从服务注册表中剔除

所以对于2.x版本,主要是两种机制来进行保活:

  • 连接本身的心跳机制,断开就直接剔除服务实例

  • Nacos主动检查机制,服务端会对20s没有发送数据的连接进行检查,出现异常时也会主动断开连接,剔除服务实例

小总结

心跳机制仅仅针对临时实例而言

1.x心跳机制是通过客户端和服务端两个定时任务来完成的,客户端定时上报心跳信息,服务端定时检查心跳时间,超过15s标记不健康,超过30s直接剔除

1.x心跳机制还有类似2.x的Redo作用,服务端发现心跳的服务信息不存在会,会将服务信息添加到注册表,相当于重新注册了

2.x是基于gRPC长连接本身的心跳机制和服务端的定时检查机制来的,出现异常直接剔除

健康检查

前面说了,心跳机制仅仅是临时实例用来保护的机制

而对于永久实例来说,一般来说无法主动上报心跳

就比如说MySQL实例,肯定是不会主动上报心跳到Nacos的,所以这就导致无法通过心跳机制来保活

所以针对永久实例的情况,Nacos通过一种叫健康检查的机制去判断服务实例是否活着

健康检查跟心跳机制刚好相反,心跳机制是服务实例向服务端发送请求

而所谓的健康检查就是服务端主动向服务实例发送请求,去探测服务实例是否活着

健康检查机制在1.x和2.x的实现机制是一样的

Nacos服务端在会去创建一个健康检查任务,这个任务每次执行时间间隔会在2000~7000毫秒之间

当任务触发的时候,会根据设置的健康检查的方式执行不同的逻辑,目前主要有以下三种方式:

  • TCP

  • HTTP

  • MySQL

TCP的方式就是根据服务实例的ip和端口去判断是否能连接成功,如果连接成功,就认为健康,反之就任务不健康

HTTP的方式就是向服务实例的ip和端口发送一个Http请求,请求路径是需要设置的,如果能正常请求,说明实例健康,反之就不健康

MySQL的方式是一种特殊的检查方式,他可以执行下面这条Sql来判断数据库是不是主库

默认情况下,都是通过TCP的方式来探测服务实例是否还活着

服务发现

所谓的服务发现就是指当有服务实例注册成功之后,其它服务可以发现这些服务实例

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.x服务订阅实现

在1.x版本的时候,服务订阅的处理逻辑大致会有以下三步:

第一步,客户端在启动的时候,会去构建一个叫PushReceiver的类

这个类会去创建一个UDP Socket,端口是随机的

其实通过名字就可以知道这个类的作用,就是通过UDP的方式接收服务端推送的数据的

第二步,调用NamingService#subscribe来发起订阅时,会先去服务端查询需要订阅服务的所有实例信息

之后会将所有服务实例数据存到客户端的一个内部缓存中

并且在查询的时候,会将这个UDP Socket的端口作为一个参数传到服务端

服务端接收到这个UDP端口后,后续就通过这个端口给客户端推送服务实例数据

第三步,会为这次订阅开启一个不定时执行的任务

之所以不定时,是因为这个当执行异常的时候,下次执行的时间间隔就会变长,但是最多不超过60s,正常是10s,这个10s是查询服务实例是服务端返回的

这个任务会去从服务端查询订阅的服务实例信息,然后更新内部缓存

这里你可能会有个疑问

既然有了服务变动推送的功能,为什么还要定时去查询更新服务实例信息呢?

其实很简单,那就是因为UDP通信不稳定导致的

虽然有Push,但是由于UDP通信自身的不确定性,有可能会导致客户端接收变动信息失败

所以这里就加了一个定时任务,弥补这种可能性,属于一个兜底的方案。

这就是1.x版本的服务订阅的实现

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.x是通过Http请求;2.x通过gRPC请求

服务订阅1.x是通过UDP来推送的;2.x就基于gRPC长连接来实现的

1.x和2.x客户端都有服务实例的缓存,也有定时对比机制,只不过1.x会自动开启;2.x提供了一个开个,可以手动选择是否开启,默认不开启

数据一致性

由于Nacos是支持集群模式的,所以一定会涉及到分布式系统中不可避免的数据一致性问题

1、服务实例的责任机制

再说数据一致性问题之前,先来讨论一下服务实例的责任机制

什么是服务实例的责任机制?

比如上面提到的服务注册、心跳管理、监控检查机制,当只有一个Nacos服务时,那么自然而言这个服务会去检查所有的服务实例的心跳时间,执行所有服务实例的健康检查任务

但是当出现Nacos服务出现集群时,为了平衡各Nacos服务的压力,Nacos会根据一定的规则让每个Nacos服务只管理一部分服务实例的

当然每个Nacos服务的注册表还是全部的服务实例数据

这个管理机制我给他起了一个名字,就叫做责任机制,因为我在1.x和2.x都提到了responsible这个单词

本质就是Nacos服务对哪些服务实例负有心跳监测,健康检查的责任。

2、CAP定理和BASE理论

谈到数据一致性问题,一定离不开两个著名分布式理论

  • CAP定理

  • BASE理论

CAP定理中,三个字母分别代表这些含义:

  • C,Consistency单词的缩写,代表一致性,指分布式系统中各个节点的数据保持强一致,也就是每个时刻都必须一样,不一样整个系统就不能对外提供服务

  • A,Availability单词的缩写,代表可用性,指整个分布式系统保持对外可用,即使从每个节点获取的数据可能都不一样,只要能获取到就行

  • P,Partition tolerance单词的缩写,代表分区容错性。

所谓的CAP定理,就是指在一个分布式系统中,CAP这三个指标,最多同时只能满足其中的两个,不可能三个都同时满足

为什么三者不能同时满足?

对于一个分布式系统,网络分区是一定需要满足的

而所谓分区指的是系统中的服务部署在不同的网络区域中

比如,同一套系统可能同时在北京和上海都有部署,那么他们就处于不同的网络分区,就可能出现无法互相访问的情况

当然,你也可以把所有的服务都放在一个网络分区,但是当网络出现故障时,整个系统都无法对外提供服务,那这还有什么意义呢?

所以分布式系统一定需要满足分区容错性,把系统部署在不同的区域网络中

此时只剩下了一致性和可用性,它们为什么不能同时满足?

其实答案很简单,就因为可能出现网络分区导致的通信失败。

比如说,现在出现了网络分区的问题,上图中的A网络区域和B网络区域无法相互访问

此时假设往上图中的A网络区域发送请求,将服务中的一个值 i 属性设置成 1

如果保证可用性,此时由于A和B网络不通,此时只有A中的服务修改成功,B无法修改成功,此时数据AB区域数据就不一致性,也就没有保证数据一致性

如果保证一致性,此时由于A和B网络不通,所以此时A也不能修改成功,必须修改失败,否则就会导致AB数据不一致

虽然A没修改成功,保证了数据一致性,AB还是之前相同的数据,但是此时整个系统已经没有写可用性了,无法成功写数据了。

所以从上面分析可以看出,在有分区容错性的前提下,可用性和一致性是无法同时保证的。

虽然无法同时一致性和可用性,但是能不能换种思路来思考一下这个问题

首先我们可以先保证系统的可用性,也就是先让系统能够写数据,将A区域服务中的i修改成1

之后当AB区域之间网络恢复之后,将A区域的i值复制给B区域,这样就能够保证AB区域间的数据最终是一致的了

这不就皆大欢喜了么

这种思路其实就是BASE理论的核心要点,优先保证可用性,数据最终达成一致性。

BASE理论主要是包括以下三点:

  • 基本可用(Basically Available):系统出现故障还是能够对外提供服务,不至于直接无法用了

  • 软状态(Soft State):允许各个节点的数据不一致

  • 最终一致性,(Eventually Consistent):虽然允许各个节点的数据不一致,但是在一定时间之后,各个节点的数据最终需要一致的

BASE理论其实就是妥协之后的产物。

3、Nacos的AP和CP

Nacos其实目前是同时支持AP和CP的

具体使用AP还是CP得取决于Nacos内部的具体功能,并不是有的文章说的可以通过一个配置自由切换。

就以服务注册举例来说,对于临时实例来说,Nacos会优先保证可用性,也就是AP

对于永久实例,Nacos会优先保证数据的一致性,也就是CP

接下来我们就来讲一讲Nacos的CP和AP的实现原理

3.1、Nacos的AP实现

对于AP来说,Nacos使用的是阿里自研的Distro协议

在这个协议中,每个服务端节点是一个平等的状态,每个服务端节点正常情况下数据是一样的,每个服务端节点都可以接收来自客户端的读写请求

当某个节点刚启动时,他会向集群中的某个节点发送请求,拉取所有的服务实例数据到自己的服务注册表中

这样其它客户端就可以从这个服务节点中获取到服务实例数据了

当某个服务端节点接收到注册临时服务实例的请求,不仅仅会将这个服务实例存到自身的服务注册表,同时也会向其它所有服务节点发送请求,将这个服务数据同步到其它所有节点

所以此时从任意一个节点都是可以获取到所有的服务实例数据的。

即使数据同步的过程发生异常,服务实例也成功注册到一个Nacos服务中,对外部而言,整个Nacos集群是可用的,也就达到了AP的效果

同时为了满足BASE理论,Nacos也有下面两种机制保证最终节点间数据最终是一致的:

  • 失败重试机制

  • 定时对比机制

失败重试机制是指当数据同步给其它节点失败时,会每隔3s重试一次,直到成功

定时对比机制就是指,每个Nacos服务节点会定时向所有的其它服务节点发送一些认证的请求

这个请求会告诉每个服务节点自己负责的服务实例的对应的版本号,这个版本号随着服务实例的变动就会变动

如果其它服务节点的数据的版本号跟自己的对不上,那就说明其它服务节点的数据不是最新的

此时这个Nacos服务节点就会将自己负责的服务实例数据发给不是最新数据的节点,这样就保证了每个节点的数据是一样的了。

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

这个选举算法比较复杂,完全值得另写一篇文章,这里就不细说了。不过立个flag,如果本篇文章点赞量超过28个,我连夜爆肝,再来一篇。

当有写请求时,如果请求的节点不是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节点通过一定的机制也是能够保证读到最新的数据

数据模型

在Nacos中,一个服务的确定是由三部分信息确定

  • 命名空间(Namespace):多租户隔离用的,默认是public

  • 分组(Group):这个其实可以用来做环境隔离,服务注册时可以指定服务的分组,比如是测试环境或者是开发环境,默认是DEFAULT_GROUP

  • 服务名(ServiceName):这个就不用多说了

通过上面三者就可以确定同一个服务了

在服务注册和订阅的时候,必须要指定上述三部分信息,如果不指定,Nacos就会提供默认的信息

不过,在Nacos中,在服务里面其实还是有一个集群的概念

在服务注册的时候,可以指定这个服务实例在哪个集体的集群中,默认是在DEFAULT集群下

在SpringCloud环境底下可以通过如下配置去设置

spring
  cloud:
    nacos:
      discovery:
        cluster-name: sanyoujavaCluster

在服务订阅的时候,可以指定订阅哪些集群下的服务实例

当然,也可以不指定,如果不指定话,默认就是订阅这个服务下的所有集群的服务实例

我们日常使用中可以将部署在相同区域的服务划分为同一个集群,比如杭州属于一个集群,上海属于一个集群

这样服务调用的时候,就可以优先使用同一个地区的服务了,比跨区域调用速度更快。

总结

到这,也是讲完了Nacos作为注册中心核心的实现原理,

不知道你读完整篇文章是否有所收获

不过有一点可以确定的是,能够看到这里的那一定都是真爱粉了

联系方式

关于文章中大家有任何疑问可以通过关注公众号《编程乐学》进行留言,同时,公众号还有更多有趣的项目以及关于学习编程的笔记资料大家可以看看,欢迎大家进行留言。

;