Nacod服务注册与发现(AP架构)、心跳检查机制


1. Nacos核心功能点

        

Nacos架构图如下:
在这里插入图片描述
结合以上架构图,Nacos的核心功能点有如下几点:

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

问题:各个服务是如何通过Nacos进行调用的?

  1. 微服务系统在启动时将自己注册到服务注册中心,同时外发布 Http 接口供其它系统调用(一般都是基于Spring MVC)
  2. 服务消费者基于 Feign 调用服务提供者对外发布的接口,先对调用的本地接口加上注解@FeignClientFeign会针对加了该注解的接口生成动态代理,服务消费者针对 Feign 生成的动态代理去调用方法时,会在底层生成Http协议格式的请求,类似 /stock/deduct?productId=100
  3. Feign 最终会调用Ribbon从本地的Nacos注册表的缓存里根据服务名取出服务提供在机器的列表,然后进行负载均衡并选择一台机器出来,对选出来的机器IP和端口拼接之前生成的url请求,生成调用的Http接口地址 http://192.168.0.60:9000/stock/deduct?productId=100,最后基于HTTPClient调用请求

下面就从源码角度更细致的了解一下Nacos的核心功能点的底层原理
        

2. 服务注册

        在以Nacos为焦点时,订单服务、库存服务都属于客户端,而Nacos注册中心则作为服务端接收各个微服务的注册请求!所以在服务注册逻辑中又分为两部分

  1. 客户端向Nacos服务端发起注册请求
  2. Nacos服务端保存服务实例到注册表中

        

①: 客户端向Nacos服务端发起注册请求

订单、库存等微服务服务想要注册到Nacos,首先要引入Nacos的依赖

   <!-- nacos服务注册与发现 -->
   <dependency>
       <groupId>com.alibaba.cloud</groupId>
       <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
   </dependency>

        根据spring boot 的自动配置原理, 在引入依赖之后,spring boot会通过SPI的方式读取META-INF/spring.factories目录下的配置信息,并把对应的配置类加载到容器中,以供项目后续使用
在这里插入图片描述
其中服务注册逻辑 就在上图标红框的NacosServiceRegistryAutoConfiguration类中。

public class NacosServiceRegistryAutoConfiguration {

	//生成bean对象:NacosServiceRegistry 
	@Bean
	public NacosServiceRegistry nacosServiceRegistry(
			NacosDiscoveryProperties nacosDiscoveryProperties) {
		return new NacosServiceRegistry(nacosDiscoveryProperties);
	}

	//生成bean对象:NacosRegistration 
	@Bean
	@ConditionalOnBean(AutoServiceRegistrationProperties.class)
	public NacosRegistration nacosRegistration(
			ObjectProvider<List<NacosRegistrationCustomizer>> registrationCustomizers,
			NacosDiscoveryProperties nacosDiscoveryProperties,
			ApplicationContext context) {
		return new NacosRegistration(registrationCustomizers.getIfAvailable(),
				nacosDiscoveryProperties, context);
	}

	//生成bean对象:NacosAutoServiceRegistration 
	//这个NacosAutoServiceRegistration 才是真正的注册逻辑,
	//上面两个bean 是生成NacosAutoServiceRegistration 的必要参数!
	@Bean
	@ConditionalOnBean(AutoServiceRegistrationProperties.class)
	public NacosAutoServiceRegistration nacosAutoServiceRegistration(
			NacosServiceRegistry registry, //上面的bean
			AutoServiceRegistrationProperties autoServiceRegistrationProperties,
			NacosRegistration registration //上面的bean) {
		return new NacosAutoServiceRegistration(registry,
				autoServiceRegistrationProperties, registration);
	}
}

        上面代码可以看到NacosAutoServiceRegistration才是真正的注册类,而NacosAutoServiceRegistration的继承关系图如下:
在这里插入图片描述
        可以看到NacosAutoServiceRegistration实现了事件监听器接口ApplicationListener,那么必然会在onApplicationEvent方法中监听某个事件,如下所示,监听的是WebServerInitializedEvent事件

	@Override
	@SuppressWarnings("deprecation")
	public void onApplicationEvent(WebServerInitializedEvent event) {
		bind(event);
	}

