你应该了解的Nacos注册中心

背景

前段时间有新闻报道,国外HashiCorp在官网宣布:不允许中国境内使用、部署和安装该企业旗下的企业版产品和软件。

其中Consul是Java的spring cloud开发者非常熟悉的一个服务发现和配置中心的中间件,很多人担心是否Consul会受到影响,目前来看HashiCorp只是对商业版进行了禁止使用,还没有对开源版本进行限制,所以使用Consul的小伙伴不用担心。但是随着时间的发展,不同地区的对抗会不断的升级,说不定有一天开源的版本会被也会被宣布禁用,所以我们需要知道如何去替代Consul。

在2008年的时候,那个时候zk还没出来,阿里巴巴当时内部需要做服务发现,于是自研了ConfigServer,过了十年,在2018年7月的时候,阿里发布了Nacos(ConfigServer开源实现)0.1.0版本,到现在快两年了已经到了1.3.0版本,现在已经可以支持很多功能了:

  • 服务注册和发现:nacos和很多rpc框架已经做了集成,比如dubbo,SpringCloud等,方便我们拿来即用,同时也开放了比较简易的api方便我们对自己的rpc进行定制。
  • 配置管理:类似apllo的一个配置管理中心,让我们不用把配置写在文件中了,在后台进行统一的管理。
  • 地址服务器: 方便我们对不同环境不同隔离场景的nacos进行寻址。
  • 安全与稳定性: 性能监控,加密传输,权限控制管理等等。

对于nacos的来说,最大的核心功能就是服务注册和配置管理,我的文章主要也是介绍这两大模块,这篇文章主要是介绍Nacos服务发现-注册相关的一些使用,原理以及对比其他的一些优化。

基本概念

首先我们来看看再Nacos中服务发现-注册的一些基本概念:

  • 命名空间(namespace):命名空间属于Nacos顶层的结构,用于进行租户级别的隔离,我们最常用的就是不同环境比如测试环境,线上环境进行隔离。
  • 服务(Service):服务的概念就和我们平常的微服务一一对应,比如订单服务,物流服务等等。一个命名空间下可以有多个Service,不同的命名空间可以有相同的Service,比如测试环境和线上环境都可以有订单服务。
  • 虚拟集群:一个服务下所有的机器组成一个集群,在Nacos中还可以对集群根据需要进行进一步划分成虚拟集群。
  • 实例:粗略一点理解就是一台机器或者一个虚拟机就是一个实例,细粒一点理解就是一个或多个服务的具有可访问网络地址(IP:Port)的进程。

上面是Nacos官网文档中给出的服务领域模型图,从图上我们可以知道层级关系的属于是:服务-集群-实例,同时在服务,集群和实例中都会保存一些数据用作其他的需求。

其实一说到服务注册很多人首先会想到Zookeeper,其实ZK并没有直接提供服务注册订阅的功能,在ZK中要实现这些功能,你必须要自己一个一个的去划分文件目录,非常不方便,并且ZK的Api也特别难用,对于Nacos来说服务注册的Api使用如下:

代码语言:javascript
复制
        Properties properties = new Properties();
        properties.setProperty("serverAddr", System.getProperty("serverAddr"));
        properties.setProperty("namespace", System.getProperty("namespace"));
    NamingService naming = NamingFactory.createNamingService(properties);

    naming.registerInstance("microservice-mmp-marketing", "11.11.11.11", 8888, "TEST1");

    naming.subscribe("microservice-mmp-marketing", new EventListener() {
        @Override
        public void onEvent(Event event) {
            System.out.println(((NamingEvent)event).getServiceName());
            System.out.println(((NamingEvent)event).getInstances());
        }
    });

我们只需要创建一个NamingService,然后调用registerInstance方法和subscribe方法就可以完成我们服务的注册和订阅了。

如果对Nacos快速接入有兴趣的同学可以去官网详细看一下,这里就不展开介绍了,https://nacos.io/zh-cn/docs/quick-start.html

AP or CP

CAP

