nacos核心源码探索之旅-注册中心

1,355 阅读10分钟

本文涉及源码地址:点击跳转至码云的@link笔记

image.png


1. 环境准备

拉取我配置好的源码,切换到tjs-study-fetch-master分支即可

1)代码结构

image.png

2)如何启动服务端

  • 执行sql脚本

image.png

  • 更改配置

image.png

  • 增加jvm参数

-Dnacos.standalone=false -Dserver.port=8848 -Dnacos.home=xxx/cluster.conf image.png

  • 右键启动多个节点

启动类:com.alibaba.nacos.Nacos

  • 随便找个节点,创建命名空间

image.png

3)如何启动客户端

image.png

如果要自己搭建,nacos有很多版本,处理不好容易遇到与spring cloud整合时版本冲突的问题,建议使用阿里云脚手架初始化start.aliyun.com/

2. 制定本次阅读的目的

源码内容过多,一定要有清晰地目的,不然容易陷进去,本次主要梳理注册中心的核心流程

  • [客户端A如何注册到服务端]
  • [服务端集群内部如何同步数据]
  • [客户端A如何请求客户端B]
  • [客户端A如何下线,客户端B如何感知]
  • [节点C1下线,客户端A如何切换到节点C2]
  • [节点C3宕机一段时间后,重新启动时如何找回数据]
  • [心跳机制]

3. 如何寻找切入点

1. 官方文档

简单阅读下官方文档,对官方文档大概有什么东西有个印象,方便解决后面可能遇到的问题

例子: nacos文档提供了很多接口文档,可以根据接口名去寻找其在源码中的位置,并打上断点,观察调用链

2. 日志法

更改日志级别,大部分源码都会在关键节点打印日志。在关键日志所在的代码行,打上断点,观察调用链

例子: 查看日志可知,注册成功后会在NacosServiceRegistry打印register finished

registerService方法调用的api正和官网的注册服务api一致,所以此处必然就是注册时调用的地方,在此处打上断点,观察调用链即可分析出客户端注册逻辑

3. 终点法

尝试抛开具体实现,以终点作为切入点去debug,观察调用链后自下而上,逆推完整的调用逻辑

例子: 集群间的心跳同步,最终肯定是要重置心跳时间的。那么我们在HealthCheckInstancePublishInfo#setLastHeartBeatTime打上断点即可

PS: 常见的点有get/set方法、构造函数、List就找add/remove、Map就找put/computeIfAbsent、抽象接口等处打断点

4. 跳出思维,多角度尝试

不要一种方法死磕到底,抛开具体细节,反问自己,如果一切都成立,那么会有哪些现象或结论?

例子: 如果服务端注册处理成功,肯定会有个变量缓存已经注册的微服务实例信息,那么在这个变量变更的方法打上断点,分析相关调用链,即可快速分析服务端注册实现,那么如何找到这个变量呢?

  • 注册切入

查看文档,注册接口为/nacos/v1/ns/instance。在服务端源码中,找到客户端调用的的controller层实现类,发现这里代码并不好看懂

  • 查询切入

换个思路,注册代码不好看懂,尝试从查询切入,找到查询方法最终是查的哪个变量R,然后再看这个这个变量R是在哪个方法注入的,即可找到切入点

查看官方文档可知,查询是调用/nacos/v1/ns/instance/list这个接口,找到其实现类

4. 核心流程

核心流程的代码梳理如下,也可以点击跳转至码云的@link笔记查看 鼠标右键@link即可跳转至目标方法,方便后面快速找回记忆

image.png

如何使用@link找回记忆 .gif

4.1 客户端A如何注册到服务端

点击跳转至码云的@link笔记

4.2 服务端集群内部如何同步数据

点击跳转至码云的@link笔记

4.3 客户端A如何请求客户端B

点击跳转至码云的@link笔记

4.4 客户端A如何下线,客户端B如何感知

点击跳转至码云的@link笔记

4.5 节点C1下线,客户端A如何切换到节点C2

点击跳转至码云的@link笔记

4.6 节点C3宕机一段时间后,重新启动时如何找回数据

点击跳转至码云的@link笔记

4.7 心跳机制

点击跳转至码云的@link笔记

5. 调试经验

1)idea同时启动多个服务,方便集群调试

2)更改日志级别

指定日志目录:-Dnacos.logs.path=/xxx/nacos/console/src/main/resources/home/8848/logs

配置文件:/xxx/nacos/core/src/main/resources/META-INF/logback/nacos.xml

3)naocs源码用了很多事件机制

  • 在哪里发布事件:在事件构造方法打上断点
  • 在哪里消费事件:

4)延迟任务机制的task实现了Runnable,关注AbstractExecuteTask#run的具体实现即可

5)集群间的同步,关注com.alibaba.nacos.consistency.DataOperation枚举的调用处作为切入点

6)客户端与服务端、服务端与服务端的grpc请求都是在com.alibaba.nacos.core.remote.RequestHandler#handle的实现类处理的

6. 新旧版本对比

旧版本主要疼点:注册中心绝大部分数据变化不频繁,旧版本很多请求都是无效的

新版本核心优势:grpc空耗少,高吞吐

  1. 注册、心跳等重定向至目标节点
  • 旧版本:重定向至目标节点后,再执行注册 image.png
  • 新版本:首次启动随机绑定一个节点,无需重定向,直接执行注册 image.png
  • 业务让步:旧版的重定向,目的是为了使得每个节点的处理数量尽可能均衡,但是代价却是可能每次请求都多转发了一次。新版使用随机数,虽然可能导致处理数量相对旧版没有那么均衡(最坏的情况,所有节点首次随机数都为同一个值),但是相对旧版每次转发,性能更高