Spring Boot 启动事件顺序:

  1. ApplicationStartingEvent:这个事件在 Spring Boot 应用运行开始时,且进行任何处理之前发送(除了监听器和初始化器注册之外)。
  2. ApplicationEnvironmentPreparedEvent:这个事件在初始化环境时prepareEnvironment时调用,在 Spring 上下文(context)创建之前发送。
  3. ApplicationContextInitializedEvent:这个事件在容器的准备阶段prepareContext时被调用。此时应用初始化器(ApplicationContextInitializers)已经被初始化完毕,在(启动类的) bean 定义被加载之前发送。
  4. ApplicationPreparedEvent:这个事件是在 Spring 上下文(context)刷新之前,且在(启动类的)bean 的定义被加载之后发送,代码也在prepareContext内部!
  5. ApplicationStartedEvent:这个事件是在 Spring 上下文(context)刷新之后,在refreshContext(context);被调用的
  6. AvailabilityChangeEvent:这个事件紧随上个事件之后发送,状态:ReadinessState.CORRECT,表示应用已处于活动状态。
  7. ApplicationReadyEvent:这个事件在任何 application/ command-line runners 调用之后发送。
  8. AvailabilityChangeEvent:这个事件紧随上个事件之后发送,状态:ReadinessState.ACCEPTING_TRAFFIC,表示应用可以开始准备接收请求了。
  9. ApplicationFailedEvent:这个事件在应用启动异常时进行发送。

除了这些事件以外,以下事件也会在 ApplicationPreparedEvent 之后和 ApplicationStartedEvent 之前发送:

  1. WebServerInitializedEvent:这个 Web 服务器初始化事件在 WebServer 启动之后发送,对应的还有 ServletWebServerInitializedEvent(Servlet Web 服务器初始化事件)、ReactiveWebServerInitializedEvent(响应式 Web 服务器初始化事件)。
  2. ContextRefreshedEvent:这个上下文刷新事件是在 Spring 应用上下文(ApplicationContext)刷新之后发送。

监听到WebServerInitializedEvent事件发布时,会在onApplicationEvent方法中执行bind(event)方法!

	@Deprecated
	public void bind(WebServerInitializedEvent event) {
		//获取容器
		ApplicationContext context = event.getApplicationContext();
		if (context instanceof ConfigurableWebServerApplicationContext) {
			if ("management".equals(((ConfigurableWebServerApplicationContext) context)
					.getServerNamespace())) {
				return;
			}
		}
		this.port.compareAndSet(0, event.getWebServer().getPort());
		//进入start()方法
		this.start();
	}

然后在this.start();中执行注册逻辑:

    public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
        NamingUtils.checkInstanceIsLegal(instance);
        String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
        if (instance.isEphemeral()) {
            BeatInfo beatInfo = this.beatReactor.buildBeatInfo(groupedServiceName, instance);
            //每隔5秒向服务端发送一次心跳,证明当前服务是存活的!
            this.beatReactor.addBeatInfo(groupedServiceName, beatInfo);
        }
		//注册实例
        this.serverProxy.registerService(groupedServiceName, groupName, instance);
    }

registerInstance()注册方法中主要做了两件事情

  1. 定时向服务端发送心跳。开启一个定时线程池,每隔Period:5s秒钟向服务端发送一次心跳,证明当前服务是健康的。其中在BeatTaskrun方法中采用递归调用this.executorService.schedule来达到不断发送心跳的目的!发心跳更新服务实例的最后心跳时间,防止该实例被服务端剔除
    public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
        LogUtils.NAMING_LOGGER.info("[BEAT] adding beat: {} to beat map.", beatInfo);
        String key = this.buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
        BeatInfo existBeat = null;
        if ((existBeat = (BeatInfo)this.dom2Beat.remove(key)) != null) {
            existBeat.setStopped(true);
        }

        this.dom2Beat.put(key, beatInfo);
        //发送心跳的定时任务,Period:5秒
        this.executorService.schedule(new BeatReactor.BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
        MetricsMonitor.getDom2BeatSizeMonitor().set((double)this.dom2Beat.size());
    }
  1. 请求服务端的服务注册接口,向服务端注册服务:其中serverProxy.registerService()会向Nacos发起注册请求,请求体包含namespaceIdserviceNameipport等等!
    public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
        LogUtils.NAMING_LOGGER.info("[REGISTER-SERVICE] {} registering service {} with instance: {}", new Object[]{this.namespaceId, serviceName, instance});
        //封装服务参数
        Map<String, String> params = new HashMap(16);
        params.put("namespaceId", this.namespaceId);
        params.put("serviceName", serviceName);
        params.put("groupName", groupName);
        params.put("clusterName", instance.getClusterName());
        params.put("ip", instance.getIp());
        params.put("port", String.valueOf(instance.getPort()));
        params.put("weight", String.valueOf(instance.getWeight()));
        params.put("enable", String.valueOf(instance.isEnabled()));
        params.put("healthy", String.valueOf(instance.isHealthy()));
        params.put("ephemeral", String.valueOf(instance.isEphemeral()));
        params.put("metadata", JacksonUtils.toJson(instance.getMetadata()));
        //Http调用Nacos注册接口地址! 
        //UtilAndComs.nacosUrlInstance 就是 /nacos/v1/ns/instance
        this.reqApi(UtilAndComs.nacosUrlInstance, params, "POST");
    }

