如果您对Nacos工作流程和原理还不是很清楚的话,建议从前面的文章开始看:
1、nacos功能简介
2、Nacos服务注册-客户端自动注册流程
3、Nacos服务注册-客户端(nacos-client)逻辑
4、Nacos服务注册-服务端(nacos-naming)逻辑
nacos心跳机制与健康检查流程图:
一、心跳
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)试想下,如果服务端由于某种原因挂了(注册表数据都会丢失),客户端此时心跳还会一直发,等到服务端故障修复重新启动之后,接受到客户端的心跳就会重新注册;
(2)客户端启动时,会开启心跳任务(延迟5秒执行),但是由于网络原因,导致经过了5秒服务还没有注册到注册中心,此时开始发送心跳,服务端也会进行注册;
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。
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并无法感知到,那么下次请求就还可能打到那台挂了的实例上面,就会造成请求不可用,而注册中心就能很好的解决这个问题。