【Nacos源码系列】Nacos心跳机制原理

文章目录

  • 心跳机制是什么
  • Nacos心跳机制
    • 客户端心跳
    • 服务端接收心跳
  • 总结

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。

心跳机制是什么

心跳机制是一种用于监测和管理微服务可用性的机制,它用来维护注册中心和服务提供者之间的连接状态,并及时更新服务实例的状态信息。

心跳机制包括两个主要组件:心跳发送方(客户端)和心跳接收方(服务端)。

在微服务架构中,心跳机制是一种用于监测和管理微服务可用性的机制。由于微服务架构通常由多个相互独立的微服务组成,每个微服务都有自己的生命周期和状态,因此需要一种方法来实时检测和通知微服务的健康状况。

微服务的心跳机制包括两个主要组件:心跳发送方和心跳接收方。

  1. 心跳发送方(Heartbeat Sender):每个微服务都会定期发送称为心跳消息的请求到一个中央位置(例如注册中心或负载均衡器)。这个心跳消息包含有关该微服务的健康信息,如服务是否正常运行、负载情况、资源消耗等。心跳消息的频率可以根据需求进行配置,通常是以固定的时间间隔发送。

  2. 心跳接收方(Heartbeat Receiver):中央位置上的组件(如注册中心或负载均衡器)负责接收并处理微服务发送的心跳消息。它会记录每个微服务的心跳,并根据心跳消息的到达情况和内容来判断微服务的可用性。如果心跳消息超过一定时间没有到达,或者心跳消息中报告了错误状态,中央位置可以采取相应的措施,如将该微服务标记为不可用、重新分配负载或发送警报通知等。

通过心跳机制,微服务架构可以实时监测微服务的健康状态,从而实现故障检测和自动恢复。当某个微服务出现故障或不可用时,其他微服务可以感知到并做出相应的处理,以确保整个系统的稳定性和可用性。此外,心跳机制还可以协助进行负载均衡、容量规划和资源管理等任务,提高整体系统的效率和性能。

Nacos心跳机制

当一个服务注册到Nacos注册中心时,它会向Nacos发送一个心跳包,告诉Nacos它仍然处于活动状态。服务提供者定期发送心跳包,以保证其状态信息是最新的。如果一个服务提供者在指定的时间段内没有发送心跳包,Nacos就会将该服务提供者的状态设置为不可用,并将其从可用服务列表中移除。

对于超过15s没有发送客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)。

本文将从客户端和服务端两个角度介绍Nacos心跳机制的原理。

客户端心跳

之前介绍Nacos注册原理的时候说过,在NacosNamingService#registerInstance()方法注册服务实例时会开启一个心跳定时任务。

private BeatReactor beatReactor;

@Override
public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
    String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
    // 临时节点
    if (instance.isEphemeral()) {
        //封装心跳信息
        BeatInfo beatInfo = beatReactor.buildBeatInfo(groupedServiceName, instance);
        //加入定时任务
        beatReactor.addBeatInfo(groupedServiceName, beatInfo);
    }
    serverProxy.registerService(groupedServiceName, groupName, instance);
}

//Instance对象封装BeatInfo
public BeatInfo buildBeatInfo(String groupedServiceName, Instance instance) {
    BeatInfo beatInfo = new BeatInfo();
    beatInfo.setServiceName(groupedServiceName);
    beatInfo.setIp(instance.getIp());
    beatInfo.setPort(instance.getPort());
    beatInfo.setCluster(instance.getClusterName());
    beatInfo.setWeight(instance.getWeight());
    beatInfo.setMetadata(instance.getMetadata());
    beatInfo.setScheduled(false);
    beatInfo.setPeriod(instance.getInstanceHeartBeatInterval());
    return beatInfo;
}

Nacos会对临时节点创建一个定时任务并进行心跳检查。
下面重点来看一下 BeatReactor#addBeatInfo方法:

//存放客户端心跳信息
public final Map<String, BeatInfo> dom2Beat = new ConcurrentHashMap<String, BeatInfo>();