Nacos官方文档暴露的接口地址如下所示,与reqApi()的请求地址一致!
在这里插入图片描述

客户端注册逻辑到此为止!接下来看一下Nacos服务端如何处理客户端发来的注册请求!

        

②: Nacos服务端保存服务实例到注册表中

        Nacos服务器端代码是Rest风格的,其本质还是一个spring boot项目,服务注册逻辑在InstanceController

@RestController
//这个请求地址就是客户端注册地址: /nacos/v1/ns/instance
@RequestMapping(UtilsAndCommons.NACOS_NAMING_CONTEXT + "/instance")
public class InstanceController {

	//注册服务
    @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 = parseInstance(request);
        
        //注册实例
        serviceManager.registerInstance(namespaceId, serviceName, instance);
        return "ok";
    }
}

注册实例registerInstance()方法如下:

    public void registerInstance(String namespaceId, String serviceName, Instance instance) throws NacosException {
        //1.根据namespaceId、serviceName构建一个双层map的Service注册表结构外壳
        //2.开启一个延迟定时线程池,延时5秒,每隔5秒发起一次心跳检测
        createEmptyService(namespaceId, serviceName, instance.isEphemeral());

        //去双层map中获取这个Service注册表对象
        Service service = getService(namespaceId, serviceName);

        if (service == null) {
            throw new NacosException(NacosException.INVALID_PARAM,
                    "service not found, namespace: " + namespaceId + ", service: " + serviceName);
        }
        //把instance填充到Service注册表中
        addInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
    }

createEmptyService方法主要做了两件事情:

  1. 根据namespaceIdserviceName构建一个Service注册表结构外壳,此时还没有填入instance实例。服务注册表的结构如下文所示。

  2. 开启一个延迟定时线程池,延时5秒,每隔5秒发起一次心跳检测,如果15秒没有响应,则把服务的健康状态置为false,如果30秒没有响应,才踢出该服务!

    public static void scheduleCheck(ClientBeatCheckTask task) {
    	//开启延时线程池,5秒后执行,每隔5秒进行一次健康检测
    	//执行的任务就是 ClientBeatCheckTask 中的run方法!
        futureMap.computeIfAbsent(task.taskKey(),
                k -> GlobalExecutor.scheduleNamingHealth(task, 5000, 5000, TimeUnit.MILLISECONDS));
    }

进入ClientBeatCheckTaskrun方法,查看心跳检查逻辑!

 @Override
    public void run() {
        try {
            //集群健康检查
            if (!getDistroMapper().responsible(service.getName())) {
                return;
            }

            if (!getSwitchDomain().isHealthCheckEnabled()) {
                return;
            }
            //1.获取所有的服务列表
            List<Instance> instances = service.allIPs(true);
            
            //2.遍历服务列表
            for (Instance instance : instances) {
            	//4.如果 当前时间 - 服务的最后一次心跳时间 > 15秒
                if (System.currentTimeMillis() - instance.getLastBeat() > instance.getInstanceHeartBeatTimeOut()) {
                    if (!instance.isMarked()) {
                        if (instance.isHealthy()) {
                        	//设置服务健康状态为false,此时并未剔除服务
                            instance.setHealthy(false);
                            Loggers.EVT_LOG
                                    .info("{POS} {IP-DISABLED} valid: {}:{}@{}@{}, region: {}, msg: client timeout after {}, last beat: {}",
                                            instance.getIp(), instance.getPort(), instance.getClusterName(),
                                            service.getName(), UtilsAndCommons.LOCALHOST_SITE,
                                            instance.getInstanceHeartBeatTimeOut(), instance.getLastBeat());
                            getPushService().serviceChanged(service);
                            // 发布服务变更事件
                            ApplicationUtils.publishEvent(new InstanceHeartbeatTimeoutEvent(this, instance));
                        }
                    }
                }
            }
            
            if (!getGlobalConfig().isExpireInstance()) {
                return;
            }
            
            // then remove obsolete instances:
            for (Instance instance : instances) {
                
                if (instance.isMarked()) {
                    continue;
                }
                //5.如果 当前时间 - 服务的最后一次心跳时间 > 30秒,则剔除服务
                if (System.currentTimeMillis() - instance.getLastBeat() > instance.getIpDeleteTimeout()) {
                    Loggers.SRV_LOG.info("[AUTO-DELETE-IP] service: {}, ip: {}", service.getName(),
                            JacksonUtils.toJson(instance));
                    //剔除服务!
                    deleteIp(instance);
                }
            }
            
        } catch (Exception e) {
            Loggers.SRV_LOG.warn("Exception while processing client beat time out.", e);
        }
    }

        
