Nacos Server Register and Discovery

前言

之前尝试去简单梳理了一下RocketMQ的两个模块的初始执行流程,感觉还是没有做到与客户端结合,很多点停留在表面,并没有深入地去思考其为什么要这么写,后续还要继续完善。因为之前的一个个人项目中用到了Zookeeper作为注册中心,就想深入地去看一下同为注册中心的Nacos,这篇文章参考了Nacos的官方文档,从其数据模型到实现逻辑一一展开。

Nacos 注册中心的设计原理

一、数据模型

在讲服务注册与发现的源码之前,我们先要对 Nacos 中的数据模型有一个简单了印象。注册中心的核心数据是服务的名字和它对应的网络地址,其还包括了实例的健康状态权限权重等等信息。Nacos 采用的是一种服务-集群-实例分级模型,这样基本可以满足服务在所有场景下的数据存储和管理。

image-20231221103337563

除此之外,Nacos 提供了四层的数据逻辑隔离模型,用户账号对应的可能是⼀个企业或者独立的个体,这个数据⼀般情况下不会透传到服务注册中心。⼀个用户账号可以新建多个命名空间,每个命名空间对应 ⼀个客户端实例,这个命名空间对应的注册中心物理集群是可以根据规则进行路由的,这样可以让注册中心内部的升级和迁移对用户是无感知的,同时可以根据用户的级别,为用户提供不同服务级别的物理集群。再往下是服务分组服务名组成的二维服务标识,可以满足接口级别的服务隔离。

image-20231221104722338

Nacos 的数据模型很复杂,为了适配各种场景,其中存在非常多的属性,但在大多数场景下,我们可以忽略一些数据属性,只关注于我们场景需要的,如当我们不配置 Group 时,Nacos 会为我们分配一个默认 group。

二、Nacos 服务注册与发现源码解读

服务注册

既然是服务注册,那我们肯定要从客户端开始看起。这里用到了 SpringBoot 的自动装配,就不在多赘述了,每一个 Nacos 客户端都会引入这些启动类,顾名思义,NacosDiscoveryAutoConfiguration 应该就是总的入口了,我们继续看。

image-20231221105823461

我们跟进来,就可以看到 NacosServiceRegistry,很明显这个应该就是服务注册的入口了。

image-20231221105823461

我们跟进入就会看到服务注册的核心方法,这里我们可以看到,Instance 其实就是服务的单个实例,其中包含了很多数据,此时我们就可从中初见 Nacos 的服务分级模型,Service - Cluster - Instance,这里有些数据我们在配置的时候故意忽略了,但其都有一个默认初始值,也就是之前说的,只需要关注我们场景中需要的属性即可。

image-20231221110812123

我们继续跟进去,在这里可以看到 addBeatInfo() 方法,顾名思义,这个应该就是客户端心跳机制的实现了,但这里我们先不看,专注于服务注册,后续再展开。

image-20231221111452795

这里就是客户端的服务注册核心逻辑,将 Instance 转为 Map,然后通过 HTTP 请求发送给 Nacos 服务端。

image-20231221111622398

这个接口我们在 Nacos 官方文档中也可以看到。

image-20231221111854984

此时 Nacos 客户端的服务注册的任务就完成了,我们接着看服务端是怎样处理的。我提前把 Nacos 2.3 的源码给拉下来了,进去之后你就会发现,Nacos 本质也是一个 Web 项目,其中 console 就是 Nacos 的核心启动入口。naming 就是服务注册与发现的核心模块,在里面我们就可以找到服务注册的 POST 接口。

image-20231221112148896

这里应该很熟悉了,SpringBoot 项目的Controller。我们抓着主线看,在这里首先把客户端传的 Map 转换为 Instance,然后通过getInstanceOperator().registerInstance() 注册服务。

image-20231221112718283

值得一提的是,在 Nacos2.0 后将是否持久化的数据抽象至服务级别且不再允许⼀个服务同时存在持久化实例和非持久化实例,实例的持久化属性继承自服务的持久化属性。这是官方的说法,意味着一个服务下面的实例只能是临时的 or 持久的,一般默认是 ephemeral,即临时实例。这里我们演示的是临时实例的注册逻辑,就看 EphemeralClientOperationServiceImplregisterInstance 的具体实现。

image-20231221113307273

在这里可以看到有两个对象,ClientService。Service就是注册的服务,一般会维护命名空间、组名、服务名、临时客户端标识等信息,服务下面就是实例集合。Nacos在2.0 版本之后新增了Client模型,每个Client都有自身唯一的ClientId,一个gRPC长连接对应一个Client。Client负责管理一个客户端的服务实例注册Publish和服务订阅Subscribe。

image-20231226095730590

Client的创建及gRPC连接

这就是Client接口的具体方法,我们根据方法名大概就能猜出其作用,主要是维护客户端的一些信息。

image-20231226101254894

我们进到 createIpPortClientIfAbsent 方法里,就可以看到其主要逻辑,这里用到了 ClientManager,这是用来管理 Client的创建、获取、释放的接口,可以看到 ClientManager 也对实例类型做了不同的实现类。

