【Nacos】Nacos1.4.x服务注册源码分析

目录

〇、Nacos注册中心核心功能点

一、Nacos服务注册流程图

二、Nacos客户端如何发起服务注册及心跳发送请求

[2.1 引入依赖](#2.1 引入依赖)

[2.2 Nacos中的spring.factories](#2.2 Nacos中的spring.factories)

1、NacosDiscoveryClientAutoConfiguration

2、NacosDiscoveryAutoConfiguration

[2.3 Nacos客户端自动注册入口类:NacosDiscoveryClientAutoConfiguration](#2.3 Nacos客户端自动注册入口类:NacosDiscoveryClientAutoConfiguration)

[2.4 Nacos客户端服务注册类:NacosServiceRegistry](#2.4 Nacos客户端服务注册类:NacosServiceRegistry)

[2.4.1 Client服务续约](#2.4.1 Client服务续约)

[2.4.2 Client服务注册](#2.4.2 Client服务注册)

三、Nacos服务端如何接收心跳、服务注册请求

[3.1 先找源码入口](#3.1 先找源码入口)

[3.2 Server端接收服务注册请求](#3.2 Server端接收服务注册请求)

[3.2.1 本地缓存serviceMap注册表的结构](#3.2.1 本地缓存serviceMap注册表的结构)

下面我们来着重看一下本地缓存注册表serviceMap的结构:

步骤1:创建服务信息存入本地缓存,并开启健康检查定时任务

步骤2:从本地缓存Map中获取服务信息

步骤3:校验Service服务对象不能为null

步骤4:服务实例信息"持久化"(我们这里是临时实例,所以服务实例信息"持久化"只是将其存到一个内存变量中)

[1. 往DataStore的dataMap中添加数据](#1. 往DataStore的dataMap中添加数据)

[2. 进入到Notifier类内部,通知服务实例信息变更](#2. 进入到Notifier类内部,通知服务实例信息变更)

[3.3 Server端接收心跳请求](#3.3 Server端接收心跳请求)

四、Nacos服务注册总结

[4.1 为什么服务端要用异步去注册,而不用同步?](#4.1 为什么服务端要用异步去注册,而不用同步?)

[4.2 注册表的设计如何防止多节点读写并发冲突?](#4.2 注册表的设计如何防止多节点读写并发冲突?)

[4.3 为什么要这么设计双层Map呢?](#4.3 为什么要这么设计双层Map呢?)

[4.4 Nacos服务注册发现性能测试](#4.4 Nacos服务注册发现性能测试)


〇、 Nacos 注册中心 核心功能点

本章节讲详细讲解Nacos 1.4.X版本的注册中心源码,我们先来了解一下Nacos注册中心的核心功能点:

  • 服务注册 :Nacos Client会通过发送REST请求的方式向Nacos Server注册自己的服务,提供自身的元数据,比如ip地址、端口等信息。Nacos Server接收到注册请求后,就会把这些元数据信息存储在一个双层的内存Map中。
  • 服务心跳 :在服务注册后,Nacos Client会维护一个定时心跳来持续通知Nacos Server,说明服务一直处于可用状态,防止被剔除。默认5s发送一次心跳。
  • 服务健康检查 :Nacos Server会开启一个定时任务用来检查注册服务实例的健康情况,对于超过15s没有收到客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)
  • 服务发现 :服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存
  • 服务同步 :Nacos Server集群之间会互相同步服务实例,用来保证服务信息的一致性。

一、 Nacos 服务注册流程图

二、 Nacos 客户端如何发起服务注册及心跳发送请求

我们先从Nacos客户端为切入点,讲解服务注册的源码。Nacos客户端也就是要注册的微服务端。

2.1 引入依赖

要想讲自己注册到注册中心,微服务就要引入Nacos注册中心依赖。这个就作为我们读源码的入口。

在pom中引入:

XML 复制代码
<!-- nacos服务注册与发现依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

Spring Boot我们知道有一个很重要的东西:spring.factories,这个是针对引入的jar包做自动配置用的;在Spring Boot项目运行时,SpringFactoriesLoader 类会去寻找META-INF/spring.factories文件,spring.factories用键值对的方式记录了所有需要加入容器的类,key为:org.springframework.boot.autoconfigure.EnableAutoConfiguration,value为各种xxxAutoConfiguration;或key为:org.springframework.cloud.bootstrap.BootstrapConfiguration,value为各种xxxBootstrapConfiguration。

大家对EnableAutoConfiguration都比较熟悉了,就是进行自动配置的;而BootstrapConfiguration可能会相对陌生一点,它是做自定义启动配置的。总的来说,通过这个spring.factories文件:所有的Bean都会在SpingApplicatin启动前加入到它的上下文(容器)中。

2.2 Nacos 中的 spring.factories

下面我们就来看一下Nacos的spring.factories。查看引入的nacos-discovery包的层级结构,找到spring.factories文件;

打开这文件看一下:

上面我有聊到BootstrapConfiguration是在Spring Boot项目启动时自动加载配置(配置类)到SpingApplicatin的上下文(容器)中。BootstrapConfiguration对应的value是NacosDiscoveryClientConfigServiceBootstrapConfiguration这个类的全限定名。

我们进去点进去看一下这个类NacosDiscoveryClientConfigServiceBootstrapConfiguration:

它啥也没干,就引入了两个自动配置类:NacosDiscoveryClientAutoConfiguration,NacosDiscoveryAutoConfiguration;我们也不知道哪个是和服务注册相关的,先都点进去看看。

1 NacosDiscoveryClientAutoConfiguration

从这个类的方法命名来看,好像没有和注册相关的,先看看下一个类吧。

2 NacosDiscoveryAutoConfiguration

这个类里能看到带注册register这个单词的方法名,感觉就是NacosDiscoveryAutoConfiguration这个类了。

自动配置类的源码中注入的Bean的名称带"Auto"的,这样的Bean一般都很重要。这个其实属于约定俗成的代码规范,只要是开源项目的作者写代码比较规范,一般都会遵循这个原则。

还有就是一般start()、begin()、init()这种方法,在源码中都比较重要,一定要深入看,不能跳过。

2.3 Nacos 客户端自动注册入口类: NacosDiscoveryClientAutoConfiguration

NacosDiscoveryAutoConfiguration类有三个内部类:NacosServiceRegistry(完成服务注册功能,实现ServiceRegistry接口),NacosRegistration(注册时用来存储Nacos服务端的相关信息),NacosAutoServiceRegistration(自动注册功能)。

我们这里就重点关注NacosAutoServiceRegistration类和NacosServiceRegistry类,前者实现Nacos客户端自动进行服务注册的功能(微服务启动后就自动触发注册服务的流程),而后者就是Nacos客户端真正发送服务注册请求的类(NacosAutoServiceRegistration触发自动注册流程后会通过调用NacosServiceRegistry的方法完成服务注册请求的发送)。

我们先从客户端自动注册服务入手。这个自动注册功能是怎么实现的呢?我们看一下NacosAutoServiceRegistration类的源码:

我们能看到这个类中有一个register()方法,从命名来看就能意识到发起注册服务请求的方法就是这个,那么它是在什么时机被调用的呢?我们接着往下分析。

由上图我们可以看到NacosAutoServiceRegistration类继承自AbstractAutoServiceRegistration抽象类:

再看AbstractAutoServiceRegistration类实现了ApplicationListener接口。ApplicationListener接口实际是一个事件监听器,其监听WebServerInitializedEvent事件。WebServerInitializedEvent类型(Web应用初始化事件)的事件会在Spring容器启动的时候发布,也就是说当Spring Boot项目启动的时候,监听器就会监听到该事件进而触发onApplicationEvent()方法。

onApplicationEvent()方法会调用AbstractAutoServiceRegistration类的bind()方法,在bind()中会调用AbstractAutoServiceRegistration类的start()方法。

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| |--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 🛈 | @Deprecated 注解 1. 说明: @Deprecated 表示此方法已废弃、暂时可用,但以后此类或方法都不会再更新、后期可能会删除,建议后来人不要调用此方法。 2. 用法: 此注解可用于类上、方法上、属性上。 通常在给定此注解后,应该在方法注释中同样说明:废弃此方法后的代替方法是哪个、处理原逻辑代替方案是什么 、本身不打算代替,而是直接清除的,则最好给出会清除此方法的具体代码版本号 。 | |

start()方法中再调用AbstractAutoServiceRegistration类的register()方法。

这个register()方法就是我们在开头说的NacosAutoServiceRegistration类的register()方法。至此我们就知道了NacosAutoServiceRegistration#register()是在哪里被调用的了。继续往下看,又调用了一个register()方法。

tips:这里和AQS一样,是典型模板方法设计模式

这个方法中又调用了ServiceRegistry接口的register()方法,我们接着看ServiceRegistry接口的实现类,点一下发现直接蹦到了NacosServiceRegistry类上,这下我们终于来到了真正要发送服务注册请求的类了,NacosAutoServiceRegistration类和NacosServiceRegistry类被我们串起来了。

其实spring-cloud-commons包中定义了一套服务注册规范,集成Spring Cloud实现服务注册的组件都会实现ServiceRegistry接口。所以Spring Cloud中所有实现服务注册功能的类,都会实现ServiceRegistry接口。

2.4 Nacos 客户端服务注册类: NacosServiceRegistry

现在我们继续接着NacosServiceRegistry#register()方法来看:

先是组装服务实例信息,然后走入到了NamingService接口#registerInstance()方法,继续往里追踪,点到NamingService接口的实现类NacosNamingService;

NacosNamingService类在初始化的时候会实例几个比较关键的类:

java 复制代码
// 1)用于发送心跳
private BeatReactor beatReactor;
// 2)事件分发器:订阅的服务发生改变时,Nacos服务端就会通知到当前Nacos Client端;
//    Client端收到这个通知后,将通知的事件分发给对应的观察者,也就是我们自己实现的listener
private EventDispatcher eventDispatcher;
// 3)用于与Nacos服务端进行通信
private NamingProxy serverProxy;

回到主流程,NacosNamingService#registerInstance()方法套娃如下:

java 复制代码
@Override
public void registerInstance(String serviceName, Instance instance) throws NacosException {
    registerInstance(serviceName, Constants.DEFAULT_GROUP, instance);
}

/**
 * serviceName 服务名称
 * groupName 群组名称
 * instance 服务实例对象
 * 
 */
@Override
public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
    // 判断服务的实例节点是否为临时节点
    if (instance.isEphemeral()) {
        // 组装心跳信息
        BeatInfo beatInfo = new BeatInfo();
        beatInfo.setServiceName(NamingUtils.getGroupedName(serviceName, groupName));
        beatInfo.setIp(instance.getIp());
        beatInfo.setPort(instance.getPort());
        beatInfo.setCluster(instance.getClusterName());
        beatInfo.setWeight(instance.getWeight());
        beatInfo.setMetadata(instance.getMetadata());
        beatInfo.setScheduled(false);
        long instanceInterval = instance.getInstanceHeartBeatInterval();
        // 设置心跳周期
        beatInfo.setPeriod(instanceInterval == 0 ? DEFAULT_HEART_BEAT_INTERVAL : instanceInterval);
        
        // 1)发送心跳
        beatReactor.addBeatInfo(NamingUtils.getGroupedName(serviceName, groupName), beatInfo);
    }
    // 2)注册服务    
    serverProxy.registerService(NamingUtils.getGroupedName(serviceName, groupName), groupName, instance);
}

这个方法有两个重要的点:

  1. 如果实例节点是临时节点的话(默认是临时的),会组装一个心跳信息,然后通过BeatReactor组件发送心跳到服务端,也就是服务续约;
  2. 调用ServerProxy组件注册服务到服务端,即服务上线。

在聊服务续约服务注册之前,我们先看一下要注册服务的服务名称的组成,其实就是就是通过NamingUtils.getGroupedName(serviceName, groupName)方法将入参传递过来的groupName和serviceName用@@拼接起来作为新的serviceName,以达到Group层的数据隔离,代码如下:

NamingUtils.getGroupedName(serviceName, groupName)

java 复制代码
// NamingUtils
public static String getGroupedName(String serviceName, String groupName) {
    // Constants.SERVICE_INFO_SPLITER常量就是"@@"
    return groupName + Constants.SERVICE_INFO_SPLITER + serviceName;
}

2.4.1 Client 服务续约

即Nacos Client端定时发送心跳到服务端。

在BeatReactor#addBeatInfo()方法中,会启动一个定时任务,立即执行BeatTask这个任务,并且把5s作为一个周期去发送心跳。

方法中传入的BeantInfo类型的参数,里面存放了一些信息,比如当前服务名称+ip+端口+权重+当前属于什么cluster+心跳周期等等。

我们接着来看BeatTask这个Runnable接口的实现类,是如何运行的?

我们可以看到它主要展现两个能力:

  1. 调用NamingProxy向Nacos服务端发送心跳;
  2. 再开启一个定时任务,延时5s再持续发送心跳到Nacos服务端,即默认每5s向Nacos服务端发送一次心跳。

发送心跳就很简单了,直接采用HTTP接口调用的方式,调用服务端的**/nacos/v1/ns/instance/beat**接口。

​​​​​​​

2.4.2 Client 服务注册

在NamingProxy#registerService()方法中直接发起HTTP请求(post请求)调用Nacos Server的接口**/nacos/v1/ns/instance**进行服务注册:

接口地址常量值如下:

java 复制代码
public static String WEB_CONTEXT = "/nacos";
public static String NACOS_URL_BASE = WEB_CONTEXT + "/v1/ns";
public static String NACOS_URL_INSTANCE = NACOS_URL_BASE + "/instance";

到这里,客户端注册服务到服务端的过程就结束了,接下来分析服务端处理请求的源码。

三、 Nacos 服务端如何接收心跳、服务注册请求

上面我们聊到了Nacos Client端分别调用服务端的**/nacos/v1/ns/instance/beat****、** /nacos/v1/ns/instance接口进行服务续约和服务注册,下面我们聊一下服务端对这个两个请求是如何处理的?

3.1 先找源码入口

Nacos Client通过NamingProxy类调用Nacos Server,以开源框架的命名规范来看,Nacos Server的源码中应该有个和naming相关命名的模块;

接着往下展开,找到controllers包下的InstanceController:

这个Controller就是处理客户端注册服务请求的入口。

这个Controller的请求路径就是/nacos/v1/ns/instance。

这个Controller里面有一个Post方法,很明显这方法就是服务端处理客户端传来的实例Instance进行注册的:

我们就以该方法为入口,来进行Nacos服务端处理注册服务请求源码分析。

3.2 Server 端接收服务注册请求

InstanceController#register()方法是Nacos Server接收服务注册请求的入口:

java 复制代码
@CanDistro
@PostMapping
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public String register(HttpServletRequest request) throws Exception {
    // 通过请求报文获取要注册服务所在的命名空间    
    final String namespaceId = WebUtils
            .optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
    // 通过请求报文获取要注册服务的服务名
    final String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
    NamingUtils.checkServiceNameFormat(serviceName);
    // 通过请求报文中的数据构建要注册的服务实例对象    
    final Instance instance = HttpRequestInstanceBuilder.newBuilder()
            .setDefaultInstanceEphemeral(switchDomain.isDefaultInstanceEphemeral()).setRequest(request).build();

    // 真正注册服务的地方,serviceName为注册的服务名,namespaceId默认为public
    getInstanceOperator().registerInstance(namespaceId, serviceName, instance);
    return "ok";
}

其中namespaceId可以做一层数据隔离:

我们接着往里看getInstanceOperator().registerInstance():

InstanceOperator接口有两个实现类,分别是:InstanceOperatorClientImpl,InstanceOperatorServiceImpl,由于我这里是作为服务端,所以我们关注InstanceOperatorServiceImpl实现类:

  1. 组装服务的实例信息,包含:IP、Port
  2. 调用ServiceManager的registerInstance()方法,完成真正的服务注册操作。

ServiceManager#registerInstanc()方法中主要做四件事:

  1. 如果服务不存在,则创建一个服务(注意这一步只会存储服务的信息,并不会存储服务实例,服务实例是在第四步完成存储的);
  2. 从本地缓存 Map(namespace, Map(group::serviceName, Service)) 中获取服务信息;
  3. 校验服务不能为null;
  4. 将服务的实例信息添加到DataStore的dataMap缓存中

在分别讲解这四个步骤之前,先来了解一下本地缓存serviceMap的结构。

3.2.1 本地缓存 serviceMap 注册表的结构

首先我们回想一下Nacos的服务模型,如下图所示。从上到下分别是:命令空间->服务分组->服务->集群->实例。各个部分对应有什么作用呢。

  1. 命名空间起到环境隔离的作用,比如隔离生产环境和测试环境;
  2. 服务分组,当服务太多可对服务进行高一层的分组,默认DEFAULT_GROUP
  3. 服务,比如订单服务,用户服务
  4. 集群,服务可以在全国各地部署几百个实例,可把杭州或上海的实例放到各自的杭州集群或上海集群中
  5. 实例就是真正一个部署的实例

好了,那么如果用Java数据结构来表示上面的注册关系表呢,没错,可以用嵌套的Map集合,各服务模型和Map的对应关系如下:

Nacos采用数据的分级存储模型,对应到Java代码用多层Map表示。最外层ServiceMap以命名空间为key,value也是一个内层Map。内层Map以group拼接serviceName作为key,value是一个服务对象。服务对象内部也是一个ClusterMap,以集群名字作为key,value是一个集群对象。集群对象内部是一个实例集合,分为持久和临时实例集合。源码中的命名如下:

java 复制代码
/**
 * ServiceManager服务管理器内部
 * Map(namespace, Map(group::serviceName, Service)).
 */
Map<String, Map<String, Service>> serviceMap = new ConcurrentHashMap<>();

// service对象持有集群map
// Map<inerfaceName,Cluster>
Map<String, Cluster> clusterMap = new HashMap<>();

// 集群对象 永久实例集合
Set<Instance> persistentInstances = new HashSet<>();
// 临时实例集合
Set<Instance> ephemeralInstances = new HashSet<>();

下面我们来着重看一下本地缓存注册表serviceMap的结构:

java 复制代码
/**
 * 服务注册表serviceMap
 * Map(namespace, Map(group::serviceName, Service))
 */
private final Map<String, Map<String, Service>> serviceMap = new ConcurrentHashMap<>();

我们从表面上可以看到这是一个双层Map结构(但实际上是三层Map,这个下面会分析),第一层的key存储的是namespace,第一层的value存储的是第二层的Map<String,Service>的对象。第二层的key是group(群组)和serviceName(服务名称)拼起来的字符串,它们两个用"@@"拼接生成这个key,第二层的value是一个Service类型的对象,这个Service类型的对象中还有一层结构。由此可见本地缓存serviceMap的结构要比双层Map更复杂一些。

我们看下Service里面是什么,它的成员属性中有个Map对象。

​​​​​​​

这个Map的结构是<String, Cluster>,里面还有个Cluster类型,现在知道这个双层Map有多复杂了吧。

再来看这个Cluster类型的结构。

我们终于看到了Instance类型,前面的源码中已经讲过了,传入的Instance类型对象就是要注册的服务实例。

在Cluster类中有两个Set<Instance>分别是ephemeralInstances(存储临时服务实例)和persistentInstances(存储持久服务实例)。

由此可见,当我们把服务实例存储到本地缓存serviceMap中时,本质上最终是落到了这个Set<Instance>对象的。

看下clusterMap:

我们来总结一下:

本地缓存serviceMap结构如下:

Map<String, Map<String, Service>> serviceMap; -- 注册表结构

再细化下结构

Service:

Map<String, Cluster> clusterMap; --Service类就是代表的一个服务,这个类的对象中可能会存储这个服务的多个节点实例

Cluster:

Set<Instance>

所以最后核心注册表结构是:

Map<public, Map<DEFAULT_GROUP@@provider, Map<DEFAULT, Set<Instance>>>>

上面的类型中写的都是对应要存储的内容。比如NameSpace默认就是public,默认Group就是DEFAULT_GROUP,provider是我们要注册的服务名字,Cluster默认就是DEFAULT(Cluster一般就用默认DEFAULT,并不会去做区分,也就是只有一个单机房。但是如果有多机房需求的话,就要对Cluster设置不同的Key了)。

所以,这个serviceMap本地缓存实际上是一个三层Map结构:

Map<String, Map<String, Map<String, Set<Instance>>>>

Nacos服务注册表结构:Map<namespace, Map<group::serviceName, Service>>结构图:

举例说明:

上面这个图我们能看出,group::serviceName这个Key其实已经唯一指定了一个服务了,也就是上图中的Service就是一个服务。至于Service中内部的cluster,这个只是将同一个微服务部署在多地不同的机房节点而已,做了分布式部署。

好了,我们终于讲完了本地缓存serviceMap的结构,这样就能更好的理解下面要讲的代码了,现在我们来分别讲解这四个步骤。

步骤 1 创建服务信息存入本地缓存,并开启健康检查定时任务

这里的操作也很简单,就是不断地套娃。最终将服务信息保存到本地缓存serviceMap中。

java 复制代码
// serviceName:这里的serviceName已经是group@@serviceName的格式了
public void createEmptyService(String namespaceId, String serviceName, boolean local) throws NacosException {
    createServiceIfAbsent(namespaceId, serviceName, local, null);
}

public void createServiceIfAbsent(String namespaceId, String serviceName, boolean local, Cluster cluster)
        throws NacosException {
    
    // 尝试从本地缓存serviceMap中获取服务信息
    Service service = getService(namespaceId, serviceName);
    // 如果本地缓存中没有服务信息,则创建一个,并将其放入本地缓存中
    if (service == null) {
        
        Loggers.SRV_LOG.info("creating empty service {}:{}", namespaceId, serviceName);
        // 创建一个服务信息service
        service = new Service();
        // 设置服务信息的属性
        // 设置服务名(此时这个服务名是group@@serviceName的格式)
        service.setName(serviceName);
        // 设置命名空间ID
        service.setNamespaceId(namespaceId);
        // 设置服务的group名
        service.setGroupName(NamingUtils.getGroupName(serviceName));
        // now validate the service. if failed, exception will be thrown
        service.setLastModifiedMillis(System.currentTimeMillis());
        service.recalculateChecksum();
        if (cluster != null) {
            cluster.setService(service);
            service.getClusterMap().put(cluster.getName(), cluster);
        }
        // 验证服务信息
        service.validate();
        // 此时service已经存入了服务信息,但是还没有存入服务实例,所以此时service的实例列表(就是上面讲的本地缓存结构中的Cluster对象中的Set)为空
        // 存储服务信息和初始化
        putServiceAndInit(service);
        if (!local) {
            addOrReplaceService(service);
        }
    }
}

private void putServiceAndInit(Service service) throws NacosException {
    // 保存服务信息到本地缓存中
    putService(service);
    service = getService(service.getNamespaceId(), service.getName());
    // 服务初始化,会做健康检查
    service.init();
    // 添加两个监听器,使用Raft协议和 Distro协议维护数据一致性的,包括:Nacos Client感知服务提供者实例变更
    consistencyService
            .listen(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), true), service);
    consistencyService
            .listen(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), false), service);
    Loggers.SRV_LOG.info("[NEW-SERVICE] {}", service.toJson());
}

// "服务注册"操作,也就是将Service对象(此时Service对象中只保存了服务信息,没有保存服务实例)保存到本地缓存serviceMap中。
public void putService(Service service) {
    if (!serviceMap.containsKey(service.getNamespaceId())) {
        serviceMap.putIfAbsent(service.getNamespaceId(), new ConcurrentSkipListMap<>());
    }
    serviceMap.get(service.getNamespaceId()).putIfAbsent(service.getName(), service);
}

注意:在service.init()初始化服务时,会启动一个定时任务做不健康服务的服务剔除:

java 复制代码
/**
 * Service#init()
 */
public void init() {
    // 开始一个定时任务,对不健康的服务实例做服务下线/剔除,点进去
    HealthCheckReactor.scheduleCheck(clientBeatCheckTask);
    for (Map.Entry<String, Cluster> entry : clusterMap.entrySet()) {
        entry.getValue().setService(this);
        entry.getValue().init();
    }
}

@JsonIgnore
private ClientBeatCheckTask clientBeatCheckTask = new ClientBeatCheckTask(this);

走到HealthCheckReactor.scheduleCheck()方法中,会延时5s开启一个固定延时5s(5000ms)的FixedDelay类型的定时任务执行服务剔除操作:

​​​​​​​

​​​​​​​

另外,由于ClientBeatCheckTask不是NacosHealthCheckTask的实现类,所以定时任务中执行的方法为ClientBeatCheckTask中的run()方法。

​​​​​​​

看一下ClientBeatCheckTask的run()方法:

通过判断当前时间和实例最后一次心跳时间的间隔是否大于阈值(服务端当前时间 - 实例发送最后一次心跳的时间 > 实例心跳超时时间),来决定是否进行服务剔除/下线;

默认情况下如果超过15s无响应,健康状态置为false(剔除),超过30s,会下线此实例。

这个getInstanceHeartBeatTimeOut方法,点进去看,默认超过15s后健康状态置false。

步骤 2 :从本地缓存 Map 中获取服务信息

从本地缓存serviceMap中获取服务信息,没别的啥操作。

java 复制代码
public Service getService(String namespaceId, String serviceName) {
    // 如果namespaceId没有的话
    if (serviceMap.get(namespaceId) == null) {
        return null;
    }
    // 从本地缓存serviceMap中获取Service服务对象,此时Service对象中只保存了服务信息,没有保存服务实例
    return chooseServiceMap(namespaceId).get(serviceName);
}

public Map<String, Service> chooseServiceMap(String namespaceId) {
    return serviceMap.get(namespaceId);
}

步骤 3 :校验 Service 服务对象不能为 null

一个单纯的判空处理:

java 复制代码
public void checkServiceIsNull(Service service, String namespaceId, String serviceName) throws NacosException {
    if (service == null) {
        throw new NacosException(NacosException.INVALID_PARAM,
                "service not found, namespace: " + namespaceId + ", serviceName: " + serviceName);
    }
}

步骤 4 :服务实例信息"持久化"(我们这里是临时实例,所以服务实例信息 " 持久化 " 只是将其存到一个内存变量中)

将服务的相应实例保存到DataStore的serviceMap中。由于存在多个服务实例同时注册的场景,所以要加一个synchronized锁。

java 复制代码
public void addInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
        throws NacosException {
    
    String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);
    
    Service service = getService(namespaceId, serviceName);
    
    synchronized (service) {
        List<Instance> instanceList = addIpAddresses(service, ephemeral, ips);
        // 创建服务实例对象        
        Instances instances = new Instances();
        instances.setInstanceList(instanceList);
        // 保存服务实例,key为namespaceId和serviceName的结合体,点进去
        consistencyService.put(key, instances);
    }
}

ConsistencyService是一个接口,它有很多实现类,这个是读源码的一个不好的场景,我不知道应该进哪个,教大家一个技巧,当你不知道的时候,凭经验猜,猜不出来,就在这个put()方法打个断点看一下即可。

发现使用的是DelegateConsistencyServiceImpl实现类。

其实我们完全可以通过经验才出来。如果猜的话,先看下这个consistencyService对象源头来自哪里

这里注入的name有Delegate这个单词,我凭经验猜就是实现类DelegateConsistencyServiceImpl,事实证明我是对的。

那我们就继续看DelegateConsistencyServiceImpl.put()方法源码:

发现它是调用的mapConsistencyService(key).put(key, value)。看一下这个put()方法,又是一堆实现类,还是技巧,要么凭经验,要么加断点。我们来凭借经验判断一下。去看mapConsistencyService(key)这个返回的是什么类。

这个key实际上就是我上面发的,判断这个key是不是包含临时标志ephemeral。

  • ephemeralConsistencyService:临时实例的注册
  • persistentConsistencyService:持久实例的注册

那么我们判断返回值一定是第一个ephemeralConsistencyService类,因为默认就是临时实例,我们讲解源码就是采用的默认临时实例讲解的。

进入ephemeralConsistencyService的实现类,发现只有一个

至此,我们就知道了其实调用的就是DistroConsistencyServiceImpl这个类的put()方法。

当然,上面讲的是第一次读源码的时候啥都不知道才会采用的技巧,其实现在我们就知道了这个结论了,由于我们默认是采用ephemeral方式(在聊Nacos Client时我们也有提到,服务端见Instance#isEphemeral() 或 SwitchDomain#isDefaultInstanceEphemeral()),所以以临时实例为例,我们就去看DistroConsistencyServiceImpl实现类。如果是持久实例,则关注RaftConsistencyServiceImpl。【由命名也能看出来,临时实例采用Distro一致性协议,持久实例采用Raft一致性协议,临时实例使用AP架构,持久实例使用CP架构】

java 复制代码
@Override
public void put(String key, Record value) throws NacosException {
    // 服务实例持久化,点进去
    onPut(key, value);
    // If upgrade to 2.0.X, do not sync for v1.
    if (ApplicationUtils.getBean(UpgradeJudgement.class).isUseGrpcFeatures()) {
        return;
    }
    // Nacos集群间数据同步,采用distro协议
    distroProtocol.sync(new DistroKey(key, KeyBuilder.INSTANCE_LIST_KEY_PREFIX), DataOperation.CHANGE,
            DistroConfig.getInstance().getSyncDelayMillis());
}

进入到DistroConsistencyServiceImpl类的onPut()方法:

java 复制代码
public void onPut(String key, Record value) {
    
    if (KeyBuilder.matchEphemeralInstanceListKey(key)) {
        Datum<Instances> datum = new Datum<>();
        datum.value = (Instances) value;
        datum.key = key;
        datum.timestamp.incrementAndGet();
        // 往DataStore的dataMap中添加数据,点进去
        dataStore.put(key, datum);
    }
    // 如果listener中没有这个key的话直接返回,key是在创建Service时添加进去的,见ServiceManager#putServiceAndInit()方法
    if (!listeners.containsKey(key)) {
        return;
    }
    // 通知Nacos client服务端服务实例信息发生变更,这里是先添加任务;点进去
    notifier.addTask(key, DataOperation.CHANGE);
}
1. DataStore dataMap 中添加数据

DataStore对象是DistroConsistencyServiceImpl类持有的成员属性。

java 复制代码
@Component
public class DataStore {
    
    private Map<String, Datum> dataMap = new ConcurrentHashMap<>(1024);
    
    public void put(String key, Datum value) {
        // 单纯的往一个Map缓存放一下数据
        dataMap.put(key, value);
    }
}
2. 进入到 Notifier 类内部,通知服务实例信息变更

进入到DistroConsistencyServiceImpl的内部类Notifier,先看其addTask()方法:

最重要的一步将服务实例信息添加到tasks任务队列中(阻塞队列),按常理来说到这也就结束了。但是添加到任务队列之后呢,再怎么处理?

我们注意到DistroConsistencyServiceImpl中有一个**@PostConstruct** 修饰的init()方法,也就是说在DistroConsistencyServiceImpl类构造器执行之后会执行这个方法启动Notifier通知器:

我们进入Notifer#run()方法:

首先从tasks任务队列中取出任务,调用自己的handle()方法处理任务:

handle()方法中调用监听器listener的onChange()/onDelete()方法执行相应通知数据更新、删除的逻辑。

下面我着重看一下通知数据变更的onChange()方法:

RecordListener是一个接口,它有三个实现,我们进入到它的实现类Service中,其他两个实现类都是处理持久化示例的;

着重看一下onChange()中调用的updateIPs()方法,这个方法就是真正执行服务注册的逻辑,其他代码不涉及到主线逻辑,暂时不看:

遍历要注册的instance集合,采用一个临时的Map记录clusterName和Instance实例的对应关系,然后更新clusterMap<clusterName, Cluster>(这个clusterMap就是前面讲的本地缓存serviceMap持有的那个clusterMap,之前本地缓存中只存储了服务信息,现在把服务实例也添加进去了):

维护Cluster中实例的代码updateIPs()方法大家感兴趣可以点进去看看,主要思想还是比对新老数据,找出新的instance,与挂掉的instance,最后更新 cluster对象里面的存放临时节点的集合 或 存放永久节点的集合。

updateIPs()方法会在最后调用PushService通知单客户端服务信息发生变更。传入参数ephemeral默认就是true,临时示例。

这里是先创建一个服务的副本,在副本上进行写操作,当写操作完成之后,再用副本去覆盖旧的数据。这种方法是为了避免多节点读写并发冲突,属于写时复制(CopyOnWrite)思想。这样可以不用加锁就避免了并发冲突问题,大大提高了并发量和性能。

最终会写到这里来,用写好的副本覆盖掉旧数据。这里就是注册表的最核心的地方:

这个就是前面讲本地缓存serviceMap中持有的Cluster对象中的成员属性ephemeralInstances,这里面就存储了服务实例对象。

至此,Nacos服务注册的服务端操作流程就结束了。

Nacos的服务注册,服务端主要是将服务信息和服务的实例信息都保存到两个Map(serviceMap和dataMap)中;启动一个延时5s执行的定时任务做服务剔除/下线(默认 15s 无响应就将健康状态设置为 false 30s 无响应就将服务下线); Notifier****做普通服务集群实例信息维护,调用PushService通知客户端服务信息发生变更

由上面的源码也能够看出,服务是异步注册的。因为注册中心一定要能支撑高并发量。

3.3 Server 端接收心跳请求

InstanceController#beat()方法是Nacos Server接收心跳请求的入口:

其内部调用InstanceOperator接口的handleBeat()方法做心跳判断。

InstanceOperator有两个实现类(InstanceOperatorClientImpl和InstanceOperatorServiceImpl),我们这里讨论的是服务端如何处理客户端的心跳请求,因此我们看InstanceOperatorServiceImpl类:

InstanceOperatorServiceImpl#handleBeat():

  1. 如果服务还没有注册,就先注册;
  2. 接着获取服务信息,校验服务不能为null;
  3. 最后,调用Service#processClientBeat()处理客户端心跳;

Service#processClientBeat()中启动一个只执行一次的任务:

任务的逻辑体现在ClientBeatProcessor#run()方法中:

  1. 更新实例的最后一次心跳时间,进而决定服务是否需要下线/剔除;
  2. 如果服务之前是不可用的,则设置服务为可用的,并调用PushService通知客户端;

在run()方法中的这个for循环,会去遍历所有的instance实例,拿到一个后,设置最后的心跳时间为当前时间,比如我在

0s的时候注册个实例,5s后发个心跳,把当前实例的最后心跳时间设置为当前时间,后面方便去把实例下线, 或者健康状态置为false。

服务端对于心跳的处理,主要两件事:维护心跳的最后一次时间,如果服务变为可用状态则通知客户端;另外在服务注册创建Service服务时会启动一个固定延时5S执行的定时任务做服务的剔除,其中以心跳时间为根本逻辑。

四、 Nacos 服务注册总结

客户端启动的时候,发起http请求,发送一些注册参数到服务端。服务端会开启一个线程把这些参数放在一个阻塞队列里,并异步的消费去把这些放在一个双层Map(实际是三层)中的Set集合,实现注册的逻辑(默认是走临时实例的流程,后面的笔记我们会再讲解持久实例)。

这个异步消费的线程在注册临时实例的实现类执行完构造方法后就启动了:

我们再来解答几个问题。

4.1 为什么服务端要用异步去注册,而不用同步?

设想下如果这个中间件,采用同步注册,如果运维启动一批服务注册上去,先注册,再消费队列,每个服务的启动时间是不是很久,如果我的一个项目中引入很多很多中间件,每个中间件都要同步去做这些事情,那整个系统启动非常慢导致不可用。所以要采用异步消费。

简单来说,就是为了支持高并发注册。采用内存队列的方式进行服务注册,也就是说客户端在把自己的信息注册到Nacos Server的时候,并不是同步把信息写入到注册表中的,而且采取了先写入内存队列中,然后用独立的线程池来消费队列进行注册的。

从源码可看出最终会执行listener.onChange()这个方法,并把Instances传入,然后进行真正的注册逻辑,这里的设计就是为了提高Nacos Server的并发注册量。这里再提一下,在进行队列消费的时候其实最终也是采用的JDK的线程池。

4.2 注册表的设计如何防止多节点读写并发冲突?

这个注册表会不停的修改,那么其他服务拉取这个注册表的时候,保证数据怎么正确?

采用CopyOnWrite思想 ,我们知道注册的逻辑比较复杂,很多步骤,每一步都可能会改这个注册表的结构和逻辑,我们不可能加锁,性能效率会非常低而且并发很低。解决这种读写冲突问题,我们采用写时复制思想;阿里的中间件不会随随便便加把锁,所以在写的时候,修改的是一份副本,修改完这个副本后,再替换注册表,读的时候是读真正替换后的注册表!!

等于是读写分离,但是有个弊端,你写你的,我读我的,有可能会导致数据不一致,只有当替换回来的时候,我才能读到新的数据。

在Nacos中的具体实现就是:updateIps方法中传入了一个List<Instance> ips,然后用ips跟之前注册表中的Instances进行比较,分别得出需要添加、更新、和删除的实例,然后做一些相关的操作,比如Instance的一些属性设置、启动心跳、删除心跳等等,最后把处理后的List<Instance> ips,直接替换内存注册表,这样如果同时有读的请求,其实读取是之前的老注册表的信息,这样就很好的控制了并发读写冲突问题,这个思想就是Copy On Write思想,在JDK源码中并发包里也有一些相关的实现,比如:CopyOnWriteArrayList。

虽然写时复制提高了我们的并发了,但是对数据的实时性就不能很好的保证(其实Nacos的实时性已经很好了)。但是这个对于Nacos提供的功能来说影响大吗,其实并不大,无非就是生产者启动慢点罢了,延迟一点的感知到服务变化其实对整个系统的影响并不大,Eureka都延迟几十秒,Nacos这个延迟并不大,后面我会说到客户端也会定时拉取服务端最新的注册信息,以及剔除下线的服务。采用这种写时复制的方案,大大的提升了并发,已经很不错了,总不能又要高并发,又要实时感知及时。

当然也不存在每个服务都复制一份去写,因为服务端后台就一个线程去从队列中获取排队的服务来将其注册,不存在多个线程去对不同的服务进行写时复制的情况。

就是在这个方法中进行写时复制(CopyOnWrite)的。

4.3 为什么要这么设计双层 Map 呢?

1、考虑到目前开发的环境,和市面上公司的情况,有的公司钱不多,不能支撑每个环境都做一套注册中心。Nacos支持只需要部署一套环境就能支持你所有的开发环境,区分namespace和group。

2、高可扩展,大型互联网公司,一定是多机房部署,比如深圳机房,华南机房,不可能只有一个机房在北京,内蒙古那边访问个淘宝要等很久才能出来,所以双层Map中会有Cluster,通过Cluster区分哪个集群属于哪个机房部署。这种商用中间件一定是这样多扩展的。

4.4 Nacos 服务注册发现性能测试

1.x单机并发量最大1.4w,2.x并发量能扩大一倍。


相关文章: 【微服务】微服务架构演进_微服务架构演进图 l1 l2 l3-CSDN博客

【Nacos】Alibaba Nacos注册中心实战-CSDN博客

参考文章:https://blog.csdn.net/Saintmm/article/details/121981184?spm=a2c6h.12873639.article-detail.7.38171d5cL6JHgD#t2

相关推荐
wellc31 分钟前
SpringBoot集成Flowable
java·spring boot·后端
Hui Baby1 小时前
springAi+MCP三种
java
hsjcjh1 小时前
【MySQL】C# 连接MySQL
java
敖正炀1 小时前
LinkedBlockingDeque详解
java
wangyadong3171 小时前
datagrip 链接mysql 报错
java
untE EADO1 小时前
Tomcat的server.xml配置详解
xml·java·tomcat
ictI CABL2 小时前
Tomcat 乱码问题彻底解决
java·tomcat
敖正炀2 小时前
DelayQueue 详解
java
敖正炀2 小时前
PriorityBlockingQueue 详解
java
shark22222222 小时前
Spring 的三种注入方式?
java·数据库·spring