Service注册表结构
        
Service注册表结构其实是一个双层map的结构 Map<namespace, Map<group::serviceName, Service>>

private final Map<String, Map<String, Service>> serviceMap = new ConcurrentHashMap<>();

其中内层MapMap<String, Service>>中的Service中又有一个clusterMap属性,表示同一集群下的节点

private Map<String, Cluster> clusterMap = new HashMap<>();

clusterMap也是一个map结构,mapvalue是一个Cluster类,在这个Cluster类中还有一个几个Set集合,而这些Set集合才是真正存储服务实例的地方!

//存储持久化服务实例
@JsonIgnore
private Set<Instance> persistentInstances = new HashSet<>();

//存储临时服务实例
@JsonIgnore
private Set<Instance> ephemeralInstances = new HashSet<>();

可以看出这个服务注册表的结构还是挺复杂的!其结构如下图所示:
在这里插入图片描述

        双层MapService注册表的外壳创建完成后,此时还需要把服务实例instance注册进去,才算服务注册完成,接下来看addInstance()方法,添加服务实例

其中实例分为两种

  1. 临时实例
  2. 持久实例
    public void addInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
            throws NacosException {

        //获取实例的key。key分为临时实例 和 持久化实例
        //根据入参ephemeral去判断,ephemeral默认为true,默认是临时实例
        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);
            //把(临时、持久)实例放入队列
            //注意:此处会根据实例类型 选择AP架构或者CP架构 的存储方式!
            consistencyService.put(key, instances);
        }
    }

注意:在执行consistencyService.put方法时,nacos会根据不同的实例类型选择不同的架构

  1. 临时实例,选择AP架构,使用Distro协议,分布式协议的一种,阿里内部的协议,服务是放在内存中!
  2. 持久实例,选择CP架构,使用Raft协议,点击查看Nacos的CP架构详情!!,服务是放在磁盘中!
    在这里插入图片描述

本章先探讨AP架构下的Nacos的注册逻辑, 持续跟进consistencyService.put(key, instances)方法,如下:

    @Override
    public void put(String key, Record value) throws NacosException {
    	//把任务放入队列
        onPut(key, value);
        distroProtocol.sync(new DistroKey(key, KeyBuilder.INSTANCE_LIST_KEY_PREFIX), DataOperation.CHANGE,
                globalConfig.getTaskDispatchPeriod() / 2);
    }

把任务放入队列是onPut()方法做的,来看一下onPut()方法做了什么?

    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.put(key, datum);
        }
        
        if (!listeners.containsKey(key)) {
            return;
        }
        //由notifier把实例放入阻塞队列不管了,实现了异步
        notifier.addTask(key, DataOperation.CHANGE);
    }

        可以看到onPut()方法中由Notifier.addTask()把实例放入阻塞队列不管了,实现了异步,可真正把实例放入双层Map中的Set集合的逻辑是由谁做的呢?

        我们发现onPut()方法是DistroConsistencyServiceImpl类中的一个方法,由于这个类中的init()方法加了@PostConstruct注解,所以DistroConsistencyServiceImpl类在初始化完毕,就会执行init()方法,init()方法内容如下:

    @PostConstruct
    public void init() {
    	//这是一个线程池,初始化完毕后会有线程去执行对应的任务 Notifier
        GlobalExecutor.submitDistroNotifyTask(notifier);
    }

        可以看到在init()方法中开启了一个线程池执行Notifier这样一个线程,由于Notifier实现了Runable接口,其内部的run方法会在DistroConsistencyServiceImpl类在初始化完毕后被执行,run()内部的逻辑就会把服务实例放入放入双层Map中的Set集合中!