说到分布式系统就一定离不开CAP定理,CAP定理叫作布鲁尔定理。对于设计分布式系统来说(不仅仅是分布式事务)的架构师来说,CAP就是你的入门理论。

  • C (一致性):对某个指定的客户端来说,读操作能返回最新的写操作。对于数据分布在不同节点上的数据上来说,如果在某个节点更新了数据,那么在其他节点如果都能读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。
  • A (可用性):非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。可用性的两个关键一个是合理的时间,一个是合理的响应。合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的,这里的正确指的是比如应该返回50,而不是返回40。
  • P (分区容错性):当出现网络分区后,系统能够继续工作。打个比方,这里个集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。

熟悉CAP的人都知道,三者不能共有,如果感兴趣可以搜索CAP的证明,在分布式系统中,网络无法100%可靠,分区其实是一个必然现象,如果我们选择了CA而放弃了P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是A又不允许,所以分布式系统理论上不可能选择CA架构,只能选择CP或者AP架构。

对于CP来说,放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致。

对于AP来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的BASE也是根据AP来扩展。

顺便一提,CAP理论中是忽略网络延迟,也就是当事务提交时,从节点A复制到节点B,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。同时CAP中选择两个,比如你选择了CP,并不是叫你放弃A。因为P出现的概率实在是太小了,大部分的时间你仍然需要保证CA。就算分区出现了你也要为后来的A做准备,比如通过一些日志的手段,是其他机器回复至可用。

注册中心的选择

上面说了所有的分布式系统都会对CAP进行抉择,同样的注册中心也不例外。Zookeeper是很多做服务注册中心的首选,我目前所在的公司也在使用ZooKeeper当做注册中心,但是随着公司的发展,ZK越来越不稳定,并且多机房的服务发现也非常困难,在这篇文章中阿里巴巴为什么不用 ZooKeeper 做服务发现?,更加详细的阐述了阿里为什么不用ZK当作注册中心。这里我简单的阐述一下:

  • 性能不满足,无法水平扩展:熟悉ZK的同学都知道ZK是往Leader(主节点)去写数据的,所以很难进行水平扩展,当公司达到一定规模的时候,ZK就不适合做注册中心,频繁的读写很容易导致ZK不稳定。对于这种情况,可以分多个ZK集群,但是就涉及到一个问题,开始的时候各个集群可能不会打交道,但是到后期如果有什么协同的业务,各个集群的服务之间互相调用就成为了一个难点。
  • ZooKeeper API难用:Zookeeper的使用真的是需要一个比较精通的专家,你需要熟悉他的很多异常,以及对这些异常到底做什么处理。
  • 注册中心不需要存储一些历史的变化:注册中心理论上来说只需要知道此时此刻在注册中心上,有哪些服务和实例进行了注册,但是ZK会持续的记录事务日志,以便后续进行修复。
  • 同机房不可连通:如果我们有一个三机房容灾5节点部署结构,如下图所示:

如果机房3 和 机房1,2 出现了网络分区,也就是机房内部是好的,但是机房之间是不连通的,由于本机房的ZK出现了分区,当前ZK也会不可用的,那么机房内部的服务就会无法使用注册中心导致无法进行互相调用,很明显这个是不允许的,机房内部之间的网络是好的,就应该允许在同机房内部进行调用。

基于上面来看ZK已经不太适合作为我们的注册中心来用了,换句话来说注册中心不太需要CP,我们应该更多的提供是AP。

Nacos中的协议

Distro

在Nacos的Instance(实例)中提供了一个ephemeral字段,这个字段是bool类型,这个字段和ZK中的含义差不多,都代表的是否是临时节点,在Nacos中如果是临时节点就会使用AP协议,如果不是临时节点就会走CP。当然在注册中心中所有的实例其实默认都是临时节点。

在Nacos中为了实现AP,自己定制了一套Distro协议。下面我们分析一下Distro到底是什么:

纯内存保存

在Distro中所有的数据都是用内存进行保存,在DistroConsistencyService中有:

可以看见Distro是用ConcurrentHashMap作为存储的容器,不需要使用额外的文件进行存储。有同学会问了如果我这个机器宕机了,内存信息就全部丢失了,那我这部分数据怎么恢复呢?

在DistroConsistencyService的load方法中,我们遍历所有的非自己的Server,然后将数据进行同步,如果其中某一个同步成功了,那就不需要向其他的Server发出同步信息,。这样就可以将数据全部恢复。

最终一致