PS:哪个节点执行的注册,哪个节点就要负责那一个客户端的版本检查与同步。如果所有注册请求都打到了同一个节点,那单节点压力可能会过大

  1. 维持注册信息(续命)
  • 旧版本:客户端定时5秒调用/beat接口+服务端定时5秒清理非活跃实例
  • 新版本:依赖grpc自身的keeplive机制,实现grpc的失联回调接口,近实时监听下线
  • 优点:极大的减少了客户端与服务端的请求数,降低了资源的占用,提高了tps
  1. 客户端实例缓存
  • 旧版本:服务端处理完变更后使用UDP同步给其他客户端+客户端定时30秒更新一次
  • 新版本:服务端处理完变更后grpc同步给其他客户端
  • 优点:grpc推送比UDP更可靠

7. 吸收源码

7.1 贴合业务,定制的同步协议Distro

nacos没有照搬redis/zookeeper等复杂的主从同步、raft协议等,而是紧贴业务,去中心化,自己实现的Distro同步协议(AP)。

告诉我们,做事情不可一昧照搬,应该

  • 分析核心疼点

疼点,实际上客户端数据变化少,但是http心跳包/beat接口,却占据了大部分资源 -> 改用grpc监听回调,完美解决

  • 权衡业务容忍性与程序准确性

如果客户端A发起注册后,为了保证强一致性,那么就要等到服务端所有节点都同步完成,才返回成功,期间可能服务端还不可用,那注册/查询tps将大打折扣。

但是注册中心这个场景下,获取客户端A列表时,即使这次没有查到客户端A1,等个几秒,只要下次能查到A1,那么对业务并没有任何影响。

所以业务让步,牺牲程序部分准确性。放弃服务端同步的实时一致性,只要保证最终一致性即可

遇到业务难点、数据量大、并发高的时候,贴合业务,尝试站在客户/业务的角度思考下

a) 这么大的数据量,客户/业务真正的关心的数据是哪些?真正有用/变化的数据是哪些?

b) 是否真的有必要达到'完美',客户/业务能够接受哪些让步?

7.2 兜底意识

真实场景下的程序运行中,可能存在各种各样的意外,所以要学会考虑给程序上一份保险-兜底

比如:客户端A注册到服务C1后,除了调用grpc接口近实时同步给C2外。还会有一个定时器,间隔5秒向所有其他服务节点确认客户端A的缓存版本号,如果不一致,则发起兜底同步,拉齐数据

7.3 copy-on-write机制

旧版本截图:

写时加锁,直接替换引用,保证数据最终一致性。读时完全无锁,但可能有一定的延迟

核心原理:读、写至少有一个用新的引用

疑问:为什么消费队列时,没有volatile修饰,也没有synchronized对象锁,也可以保证可见性?

原因:阻塞队列消费线程只有1个线程,不存在冲突。读取时只需要最终一致性即可

但是在获取注册信息的时候,需要复制一份,否则循环的时候会触发fast-fail或删除导致的数组越界

验证示例:

7.4 事件机制

  • 优点

1)方便插件式扩展

例子:触发A的时候,需要通知B;后面版本需要新增通知C

直接调用:A和C都需改动。C需要提供方法M,方法名可能还不一样;A需要调用C

事件机制:C改动即可。C新增消费A的方法即可

附加优点:松耦合、模块化

  • 缺点

1)不方便新人调试和跟踪

例子:A调用B,B调用C,C调用D

直接调用:在D处打上断点即可获得完整的调用链

事件机制:先看C在哪里发布,然后再找到C是由于消费B触发的,再看B在哪里发布...

解决:层级较深的事件,写好某个业务的完整事件链路的注释;规划好模块,同一类事件放到一个类中消费/发布

7.5 写法

1)单一职责原则。1个监听器监听多个同业务事件


2)一个业务多个类公用日志常量

7.6 http改成grpc

  • 相对于http-json传输的优点
  1. 数据传输效率高

数据类型、结构依赖于预定义的proto文件、压缩算法等。所以
序列化数据体积小(是json的20%-80%)、反序列化快,数据传输效率高

  1. 取消业务自定义心跳,各种监听回调

grpc基于http2的长连接协议,内部实现也是通过定时ping帧(请求数据量极少)来keepalive,极大的减少了 http 请求频繁的连接创建和销毁过程,能大幅度提升性能,节约资源
其可以感知对端是否下线,下线后执行回调。比如nacos客户端异常下线后,服务端能马上感知到,而旧版需要依赖ncaos自己的心跳包http接口/beat来实现

  1. 多路复用,双端推送

同一个连接通道,客户端、服务端可以并行相互推送多个消息。

多路复用:公用一个连接,无需每次请求创建TCP连接;并行同时发送请求A、B,不需等待A响应成功再发送B;主要原理,一个请求持有一个id,双端根据请求id找到归属的请求,去发送/接收消息

  • 相对于json传输的缺点
  1. proto不可读
  2. 浏览器不可以直接访问
  3. 用的人不多,学习成本高
  • 相对于netty

netty为gRPC的底层通信框架。grpc重新实现了neety的序列化等功能

8. 流程图

image.png