在这里插入图片描述
        run方法内容如下:死循环的去阻塞队列中取注册任务,但不会一直占用cpu,因为采用的是阻塞队列,如果没有任务当前线程会被阻塞,并不会占用cpu,阻塞队列这单设计的很好!

        @Override
        public void run() {
            Loggers.DISTRO.info("distro notifier started");
            
            for (; ; ) {
                try {
                    Pair<String, DataOperation> pair = tasks.take();
                    handle(pair);
                } catch (Throwable e) {
                    Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
                }
            }
        }

        在Notifierrun方法中找到updateIps()方法,可以看到注册逻辑,为了避免对服务实例集合Set<Instance>的并发读写问题,Nacos在注册时,采用了 写时复制、读写分离 的思想,有效的避免了并发读写问题,但会造成一定的数据一致性问题,在AP架构下,这种问题可以忽略,因为客户端是定时拉取,保证最终一致性的!

    public void updateIps(List<Instance> ips, boolean ephemeral) {
        //获取原来的(临时、持久)实例
        Set<Instance> toUpdateInstances = ephemeral ? ephemeralInstances : persistentInstances;
        
        HashMap<String, Instance> oldIpMap = new HashMap<>(toUpdateInstances.size());
        //把原来的(临时、持久)实例放入 oldIpMap中,让客户端去读
        for (Instance ip : toUpdateInstances) {
            oldIpMap.put(ip.getDatumKey(), ip);
        }
        //写时复制,写的时候复制一个新的副本,读写分离,避免并发读写问题
        List<Instance> updatedIPs = updatedIps(ips, oldIpMap.values());
        if (updatedIPs.size() > 0) {
            for (Instance ip : updatedIPs) {
                Instance oldIP = oldIpMap.get(ip.getDatumKey());

                if (!ip.isMarked()) {
                    ip.setHealthy(oldIP.isHealthy());
                }
                
                if (ip.isHealthy() != oldIP.isHealthy()) {
                    // ip validation status updated
                    Loggers.EVT_LOG.info("{} {SYNC} IP-{} {}:{}@{}", getService().getName(),
                            (ip.isHealthy() ? "ENABLED" : "DISABLED"), ip.getIp(), ip.getPort(), getName());
                }
                
                if (ip.getWeight() != oldIP.getWeight()) {
                    // ip validation status updated
                    Loggers.EVT_LOG.info("{} {SYNC} {IP-UPDATED} {}->{}", getService().getName(), oldIP.toString(),
                            ip.toString());
                }
            }
        }
        
        List<Instance> newIPs = subtract(ips, oldIpMap.values());
        if (newIPs.size() > 0) {
            Loggers.EVT_LOG
                    .info("{} {SYNC} {IP-NEW} cluster: {}, new ips size: {}, content: {}", getService().getName(),
                            getName(), newIPs.size(), newIPs.toString());
            
            for (Instance ip : newIPs) {
                HealthCheckStatus.reset(ip);
            }
        }
        
        List<Instance> deadIPs = subtract(oldIpMap.values(), ips);
        
        if (deadIPs.size() > 0) {
            Loggers.EVT_LOG
                    .info("{} {SYNC} {IP-DEAD} cluster: {}, dead ips size: {}, content: {}", getService().getName(),
                            getName(), deadIPs.size(), deadIPs.toString());
            
            for (Instance ip : deadIPs) {
                HealthCheckStatus.remv(ip);
            }
        }
        //新的实例集合
        toUpdateInstances = new HashSet<>(ips);
        
        if (ephemeral) {
        	//写完后,用新的实例集合替换原来的旧的实例集合
            ephemeralInstances = toUpdateInstances;
        } else {
            persistentInstances = toUpdateInstances;
        }
    }


3. 服务发现

        服务发现是指:客户端从Nacos服务端获取对应的服务列表,比如订单服务要调用库存服务时,会先根据库存服务的服务名、服务group、命名空间ID等信息去nacos服务端获取库存服务的服务列表,保存在本地后,再通过负载均衡策略,找到一个合适的库存服务去请求!

客户端提供了一个类NacosNamingService.getAllInstances()去查询服务列表

    public List<Instance> getAllInstances(String serviceName, String groupName, List<String> clusters, boolean subscribe) throws NacosException {
        ServiceInfo serviceInfo;
        if (subscribe) {
        	//获取客户端的服务缓存列表
            serviceInfo = this.hostReactor.getServiceInfo(NamingUtils.getGroupedName(serviceName, groupName), StringUtils.join(clusters, ","));
        } else {
            serviceInfo = this.hostReactor.getServiceInfoDirectlyFromServer(NamingUtils.getGroupedName(serviceName, groupName), StringUtils.join(clusters, ","));
        }

        List list;
        return (List)(serviceInfo != null && !CollectionUtils.isEmpty(list = serviceInfo.getHosts()) ? list : new ArrayList());
    }