虽然我们说的是AP,但是其实我们还是需要保证最终一致,防止长时间各个节点数据不一致,在DistroConsistencyService的Put方法中会对TaskDispatcher添加一个任务,如下图:

TaskDispatcher其实叫DataSyncTaskDispatcher 更加贴切,主要用来进行数据同步:

其核心逻辑就是上面的whie循环,主要看我标红的代码,这里并不是来一个更新就发送一个,如果服务的变更比较频繁,如果来一个发送一个效率必然很低,所以在这里采取了一个合并的策略,如果更新的数据达到一定的数量这里默认是1000,或者说距离上一次发送已经超过了一定的时间这里默认是2s,都会进行一个发送,这里的发送也是生成一个SyncTask然后放到线程池中进行异步发送。

这个时候有同学会问,如果我某个机器刚刚上线,刚好没有收到更新的这个数据这个怎么办呢?在Nacos中也会有一个兜底的策略TimedSync,这个任务每5s会执行一次,具体的执行任务代码如下:

注意圈红的部分,这里并不是同步所有的数据,而是遍历所有的数据,将属于自己管理那部分数据才会同步(什么数据才属于自己管理呢?下个小节会细讲),然后获取所有的非自己的Server将这些数据进行check发送。

通过这两种方式:实时的更新和定时的更新我们就可以保证所有的Nacos节点上的数据最后都是最终一致。

水平扩展

ZK的一个缺点就是无法进行水平扩展,这个是CP的一大问题,随着公司的发展,规模变大之后,你很难在撑起现在的业务了。在Distro中没有Leader这个角色,每个节点都可以处理读写,通过这样的方式,我们可以任意的水平扩展Nacos的节点来完成我们的需要。

在Distro中并不是每个节点都可以处理所有的读请求,但是写请求并不是每个节点都可以处理的,每个节点会根据key的hash值来判断是否应该是自己处理。写请求访问的是域名这个是会随机打到每个节点上的,Nacos是怎么做到让这些写请求打到对应的机器上呢,这个答案就在DistroFilter中:

这个ServletFilter会对每个请求做一些过滤,如果发现这个请求不是自己的,那么就会转发这个请求到对应的服务器进行处理,收到结果之后再返回给用户。

这里Nacos其实可以做个优化,我们可以发现转发的时候这个动作是同步的,我们这里可以使用异步发送,并且开启serlvet的异步,这个转发节点就可以类似网关一样不需要同步的等待,可以增加Nacos集群的吞吐量

Distro的这些优势对比其他的CP协议来说在注册中心这方面非常大,并且整个协议的实现也比他们简单很多,非常容易理解,如果以后大家涉及注册中心协议的一些涉及,可以参照这种思路。

Raft

在Nacos中其实也有强一致性的协议,使用的是Raft,有两个地方使用了Raft:

  • 注册中心中,Nacos中有一些数据需要持久化存储的,我们会使用Raft去进行数据的一致性同步存储,比如Service,命名空间的一些数据,Nacos认为实例是一个变化比较快的,临时的数据,不需要Raft这种一致性较高的协议,但是Service和命名空间是一个变化比较少的数据,适合做持久化存储。注册中心的Raft实现。目前是使用的自己写的一套Raft,阅读了一下细节和标准的Raft还是有一些差别的,比如日志的连续性在Nacos的Raft协议没有进行保证。
  • Nacos在1.3.0之后为了开始逐渐的使用标准的raft协议的实现sofa-jraft,暂时只在配置中心中进行了使用,配置中心之前只能使用mysql存储,1.3.0之后Nacos借鉴了 Etcd 的通过Raft协议将单机KV存储转变为分布式的KV存储的设计思想,基于SOFA-JRaft以及Apache Derby构建了一个轻量级的分布式关系型数据库,同时保留了使用外置数据源的能力,用户可以根据自己的实际业务情况选择其中一种数据存储方案。

具体的Raft协议细节,这里就不展开细说了,有兴趣的可以看一下翻译的论文: https://www.infoq.cn/article/raft-paper

节点的注册和订阅

在注册中心中另一个比较重要的就是我们的节点如何进行注册和订阅,如何进行心跳检测防止节点宕机,订阅的节点如何能实时收到更新。

节点的注册

