如果您对Nacos工作流程和原理还不是很清楚的话,建议从前面的文章开始看:
1、nacos功能简介
2、Nacos服务注册-客户端自动注册流程
3、Nacos服务注册-客户端(nacos-client)逻辑
前面介绍了Nacos服务注册流程中客户端部分的处理流程和原理,那么客户端将注册请求通过HTTP发给服务端之后,服务端会怎么处理呢?服务端是如何存储客户端注册过来的实例数据的?服务端接受到客户端心跳又是如何处理的?等等……这些问题将会在这篇文章里得到解答。
1、服务注册
在上篇中说到,NamingProxy.reqApi方法中会调用callServer(…)方法,在callServer(…)方法中就会发起一个HTTP调用,请求地址为:/nacos/v1/ns/instance,根据该地址,我们到nacos-naming包中找到对应的controller类InstanceController,其中register(request)方法便是服务注册的接口:
@CanDistro
@PostMapping
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public String register(HttpServletRequest request) throws Exception {
final String namespaceId = WebUtils
.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
final String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
NamingUtils.checkServiceNameFormat(serviceName);
//将请求中的数据封装程Instance对象
final Instance instance = parseInstance(request);
//服务注册,主要是将实例Instance放入注册表中
serviceManager.registerInstance(namespaceId, serviceName, instance);
return "ok";
}
public void registerInstance(String namespaceId, String serviceName, Instance instance) throws NacosException {
//创建空的service,其实这个方法只是构建了一个空的服务注册表,并没有真正的注册服务
createEmptyService(namespaceId, serviceName, instance.isEphemeral());
Service service = getService(namespaceId, serviceName);
if (service == null) {
throw new NacosException(NacosException.INVALID_PARAM,
"service not found, namespace: " + namespaceId + ", service: " + serviceName);
}
//注册服务到注册表
addInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
}
主要看下serviceManager.registerInstance(namespaceId, serviceName, instance)方法,该方法位于ServiceManager类中,这是nacos中的一个核心类,用来管理服务实例的。
ServiceManager类中有个@PostConstruct注解标注的方法init(),会在项目启动时ServiceManager类初始化成bean的时候执行,该方法中会开启3个定时任务:ServiceReporter(第1篇功能简介也有简单介绍)、UpdatedServiceProcessor、EmptyServiceAutoClean,本文主要讲服务注册的主线流程,这几个定时任务会放在后面讲
1.1 注册表结构
到这里首先需要说下服务端用于保存实例数据的数据结构(注册表),只有了解了注册表的结构之后,后面的处理逻辑才会更容易理解。ServiceManager中有个属性serviceMap,它就是注册表,是一个双重map:Map(namespace, Map(group::serviceName, Service)),用图来表示更加清晰一点:
图中涉及到了几个名词:
- Namespace:比较好理解,主要用于环境隔离,如dev、prod等,nacos中对应数据模型:
com.alibaba.nacos.console.model.Namespace; - Group:nacos注册中心中没有Group的数据模型,我觉得这是个逻辑概念,处于同一个Namespace下的服务还可以以Group进行隔离,可以在Namespace的基础上提供更小粒度的隔离;
- Service:服务信息,可以对照具体的微服务(如帐号服务、订单服务)来理解,里面封装了集群列表,而集群又封装了实例列表,nacos中对应数据模型:com.alibaba.nacos.naming.core.Service;
- Cluster:集群信息,每个服务可以部署多个集群(如多机房部署,一个机房就是一个集群),nacos中对应数据模型:com.alibaba.nacos.naming.core.Cluster;
- Instance:具体的服务实例信息,如果单个账号服务实例,如果是单集群部署,就会对应一个实例列表,一个实例列表都处于一个Cluster中;如果是多集群部署,那么就会存在多个Cluster,每个Cluster都会存储这样一个实例列表。nacos中对应实例的数据模型:com.alibaba.nacos.naming.core.Instance;
所以nacos的注册表结构这种设计可以很好地满足各种部署需求,如测试环境、生产环境、开发环境,每个环境还可以单机、单集群、多集群部署等等,从而提高了可扩展性。
1.2 构造注册表外壳
createEmptyService(namespaceId, serviceName, instance.isEphemeral())方法创建了一个空的service,并将这个空的service放入注册表(serviceMap)中,构造的代码比较简单,这里不一一说了,但是这里有一个非常重要的功能:服务端健康检查。
1.2.1 服务健康检查
在putServiceAndInit(service)方法中,有这么一句代码:service.init(),我们看一下:
public void init() {
//健康检测任务
HealthCheckReactor.scheduleCheck(clientBeatCheckTask);
for (Map.Entry<String, Cluster> entry : clusterMap.entrySet()) {
entry.getValue().setService(this);
entry.getValue().init();
}
}
可以看到这里提交了一个ClientBeatCheckTask异步任务,延迟5秒执行,之后每5秒执行一次,ClientBeatCheckTask是一个线程,直接看它的run()方法:
/**
* 心跳检查线程,心跳检查逻辑:
* 1、如果客户端实例超过15秒还没有发送心跳过来,则将实例健康状态改成false
* 2、如果客户端实例超过30秒还没有发送心跳过来,则剔除该实例
* **/
@Override
public void run() {
try {
//权威节点判定,根据serviceName进行取模然后得到对应的server节点,
//也就是说,每个客户端实例只会由固定的一台nacos-server节点进行健康检查
if (!getDistroMapper().responsible(service.getName())) {
return;
}
if (!getSwitchDomain().isHealthCheckEnabled()) {
return;
}
//获取当前服务的所有临时实例
List<Instance> instances = service.allIPs(true);
// first set health status of instances:
for (Instance instance : instances) {
//如果客户端实例超过15秒还没有发送心跳过来,则将实例健康状态改成false
//客户端心跳时间可以通过preserved.heart.beat.timeout配置,在nacos控制台服务实例元数据中配置,默认15秒
if (System.currentTimeMillis() - instance.getLastBeat() > instance.getInstanceHeartBeatTimeOut()) {
if (!instance.isMarked()) {
if (instance.isHealthy()) {
instance.setHealthy(false);
Loggers.EVT_LOG
.info("{POS} {IP-DISABLED} valid: {}:{}@{}@{}, region: {}, msg: client timeout after {}, last beat: {}",
instance.getIp(), instance.getPort(), instance.getClusterName(),
service.getName(), UtilsAndCommons.LOCALHOST_SITE,
instance.getInstanceHeartBeatTimeOut(), instance.getLastBeat());
getPushService().serviceChanged(service);
ApplicationUtils.publishEvent(new InstanceHeartbeatTimeoutEvent(this, instance));
}
}
}
}
if (!getGlobalConfig().isExpireInstance()) {
return;
}
// then remove obsolete instances:
for (Instance instance : instances) {
if (instance.isMarked()) {
continue;
}
//如果客户端实例超过30秒还没有发送心跳过来,则剔除该实例
//客户端实例剔除超时时间可通过preserved.ip.delete.timeout配置,,在nacos控制台服务实例元数据中配置,默认30秒
if (System.currentTimeMillis() - instance.getLastBeat() > instance.getIpDeleteTimeout()) {
// delete instance
Loggers.SRV_LOG.info("[AUTO-DELETE-IP] service: {}, ip: {}", service.getName(),
JacksonUtils.toJson(instance));
deleteIp(instance);
}
}
} catch (Exception e) {
Loggers.SRV_LOG.warn("Exception while processing client beat time out.", e);
}
}
代码本身很简单,而且我也加了详细的注释,这就是服务端的健康检查任务,用于检测客户端的心跳,如果客户端挂了,服务端并不是简单粗暴地直接就从注册表中剔除,而是先修改健康状态为false,然后再剔除,服务剔除——deleteIp(instance)方法通过HTTP调用/v1/ns/instance地址(HTTP的DELETE方式调用,restful风格),服务剔除逻辑大家自己去看下实现不,我就本文不详细说了,其实跟注册逻辑非常相似,也是先从Service中取出已存在的实例列表,去除掉当前剔除的实例,最后将更新后的实例列表再更新到注册表中(consistencyService.put(key, instances),下面会讲这个方法)。
1.3 将实例数据存入注册表
构造好壳子之后,就是往这个壳子里添加实例,代码:
public void addInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
throws NacosException {
//生成key,AP模式的key=com.alibaba.nacos.naming.iplist.ephemeral. +
//namespaceId + ## + serviceName
String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);
Service service = getService(namespaceId, serviceName);
synchronized (service) {
//这句代码主要是将注册表中已经存在的实例与当前注册过来的实例给合并为list
//同时也会更新服务端本地内存中注册表的缓存:DataStore
List<Instance> instanceList = addIpAddresses(service, ephemeral, ips);
Instances instances = new Instances();
instances.setInstanceList(instanceList);
//将上面合并后的实例列表更新到注册表中
consistencyService.put(key, instances);
}
}
再看consistencyService.put(key, instances)方法:
@Override
public void put(String key, Record value) throws NacosException {
mapConsistencyService(key).put(key, value);
}
private ConsistencyService mapConsistencyService(String key) {
return KeyBuilder.matchEphemeralKey(key) ? ephemeralConsistencyService : persistentConsistencyService;
}
这里有一个很重要的地方就是mapConsistencyService(key),可以看到这句代码是根据key值的不同取不同的对象,key的生成在上面的addInstance方法里,临时实例的key=com.alibaba.nacos.naming.iplist.ephemeral. + namespaceId + ## + serviceName,包含“ephemeral”关键字,因此这里匹配key的时候,如果包含“ephemeral‘就会返回ephemeralConsistencyService对象,该对象类型是EphemeralConsistencyService,这是一个接口,该接口只有一个实现类:DistroConsistencyServiceImpl,因此mapConsistencyService(key).put(key, value)就会进入DistroConsistencyServiceImpl.put(key, value)方法中。
其实nacos支持AP和CP两种模式的,默认是AP模式,不知道大家有没有注意到,默认情况下,我们注册到naocs注册中心的实例都是临时实例,而临时实例对应的就是AP模式,可以通过在配置文件中配置ephemeral属性来指定持久实例还是临时实例,默认为true。
nacos自己实现了Distro协议用于实现AP模式下的数据的一致性,关于这个协议的实现机制将贯穿整个AP模式下的服务注册流程中,后面会多次讲到。
在nacos控制台页面中可以看到是临时实例还是持久实例:
继续看下put方法:
@Override
public void put(String key, Record value) throws NacosException {
onPut(key, value);
//nacos-server集群同步,注意这里同步的是nacos-client的服务注册信息,不是集群状态信息
//集群状态信息同步有专门的定时任务ServerStatusReporter进行同步
distroProtocol.sync(new DistroKey(key, KeyBuilder.INSTANCE_LIST_KEY_PREFIX), DataOperation.CHANGE,
globalConfig.getTaskDispatchPeriod() / 2);
}
public void onPut(String key, Record value) {
if (KeyBuilder.matchEphemeralInstanceListKey(key)) {
Datum<Instances> datum = new Datum<>();
datum.value = (Instances) value;
datum.key = key;
datum.timestamp.incrementAndGet();
dataStore.put(key, datum);
}
if (!listeners.containsKey(key)) {
return;
}
notifier.addTask(key, DataOperation.CHANGE);
}
distroProtocol.sync是用来nacos-server集群节点间数据同步的,通过异步任务来实现,DistroProtocol类是Distro协议的其中一个核心实现,这个任务后面单独会说,本文不作细讲
在onPut方法中,第一个if分支其实就是存储缓存DataStore,上文中已经提到过,注册的时候会将注册的实例数据放入DataStore中,后面异步注册的实例数据也是从这里获取的。
1.3.1 服务异步注册
上面代码的核心是最后一句,这也是nacos服务注册的其中一个设计精髓:异步方式进行服务注册,这样能大大地提高服务注册的并发,提升性能。具体实现是通过一个线程Notifier实现,准确地说这是一个线程+阻塞队列的设计,Notifier中包含了一个阻塞队列tasks,线程启动后就循环扫描该队列,由主线程往这个队列里放数据,异步注册架构设计如图:
addTask会将服务实例的key放进阻塞队列ArrayBlockingQueue中,并且传入一个DataOperation.CHANGE操作类型:
public void addTask(String datumKey, DataOperation action) {
if (services.containsKey(datumKey) && action == DataOperation.CHANGE) {
return;
}
if (action == DataOperation.CHANGE) {
services.put(datumKey, StringUtils.EMPTY);
}
tasks.offer(Pair.with(datumKey, action));
}
由于是异步注册的,其实到这里,服务注册的主线程已经结束了。虽然我们知道放进队列之后,肯定会有地方从这个队列中取出来处理,但是一直到主线程都结束了,也没见到从队列中取数据的地方在哪。这个地方可能很多同学会比较容易忽视,我们来看下addTask方法是在DistroConsistencyServiceImpl(@Service标注)类中,其实这个类中存在这么一个方法init(),并且标注了@PostConstruct注解:
@PostConstruct
public void init() {
GlobalExecutor.submitDistroNotifyTask(notifier);
}
看到这里就恍然大悟了,在项目启动的时候,这个方法就会执行,只干了一件事:通过线程池提交了Notifier线程,Notifier是DistroConsistencyServiceImpl的内部类,而addTask方法正式在这个内部类中。所以既然线程已经开启,那么理所当然地去看它的run()方法即可。
@Override
public void run() {
Loggers.DISTRO.info("distro notifier started");
for (; ; ) {
try {
//死循环从阻塞队列tasks中取数据
//如果没有服务注册,那么tasks就为空,该线程就会一直阻塞
Pair<String, DataOperation> pair = tasks.take();
handle(pair);
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
}
}
}
private void handle(Pair<String, DataOperation> pair) {
try {
String datumKey = pair.getValue0();
DataOperation action = pair.getValue1();
services.remove(datumKey);
int count = 0;
if (!listeners.containsKey(datumKey)) {
return;
}
for (RecordListener listener : listeners.get(datumKey)) {
count++;
try {
//进入这个分支
if (action == DataOperation.CHANGE) {
listener.onChange(datumKey, dataStore.get(datumKey).value);
continue;
}
if (action == DataOperation.DELETE) {
listener.onDelete(datumKey);
continue;
}
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] error while notifying listener of key: {}", datumKey, e);
}
}
if (Loggers.DISTRO.isDebugEnabled()) {
Loggers.DISTRO
.debug("[NACOS-DISTRO] datum change notified, key: {}, listener count: {}, action: {}",
datumKey, count, action.name());
}
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
}
}
1.3.2 更新注册表
前面onPut方法中调用notifier.addTask(key, DataOperation.CHANGE)时,传进来的操作类型是DataOperation.CHANGE,那么这里就是进入if (action == DataOperation.CHANGE)分支,最终就看到了更新注册表的操作:
/**
* Update instances.
*
* 更新注册表,这里采用了写时复制思想:即将注册表拷贝一个副本出来,更新这个副本,
* 但是服务发现的时候还是从注册表里获取,待全部更新完毕再将副本替换回注册表中,
* 这样就避免了注册表的读写并发问题,这种方式不用加锁,从而大大提升了性能
* @param instances instances
* @param ephemeral whether is ephemeral instance
*/
public void updateIPs(Collection<Instance> instances, boolean ephemeral) {
Map<String, List<Instance>> ipMap = new HashMap<>(clusterMap.size());
for (String clusterName : clusterMap.keySet()) {
ipMap.put(clusterName, new ArrayList<>());
}
for (Instance instance : instances) {
try {
if (instance == null) {
Loggers.SRV_LOG.error("[NACOS-DOM] received malformed ip: null");
continue;
}
if (StringUtils.isEmpty(instance.getClusterName())) {
instance.setClusterName(UtilsAndCommons.DEFAULT_CLUSTER_NAME);
}
if (!clusterMap.containsKey(instance.getClusterName())) {
Loggers.SRV_LOG
.warn("cluster: {} not found, ip: {}, will create new cluster with default configuration.",
instance.getClusterName(), instance.toJson());
Cluster cluster = new Cluster(instance.getClusterName(), this);
cluster.init();
getClusterMap().put(instance.getClusterName(), cluster);
}
List<Instance> clusterIPs = ipMap.get(instance.getClusterName());
if (clusterIPs == null) {
clusterIPs = new LinkedList<>();
ipMap.put(instance.getClusterName(), clusterIPs);
}
clusterIPs.add(instance);
} catch (Exception e) {
Loggers.SRV_LOG.error("[NACOS-DOM] failed to process ip: " + instance, e);
}
}
for (Map.Entry<String, List<Instance>> entry : ipMap.entrySet()) {
//make every ip mine
List<Instance> entryIPs = entry.getValue();
clusterMap.get(entry.getKey()).updateIps(entryIPs, ephemeral);
}
setLastModifiedMillis(System.currentTimeMillis());
getPushService().serviceChanged(this);
StringBuilder stringBuilder = new StringBuilder();
for (Instance instance : allIPs()) {
stringBuilder.append(instance.toIpAddr()).append("_").append(instance.isHealthy()).append(",");
}
Loggers.EVT_LOG.info("[IP-UPDATED] namespace: {}, service: {}, ips: {}", getNamespaceId(), getName(),
stringBuilder.toString());
}
代码细节不再赘述,其实这是nacos服务注册的又一个设计精髓:采用写时复制(copyOnWrite)思想更新注册表,见代码中注释。而且有一个比较关键的是拷贝的并不是整个注册表,而是注册表中Cluster中的实例列表,最终更新的也是这个实例列表,因此如果注册表中数据非常庞大(有非常多的服务实例注册到这个nacos中),最终拷贝的也只是其中很小的一部分数据,并不会占用太多的内存空间,这也是nacos设计上一个比较巧妙的地方。
2、总结
- 服务健康检查
服务端接受到客户端服务注册请求后,创建空的service时(createEmptyService方法中)会开启健康检查任务,当客户端服务掉线后,服务端并不是直接就给剔除出注册表,而是平滑下线:
(1)如果客户端实例超过15秒还没有发送心跳过来,则将实例健康状态改成false
(2)如果客户端实例超过30秒还没有发送心跳过来,则剔除该实例
关于健康检查以及客户端心跳机制我也会在下篇文章中进行详细讲解,本文只是大致介绍。
- AP模式和CP模式
Nacos比其它主流注册中心框架(如Eureka、Zookeeper等)所具备的一个明显的优势就是它即支持AP模式,也支持CP模式,其中AP模式下服务注册实现类:DistroConsistencyServiceImpl,CP模式下服务注册实现类:PersistentConsistencyServiceDelegateImpl。
- 服务异步注册
Nacos为了支持高并发服务注册,采用了异步服务注册方式:主线程将客户端的注册实例放入阻塞队列中后,主线程工作就结束了,等再有客户端有注册请求过来会再次往这个队列中放(另外,往队列中存放的其实只是实例的key,不会占用很多空间同时也提升了性能,可以支持大量实例并发注册);另外在此之前(nacos-server服务启动时)就已经开启了另一个线程:Notifier,该线程会不停(死循环)从队列中取数据并更新到注册表中。需要注意的一点是这里往队列中放是多线程,从队列中取是单线程,虽然整个注册过程是异步的,但是从队列取是单线程,不会出现写-写并发的问题,但是无法避免读-写并发。
- 注册表如何更新的?如何防止读写并发?
上面说到,服务注册过程虽然不会出现写-写并发的问题,但是却无法避免读-写并发冲突,那么Nacos是如何避免读写并发冲突的?我们最常见也最容易想到的就是正在写的时候对注册表加锁,那么读请求就只能等待写操作释放锁,这样就会影响性能,降低吞吐量。另一种不加锁的方式就是读写分离,读写分离有很多种实现方法,而Nacos采用的是写时复制(copyOnWrite)思想,即更新注册表时将注册表数据复制一份出来(只复制了注册表中的实例列表部分),对副本进行写操作(读还是注册表),操作完再替换回注册表中,这样就通过读写分离的方式避免了读写并发冲突的问题,大大提高了服务注册并发,提升了性能。