其中this.hostReactor.getServiceInfo是获取服务列表的方法,获取流程如下:

  • 获取时根据的服务名、服务group、命名空间ID等信息从本地服务列表中获取
    • 如果本地服务列表中有该服务实例集合
    • 如果本地服务列表中没有该服务实例集合,再请求Nacos服务端的/nacos/v1/ns/instance/list接口,返回服务实例列表(并不是双map结构)。获取服务列表并存储在本地
  • 开启延时定时任务,定时获取最新的服务端数据并更新到本地
    public ServiceInfo getServiceInfo(String serviceName, String clusters) {
        LogUtils.NAMING_LOGGER.debug("failover-mode: " + this.failoverReactor.isFailoverSwitch());
        String key = ServiceInfo.getKey(serviceName, clusters);
        if (this.failoverReactor.isFailoverSwitch()) {
            return this.failoverReactor.getService(key);
        } else {
        	//1.从本地缓存列表中获取实例
            ServiceInfo serviceObj = this.getServiceInfo0(serviceName, clusters);
           	//2.如果本地缓存的实例为null
            if (null == serviceObj) {
                serviceObj = new ServiceInfo(serviceName, clusters);
                this.serviceInfoMap.put(serviceObj.getKey(), serviceObj);
                this.updatingMap.put(serviceName, new Object());
                //3.请求nacos服务端的/nacos/v1/ns/instance/list方法,查询服务列表
                this.updateServiceNow(serviceName, clusters);
                this.updatingMap.remove(serviceName);
            } else if (this.updatingMap.containsKey(serviceName)) {
                synchronized(serviceObj) {
                    try {
                        serviceObj.wait(5000L);
                    } catch (InterruptedException var8) {
                        LogUtils.NAMING_LOGGER.error("[getServiceInfo] serviceName:" + serviceName + ", clusters:" + clusters, var8);
                    }
                }
            }
			//4.开启定时延时任务,持续从nacos服务端获取最新服务实例
            this.scheduleUpdateIfAbsent(serviceName, clusters);
            //5.最终还是从本地缓存列表中获取实例集合的!
            return (ServiceInfo)this.serviceInfoMap.get(serviceObj.getKey());
        }
    }

        其中定时任务获取时,延时1秒执行任务的run方法,然后在任务的finally方法中再次执行定时任务,这样就实现了持续调用,持续更新本地服务列表!

    public synchronized ScheduledFuture<?> addTask(HostReactor.UpdateTask task) {
    	//延时一秒执行 HostReactor.UpdateTask中的run方法!
        return this.executor.schedule(task, 1000L, TimeUnit.MILLISECONDS);
    }

HostReactor.UpdateTask中的run方法如下:

 public void run() {
            long delayTime = 1000L;

            try {
                ServiceInfo serviceObj = (ServiceInfo)HostReactor.this.serviceInfoMap.get(ServiceInfo.getKey(this.serviceName, this.clusters));
                if (serviceObj == null) {
                	//请求nacos服务端,并更新本地缓存列表
                    HostReactor.this.updateService(this.serviceName, this.clusters);
                    return;
                }

				。。。。。。。 /省略代码
				
            } catch (Throwable var7) {
                this.incFailCount();
                LogUtils.NAMING_LOGGER.warn("[NA] failed to update serviceName: " + this.serviceName, var7);
            } finally {
            	//finally方法中继续执行定时任务,实现持续调用,持续更新
                HostReactor.this.executor.schedule(this, Math.min(delayTime << this.failCount, 60000L), TimeUnit.MILLISECONDS);
            }

        }


4. Nacos如何处理服务事件变动?

服务事件变动示例:库存服务下线了,由于订单服务的本地缓存列表中还没来得及更新,依然会调用已下线的库存服务,发现库存服务调不通,此时怎么办呢?

  • 如果库存服务是集群架构,那么Feign在发现调不通时会触发重试机制,可以重试集群中其他可以调的通的库存服务
  • 订单服务会有定时任务默认每隔5秒钟去Nacos服务端拉取最新的库存服务列表,在这期间,已下线的库存服务仍然是不可用的!
  • 但是nacos为了提高客户端感知服务事件变动的及时性,新增了一项操作:当服务变动时,发布服务变动事件ServiceChangeEventnacos服务端监听到此事件,就通过UDP的方式向nacos客户端(订单服务)主动推送最新服务列表

        客户端定时任务拉取最新服务列表的逻辑上文已经讲过,下面看一下nacos服务端是如何实现主动推送的?结合上文的服务注册逻辑,我们已经知道在Notifierrun方法是真正的注册逻辑。持续跟进run方法,调用栈如下:

  • com.alibaba.nacos.naming.consistency.ephemeral.distro.DistroConsistencyServiceImpl.Notifier#run
  • com.alibaba.nacos.naming.consistency.ephemeral.distro.DistroConsistencyServiceImpl.Notifier#handle
  • com.alibaba.nacos.naming.core.Service#onChange
  • com.alibaba.nacos.naming.core.Service#updateIPs

发现updateIPs方法的serviceChanged方法发布了一个服务变更事件ServiceChangeEvent

 getPushService().serviceChanged(this);
    public void serviceChanged(Service service) {
        // merge some change events to reduce the push frequency:
        if (futureMap
                .containsKey(UtilsAndCommons.assembleFullServiceName(service.getNamespaceId(), service.getName()))) {
            return;
        }
        //发布一个服务变更事件ServiceChangeEvent
        this.applicationContext.publishEvent(new ServiceChangeEvent(this, service));
    }