public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    NAMING_LOGGER.info("[BEAT] adding beat: {} to beat map.", beatInfo);
    // 心跳key,形如:  DEFAULT_GROUP@@provider#192.168.71.70#9093
    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);
    //心跳定时任务 默认5s一次
    executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
    MetricsMonitor.getDom2BeatSizeMonitor().set(dom2Beat.size());
}

BeatReactor#addBeatInfo方法最后会生成一个 BeatTask 定时心跳任务,该任务默认5s执行一次。

BeatTask 类是 BeatReactor类的内部类,它实现了 Runnable 接口,主要作用是定时向服务端发送心跳。

private final ScheduledExecutorService executorService;

private final NamingProxy serverProxy;

class BeatTask implements Runnable {

    BeatInfo beatInfo;

    public BeatTask(BeatInfo beatInfo) {
        this.beatInfo = beatInfo;
    }

    @Override
    public void run() {
        if (beatInfo.isStopped()) {
            return;
        }
        long nextTime = beatInfo.getPeriod();
        try {
            //请求服务端,发送心跳
            JsonNode result = serverProxy.sendBeat(beatInfo, BeatReactor.this.lightBeatEnabled);
            //心跳时间间隔
            long interval = result.get("clientBeatInterval").asLong();

            //lightBeatEnabled是否启用轻量级心跳,轻量级心跳不会把客户端心跳信息发送给服务端,可以更快地检测到实例故障并进行处理,客户端第一次传false,之后从服务端返回true
            boolean lightBeatEnabled = false;
            if (result.has(CommonParams.LIGHT_BEAT_ENABLED)) {
                lightBeatEnabled = result.get(CommonParams.LIGHT_BEAT_ENABLED).asBoolean();
            }
            BeatReactor.this.lightBeatEnabled = lightBeatEnabled;
            if (interval > 0) {
                //将下次心跳延迟发送时间设置为从服务端返回的clientBeatInterval值
                nextTime = interval;
            }
            int code = NamingResponseCode.OK;
            if (result.has(CommonParams.CODE)) {
                code = result.get(CommonParams.CODE).asInt();
            }
            //请求资源没有找到,404
            if (code == NamingResponseCode.RESOURCE_NOT_FOUND) {
                //创建一个实例
                Instance instance = new Instance();
                instance.setPort(beatInfo.getPort());
                instance.setIp(beatInfo.getIp());
                instance.setWeight(beatInfo.getWeight());
                instance.setMetadata(beatInfo.getMetadata());
                instance.setClusterName(beatInfo.getCluster());
                instance.setServiceName(beatInfo.getServiceName());
                instance.setInstanceId(instance.getInstanceId());
                instance.setEphemeral(true);
                try {
                    //注册实例
                    serverProxy.registerService(beatInfo.getServiceName(),
                        NamingUtils.getGroupName(beatInfo.getServiceName()), instance);
                } catch (Exception ignore) {
                }
            }
        } catch (NacosException ex) {
            NAMING_LOGGER.error("[CLIENT-BEAT] failed to send beat: {}, code: {}, msg: {}",
                JacksonUtils.toJson(beatInfo), ex.getErrCode(), ex.getErrMsg());

        }
        //使用当前beatInfo定时执行心跳任务
        executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
    }
}

BeatTask 实现了 Runnable 接口,所以主要看run()方法的实现就行了。

BeatTask#run()方法,首先判断该定时任务的停止标记stopped为true,为true就不再执行该定时任务。

接着就是调用NamingProxy#sendBeat向服务端发起心跳请求,如果客户端没有在服务端的本地缓存中找到,会返回404,之后客户端就要向服务端发起注册请求。

最后再将封装了当前心跳信息的 BeatInfo对象重新放到一个 新的 BeatTask 心跳任务中去。 ScheduledExecutorService#schedule()方法是延迟一定时长执行指定任务,而且只执行一次,所以这里要在run()方法最后重新启动一个定时任务,以保证心跳的正常执行。

BeatReactor 类中有个 lightBeatEnabled属性,它用于判断是否启用轻量级心跳,轻量级心跳不会把客户端心跳信息发送给服务端,可以更快地检测到实例故障并进行处理,客户端第一次传false,之后从服务端返回true。

