Java应用目前大多已经采用微服务架构模式,也就是说一个项目中会存在不同模块的调用,不同模块会处于不同的Java进程中,需要通过网络进行调用,比如A服务要调用B服务需要通过http接口进行调用,那么很自然就会存在这些连接的管理应用
当然了,你可以直接在A服务中直接写死请求调用地址,但是如果有多个的时候就需要对代码进行修改以支持,而且也不支持负载均衡,当然了你可以自己在本地实现一个管理,但是会给本服务带来很多关于http的维护,而且就算这样,哪个服务不能用了你也要维护,会带来很多维护成本,会给开发带来很大的复杂性
所以,nacos就是为了解决这个问题而开发的,它可以统一管理这些服务以及帮你去调用,那么nacos需要解决的主要有以下的问题
1. 如何进行服务的注册,在并发情况下提升注册效率
2. 如何进行服务的拉取,已经服务列表变动后的同步
3. 维护服务列表的情况,包括数据的新增,修改与剔除等
4. 集群状态下心跳机制的同步原理
5. 集群状态下服务状态的同步原理
6. 集群状态下服务数据的同步原理等
以上,为AP架构下大概需要解决的问题,下面就大概说一下这些问题,注:这里使用的nacos是1.4.1版本,不同版本可能有细节差别,但是整体的核心原理应该不会有太多不同,这里主要是说核心原理,没有写具体的细节,以后有时间再写写具体的原理
既然nacos需要维护服务的请求列表数据,那么就需要一个数据结构来维护,nacos注册表结构设计的相对比较完善,可以支持很多种情况,数据结构如下:
Map
解释如下:
Map(namespace, Map(group::serviceName, Service)).
nacos使用的是一层双层map,其中namespace是命令空间,用于隔离环境的,比如我们开发的dev,uat等支持,group::serviceName是服务配合分组的分类,比如后台管理服务可以细分为管理商品组,管理订单组等,然后才是Service,Service里面还有以下的结构
Map
也就是还分为cluster集群,同个服务可以有多个不同的集群,比如有些服务提供者可以放在上海机房,有些机器可以放在北京机房等
最后再来看看Cluster的数据结构,里面才是真正存服务的数据,如下
Set
Set
首先,要管理服务,Nacos就需要提供一个服务注册的接口,在源码的com.alibaba.nacos.naming.controllers.InstanceController#register中,该逻辑中主要处理这么几件事,如下
1. 判断注册表没有该服务则初始化服务,(利用双重检测锁机制)
2. instance维护一个最后续约时间,默认为当时注册的时间
3. 服务端启动一个定时器扫描service下面所有实例是否过期,是的话则剔除实例
4. 服务真正的注册并不是直接注册,而是添加到阻塞队列,然后异步从阻塞队列拉取数据进行真正的注册
5. 为了提升服务消费者拉取服务的性能,在注册的时候使用了copy on write机制提升性能
nacos提供一个com.alibaba.nacos.naming.healthcheck.ClientBeatCheckTask任务来定时进行扫描处理
客户端注册服务时同时启动一个心跳任务,每隔一定时间就发一个心跳给nacos
客户端注册服务的时候,只是放到阻塞队列里,逻辑在DistroConsistencyServiceImpl#onPut方法中进行处理,里面只是放在了Notifier的阻塞队列中
Notifier本身是一个线程,会不断从阻塞队列中拉取任务,run方法中真正进行异步处理注册任务
在注册的时候不会直接操作注册表,而是会使用写时复制机制处理,在Cluster#updateIps方法中进行处理
nacos提供方法InstanceController#list用于拉取服务列表,同时为了服务消息者可以感知到服务列表的变化,做了以下两种机制处理
1. 服务消费者拉取服务的时候同时传递一个udp端口给nacos,nacos通过该upd端口同时结合spring的事件机制当服务发生变化时推送给服务消费者
2. 服务消费者拉取服务后,启动一个定时器,每隔5秒钟就从nacos再拉一次服务最新列表然后覆盖本地的服务列表
AP架构下有多台服务器,那么是否需要每个服务器都扫描服务的心跳机制吗,其实是不需要的,只需要一台扫描,然后同步给其他服务器即可,所以在服务端的心跳检查任务方法中有一个方法就是为了解决这个问题的
服务端扫描任务的方法如下
public void run() {
try {
if (!getDistroMapper().responsible(service.getName())) {
return;
}
if (!getSwitchDomain().isHealthCheckEnabled()) {
return;
}
//下面是维护服务实例的健康状态的
List instances = service.allIPs(true);
// first set health status of instances:
for (Instance instance : instances) {
if (System.currentTimeMillis() - instance.getLastBeat() > instance.getInstanceHeartBeatTimeOut()) {
if (!instance.isMarked()) {
if (instance.isHealthy()) {
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;
}
if (System.currentTimeMillis() - instance.getLastBeat() > instance.getIpDeleteTimeout()) {
// delete instance
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);
}
}
其中,getDistroMapper().responsible()方法就是为了解决集群状态下心跳机制只需要一个线程处理的逻辑的,利用取模的功能把一个服务定位到同一台服务器中进行处理,如下
public boolean responsible(String serviceName) {
final List servers = healthyList;
if (!switchDomain.isDistroEnabled() || EnvUtil.getStandaloneMode()) {
return true;
}
if (CollectionUtils.isEmpty(servers)) {
// means distro config is not ready yet
return false;
}
int index = servers.indexOf(EnvUtil.getLocalAddress());
int lastIndex = servers.lastIndexOf(EnvUtil.getLocalAddress());
if (lastIndex < 0 || index < 0) {
return true;
}
int target = distroHash(serviceName) % servers.size();
return target >= index && target <= lastIndex;
}
如此,便可以解决上面的问题,但是这里会有个问题,假设当前的nacos服务器挂掉了那么会不会导致取模有问题,nacos也考虑到这个问题了,所以这里取的是健康的服务列表进行判断的,那么问题就变成了集群中如何同步服务状态的问题,也就是下面的问题
为了做到这件事,nacos起了一个定时任务处理,ServerStatusReporter是一个定时任务,就是为了解决这个问题的,会通过每个nacos的/operator/server/status方法来进行同步,假设服务器挂了,那么这个接口就会调不通,其他的nacos服务器就会把挂掉的nacos服务器从健康列表中移除掉,那么上面的取模方法就又正常了,不会有问题,不过这个剔除不是从这里弄的,而是nacos提供了一个专门的任务来保证服务器是否在在线,如下
这也是一个定时任务,每个服务器从其他服务器的接口/cluster/report获取服务器是否在线,假设不在线,nacos会发布一个事件MembersChangeEvent,而对应的监听器就会处理这个事件,从而把上面的helathList移除掉挂掉的服务器,所以取模算法就不会有问题了
ServiceReporter就是用来同步这个逻辑的,但是相对比较复杂,有多层的异步队列,这里就不细说了....
最后再说一下spring cloud 集成的原理
服务注册的原理基于boot的自动装配原理实现,首先,我们要引入以下依赖
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
引入这个依赖后查看改starer对应的spring.factories文件,可以找到看自动配置类,会引入这么一个类来处理自动注册,类名为:NacosAutoServiceRegistration,该类的父类继承了spring的ApplicationListener
1. 获取NamingService对象,为nacos注册的核心接口
2. 获取当前应用变为Instance对象
3. 如果是临时实例,也就是AP架构,是的话会提交一个BeatInfo定时任务,用于与服务端维护心跳的任务
4. 调用NamingHttpClientProxy方法注册服务
至此,便实现了自动注册的功能,让我们的配置少了许多
nacos提供了一个实现类用于获取服务列表,名为:NacosNamingService#getAllInstances,用于获取服务,该方法会几件事
1. 获取服务的时候传输一个udp端口给服务端用来当服务变化接收通知的方法
2. 启动一个定时任务来获取最新的服务列表
3. 会在客户端维护一个缓存列表,如果缓存有就不用到远程获取了
至于这个是怎么调过来的,需要结合ribbion来看,因为nacos只是个提供服务注册和发现的中间件,所以这里就先不讲了
到此,nacos在AP架构下服务注册和发现等核心设计就写完了,本篇文章只是记录一些核心的设计点,至于代码细节有兴趣的可以再进行查看,多看几遍估计就懂了,感觉这些主要还是说看思想,比如以下几个是nacos设计的亮点,如下
1. 通过阻塞队列配合异步任务处理注册服务逻辑,降低应用启动时的等待时间
2. 通过copy on write 机制来提升服务消费列表拉取的性能
3. 通过取模定位来解决集群状态下服务端心跳检查任务的负载,降低服务器的部分压力
nacos2.0以后主要也是对AP架构下做了很大的改造,主要有以下的几点
1. 对于临时实例的注册和获取改为了grpc框架进行调用(grpc是谷歌开源的跨语言rpc框架)
2. 注册表进行了比较大的改造,之前是一个双层map,2.0改为了用Clinet对象去维护
3. 服务的注册不需要再用阻塞队列和异步任务了,通过2.0设计的注册表直接放到map就行了
4. 大量的采用了spring的event事件机制,很多都是直接发布事件就结束了...
5. 服务消费者引入了订阅者的概念,也保存了对应的关系,何为订阅者,也就是如果我订单服务需要引用商品服务的接口,那么此时订单服务就是一个订阅者
6. ephemeral转移到Service中维护,也就是一个Service下面的实例要么都是临时实例,要么都是持久化实例,1.4是支持一个Service下可以同时存在临时和持久化实例的,2.0则不行了
7. 原来注册表是一个大Map,2.0是把namespace,group,等改为一个个小的map,map小能支持的并发就比较高了
8. 1.4版本是通过http进行检查心跳的,而2.0是通过一根长连接进行维护的,1.4是如果发现超时客户端没有发送心跳,就直接把客户端从服务列表中移除了,而2.0是发现客户端没有发送心跳时会先发送一个探活请求确定客户端真的不在了才会把客户端移除,其实1.4也一样可以增加这个处理,只不过没做,而且性能会比较低,对于服务端的消耗会比较大,而2.0是基于长连接的,可以直接使用
9. 在集群状态下会向服务端发送心跳信息,如果失败的话会进行集群中其他节点的重连请求
nacos2.0对注册表进行了比较大的改动,首先引入了一个Clinet对象,这个对象是grpc在服务端帮我们维护的一个客户端对象,有点类似于NIO中在ServerSocketChannel中获取的SocketChannel对象,就是在服务端中服务端对客户端的一个引用,假设有3个实例注册到nacos服务端,那么就会有3个Clinet对象,也就是说nacos2.0的注册表结构变为了如下两部分组成
Client 对象:里面有clientId 和注册的服务
ConcurrentMap
服务注册的时候就是利用新的注册表结构进行注册了,nacos服务端已经不需要阻塞队列和异步任务的处理了,只是简单的创建一个Client对象进行绑定,然后在publisherIndexes中为对应的ServiceId添加对应的绑定即可
服务拉取的时候是从publisherIndexes拉取然后整理数据的,同时还会在服务端进行维护,保存在ConcurrentMap
subscriberIndexes记录也是有用的,当某个service下的服务列表发生了变动,2.0会通过这个subscriberIndexes找到所有的客户端,然后再根据他们维持的长连接然后把变动的服务信息循环推送给所有订阅者,这里还有个细节时,如果只是某个订阅者自己过来订阅,那么会发生了一个订阅事件,只会推送给当前订阅者,而不会推送给所有订阅者,而上面是service某个实例发生了变化,它会推送给所有订阅了该service的客户端
注:disto,zab.raft协议都是表示集群中一个节点的数据同步到其他节点的意思,只是实现发现不一样,