有事件必有onApplicationEvent

  • com.alibaba.nacos.naming.push.PushService#onApplicationEvent
  • com.alibaba.nacos.naming.push.PushService#udpPush

        在udpPush方法中调用了udpSocket.send(ackEntry.origin);方法向客户端发送了UDP请求,主动推送当前服务变更状态!UDP不像TCP一样有三次握手环节,所以会发生数据丢失的情况。Nacos 这种推送模式,对于zookeeper那种通过tcp长连接来说会节省很多种资源,就算大量节点更新也不会让Nacos出现太多的性能消耗。

        在Nacos中客户端如果接收到了UDP消息,会返回一个ack,如果一定时间内Nacos服务端没有接收到ack回调,还会进行重发,当超过一定重发时间后,就不再重发了

        这种推送模式虽然不一定能完全保证最新的服务信息送达客户端,但客户端还有定时任务拉取最新服务列表,可以保证最终一致性。所以Nacos通过这两种手段,既保证了实时性,又保证了一致性!一定程度上提高Nacos客户端感知服务变动的及时性

        
UDP月TCP的区别?

  • TCP:面向连接,可靠的,速度慢,效率低
  • UDP:无连接,面向报文,不可靠,速度快,效率低

TCP的三次握手与四次挥手

  • 三次握手
    • 客户端发送一个带SYN标志的TCP报文到服务器端,并进入SYN_SEND状态,等待服务端确认
    • 服务端收到客户端的报文并返回一个同时带ACK标志和SYN标志的报文,进入SYN_RECV状态。表示确认刚才客户端的报文,同时询问客户端是否准备好通讯
    • 客户端再次回应服务端一个ACK报文,双方进入ESTABILISHED状态
  • 四次挥手
    • TCP客户端发送一个FIN,用来关闭客户端到服务端的数据传送
    • 服务端收到这个FIN,它发挥一个ACK,确认序号为收到的序号加一
    • 服务端关闭客户端的连接,发送一个FIN给客户端
    • 客户端发回ACK报文确认,并将确认序号设置为收到序号加一

为什么TCP建立连接需要三次握手,关闭连接需要四次挥手?

        客户端和服务端通信前要进行连接,“3次握手”的作用就是双方都能明确自己和对方的收、发能力是正常的。

  • 第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。
  • 第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。从客户端的视角来看,我接到了服务端发送过来的响应数据包,说明服务端接收到了我在第一次握手时发送的网络包,并且成功发送了响应数据包,这就说明,服务端的接收、发送能力正常。而另一方面,我收到了服务端的响应数据包,说明我第一次发送的网络包成功到达服务端,这样,我自己的发送和接收能力也是正常的。
  • 第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力,服务端的发送、接收能力是正常的。因为第一、二次握手后,服务端并不知道客户端的接收能力以及自己的发送能力是否正常。而在第三次握手时,服务端收到了客户端对第二次握手作的回应。从服务端的角度,我在第二次握手时的响应数据发送出去了,客户端接收到了。所以,我的发送能力是正常的。而客户端的接收能力也是正常的。

那为什么关闭时需要四次挥手?

  • 在建立连接时,服务端收到客户端的SYN连接请求报文后,可以把ACKSYN(ACK起应答作用,SYN起同步作用)放在一个报文里发送。 但关闭连接时,当服务端收到对方的FIN报文时,它仅仅表示客户端没有数据发送给服务端了,但服务端在建立连接时产生的数据还不一定都全部发送给对方了,所以服务端未必会马上关闭SOCKET,也就是说你可能还需要发送一下数据给对方之后,再发送FIN报文给对方来表示你同意现在可以关闭连接了,所以这里的ACKFIN多数情况下都是分开发送的。


5. Nacos为什么能够支撑高并发?

根据Nacos官网的压测报告,发现Nacos单机即可支持13000以上的并发!
在这里插入图片描述
作为一款高性能的中间件,保证其性能稳定的因素主要有以下几点:

  • 使用阻塞队列,异步注册
    • 客户端请求Nacos服务端的注册接口时,Nacos服务端把服务实例放在一个阻塞队列中即可,后续由一个线程完成注册!实现异步
  • 写实复制、读写分离,防止多节点并发冲突
    • 为了防止注册时对服务实例集合Set<Instance>并发读写,产生并发修改异常。Nacos在注册时先把服务实例列表复制一份出来进行修改,注意只复制对应的服务实例列表,复制的内容并不多,不会给系统造成压力,并不是复制那个双Map结构。读操作读的是原来的服务列表,等待写完后,用新的服务列表替换掉旧的服务列表即可!
    • Eureka防止读写并发冲突用的方法是:多级缓存架构,有只读缓存、读写缓存、内存注册表。各级缓存之间定时同步,客户端感知的即时性不如Nacos