【Nacos源码系列】Nacos心跳机制原理_第1张图片

下面看一下NamingProxy#sendBeat方法:

public JsonNode sendBeat(BeatInfo beatInfo, boolean lightBeatEnabled) throws NacosException {

    if (NAMING_LOGGER.isDebugEnabled()) {
        NAMING_LOGGER.debug("[BEAT] {} sending beat to server: {}", namespaceId, beatInfo.toString());
    }
    Map<String, String> params = new HashMap<String, String>(8);
    Map<String, String> bodyMap = new HashMap<String, String>(2);
    //如果不是轻量级心跳,把心跳信息发送给服务端
    if (!lightBeatEnabled) {
        bodyMap.put("beat", JacksonUtils.toJson(beatInfo));
    }
    params.put(CommonParams.NAMESPACE_ID, namespaceId);
    params.put(CommonParams.SERVICE_NAME, beatInfo.getServiceName());
    params.put(CommonParams.CLUSTER_NAME, beatInfo.getCluster());
    params.put("ip", beatInfo.getIp());
    params.put("port", String.valueOf(beatInfo.getPort()));
    String result = reqApi(UtilAndComs.nacosUrlBase + "/instance/beat", params, bodyMap, HttpMethod.PUT);
    return JacksonUtils.toObj(result);
}

【Nacos源码系列】Nacos心跳机制原理_第2张图片

客户端发送心跳相对简单一些,至此就结束了。总结一下客户端的心跳机制,针对临时节点:客户端在注册时,对于临时节点会添加一个BeatTask 定时心跳任务,默认每5s执行一次。定时心跳任务的主要作用就是定时向服务端发送 http 请求,请求路径为 /nacos/v1/ns/instance/beat。从服务端获得响应之后如果不满足服务端要求需要重新进行注册,最后默认延迟5s再次执行 BeatTask 定时心跳任务。

服务端接收心跳

服务端接收到客户端心跳会有哪些操作呢?

在服务端接收心跳的方法是com.alibaba.nacos.naming.controllers.InstanceController#beat()方法。下面看下beat方法的源码:

@Autowired
private SwitchDomain switchDomain;

@Autowired
private ServiceManager serviceManager;


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

    ObjectNode result = JacksonUtils.createEmptyJsonNode();
    //客户端心跳间隔时间,默认5s一次
    result.put(SwitchEntry.CLIENT_BEAT_INTERVAL, switchDomain.getClientBeatInterval());

    String beat = WebUtils.optional(request, "beat", StringUtils.EMPTY);
    RsInfo clientBeat = null;
    //轻量级心跳时,没有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);
    checkServiceNameFormat(serviceName);
    Loggers.SRV_LOG.debug("[CLIENT-BEAT] full arguments: beat: {}, serviceName: {}", clientBeat, serviceName);
    // 从缓存中获取实例
    Instance instance = serviceManager.getInstance(namespaceId, serviceName, clusterName, ip, port);

    //本地缓存中没有服务实例
    if (instance == null) {
        if (clientBeat == null) {
            //返回404,让客户端重新注册
            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);
    }
    //从ServiceManager#serviceMap缓存中获取service
    Service service = serviceManager.getService(namespaceId, serviceName);

    if (service == null) {
        throw new NacosException(NacosException.SERVER_ERROR,
            "service not found: " + serviceName + "@" + namespaceId);
    }
    //轻量级心跳创建一个服务端指标信息RsInfo对象
    if (clientBeat == null) {
        clientBeat = new RsInfo();
        clientBeat.setIp(ip);
        clientBeat.setPort(port);
        clientBeat.setCluster(clusterName);
    }
    //处理客户端心跳
    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;
}

通过beat方法可以看到服务端接收到客户端心跳会有以下操作:

  1. 从客户端请求参数中获取beat参数信息,并转成RsInfo对象
  2. 根据客户端信息,调用ServiceManager#getInstance()从注册中心的服务缓存中查询实例
  3. 如果实例信息不存在,并且实例符合条件的则会对实例进行注册
  4. ServiceManager#serviceMap缓存中获取服务对象service,调用Service#processClientBeat处理客户端心跳信息
  5. 响应客户端请求

