最好懂的Nacos核心源码之动态配置服务

最好懂的Nacos核心源码之动态配置服务(一)

本次的源码分享取自笔者在公司的技术分享会,并做了一些改动。

说在前面

很多人都觉得,阅读源码是一种浪费时间的行为,因为本身是没有产出的,就算学到了一些解决思路,受限于当前的公司环境,也没有空间去发挥。

也有很多人觉得源码阅读是一个非常重要的技能和习惯。通过阅读源代码,我们可以更好地理解程序的内部工作原理和逻辑,从而更好地掌握编程语言和技术。

但我是觉得,学习源码的时候,不应该去抱着一种功利的心态,有的时候不妨试着不是以一个学习者,而是一个爱好者,探索者去深入你喜欢的源码领域!这样学起来是非常有趣和充实的,有的时候甚至解决一个问题,你会觉得一天都是美好的。

那么怎么学习源码?

在此次学习的过程中,我也稍微总结了以下,主要有以下几个步骤:

  1. 提出问题。
  2. 看源码解决问题。
  3. 写个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。

为什么要用阻塞队列+单线程任务的方式取任务?

有几个好处:

  1. 服务端对客户端的响应更快。服务端只是完成了信息的包装,放入队列,接口就结束了。
  2. 这是一种削峰思想,让异步任务慢慢消费,客户端不会受影响。
  3. 写线程只有一个,不用担心多线程写的问题。
单线程任务是个死循环,会不断消耗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实例列表,维护本地缓存。在服务下线的时候,也会调用下线接口,完成下线流程。

服务端利用异步任务,内存队列,写时复制技术,检测不健康的实例,通过查询内存表返回不健康的实例。

相关推荐
石牌桥网管8 小时前
OpenSSL 生成根证书、中间证书和网站证书
网络协议·https·openssl
YCyjs8 小时前
K8S群集调度二
云原生·容器·kubernetes
Hoxy.R8 小时前
K8s小白入门
云原生·容器·kubernetes
为什么这亚子14 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
阿尔帕兹15 小时前
构建 HTTP 服务端与 Docker 镜像:从开发到测试
网络协议·http·docker
FeelTouch Labs15 小时前
Netty实现WebSocket Server是否开启压缩深度分析
网络·websocket·网络协议
ZHOU西口16 小时前
微服务实战系列之玩转Docker(十八)
分布式·docker·云原生·架构·数据安全·etcd·rbac
千天夜16 小时前
使用UDP协议传输视频流!(分片、缓存)
python·网络协议·udp·视频流
牛角上的男孩17 小时前
Istio Gateway发布服务
云原生·gateway·istio
follycat17 小时前
[极客大挑战 2019]HTTP 1
网络·网络协议·http·网络安全