以下源码解析请结合流程图分析
Nacos1.4.X注册中心核心功能源码| ProcessOn免费在线作图,在线流程图,在线思维导图
源码入口
由于是springboot工程,去找到spring-cloud-starter-alibaba-nacos-discovery-2.2.5.RELEASE.jar
中的spring.factories
文件,然后在找到文件里 EnableAutoConfiguration
对应的NacosServiceRegistryAutoConfiguration
。
服务注册
客户端
去到NacosServiceRegistryAutoConfiguration
中,其中有三个Bean
:NacosServiceRegistry
,NacosRegistration
,NacosAutoServiceRegistration
。前面两个都是第三个bean的入参,而前面两个都是ServiceInstance
。
进入NacosAutoServiceRegistration
,而它是实现了AbstractAutoServiceRegistration
,又实现了ApplicationListener
。
所以我们会去看到AbstractAutoServiceRegistration
类中的onApplicationEvent
方法(实现ApplicationListener
接口的类,spring容器启动时会调用处理事件方法)。
接下来的调用链路:bind(event)
-->start()
-->register()
-->NacosServiceRegistry#register()
-->NacosNamingService#registerInstance()
-->NamingProxy#registerService()
-->NamingProxy#reqApi(UtilAndComs.nacosUrlInstance, params, HttpMethod.POST)
。
其中UtilAndComs.nacosUrlInstance == /nacos/v1/ns/instance
,最后就是发起一个POST类型的Http请求来进行实例的注册。
到这儿客户端的服务注册就结束了,接下来就是服务端的注册逻辑了。
服务端
因为服务端也是一个springboot应用,根据POST类型的/nacos/v1/ns/instance
请求路径找到服务端的对应的 controller。
可以看到根据路径找到了服务端的请求接口,接下来就分析服务端的注册逻辑。
InstanceController#register()
-->ServiceManager#registerInstance()
这里面有两个重要的方法:
1.createEmptyService:根据namespaceId创建服务注册表结构
接下来就看一下createEmptyService()
,在这里面会创建nacos的服务注册表。先讲一下nacos的服务注册表结构:Map<String, Map<String, Service>>
createEmptyService()
-->createServiceIfAbsent()
-->putServiceAndInit()
-->putService()
该方法中就会把客户端传过来的namespaceId来创建第一层的Map结构。
2.addInstance:把客户端传入的实例instance加到服务注册表中
然后接着看addInstance()
,这里面会把新注册实例加入对应服务service的实例列表中去。然后调用DelegateConsistencyServiceImpl#put()
将service对应的全量实例instance写入内存注册表中(在里面会判断是临时实例还是持久化实例,这里传入的是临时实例,就会调用阿里自己实现AP模式的Distro协议)。
PS:后续细节的方面放到后续节点中去讲。这里接后面##Nacos高并发支撑异步任务与内存队列剖析##
服务发现
todo:等后续ribbon源码看了过后再来完善这块
实际上,服务发现是在第一次调用服务接口时,根据服务名去服务端获取的,这里要参照 ribbon 源码。
客户端
服务发现调用的是NacosNamingService#getAllInstances()
-->HostReactor#getServiceInfo()
-->getServiceInfo0()
获取客户端的服务实例缓存信息:Map<String, ServiceInfo> serviceInfoMap
,这里会判断获取的服务实例缓存是否为空;
1.如果为空,就先去服务端获取最新的服务数据,然后在执行延时执行定时任务;
updateServiceNow()
-->updateService()
-->NamingProxy#queryList(String serviceName, String clusters, int udpPort, boolean healthyOnly)
这里就会调用服务端的服务发现接口 /instance/list 。(这里传入的参数中有客户端的UDP端口,这个是方便服务端实例有变化了通过UDP协议的方式同步给客户端)
2如果不为空,直接执行延时执行定时任务,更新客户端的服务缓存;
scheduleUpdateIfAbsent()
-->ScheduledFuture<?> future = addTask(new UpdateTask(serviceName, clusters));
-->HostReactor#UpdateTask()
因为这里有一个定时任务线程池,所以在这里就会定时获取服务端最新服务,并更新到本地的任务。
最后执行:
serviceInfoMap.get(serviceObj.getKey())
这里最终就会放到本地的 Map 中(下次调用的时候就会从本地 Map 中去获取实例)。
服务端
根据服务发现的接口/v1/ns/instance/list
找到对应的接口。
InstanceController#list()
-->doSrvIpxt()
-->serviceManager.getService()
-->service.srvIPs()
这里最后其实返回的就是注册时写入的实例属性。
ephemeralInstances
persistentInstances
这里最后也会更新lastRefTime为当前时间
Nacos高并发支撑异步任务与内存队列剖析
在前面服务端注册代码最后有讲到把service对应的全量实例instance写入内存注册表中,由于这里默认传入的是临时实例
,所以接下来就会走阿里自己实现的Distro协议
。
ServiceManager#addInstance()
-->consistencyService.put()
-->DelegateConsistencyServiceImpl#mapConsistencyService()
-->DistroConsistencyServiceImpl#put()
这里就分为了三个板块,方法走到了put
,就先来讲 2 和 3。
模块2:将注册实例更新到注册表中。
onPut()
-->notifier.addTask()
-->tasks.offer()
其中notifier
是实现了Runnable
接口的一个线程类的实例,我们就看向其中的run
方法。
...
private BlockingQueue<Pair<String, DataOperation>> tasks = new ArrayBlockingQueue<>(1024 * 1024);
...
@Override
public void run() {
Loggers.DISTRO.info("distro notifier started");
for (; ; ) {
try {
// 从阻塞队列中获取数据
Pair<String, DataOperation> pair = tasks.take();
handle(pair);
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
}
}
}
这里给出了最关键的代码,其中tasks
是一个阻塞队列,在run
方法内部就是一个自旋,然后往阻塞队列中获取数据,然后就把获取的数据进行处理。
前面还有一个就是tasks.offer()
往这个阻塞队列中放数据,看到这里就会明白了这就是一个典型的“生产者-消费者模型”。
服务注册的时候到最后就是把实例给放到阻塞队列中,然后有一个另外的线程自旋从这个阻塞队列中获取注册实例进行消费。
模块3:这里是集群同步实例信息的方法,放到后面集群同步里面讲解。
在模块1中讲到了notifier
是另外一个线程,那么这个线程是什么时候启动的呢?那现在模块1的作用就来了。
模块1:在这里的init()
就会在启动的时候进行初始化,就来看看这段代码
// 项目启动时执行
@PostConstruct
public void init() {
GlobalExecutor.submitDistroNotifyTask(notifier);
}
----------------------------------------------------------------
public static void submitDistroNotifyTask(Runnable runnable) {
DISTRO_NOTIFY_EXECUTOR.submit(runnable);
}
----------------------------------------------------------------
private static final ScheduledExecutorService DISTRO_NOTIFY_EXECUTOR = ExecutorFactory.Managed .newSingleScheduledExecutorService(ClassUtils.getCanonicalName(NamingApp.class),
new NameThreadFactory("com.alibaba.nacos.naming.distro.notifier"));
项目在启动的时候就把这个线程放到了一个单线程线程池中去启动了,然后就会一直执行其中的run
方法,去自旋取数据,没有数据就进行阻塞。
源码设计精髓
在这里使用了异步任务以及内存队列进行操作,这些操作本身并不需要写入之后立即就成功,用这种方式对提升性能有很大的帮助。
Nacos注册表如何防止多节点读写并发冲突
前面讲到了从阻塞队列中拿出实例数据进行处理,在DelegateConsistencyServiceImpl#handle()
就会处理实例数据。
DelegateConsistencyServiceImpl#handle()
-->listener.onChange()
-->Service#onChange()
-->updateIPs()
-->clusterMap.get(entry.getKey()).updateIps(entryIPs, ephemeral);
-->ephemeralInstances = toUpdateInstances;
这里就会将临时的注册实例更新到cluster
的ephemeralInstances
属性上去,服务发现查找临时实例,最终从内存中找到的就是这个属性。
如果有多实例的话,这里对ephemeralInstances
进行写的时候,可能有实例对这个ephemeralInstances
进行读,这里就有可能产生一个读写并发冲突,那么nacos是如何解决的呢?
在这里注册的实例,先是把旧的实例copy了一份到新的集合,然后在这个新的集合中进行新旧实例的操作,操作完成后原来的set集合给替换掉,那现在原来的集合中就是现在最新操作的实例。
源码设计精髓
nacos这个更新注册表内存的方法,为了防止读写并发冲突,运用了Copy-On-Write
的思想防止读写并发冲突,具体做法就是把原内存结构复制一份,操作完成后再替换回真正的注册表内存里去。这里 copy 的数据 cluster 里面的所有 service,这里 copy 的粒度是很小的(copy 的粒度越小,性能越高)。
而Eureka为了防止读写并发冲突用的方法是注册表的多级缓存结构,只读缓存,读写缓存,内存注册表,各级缓存之间定时同步,客户端感知的及时性不如nacos。
Nacos服务变动事件发布源码剖析
在将注册实例信息更新到注册表内存结构里去的时候,会同时发布一个服务变化的事件。
Service#updateIPs()
-->getPushService().serviceChanged(this)
-->this.applicationContext.publishEvent(new ServiceChangeEvent(this, service))
-->onApplicationEvent()
-->udpPush()
-->udpSocket.send(ackEntry.origin)
在这个onApplicationEvent()
会使用UDP
方式将服务变动通知给订阅的客户端。
nacos使用udp推动模式的好处
nacos这种推送模式,对于zookeeper那种通过tcp
长连接来说会节约很多的资源,就算大量节点更新也不会让nacos出现太多的性能瓶颈,在nacos客户端如果接收到了udp
信息会返回一个ack
,如果一定时间nacos服务端没有收到ack
,那么还会进行重复发送。当超过一定重复时间之后,就不会重新发送了。
虽然通过udp
发送不能保证数据一定到达客户端,但是nacos客户端在服务发现时还有一个定时任务作为兜底,不需要担心数据不会更新的情况。
nacos通过这两种手段,既保证了实时性,有保证了数据更新不会漏掉。
Nacos心跳机制与服务健康检查源码剖析
心跳机制
客户端
在服务注册的工程中有如下一段代码:
如果是临时实例的话,就会执行心跳相关的代码。
这里就会执行一个定时任务线程池,其中 BeatTask
是实现 Runnable
的一个类,beatInfo.getPeriod()
就是定时任务的周期,这里配置的是每 5s
执行一次。
接着就会执行 BeatTask 的 run()
方法,定时向服务端发送心跳信息。
serverProxy.sendBeat
--> NamingProxy#sendBeat
--> reqApi(UtilAndComs.nacosUrlBase + "/instance/beat", params, bodyMap, HttpMethod.PUT)
。
向服务端发送的地址是:/v1/ns/instance/beat,最后就是发起一个PUT类型的Http请求来进行心跳机制的发送。
服务端
根据服务发现的接口/v1/ns/instance/beat
找到对应的接口。
InstanceController#beat()
--> serviceManager.getInstance(namespaceId, serviceName, clusterName, ip, port)
-->serviceManager.registerInstance(namespaceId, serviceName, instance)
--> service.processClientBeat(clientBeat)
--> HealthCheckReactor.scheduleNow(clientBeatProcessor)
--> ClientBeatProcessor#run()
--> instance.setLastBeat(System.currentTimeMillis());
这里就会根据 namespaceId, serviceName, clusterName, ip, port 从存储实例的双层 Map 中找到对应的实例。
如果没有获取到实例,这里就会重新注册实例。(如网络不通导致实例在服务端被下线或者服务端重启临时实例丢失)。
最后就会立即开启一个任务 ClientBeatProcessor
来更新客户端实例的最后心跳时间。
服务端健康检查
在服务端服务注册的流程中,
InstanceController#register()
-->ServiceManager#registerInstance()
--> createEmptyService()
-->createServiceIfAbsent()
-->putServiceAndInit()
-->service.init();
当创建nacos服务注册表中的时候有一个service.init()
就是进行心跳机制与服务健康检查的。
service.init()
-->HealthCheckReactor.scheduleCheck(clientBeatCheckTask)
这里就会执行一个定时任务线程池,开始后5s
执行,然后每隔5s
在执行一次。
然后看向clientBeatCheckTask
中的run
方法。
前面两个if
逻辑是对集群进行操作的,现在这里单机的就先不看。
代码中红框圈住的地方就说明了如果某个实例超过15s
没有收到心跳,则将它的healthy
属性设置为fase
。
接下来如果某个实例超过30s没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)。
然后走到deleteIp(instance)
中
这里其实就是调用了服务端的实例注销接口/v1/ns/instance
,然后根据接口找到对应的方法。
InstanceController#deregister()
-->ServiceManager#removeInstance()
-->removeInstance()
-->consistencyService.put(key, instances)
这里就会走到服务注册的逻辑,服务剔除其实也是实例的变动,就会走到服务变动的源码逻辑。
以下所说的集群架构都是基于 AP(点对点) 架构来讲的。
Nacos心跳在集群架构下的设计原理
如果集群有三个节点,是在每台机器上设计一个心跳好,还是在其中一台机器上设置好呢?
这里肯定是在其中一台机器上设置心跳,这台机器上如果检测到其中有注册的节点挂掉下线,那么只需要同步到其他节点,把其他机器上的节点健康状态改变就是了。
这里的代码在服务端健康检查里面的定时任务service.init()
-->HealthCheckReactor.scheduleCheck(clientBeatCheckTask)
,去到 clientBeatCheckTask
里面的 run()
方法。
接着去到 getDistroMapper().responsible(service.getName())
里面
这里就会根据服务名进去哈希运算,然后对集群数量取模,最终就只会有一台机器返回 true,以后每次都会定位到同一台机器,才会走后面的健康检测的逻辑。(这里可能在某一段时间内在多个机器上做了心跳检查,但是不影响,后续感知到了以后就好了)
如果其中某一台机器挂掉,再对集群数量取模那么定位到的就不一定是同一台机器了,那怎么办呢?(接着看Nacos集群节点状态同步的源码分析)
Nacos集群节点状态同步源码剖析
ServerListManager 类在初始化的时候会调用 init() 方法,执行 ServerStatusReporter
的定时任务。
ServerStatusReporter
ServerStatusReporter#run()
--> getServers()
--> synchronizer.send(server.getAddress(), msg)
--> ServerStatusSynchronizer#send()
这里就会拿到所有的节点,然后每隔一段时间循环对另外的机器发送 status (其实就是调用http接口),如果另外的机器没有收到 status ,就说明当前这台机器挂掉了。接着就会更新他们心跳取模的机器数量。
Nacos集群服务状态变动同步源码剖析
ServiceManager 类在初始化的时候会调用 init() 方法执行 ServiceReporter
的定时任务。
ServiceReporter
ServiceReporter#run()
--> getAllServiceNames()
--> synchronizer.send(server.getAddress(), msg)
--> ServiceStatusSynchronizer#send()
这里其实就跟上面节点状态同步的原理差不多,还是先拿到所有的注册实例信息,然后每隔一段时间循环对另外的机器发送健康检查的状态(其实就是调用http接口)。
Nacos集群服务新增数据同步源码剖析
在 《Nacos高并发支撑异步任务与内存队列剖析》 中还有其中的模块3没有讲到,那块代码其实就是来实现 Nacos集群服务新增数据同步的。
DistroConsistencyServiceImpl#put()
--> distroProtocol.sync()
--> DistroProtocol#sync()
.
其中有一个 memberManager.allMembersWithoutSelf()
就是对除开自己的其他节点进行循环(如果是单机的话肯定就不会执行了)。
这块服务新增数据同步节点的源码就参考这里流程图把,有点乱不是太好讲。