image-20231226102134724

image-20231226102412507

image-20231226102606320

我们这里就看看临时实例是怎么实现的,这里还有个 ClientFactory<IpPortBasedClient> clientFactory,这个是创建 Client 的工厂,底层实现也会区分临时、持久实例等,就不再展开。clientConnected 方法接收 clientFactory 创建的 Client 对象后,就会初始化 Client 的心跳检查定时任务,再把其放在名为 clients 的Map中。

image-20231226103036339

image-20231226103852620

image-20231226104146758

1
ConcurrentMap<String, IpPortBasedClient> clients = new ConcurrentHashMap<>()
Service的创建与注册

Service的创建其实没什么好说的,就是根据客户端的参数去 new 一个 Service 对象。

image-20231226105214671

后续的操作是通过 clientOperationService.registerInstance 进行,ClientOperationService 接口也基于不同的服务进行了区分,我们直接看 EphemeralClientOperationServiceImpl 中对 registerInstance 的具体实现。

image-20231221113904857

这里是先通过 ServiceManager 获取了一个单例的 Service 对象,维护了两个 Map,当我们需要的 Service 在Map中不存在时,就发布 ServiceMetadataEvent 事件,最终将其放到对应的Map中。

1
2
3
4
//存储Service的单例
private final ConcurrentHashMap<Service, Service> singletonRepository;
//存储某个namespace下的所有的Service
private final ConcurrentHashMap<String, Set<Service>> namespaceSingletonMaps;

image-20231226110152253

之后就通过 clientId 从 ClientManager 中获取对应的 Client,这一步其实是从 ClientManager 中的Map clients 中获取的,而且获取到的是 Client 的实现类 AbstractClient。AbstractClient 负责存储当前客户端的服务注册表,即Service与Instance的映射关系。对于单个客户端来说,同一个服务只能注册一个实例

下面就是真正注册实例的逻辑,先把服务与实例的映射关系保存在 publishers 中,然后通过 NotifyCenter 发布一个 ClientChangedEvent 服务注册事件。

image-20231226111454940

服务注册事件的处理

我们进到 NotifyCenter 的 publishEvent 方法中,可以看到其最终是通过 EventPublisher 将事件发布出去。EventPublisher 是事件发布的上层接口,上面定义了基本的方法,比如发布事件,通知订阅者处理事件等。服务注册实际上最终走的是 NamingEventPublisher 中的逻辑。

image-20231226112403694

image-20231226112606316

我们进到 NamingEventPublisher 中,就可以看到其主要逻辑,发布事件实际上是把事件存入 BlockingQueue 中,使用 BlockingQueue 有一个好处就是,对数据进行存取的时候,如果有数据就返回,没有数据就会让出 CPU 的控制权,而不会一直阻塞住线程

1
BlockingQueue<Event> queue

image-20231226112730163

我们回到 NamingEventPublisher 本身来看,可以看到其继承了 Thread 类,这样我们直接去看其 run 方法。

image-20231226113614645

在 run 方法中,我们就可以看到,先是要等待所有事件订阅者初始化完成,内部是碰到没有初始化的直接调用 ThreadUtils.sleep(1000L) 方法阻塞线程。当所有订阅者都初始化完成后,就调用 handleEvents 去处理事件。

image-20231226113723773

可以看到,handleEvents 方法中会轮训事件队列,当获取到一个 event 后,就进行处理事件。

image-20231226114004807

拿到事件的类型,之后获取订阅了该类型的所有订阅者通知每一个订阅者去处理该事件

image-20231226114214774

通过线程池或者直接 new 一个线程去执行订阅者的处理事件。

image-20231226114531560

服务注册事件最终是通过 ClientServiceIndexesManager 去处理的,我们进到里面可以看到其订阅的事件。

image-20231226115125039

我们看看 ClientServiceIndexesManager 是怎么处理服务注册事件的,handleClientDisconnect 方法是处理服务下线事件的,handleClientOperation 就是具体的客户端操作了。

image-20231226115253929

可以看到大概是将客户端与服务的映射关系存入一个Map中,这里的 publisherIndexes 就是注册表,然后发布了一个 ServiceChangedEvent 注册表变更事件。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* publisherIndexes:
* 维护Service与发布clientId列表的映射关系,
* 当有新的clientId注册,将clientId添加到clientId列表中
*/
ConcurrentMap<Service, Set<String>> publisherIndexes = new ConcurrentHashMap<>();
/**
* subscriberIndexes:
* 维护Service与订阅clientId列表的映射关系,
* 当有clientId断开连接或取消订阅,将clientId从clientId列表中移除。
*/
ConcurrentMap<Service, Set<String>> subscriberIndexes = new ConcurrentHashMap<>();

image-20231226115402661

image-20231226115528003

服务注册表变更事件最终是由 NamingSubscriberServiceV2Impl 进行处理的,之后就是进行通知订阅的客户端,这里是新建一个 PushDelayTask 延时任务,将当前服务的变更全量信息推送给所有订阅了该事件的客户端去更新服务信息

image-20231226123230501

image-20231226123302736