接下来主要看服务端如何获取客户端实例的方法ServiceManager#getInstance()和服务端是怎么处理客户端心跳的源码。

先来看下获取客户端实例的方法ServiceManager#getInstance()的源码:

public Instance getInstance(String namespaceId, String serviceName, String cluster, String ip, int port) {
    //从本地缓存获取服务信息
    Service service = getService(namespaceId, serviceName);
    if (service == null) {
        return null;
    }

    List<String> clusters = new ArrayList<>();
    clusters.add(cluster);

    //获取服务所有实例信息
    List<Instance> ips = service.allIPs(clusters);
    if (ips == null || ips.isEmpty()) {
        return null;
    }

    for (Instance instance : ips) {
        //选择客户端对应实例信息
        if (instance.getIp().equals(ip) && instance.getPort() == port) {
            return instance;
        }
    }

    return null;
}

ServiceManager#getInstance() 先从服务端的serviceMap缓存中获取服务,然后获取服务的实例列表,最后遍历实例列表找到ip和端口号相同的实例并返回。

再来看下服务端是怎么处理客户端心跳的Service#processClientBeat源码:

public void processClientBeat(final RsInfo rsInfo) {
    ClientBeatProcessor clientBeatProcessor = new ClientBeatProcessor();
    clientBeatProcessor.setService(this);
    clientBeatProcessor.setRsInfo(rsInfo);
    //处理客户端心跳
    HealthCheckReactor.scheduleNow(clientBeatProcessor);
}

processClientBeat方法会创建一个客户端心跳处理程序类 ClientBeatProcessor对象,然后执行该处理程序。

ClientBeatProcessor 是客户端心跳处理程序类,它的主要任务是处理客户端的心跳,它实现了 Runnable 接口,主要看run()方法的实现即可。

public class ClientBeatProcessor implements Runnable {
    
    public static final long CLIENT_BEAT_TIMEOUT = TimeUnit.SECONDS.toMillis(15);
    
    private RsInfo rsInfo;
    
    private Service service;
    
    @JsonIgnore
    public PushService getPushService() {
        return ApplicationUtils.getBean(PushService.class);
    }
    
    //省略get/set方法
    
    @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) {
            //ip和端口号相同
            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);
                    }
                }
            }
        }
    }
}

通过run()方法源码可以看到,服务端在接收到客户端心跳后处理心跳的步骤如下:

  1. 获取客户端所在集群中所有临时节点实例列表
  2. 循环临时节点实例列表找到对应发送心跳的客户端实例,设置该实例最新心跳时间为当前时间
  3. 如果客户端实例为非健康实例,设置该实例属性healthy设置为true,即标记为健康实例。
  4. 如果服务实例健康状态更新,就会调用`PushService#serviceChanged()方法推送服务实例最新信息到客户端以便客户端能够及时感知服务实例发生的变化,并做出相应的调整,从而保证服务的高可用性和稳定性。

总结

最后对Nacos心跳机制做一个简单的总结:

在进行服务实例注册时,会为临时节点生成一个默认5s的BeatTask心跳定时任务,BeatTask定时任务会调用NamingProxy#sendBeat向服务端发起心跳请求。
在服务的端接收到请求后会先从本地缓存中查询实例信息,获取到实例信息之后就会调用Service#processClientBeat对客户端心跳进行处理,处理的逻辑在ClientBeatProcessor#run()方法中。在处理中会从服务集群实例列表中找到对应的客户端,并将当前时间设置为最新的心跳时间。如果服务实例之前是不健康的,则将其属性healthy设置为true,即标记为健康实例,最后会调用`PushService#serviceChanged()方法推送服务实例最新信息到客户端以便客户端能够及时感知服务实例发生的变化,并做出相应的调整,从而保证服务的高可用性和稳定性。

你可能感兴趣的:(Spring,Cloud,Alibaba,Nacos心跳机制,心跳机制原理,Nacos,健康检查)