文章目录
1. Nacos核心功能点
Nacos架构图如下:
结合以上架构图,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进行调用的?
- 微服务系统在启动时将自己注册到服务注册中心,同时外发布
Http
接口供其它系统调用(一般都是基于Spring MVC
) - 服务消费者基于
Feign
调用服务提供者对外发布的接口,先对调用的本地接口加上注解@FeignClient
,Feign
会针对加了该注解的接口生成动态代理,服务消费者针对Feign
生成的动态代理去调用方法时,会在底层生成Http
协议格式的请求,类似 /stock/deduct?productId=100 Feign
最终会调用Ribbon
从本地的Nacos注册表的缓存里根据服务名取出服务提供在机器的列表,然后进行负载均衡并选择一台机器出来,对选出来的机器IP和端口拼接之前生成的url
请求,生成调用的Http接口地址http://192.168.0.60:9000/stock/deduct?productId=100
,最后基于HTTPClient
调用请求
下面就从源码角度更细致的了解一下Nacos的核心功能点的底层原理
2. 服务注册
在以Nacos
为焦点时,订单服务、库存服务都属于客户端,而Nacos注册中心则作为服务端接收各个微服务的注册请求!所以在服务注册逻辑中又分为两部分
- 客户端向
Nacos
服务端发起注册请求 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 启动事件顺序:
ApplicationStartingEvent
:这个事件在 Spring Boot 应用运行开始时,且进行任何处理之前发送(除了监听器和初始化器注册之外)。ApplicationEnvironmentPreparedEvent
:这个事件在初始化环境时prepareEnvironment
时调用,在 Spring 上下文(context)创建之前发送。ApplicationContextInitializedEvent
:这个事件在容器的准备阶段prepareContext
时被调用。此时应用初始化器(ApplicationContextInitializers)已经被初始化完毕,在(启动类的) bean 定义被加载之前发送。ApplicationPreparedEvent
:这个事件是在 Spring 上下文(context)刷新之前,且在(启动类的)bean 的定义被加载之后发送,代码也在prepareContext
内部!ApplicationStartedEvent
:这个事件是在 Spring 上下文(context)刷新之后,在refreshContext(context);
被调用的AvailabilityChangeEvent
:这个事件紧随上个事件之后发送,状态:ReadinessState.CORRECT,表示应用已处于活动状态。ApplicationReadyEvent
:这个事件在任何 application/ command-line runners 调用之后发送。AvailabilityChangeEvent
:这个事件紧随上个事件之后发送,状态:ReadinessState.ACCEPTING_TRAFFIC,表示应用可以开始准备接收请求了。ApplicationFailedEvent
:这个事件在应用启动异常时进行发送。
除了这些事件以外,以下事件也会在 ApplicationPreparedEvent 之后和 ApplicationStartedEvent 之前发送:
WebServerInitializedEvent
:这个 Web 服务器初始化事件在 WebServer 启动之后发送,对应的还有 ServletWebServerInitializedEvent(Servlet Web 服务器初始化事件)、ReactiveWebServerInitializedEvent(响应式 Web 服务器初始化事件)。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()
注册方法中主要做了两件事情
- 定时向服务端发送心跳。开启一个定时线程池,每隔
Period:5s
秒钟向服务端发送一次心跳,证明当前服务是健康的。其中在BeatTask
的run
方法中采用递归调用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());
}
- 请求服务端的服务注册接口,向服务端注册服务:其中
serverProxy.registerService()
会向Nacos
发起注册请求,请求体包含namespaceId
、serviceName
、ip
、port
等等!
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
方法主要做了两件事情:
-
根据
namespaceId
、serviceName
构建一个Service注册表结构外壳,此时还没有填入instance
实例。服务注册表的结构如下文所示。 -
开启一个延迟定时线程池,延时
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));
}
进入ClientBeatCheckTask
的run
方法,查看心跳检查逻辑!
@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
结构,map
的value
是一个Cluster
类,在这个Cluster
类中还有一个几个Set集合,而这些Set
集合才是真正存储服务实例的地方!
//存储持久化服务实例
@JsonIgnore
private Set<Instance> persistentInstances = new HashSet<>();
//存储临时服务实例
@JsonIgnore
private Set<Instance> ephemeralInstances = new HashSet<>();
可以看出这个服务注册表的结构还是挺复杂的!其结构如下图所示:
双层Map
的Service
注册表的外壳创建完成后,此时还需要把服务实例instance
注册进去,才算服务注册完成,接下来看addInstance()
方法,添加服务实例
其中实例分为两种
- 临时实例
- 持久实例
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会根据不同的实例类型选择不同的架构
- 临时实例,选择
AP
架构,使用Distro
协议,分布式协议的一种,阿里内部的协议,服务是放在内存中! - 持久实例,选择
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);
}
}
}
在Notifier
的run
方法中找到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
为了提高客户端感知服务事件变动的及时性,新增了一项操作:当服务变动时,发布服务变动事件ServiceChangeEvent
。nacos
服务端监听到此事件,就通过UDP
的方式向nacos
客户端(订单服务)主动推送最新服务列表
客户端定时任务拉取最新服务列表的逻辑上文已经讲过,下面看一下nacos
服务端是如何实现主动推送的?结合上文的服务注册逻辑,我们已经知道在Notifier
的run
方法是真正的注册逻辑。持续跟进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客户端发送一个
为什么TCP建立连接需要三次握手,关闭连接需要四次挥手?
客户端和服务端通信前要进行连接,“3次握手”的作用就是双方都能明确自己和对方的收、发能力是正常的。
- 第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。
- 第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。从客户端的视角来看,我接到了服务端发送过来的响应数据包,说明服务端接收到了我在第一次握手时发送的网络包,并且成功发送了响应数据包,这就说明,服务端的接收、发送能力正常。而另一方面,我收到了服务端的响应数据包,说明我第一次发送的网络包成功到达服务端,这样,我自己的发送和接收能力也是正常的。
- 第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力,服务端的发送、接收能力是正常的。因为第一、二次握手后,服务端并不知道客户端的接收能力以及自己的发送能力是否正常。而在第三次握手时,服务端收到了客户端对第二次握手作的回应。从服务端的角度,我在第二次握手时的响应数据发送出去了,客户端接收到了。所以,我的发送能力是正常的。而客户端的接收能力也是正常的。
那为什么关闭时需要四次挥手?
- 在建立连接时,服务端收到客户端的
SYN
连接请求报文后,可以把ACK
和SYN
(ACK
起应答作用,SYN
起同步作用)放在一个报文里发送。 但关闭连接时,当服务端收到对方的FIN
报文时,它仅仅表示客户端没有数据发送给服务端了,但服务端在建立连接时产生的数据还不一定都全部发送给对方了,所以服务端未必会马上关闭SOCKET
,也就是说你可能还需要发送一下数据给对方之后,再发送FIN报文给对方来表示你同意现在可以关闭连接了,所以这里的ACK
和FIN
多数情况下都是分开发送的。
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;
}