5、Nacos心跳机制与健康检查

如果您对Nacos工作流程和原理还不是很清楚的话,建议从前面的文章开始看:
1、nacos功能简介
2、Nacos服务注册-客户端自动注册流程
3、Nacos服务注册-客户端(nacos-client)逻辑
4、Nacos服务注册-服务端(nacos-naming)逻辑
  

nacos心跳机制与健康检查流程图:
5、Nacos心跳机制与健康检查_第1张图片
  
一、心跳
1、客户端心跳

在《3、Nacos服务注册-客户端(nacos-client)逻辑》这篇中已经提到过一次了,这里再作一些说明。在NacosNamingService.registerInstance(Stirng, String, Instance)方法中(位于nacos-client包),有这么一段代码:

if (instance.isEphemeral()) {
    BeatInfo beatInfo = beatReactor.buildBeatInfo(groupedServiceName, instance);
    beatReactor.addBeatInfo(groupedServiceName, beatInfo);
}

//然后到beatReactor.addBeatInfo方法
public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    NAMING_LOGGER.info("[BEAT] adding beat: {} to beat map.", beatInfo);
    String key = buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
    BeatInfo existBeat = null;
    //fix #1733
    if ((existBeat = dom2Beat.remove(key)) != null) {
        existBeat.setStopped(true);
    }
    dom2Beat.put(key, beatInfo);
    //开启心跳任务
    executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
    MetricsMonitor.getDom2BeatSizeMonitor().set(dom2Beat.size());
}

可能有人会这样想:这时候该实例还没有注册到注册中心,为什么在这个地方就开始开启心跳任务? 其实仔细看下executorService.schedule这个方法,它是Java的线程池ScheduledExecutorService提供的定时任务功能,它还支持延迟执行,延迟时间由beatInfo.getPeriod()指定,这里默认是5秒,也就是说这里虽然开启了心跳任务,但是实际上并没有执行,而是会等5秒之后才开始真正向客户端发送心跳,正常情况下5秒已经注册成功了,即使由于网络原因或者其它什么原因,导致5秒过后服务还没有注册上去,那么心跳也会补充注册。
  
2、服务端心跳续约
我们这里主要看下服务端对于心跳消息的处理。最终客户端心跳任务会向服务端发送HTTP请求:nacos/v1/ns/instance/beat,该接口位于nacos-naming包中的InstanceController类中,找到beat(request)方法:

@CanDistro
@PutMapping("/beat")
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public ObjectNode beat(HttpServletRequest request) throws Exception {

    ObjectNode result = JacksonUtils.createEmptyJsonNode();
    result.put(SwitchEntry.CLIENT_BEAT_INTERVAL, switchDomain.getClientBeatInterval());

    String beat = WebUtils.optional(request, "beat", StringUtils.EMPTY);
    RsInfo clientBeat = null;
    //如果beat为空,说明是轻量级心跳,为了节省网络带宽
    //客户端第一次都是重量级心跳,即beat不为空
    if (StringUtils.isNotBlank(beat)) {
        clientBeat = JacksonUtils.toObj(beat, RsInfo.class);
    }
    String clusterName = WebUtils
            .optional(request, CommonParams.CLUSTER_NAME, UtilsAndCommons.DEFAULT_CLUSTER_NAME);
    String ip = WebUtils.optional(request, "ip", StringUtils.EMPTY);
    int port = Integer.parseInt(WebUtils.optional(request, "port", "0"));
    if (clientBeat != null) {
        if (StringUtils.isNotBlank(clientBeat.getCluster())) {
            clusterName = clientBeat.getCluster();
        } else {
            // fix #2533
            clientBeat.setCluster(clusterName);
        }
        ip = clientBeat.getIp();
        port = clientBeat.getPort();
    }
    String namespaceId = WebUtils.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
    String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
    NamingUtils.checkServiceNameFormat(serviceName);
    Loggers.SRV_LOG.debug("[CLIENT-BEAT] full arguments: beat: {}, serviceName: {}", clientBeat, serviceName);
    Instance instance = serviceManager.getInstance(namespaceId, serviceName, clusterName, ip, port);

    //核心1
    //如果注册表中没有当前服务的实例,则注册
    //这个外侧分支对应场景:如果客户端启动时,过了5秒服务还没有成功注册,则由心跳进行注册(心跳延迟5秒执行)
    if (instance == null) {
        //这个子分支对应的场景:服务端挂了重启后(客户端一直有心跳且不是首次,所以是轻量级心跳),返回20404错误码
        //客户端收到20404错误码会重新发送注册请求
        if (clientBeat == null) {
            result.put(CommonParams.CODE, NamingResponseCode.RESOURCE_NOT_FOUND);
            return result;
        }

        Loggers.SRV_LOG.warn("[CLIENT-BEAT] The instance has been removed for health mechanism, "
                + "perform data compensation operations, beat: {}, serviceName: {}", clientBeat, serviceName);

        instance = new Instance();
        instance.setPort(clientBeat.getPort());
        instance.setIp(clientBeat.getIp());
        instance.setWeight(clientBeat.getWeight());
        instance.setMetadata(clientBeat.getMetadata());
        instance.setClusterName(clusterName);
        instance.setServiceName(serviceName);
        instance.setInstanceId(instance.getInstanceId());
        instance.setEphemeral(clientBeat.isEphemeral());

        serviceManager.registerInstance(namespaceId, serviceName, instance);
    }

    Service service = serviceManager.getService(namespaceId, serviceName);

    if (service == null) {
        throw new NacosException(NacosException.SERVER_ERROR,
                "service not found: " + serviceName + "@" + namespaceId);
    }
    if (clientBeat == null) {
        clientBeat = new RsInfo();
        clientBeat.setIp(ip);
        clientBeat.setPort(port);
        clientBeat.setCluster(clusterName);
    }
    //核心2
    service.processClientBeat(clientBeat);

    result.put(CommonParams.CODE, NamingResponseCode.OK);
    if (instance.containsMetadata(PreservedMetadataKeys.HEART_BEAT_INTERVAL)) {
        result.put(SwitchEntry.CLIENT_BEAT_INTERVAL, instance.getInstanceHeartBeatInterval());
    }
    //首次心跳之后,设置为轻量级心跳
    result.put(SwitchEntry.LIGHT_BEAT_ENABLED, switchDomain.isLightBeatEnabled());
    return result;
}

  
2.1 核心1——容错处理
看下上面代码中注释“核心1”代码,会首先判断注册表中是否已经存在该实例,如果不存在(想下什么情况下会不存在?)会进行重新注册;其中如果注册表不存在且clientBeat=null的情况下,会给客户端响应20404错误码(想下为什么会存在clientBeat=null?),不知道大家还记不记得在之前的文章中说到过,BeatTask.run()方法中,客户端发送心跳之后,会判断服务端响应的错误码如果是20404,就会重新HTTP调用服务端的服务注册接口进行注册。

  • 疑问1:什么情况下注册表不存在该实例?

(1)试想下,如果服务端由于某种原因挂了(注册表数据都会丢失),客户端此时心跳还会一直发,等到服务端故障修复重新启动之后,接受到客户端的心跳就会重新注册;
(2)客户端启动时,会开启心跳任务(延迟5秒执行),但是由于网络原因,导致经过了5秒服务还没有注册到注册中心,此时开始发送心跳,服务端也会进行注册;

  • 疑问2:为什么会存在clientBeat=null?

clientBeat是根据心跳请求中“beat”参数确定的,只有包含beat参数时,才不为空。先看下上面InstanceController中beat方法倒数第二句代码,会在当前心跳处理完之后设置为轻量级心跳(添加lightBeatEnabled响应参数),什么是轻量级心跳?这个要结合客户端发送心跳的地方来看,回到BeatTask.run()方法(截取片段):

boolean lightBeatEnabled = false;
//默认是重量级心跳,如果服务端响应中包含lightBeatEnabled参数,则赋值给lightBeatEnabled
if (result.has(CommonParams.LIGHT_BEAT_ENABLED)) {
    lightBeatEnabled = result.get(CommonParams.LIGHT_BEAT_ENABLED).asBoolean();
}

//然后再看下发送心跳方法serverProxy.sendBeat,存在这么一个判断:
if (!lightBeatEnabled) {
    bodyMap.put("beat", JacksonUtils.toJson(beatInfo));
}

上面代码很明了了,如果服务端响应lightBeatEnabled=true的话,则发送心跳时就不携带“beat"参数,否则携带(只有首次发送心跳时才会携带)。
也就是说只有首次心跳时,是重量级心跳(携带beat参数),后面就转为轻量级心跳,可能是为了节省带宽吧。服务端判断如果包含beat参数的话,会将beat信息转成RsInfo。

  • 其实这里我没太想明白,为什么首次心跳需要携带beatInfo信息,这些信息大多已经在commonParams里包含了,而且服务端会将该信息转成RsInfo,根据RsInfo类的信息猜测可能是为了监控做埋点的,但是只是猜测,目前还没看到具体啥作用,有知道的同学还请赐教!
      

2.2 核心2——心跳处理
现在我们看下对心跳的处理:service.processClientBeat(clientBeat)

public void processClientBeat(final RsInfo rsInfo) {
    ClientBeatProcessor clientBeatProcessor = new ClientBeatProcessor();
    clientBeatProcessor.setService(this);
    clientBeatProcessor.setRsInfo(rsInfo);
    HealthCheckReactor.scheduleNow(clientBeatProcessor);
}

//ClientBeatProcessor线程
@Override
public void run() {
    Service service = this.service;
    if (Loggers.EVT_LOG.isDebugEnabled()) {
        Loggers.EVT_LOG.debug("[CLIENT-BEAT] processing beat: {}", rsInfo.toString());
    }
    
    String ip = rsInfo.getIp();
    String clusterName = rsInfo.getCluster();
    int port = rsInfo.getPort();
    Cluster cluster = service.getClusterMap().get(clusterName);
    //取出所有临时实例
    List<Instance> instances = cluster.allIPs(true);
    
    for (Instance instance : instances) {
        if (instance.getIp().equals(ip) && instance.getPort() == port) {
            if (Loggers.EVT_LOG.isDebugEnabled()) {
                Loggers.EVT_LOG.debug("[CLIENT-BEAT] refresh beat: {}", rsInfo.toString());
            }
            instance.setLastBeat(System.currentTimeMillis());
            if (!instance.isMarked()) {
                if (!instance.isHealthy()) {
                    instance.setHealthy(true);
                    Loggers.EVT_LOG
                            .info("service: {} {POS} {IP-ENABLED} valid: {}:{}@{}, region: {}, msg: client beat ok",
                                    cluster.getService().getName(), ip, port, cluster.getName(),
                                    UtilsAndCommons.LOCALHOST_SITE);
                    getPushService().serviceChanged(service);
                }
            }
        }
    }
}

心跳处理也是开启了一个异步线程:ClientBeatProcessor,这个线程是单次执行,不是一个定时任务,在它的run()方法中,会取出注册表中所有的临时实例并遍历,正常情况下服务端的心跳处理只是instance.setLastBeat(System.currentTimeMillis()),即将上次心跳时间跟更新为当前时间就完事了;

不过当注册表中的实例健康状态已经变成了false的话,这里就需要把健康状态重新置为true,至于为什么注册表中实例会变成false,可能的一种情况就是:网络不通导致服务端长时间(超过15秒,为什么是15秒,下面健康检查会说到)没有收到客户端心跳,导致实例健康状态被改为false。

修改了实例健康状态之后,会发布一个ServiceChangeEvent事件:

public void serviceChanged(Service service) {
    // merge some change events to reduce the push frequency:
    if (futureMap
            .containsKey(UtilsAndCommons.assembleFullServiceName(service.getNamespaceId(), service.getName()))) {
        return;
    }
    this.applicationContext.publishEvent(new ServiceChangeEvent(this, service));
}

这个方法很简单,那个if判断其实是为了减少推送频率的一个措施,会判断futureMap里是否已经包含当前发送心跳的实例所在的服务,如果包含了,就直接返回,不会发布事件,否则就发布一个事件。至于futureMap里存放的到底是什么,这就需要看下PushService这个类的功能了,由于篇幅原因,PushService类我另外开了一篇单独讲解,如需了解请看下《6、Nacos服务注册——PushService类功能》
  
二、健康检查
服务端接受到客户端的服务注册请求后,在创建空的Service后,就会开启健康检查任务,代码在com.alibaba.nacos.naming.core.ServiceManager#putServiceAndInit:

private void putServiceAndInit(Service service) throws NacosException {
    putService(service);
    //初始化service,会开启健康检查任务
    service.init();
    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());
}

public void init() {
    //健康检测任务
    HealthCheckReactor.scheduleCheck(clientBeatCheckTask);
    for (Map.Entry<String, Cluster> entry : clusterMap.entrySet()) {
        entry.getValue().setService(this);
        entry.getValue().init();
    }
}

健康检查任务也是个延迟执行的定时任务,延迟5秒执行,之后每5秒执行一次,核心代码为ClientBeatCheckTask类,它是个线程,代码在《4、Nacos服务注册-服务端(nacos-naming)逻辑》中1.2.1已经说过,我这不在重复说了,可以去上篇帖子看下。这里再做两点补充:

(1)在超过15秒没收到客户端心跳时,就会把注册表中实例的健康状态改为false,而实例健康状态发生了变化后也同样会发布一个ServiceChangeEvent事件,同2.2,涉及到PushService类可以去这里看下《6、Nacos服务注册——PushService类功能》,除了发布ServiceChangeEvent事件外,还会发布InstanceHeartbeatTimeoutEvent这么个事件,这是心跳超时事件,不过nacos没有对其进行监听处理,并且在2.0.0版本被移除了

(2)超时30秒没有收到客户端心跳时,就会从注册表表剔除该实例,会使用HTTP DELETE方式调用
/v1/ns/instance地址,现在来看下剔除逻辑,该接口也是在InstanceController类中

@CanDistro
@DeleteMapping
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public String deregister(HttpServletRequest request) throws Exception {
    Instance instance = getIpAddress(request);
    String namespaceId = WebUtils.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
    String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
    NamingUtils.checkServiceNameFormat(serviceName);

    Service service = serviceManager.getService(namespaceId, serviceName);
    if (service == null) {
        Loggers.SRV_LOG.warn("remove instance from non-exist service: {}", serviceName);
        return "ok";
    }
    //移除实例
    serviceManager.removeInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
    return "ok";
}

//ServiceManager.removeInstance方法
public void removeInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
        throws NacosException {
    Service service = getService(namespaceId, serviceName);

    synchronized (service) {
        removeInstance(namespaceId, serviceName, ephemeral, service, ips);
    }
}

private void removeInstance(String namespaceId, String serviceName, boolean ephemeral, Service service,
        Instance... ips) throws NacosException {

    String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);
    //与注册表中实例列表比对,并经过移除之后得到一个新的实例列表
    List<Instance> instanceList = substractIpAddresses(service, ephemeral, ips);

    Instances instances = new Instances();
    instances.setInstanceList(instanceList);
    //将得到的新的实例列表更新到注册表
    consistencyService.put(key, instances);
}

代码实现细节这里就不再看了,最终更新注册表的方法consistencyService.put(key, instances)我们已经很熟悉了,前面服务注册的时候已经讲过,不知道的可以看下我前面的文章。
  
  
三、总结
nacos注册中心为了能够感知到客户端服务是否存活,设计了心跳机制和健康检查,就是为了给客户端实例续约的,不光是nacos,所有注册中心都会有这样的机制,可能具体实现不相同,但是最终目的只有一个:就是为了能够尽量实时感知客户端服务的存活与否,这是注册中心极为重要的一个特性。举个例子:早期我们为项目做集群部署时,往往使用nginx为应用做负载均衡,但是这种架构存在一个严重的问题,当我们需要水平扩容时,就需要修改nginx配置并重启(重启期间服务不可用);或者如果其中一个实例挂了,nginx并无法感知到,那么下次请求就还可能打到那台挂了的实例上面,就会造成请求不可用,而注册中心就能很好的解决这个问题。

你可能感兴趣的:(微服务)