代码语言:javascript
复制
naming.registerInstance("microservice-mmp-marketing", "11.11.11.11", 8888, "TEST1");

我们只需要简单的写上面这行代码就可以完成我们节点的注册了。上面代码对ServiceName为microservice-mmp-marketing,在TEST1Clsuter下,注册了一个Ip为11.11.11.11,端口为8888的实例,在注册的同时会添加一个心跳任务

如上图红色的部分,向线程池中添加了一个延迟心跳任务,默认是5s执行,

BeatTask中,首先会向Nacos-Server发送心跳,如果Nacos-Server定义了心跳的间隔接下来就会根据返回的结果修改下一次执行的时间,如果我们的服务不存在,那么说明我们的机器因为某种原因没有同步心跳,导致已经被摘除了,这里需要再次注册这个节点。

在Nacos-Server的ClientBeatCheckTask中,我们会根据Service维度,定时去扫描Service下是否有长时间没有同步的实例,默认是15s,如下面的红框中的代码:

节点的订阅

节点的订阅在不同的注册中心中都有不同的实现,一般的套路分为两种轮训和推送。

推送是指当订阅的节点发生更新的时候会主动向订阅方进行推送,我们的ZK就是推送的实现方式,客户端和服务端会建立一个TCP长连接,客户端会注册一个watcher,然后当有数据更新的时候,服务端会通过长连接进行推送。通过这种建立长连接的模式,会严重消耗服务端的资源,所以当watcher比较多,并且当更新频繁的时候,Zookeeper的性能会非常低,甚至挂掉。

轮训是指我们订阅的节点主动定时获取服务端节点的信息,然后再本地去做一个比对,如果有改变就会做一些更新。在Consul中也有一个watcher机制,但和ZK不一样的是,他是通过Http长轮询去实现的,Consul服务端会对请求的url中是否包含wait参数进行立即返回,还是先挂起等待指定wait时间内如果服务有变化在返回。使用轮训的性能可能较高但是实时性就可能不是太好。

在Nacos中,结合了这两个思想,既提供了轮训又提供了主动推送,我们先来看看轮训的部分,再UpdateTask类中的run方法中:

注意看上面的红框中,我们会根据Service维度去定时轮训,我们的ServiceInfo,然后去更新。

我们再来看下,Nacos是如何实现推送功能的,Nacos会记录上面我们的订阅者到我们的PushService, 下面是我们的PushService中的推送核心代码:

  • Step 1:通过ServiceChangeEvent事件触发我们的推送,这里要注意的是因为我们的节点都是通过distro进行更新,当我们distroT同步到其他机器上时,同样也会触发这个事件。
  • Step 2:获取本机上维护的订阅者,因为订阅者是根据是否查询过服务节点来定义的,查询过服务节点这个动作会被随机的打到不同的Nacos-Server上,所以我们每个节点都会维护一部分订阅者,并且维护的订阅者之间还会有重复,由于后续是UDP发送,重复维护订阅者的成本不是很高。
  • Step3:生成ackEntry,也就是我们发送的内容并且将其缓存起来,这里缓存主要是防止重复做压缩的过程。
  • Step4: 最后进行udp的发送

Nacos这种推送模式,对于Zookeeper那种通过tcp长连接来说会节约很多资源,就算大量的节点更新也不会让Nacos出现太多的性能瓶颈,在Nacos中客户端如果接受到了udp消息会返回一个ACK,如果一定时间Nacos-Server没有收到ACK,那么还会进行重发,当超过一定重发时间之后,就不在重发了,虽然通过udp并不能保证能真正的送到订阅者,但是Nacos还有定时轮训作为兜底,不需要担心数据不会更新的情况。 Nacos通过这两种手段,既保证了实时性,又保证了数据更新不会漏掉。

总结

Nacos虽然是一个新开源的项目,但是其架构,源码设计都是非常的精巧,比如在内部源码中使用了很多EventBus这种架构,将很多流程都解耦开来,并且其Distro协议思路也是非常值得我们学习的。

在Nacos的注册中心中还有一些其他的细节,比如根据标签进行Selector等等。这些有兴趣的可以下来再去Nacos文档中了解一下。

如果大家觉得这篇文章对你有帮助,你的关注和转发是对我最大的支持,O(∩_∩)O: