最好懂的Nacos核心源码之动态配置服务(一)
本次的源码分享取自笔者在公司的技术分享会,并做了一些改动。
说在前面
很多人都觉得,阅读源码是一种浪费时间的行为,因为本身是没有产出的,就算学到了一些解决思路,受限于当前的公司环境,也没有空间去发挥。
也有很多人觉得源码阅读是一个非常重要的技能和习惯。通过阅读源代码,我们可以更好地理解程序的内部工作原理和逻辑,从而更好地掌握编程语言和技术。
但我是觉得,学习源码的时候,不应该去抱着一种功利的心态,有的时候不妨试着不是以一个学习者,而是一个爱好者,探索者去深入你喜欢的源码领域!这样学起来是非常有趣和充实的,有的时候甚至解决一个问题,你会觉得一天都是美好的。
那么怎么学习源码?
在此次学习的过程中,我也稍微总结了以下,主要有以下几个步骤:
- 提出问题。
- 看源码解决问题。
- 写个mini版demo。
之后的文章也会以这个思路来说说Nacos。我并不会贴太多的源码,更多的是宏观上的东西。 只有必要的我才会贴。
Nacos是什么?
Nacos官网地址:nacos.io/zh-cn/docs/...
官网是这么说的:Nacos 致力于帮助您发现、配置和管理微服务。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理。
它的首页有三大模块:
所有的源码都是为了实现这三个模块!
此次文章的讲解只包含动态配置服务模块。要问为什么的话,因为笔者只看了这么多,后续模块会慢慢出的 。
此次Nacos的版本是1.x
客户端是如何发起注册的?
你是否好奇过,为什么你的微服务项目在引入依赖,在yml中进行一些七七八八的配置,就能把服务注册上去?
这段配置相必大家会很眼熟。
yaml
spring:
application:
name: user-service
cloud:
nacos:
config:
server-addr: http://127.0.0.1:8848
prefix: user-service
file-extension: yml
discovery:
server-addr: http://127.0.0.1:8848
group: DEFAULT_GROUP_3
cluster-name: HZ
Nacos是这么做的:利用spring的自动注册机制,将需要自动注册的类写在spring.factories文件中。这样spring就可以扫描到这些bean,并且去管理他们。
而我们在配置文件中写的配置,也是这样直接交给spring的。
而真正发起服务这个行为也是借助了spring的另外一项功能------事件监听机制。
对于任何实现了 ApplicationListener<Event>
的接口,都会对传入的Event事件进行监听,从而进行其它行为的触发。
而nacos监听的事件正是WebServerInitializedEvent
听这名字就知道是个初始化的事件。
而实现 ApplicationListener<Event>
的接口就需要实现onApplicationEvent方法,这个方法就是实际发起注册的地方。
客户端注册有哪些行为?
第一步 :利用反射机制选择合适的用于发起注册的namingService
的实现类 (这里的namingservice就是一个接口,里面规定了各种实现注册链路的方法) 。
这一步其实利用了路径去构造合适的实现类,还利用了双检锁去控制只有一个实例的创建。
ini
Class<?> driverImplClass = Class.forName("com.alibaba.nacos.client.naming.NacosNamingService");
但为什么要利用反射呢?
其实Nacos客户端多处都用到了反射,它可以在编译时候动态选择具体的类,更加灵活。
第二步: 通过之前被加载成bean的你写在配置文件里面的众多配置,包装成一个注册实例。
第三步 :利用动态生成的namingService
的实现类,带着实例信息,服务信息去注册。
从这里开始,真正的注册行为就开始了。
首先,他会根据你的实例信息,服务信息,构建你的心跳信息。这个心跳是为了确保你这个实例是否存活用的。构建完成后,他会把这个静态的心跳信息,传入一个心跳任务,心跳任务做的事情核心就是发送请求给Nacos
服务端,向他告知,你还存活。这个心跳任务是放在ScheduledExecutorService
中的,我们知道,这个是个JUC
包下的定时任务的一个类,nacos会根据传入的参数,设置合适的线程数,定期发送请求心跳。
然后他会发送注册请求,这个注册请求很简单,也是对注册实例,请求信息的整合,发送而已。
为什么用延时任务?
我们首先要知道:ScheduledExecutorService
是Java中用于周期性执行任务的接口,提供了一些方法来创建和管理定时任务。它可以用来实现定时任务、周期性任务、延迟任务等。
而
arduino
executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
源码里则是用延时任务的方式去执行的。为了让该任务迭代定期发送心跳,每一次任务都需要执行上面这段代码去进行下一次任务的迭代。
那为什么要这么做呢?直接设置一个period不就好了?在我再次深入阅读后发现,这里面的奥秘太多了。
其实每次发送心跳信息时候,向服务端发送请求是要耗时的,比如200ms。如果用定时任务我要求5s发一次,那么在该任务结束前,下一次任务发起时,花费了5s + 200ms。无法确保心跳信息及时执行,因此需要服务端进行计算自己花费了多少时间,然后客户端就利用延时任务,派发下一次任务的时间是 4s 80ms 而不是 5s。
服务端如何处理注册请求?
在客户端的源码中,发送了注册请求和用延时队列实现的5s一次的心跳请求。我们先看看注册请求发生了什么。
这个路径是 /nacos/v1/ns/instance
,对应着我们去服务端源码里看。
第一步,根据传入的服务信息,实例信息进行校验。先查服务端注册表,如果服务端的注册表没有实例信息,那么就根据服务信息,实例信息,包装成一个service
对象。
第二步,创建一个空service,同样利用双检锁,以 namespaceId
作为 key , ConcurrentSkipListMap
作为value,存入 Map<String, Map<String, Service>> 的结构。这个map就是存放了官网所描述的数据模型。
第一个String 代表的是
namespaceId
。map里面的String 则代表的是groupId
为什么要用ConcurrentSkipListMap
来存储每个命名空间下的组?
其实这是因为ConcurrentSkipListMap
相比于其它并发类的map,底层是跳表结构,是有序的,可以方便的根据groupId
去获取相对的service。我们也可以按照服务名称进行排序。同时它也支持范围操作,比如以 xxx 为前缀的groupId。用户只需要查找以 xxx 为前缀的所有服务即可。
第三步,将addInstance这个行为包装成一个任务,加入阻塞队列中。
swift
private BlockingQueue<Pair<String, DataOperation>> tasks = new ArrayBlockingQueue<>(1024 * 1024);
有同学会好奇了,这里为什么只是把任务放入阻塞队列中?什么时候去取任务?
这里我们继续追溯源码,发现在一个类中有个被@PostConstruct
修饰的方法,这个方法提交了一个任务。
arduino
@Override
public void run() {
for (; ; ) {
//阻塞队列取任务
Pair<String, DataOperation> pair = tasks.take();
handle(pair);
}
}
}
这个任务是一个死循环。不断从阻塞队列中取任务,然后后续就是对内存中的服务进行修改。
第四步,利用写时复制思想,更改存放服务的map。
为什么要用阻塞队列+单线程任务的方式取任务?
有几个好处:
- 服务端对客户端的响应更快。服务端只是完成了信息的包装,放入队列,接口就结束了。
- 这是一种削峰思想,让异步任务慢慢消费,客户端不会受影响。
- 写线程只有一个,不用担心多线程写的问题。
单线程任务是个死循环,会不断消耗CPU吗?
其实是不会的。这里就是阻塞队列的概念了,如果队列中没有任务,那么阻塞队列会让该线程阻塞在那里,此时操作系统的时间片就会分给其它线程用了。
什么是写时复制?
虽然我们是单线程写,不会有写的并发冲突。但是是多线程读,因此会有读写并发冲突。此时就需要利用写时复制技术。简单来说就是把表中的数据,全部拿出来,赋值给另一张表,然后在新表中操作完成之后,再把原表的引用指向新表。
服务之间是如何相互调用的?
在最开始学习nacos的时候,当一个服务调用另一个服务的时候,我会下意识认为,nacos是个代理人,就像我们平时开的代理一样,会帮助我转发请求,但是看完源码后,我发现这是完全错误的!
其实每个客户端都会有一个微服务的缓存列表,客户端会定时去从服务端获取最新的列表,在调用的时候会查询到相应的调用信息,如ip,端口,然后根据负载均衡选择一个合适的,由客户端去发起调用。
第一步,查询本地缓存,这里的本地缓存是个Map
arduino
private final Map<String, ServiceInfo> serviceInfoMap;
第二步,如果查不到本地缓存,就立刻向nacos服务端发起调用,查询到最新的服务列表。
第三步,不管查没查到,都开启一个定时任务,维护本地缓存,这里的定时任务,也是延时任务实现的,由于上文说过,思想类似,这里不再赘述。
第四步,返回本地缓存中的要调用的服务信息。
服务端如何维护不健康的实例?
如果一个服务调用另一个服务的时候,另一个服务延时过长,或者直接宕机了,那么服务就极其不稳定!那么nacos是怎么确认一个实例是否健康的呢?
前面说过,客户端在发起注册的同时,会利用延时任务,定期发送心跳请求。那么服务端是如何处理这个心跳请求的呢?
第一步,通过客户端的实例信息,在内存表中进行查找,如果找不到,就去内存表中重新注册。
第二步,提交一个异步任务,在内存表中,根据ip,port等信息遍历找到对应的实例,把lastBeat改为当前时间。
ini
instance.setLastBeat(System.currentTimeMillis());
这是正常来说,健康的实例该走的逻辑,但如果实例不健康呢?
其实,在客户端第一次发起注册的时候,服务端就又会开启一个异步任务,这个任务的作用就是检查内存表中的那些客户端不健康,如果不健康,就对他处理。这个任务做的事情如下:
第一步:对内存表中的所有实例进行遍历,如果当前时间和实例的lastBeat的时间大于健康时间,那么就直接把这个实例标记为不健康。
第二步,再次遍历内存表中实例,如果当前时间和实例的lastBeat的时间大于删除时间,就直接把这个实例从内存表中移除。
客户端下线了会怎么样?
对于客户端来说,把这个服务暂停就意味着下线,在下线前需要对服务端负责,为此,他会去请求服务端,告知它已经下线了。
这里和注册逻辑类似,也是先把这个下线行为,包装成任务,扔进阻塞队列中,利用单线程去任务,并利用写时复制技术去完成内存中的服务信息的移除。
这里下线和上线,都是在操作内存表,进行修改,这里甚至方法都是一样的,nacos是怎么做到的呢?
ini
if (action == DataOperation.CHANGE) {
listener.onChange(datumKey, dataStore.get(datumKey).value);
continue;
}
if (action == DataOperation.DELETE) {
listener.onDelete(datumKey);
continue;
}
这里nacos是定义了两种行为,对于不同的行为,执行不同的对内存的操作。
在写时复制完成后,nacos服务端会对内存修改这个行为,发送一个UDP的通知
scss
getPushService().serviceChanged(this);
这个事件是遍历循环客户端,对每个客户端发送udp通知,告诉他们,注册表变动了。
udp包丢失了怎么办?
其实这是很常见的,因为udp本身就是一种无连接,不可靠的协议,丢包现象是可能发生的,难道客户端就无法接收到服务注册表变更的通知了吗?
其实nacos这里的设计就很巧妙了。udp发包其实是一种推送行为,同时客户端也可以利用心跳机制去主动获取服务端的注册表呀!这样也可以保证注册表的及时更新,我把它理解成一个兜底策略。
总结
客户端利用spring的事件监听机制,调用注册接口,开始心跳任务做健康检测,也会做负载均衡,查询nacos实例列表,维护本地缓存。在服务下线的时候,也会调用下线接口,完成下线流程。
服务端利用异步任务,内存队列,写时复制技术,检测不健康的实例,通过查询内存表返回不健康的实例。