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

2023年 7月 26日 67.6k 0

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

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

说在前面

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

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

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

那么怎么学习源码?

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

  • 提出问题。
  • 看源码解决问题。
  • 写个mini版demo。
  • 之后的文章也会以这个思路来说说Nacos。我并不会贴太多的源码,更多的是宏观上的东西。 只有必要的我才会贴。

    Nacos是什么?

    Nacos官网地址:nacos.io/zh-cn/docs/…

    官网是这么说的:Nacos 致力于帮助您发现、配置和管理微服务。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理。

    它的首页有三大模块:

    image-20230725144854828.png

    image-20230725144900073.png

    image.png

    所有的源码都是为了实现这三个模块!

    此次文章的讲解只包含动态配置服务模块。要问为什么的话,因为笔者只看了这么多,后续模块会慢慢出的 。

    此次Nacos的版本是1.x

    客户端是如何发起注册的?

    你是否好奇过,为什么你的微服务项目在引入依赖,在yml中进行一些七七八八的配置,就能把服务注册上去?

    这段配置相必大家会很眼熟。

    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事件进行监听,从而进行其它行为的触发。

    而nacos监听的事件正是WebServerInitializedEvent听这名字就知道是个初始化的事件。

    而实现 ApplicationListener的接口就需要实现onApplicationEvent方法,这个方法就是实际发起注册的地方。

    客户端注册有哪些行为?

    第一步:利用反射机制选择合适的用于发起注册的namingService 的实现类 (这里的namingservice就是一个接口,里面规定了各种实现注册链路的方法) 。

    这一步其实利用了路径去构造合适的实现类,还利用了双检锁去控制只有一个实例的创建。

    Class driverImplClass = Class.forName("com.alibaba.nacos.client.naming.NacosNamingService");
    

    但为什么要利用反射呢?

    其实Nacos客户端多处都用到了反射,它可以在编译时候动态选择具体的类,更加灵活。

    第二步: 通过之前被加载成bean的你写在配置文件里面的众多配置,包装成一个注册实例。

    第三步:利用动态生成的namingService 的实现类,带着实例信息,服务信息去注册。

    从这里开始,真正的注册行为就开始了。

    首先,他会根据你的实例信息,服务信息,构建你的心跳信息。这个心跳是为了确保你这个实例是否存活用的。构建完成后,他会把这个静态的心跳信息,传入一个心跳任务,心跳任务做的事情核心就是发送请求给Nacos服务端,向他告知,你还存活。这个心跳任务是放在ScheduledExecutorService中的,我们知道,这个是个JUC包下的定时任务的一个类,nacos会根据传入的参数,设置合适的线程数,定期发送请求心跳。

    然后他会发送注册请求,这个注册请求很简单,也是对注册实例,请求信息的整合,发送而已。

    为什么用延时任务?

    我们首先要知道:ScheduledExecutorService是Java中用于周期性执行任务的接口,提供了一些方法来创建和管理定时任务。它可以用来实现定时任务、周期性任务、延迟任务等。

    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 的结构。这个map就是存放了官网所描述的数据模型。

    image-20230725215708084.png

    第一个String 代表的是namespaceId 。map里面的String 则代表的是groupId

    为什么要用ConcurrentSkipListMap来存储每个命名空间下的组?

    其实这是因为ConcurrentSkipListMap相比于其它并发类的map,底层是跳表结构,是有序的,可以方便的根据groupId去获取相对的service。我们也可以按照服务名称进行排序。同时它也支持范围操作,比如以 xxx 为前缀的groupId。用户只需要查找以 xxx 为前缀的所有服务即可。

    第三步,将addInstance这个行为包装成一个任务,加入阻塞队列中。

    private BlockingQueue tasks = new ArrayBlockingQueue(1024 * 1024);
    

    有同学会好奇了,这里为什么只是把任务放入阻塞队列中?什么时候去取任务?

    这里我们继续追溯源码,发现在一个类中有个被@PostConstruct修饰的方法,这个方法提交了一个任务。

     @Override
            public void run() {            
                for (; ; ) {
                        //阻塞队列取任务
                    Pair pair = tasks.take();
                        handle(pair);
                    }
                }
            }
    

    这个任务是一个死循环。不断从阻塞队列中取任务,然后后续就是对内存中的服务进行修改。

    第四步,利用写时复制思想,更改存放服务的map。

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

    有几个好处:

  • 服务端对客户端的响应更快。服务端只是完成了信息的包装,放入队列,接口就结束了。
  • 这是一种削峰思想,让异步任务慢慢消费,客户端不会受影响。
  • 写线程只有一个,不用担心多线程写的问题。
  • 单线程任务是个死循环,会不断消耗CPU吗?

    其实是不会的。这里就是阻塞队列的概念了,如果队列中没有任务,那么阻塞队列会让该线程阻塞在那里,此时操作系统的时间片就会分给其它线程用了。

    什么是写时复制?

    虽然我们是单线程写,不会有写的并发冲突。但是是多线程读,因此会有读写并发冲突。此时就需要利用写时复制技术。简单来说就是把表中的数据,全部拿出来,赋值给另一张表,然后在新表中操作完成之后,再把原表的引用指向新表。

    服务之间是如何相互调用的?

    在最开始学习nacos的时候,当一个服务调用另一个服务的时候,我会下意识认为,nacos是个代理人,就像我们平时开的代理一样,会帮助我转发请求,但是看完源码后,我发现这是完全错误的!

    其实每个客户端都会有一个微服务的缓存列表,客户端会定时去从服务端获取最新的列表,在调用的时候会查询到相应的调用信息,如ip,端口,然后根据负载均衡选择一个合适的,由客户端去发起调用。

    第一步,查询本地缓存,这里的本地缓存是个Map

     private final Map serviceInfoMap;
    

    第二步,如果查不到本地缓存,就立刻向nacos服务端发起调用,查询到最新的服务列表。

    第三步,不管查没查到,都开启一个定时任务,维护本地缓存,这里的定时任务,也是延时任务实现的,由于上文说过,思想类似,这里不再赘述。

    第四步,返回本地缓存中的要调用的服务信息。

    服务端如何维护不健康的实例?

    如果一个服务调用另一个服务的时候,另一个服务延时过长,或者直接宕机了,那么服务就极其不稳定!那么nacos是怎么确认一个实例是否健康的呢?

    前面说过,客户端在发起注册的同时,会利用延时任务,定期发送心跳请求。那么服务端是如何处理这个心跳请求的呢?

    第一步,通过客户端的实例信息,在内存表中进行查找,如果找不到,就去内存表中重新注册。

    第二步,提交一个异步任务,在内存表中,根据ip,port等信息遍历找到对应的实例,把lastBeat改为当前时间。

    instance.setLastBeat(System.currentTimeMillis());
    

    这是正常来说,健康的实例该走的逻辑,但如果实例不健康呢?

    其实,在客户端第一次发起注册的时候,服务端就又会开启一个异步任务,这个任务的作用就是检查内存表中的那些客户端不健康,如果不健康,就对他处理。这个任务做的事情如下:

    第一步:对内存表中的所有实例进行遍历,如果当前时间和实例的lastBeat的时间大于健康时间,那么就直接把这个实例标记为不健康。

    第二步,再次遍历内存表中实例,如果当前时间和实例的lastBeat的时间大于删除时间,就直接把这个实例从内存表中移除。

    客户端下线了会怎么样?

    对于客户端来说,把这个服务暂停就意味着下线,在下线前需要对服务端负责,为此,他会去请求服务端,告知它已经下线了。

    这里和注册逻辑类似,也是先把这个下线行为,包装成任务,扔进阻塞队列中,利用单线程去任务,并利用写时复制技术去完成内存中的服务信息的移除。

    这里下线和上线,都是在操作内存表,进行修改,这里甚至方法都是一样的,nacos是怎么做到的呢?

      if (action == DataOperation.CHANGE) {
                                listener.onChange(datumKey, dataStore.get(datumKey).value);
                                continue;
                            }
                            
                            if (action == DataOperation.DELETE) {
                                listener.onDelete(datumKey);
                                continue;
                            }
    

    这里nacos是定义了两种行为,对于不同的行为,执行不同的对内存的操作。

    在写时复制完成后,nacos服务端会对内存修改这个行为,发送一个UDP的通知

     getPushService().serviceChanged(this);
    

    这个事件是遍历循环客户端,对每个客户端发送udp通知,告诉他们,注册表变动了。

    udp包丢失了怎么办?

    其实这是很常见的,因为udp本身就是一种无连接,不可靠的协议,丢包现象是可能发生的,难道客户端就无法接收到服务注册表变更的通知了吗?

    其实nacos这里的设计就很巧妙了。udp发包其实是一种推送行为,同时客户端也可以利用心跳机制去主动获取服务端的注册表呀!这样也可以保证注册表的及时更新,我把它理解成一个兜底策略。

    总结

    客户端利用spring的事件监听机制,调用注册接口,开始心跳任务做健康检测,也会做负载均衡,查询nacos实例列表,维护本地缓存。在服务下线的时候,也会调用下线接口,完成下线流程。

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

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论