6. Nacos集群模式下如何进行心跳检查

        Nacos单机模式的心跳机制上文已经分析过,主要逻辑是:开启一个延迟定时线程池,延时5秒,每隔5秒发起一次心跳检测,如果15秒没有响应,则把服务的健康状态置为false,如果30秒没有响应,才踢出该服务!

        在集群模式下,是否所有的机器都会向nacos客户端发送心跳检测?

        答案是否定的!集群模式下的心跳检查与单机模式一样,也是由定时任务触发,发生在ClientBeatCheckTask#类的run方法中。

在AP架构下

  • 集群模式下的心跳检查,并不是所有的集群节点都去发送心跳检查任务,而是通过服务名的hash值与集群中的机器数量做取模运算,看返回结果是否等于某个值,如果不等于直接return,不再执行执行心跳检查任务,这样就只有一台机器会发送心跳检查。

        集群模式下,当心跳检查出现问题,有服务宕机,服务健康状态被修改或者服务被剔除。那么其他集群节点是如何感知并做到数据同步的呢?

  • 其实Naocs的集群节点之间也会发送定时心跳,除了检测集群节点是否挂掉,也会通过定时任务获取最新的数据信息,同步到自己的机器节点上, 各服务之间通过请求Nacos服务端的REST风格的接口完成Http调用。如果发现服务健康状态变成false,那么通过心跳获取到健康状态后,也会更新当前节点的健康状态为false
 @Override
    public void run() {
        try {
            //集群健康检查:集群中只会有一台机器返回true,继续执行,其他机器直接return
            if (!getDistroMapper().responsible(service.getName())) {
                return;
            }

            if (!getSwitchDomain().isHealthCheckEnabled()) {
                return;
            }
            //1.获取所有的服务列表
            List<Instance> instances = service.allIPs(true);
            
            //2.遍历服务列表
            for (Instance instance : instances) {
            	//4.如果 当前时间 - 服务的最后一次心跳时间 > 15秒
                if (System.currentTimeMillis() - instance.getLastBeat() > instance.getInstanceHeartBeatTimeOut()) {
                    if (!instance.isMarked()) {
                        if (instance.isHealthy()) {
                        	//设置服务健康状态为false,此时并未剔除服务
                            instance.setHealthy(false);
                            Loggers.EVT_LOG
                                    .info("{POS} {IP-DISABLED} valid: {}:{}@{}@{}, region: {}, msg: client timeout after {}, last beat: {}",
                                            instance.getIp(), instance.getPort(), instance.getClusterName(),
                                            service.getName(), UtilsAndCommons.LOCALHOST_SITE,
                                            instance.getInstanceHeartBeatTimeOut(), instance.getLastBeat());
                            getPushService().serviceChanged(service);
                            // 发布服务变更事件
                            ApplicationUtils.publishEvent(new InstanceHeartbeatTimeoutEvent(this, instance));
                        }
                    }
                }
            }
            
            if (!getGlobalConfig().isExpireInstance()) {
                return;
            }
            
            // then remove obsolete instances:
            for (Instance instance : instances) {
                
                if (instance.isMarked()) {
                    continue;
                }
                //5.如果 当前时间 - 服务的最后一次心跳时间 > 30秒,则剔除服务
                if (System.currentTimeMillis() - instance.getLastBeat() > instance.getIpDeleteTimeout()) {
                    Loggers.SRV_LOG.info("[AUTO-DELETE-IP] service: {}, ip: {}", service.getName(),
                            JacksonUtils.toJson(instance));
                    //剔除服务!
                    deleteIp(instance);
                }
            }
            
        } catch (Exception e) {
            Loggers.SRV_LOG.warn("Exception while processing client beat time out.", e);
        }
    }

选择集群中的一台机器发送心跳:取模distroHash(serviceName) % servers.size()

    public boolean responsible(String serviceName) {
		。。。。。。//省略代码
        int index = servers.indexOf(EnvUtil.getLocalAddress());
        int lastIndex = servers.lastIndexOf(EnvUtil.getLocalAddress());
        if (lastIndex < 0 || index < 0) {
            return true;
        }
        
        //拿前来注册的服务名的hash值 和 nacos服务器的数量取模
        int target = distroHash(serviceName) % servers.size();
        //取模结果看是否等于某个值!等于返回true
        return target >= index && target <= lastIndex;
    }
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值