✨Nacos2✨服务订阅与推送✨


正文

Nacos作为服务注册中心,整体的一个工作示意图如下所示。

作为一个客户端如果想要知道关心的服务的实例信息,首先可以去订阅 这个服务,只要订阅了这个服务,后续这个服务如果发生了实例信息变更,服务端就会推送 变更消息到客户端,同时客户端也会针对某个服务创建定时拉取的任务,也就是周期性的将服务的实例信息拉取下来并完成缓存。

本文将结合源码,对上述流程进行学习。

往期文章如下

Nacos2注册中心服务端启动流程
Nacos2服务注册流程
Nacos2心跳与健康检查

一. 客户端订阅服务

Nacos 中,查询某一个服务的实例时就会一并进行服务订阅,这段逻辑在nacos-client 包的NacosNamingService 中,具体是selectInstances() 方法,该方法有很多重载方法,我们需要关注的是下面这一段代码。

java 复制代码
@Override
public List<Instance> selectInstances(String serviceName, String groupName, List<String> clusters, boolean healthy,
        boolean subscribe) throws NacosException {
    
    // ServiceInfo就代表我们要查询的服务的实例的信息
    ServiceInfo serviceInfo;
    String clusterString = StringUtils.join(clusters, ",");
    if (subscribe) {
        // 优先从缓存中获取ServiceInfo
        serviceInfo = serviceInfoHolder.getServiceInfo(serviceName, groupName, clusterString);
        if (null == serviceInfo) {
            // 缓存中没有对应服务的ServiceInfo则向服务端发起订阅请求
            // 在向服务端发起订阅请求的同时服务端也会返回实例信息回来
            serviceInfo = clientProxy.subscribe(serviceName, groupName, clusterString);
        }
    } else {
        // 仅从服务端获取对应实例信息而不订阅
        // 通常这个逻辑是不用关注的
        serviceInfo = clientProxy.queryInstancesOfService(serviceName, groupName, clusterString, 0, false);
    }
    // 最终拿到ServiceInfo就将里面的Instance实例全部返回
    return selectInstances(serviceInfo, healthy);
}

上述方法需要重点关注下面这些点。

  1. ServiceInfo 代表客户端要查询的服务的信息 。服务下的所有实例的信息Instance 就包含在ServiceInfo里面;
  2. ServiceInfo 会优先从缓存中获取 。这里的缓存就是ServiceInfoHolder对象,缓存信息表示如下:
java 复制代码
// 键是groupName + @@ + serviceName
// 值就是对应的服务实例信息对象
ConcurrentMap<String, ServiceInfo> serviceInfoMap;
  1. 如果缓存中没有对应 ServiceInfo 则向服务端发起订阅

向服务端发起订阅的逻辑在NamingClientProxyDelegatesubscribe() 方法中,如下所示。

java 复制代码
@Override
public ServiceInfo subscribe(String serviceName, String groupName, String clusters) throws NacosException {
    NAMING_LOGGER.info("[SUBSCRIBE-SERVICE] service:{}, group:{}, clusters:{} ", serviceName, groupName, clusters);
    String serviceNameWithGroup = NamingUtils.getGroupedName(serviceName, groupName);
    String serviceKey = ServiceInfo.getKey(serviceNameWithGroup, clusters);
    // 这里是定时拉取服务的逻辑
    // 后面会单独分析这一块儿
    serviceInfoUpdateService.scheduleUpdateIfAbsent(serviceName, groupName, clusters);
    // 定时拉取服务后可能缓存中就有对应的ServiceInfo了
    // 所以这里需要再从缓存中获取一次
    ServiceInfo result = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
    if (null == result || !isSubscribed(serviceName, groupName, clusters)) {
        // 通过grpc客户端向服务端发送SubscribeServiceRequest进行订阅创建
        // 创建订阅的同时也会得到服务实例信息
        result = grpcClientProxy.subscribe(serviceName, groupName, clusters);
    }
    // 更新缓存
    serviceInfoHolder.processServiceInfo(result);
    return result;
}

到这里其实客户端的订阅服务流程就结束了,其中有一个遗留的点就是在发起订阅前,有一步是创建拉取服务的定时任务,这块儿逻辑单独放到第三节进行分析,除此之外的逻辑,这里进行一个小结。

  1. Nacos 客户端查询服务实例信息会优先从本地缓存中获取 。本地缓存叫做ServiceInfoHolder ,以键值对的形式缓存了服务实例信息ServiceInfo ,其中键是服务所属组拼接上服务名,值就是ServiceInfo
  2. 缓存中如果获取不到服务实例信息则通过 grpc 客户端向服务端发送 SubscribeServiceRequest 来创建服务订阅

二. 服务端处理服务订阅

客户端发送的服务订阅请求叫做SubscribeServiceRequest ,相应的服务端的处理者肯定就是SubscribeServiceRequestHandler 方法,其handle() 方法如下所示。

java 复制代码
@Override
@Secured(action = ActionTypes.READ, parser = NamingResourceParser.class)
public SubscribeServiceResponse handle(SubscribeServiceRequest request, RequestMeta meta) throws NacosException {
    // 进行要订阅的服务的信息的获取与封装
    String namespaceId = request.getNamespace();
    String serviceName = request.getServiceName();
    String groupName = request.getGroupName();
    String app = request.getHeader("app", "unknown");
    String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
    Service service = Service.newService(namespaceId, groupName, serviceName, true);
    Subscriber subscriber = new Subscriber(meta.getClientIp(), meta.getClientVersion(), app,
            meta.getClientIp(), namespaceId, groupedServiceName, 0, request.getClusters());
    ServiceInfo serviceInfo = ServiceUtil.selectInstancesWithHealthyProtection(serviceStorage.getData(service),
            metadataManager.getServiceMetadata(service).orElse(null), subscriber);
    if (request.isSubscribe()) {
        // 在这里会发布一个ClientSubscribeServiceEvent事件
        clientOperationService.subscribeService(service, subscriber, meta.getConnectionId());
    } else {
        clientOperationService.unsubscribeService(service, subscriber, meta.getConnectionId());
    }
    return new SubscribeServiceResponse(ResponseCode.SUCCESS.getCode(), "success", serviceInfo);
}

上述方法中,首先会从请求中获取出订阅的服务的相关信息,然后就会发布一个对于该服务的订阅事件ClientSubscribeServiceEvent ,同时ClientServiceIndexesManager会监听该事件,对应的监听逻辑如下所示。

java 复制代码
private void handleClientOperation(ClientOperationEvent event) {
    Service service = event.getService();
    String clientId = event.getClientId();
    if (event instanceof ClientOperationEvent.ClientRegisterServiceEvent) {
        addPublisherIndexes(service, clientId);
    } else if (event instanceof ClientOperationEvent.ClientDeregisterServiceEvent) {
        removePublisherIndexes(service, clientId);
    } else if (event instanceof ClientOperationEvent.ClientSubscribeServiceEvent) {
        // 在这里处理订阅事件
        addSubscriberIndexes(service, clientId);
    } else if (event instanceof ClientOperationEvent.ClientUnsubscribeServiceEvent) {
        removeSubscriberIndexes(service, clientId);
    }
}

继续跟进处理订阅事件的逻辑。

java 复制代码
private void addSubscriberIndexes(Service service, String clientId) {
    // 先建立起客户端与订阅的服务的映射关系
    subscriberIndexes.computeIfAbsent(service, (key) -> new ConcurrentHashSet<>());
    if (subscriberIndexes.get(service).add(clientId)) {
        // 发布一个ServiceSubscribedEvent事件
        NotifyCenter.publishEvent(new ServiceEvent.ServiceSubscribedEvent(service, clientId));
    }
}

ClientServiceIndexesManager 在处理订阅事件时,首先会建立起客户端与订阅的服务的映射关系,然后再发布一个ServiceSubscribedEvent 事件,该事件的监听者是NamingSubscriberServiceV2Impl,对应处理逻辑如下。

java 复制代码
@Override
public void onEvent(Event event) {
    if (!upgradeJudgement.isUseGrpcFeatures()) {
        return;
    }
    if (event instanceof ServiceEvent.ServiceChangedEvent) {
        // 例如服务注册或实例健康探测失败时会触发ServiceChangedEvent事件
        // 此时会将对应服务的实例信息推送给所有的订阅者
        ServiceEvent.ServiceChangedEvent serviceChangedEvent = (ServiceEvent.ServiceChangedEvent) event;
        Service service = serviceChangedEvent.getService();
        delayTaskEngine.addTask(service, new PushDelayTask(service, PushConfig.getInstance().getPushTaskDelay()));
    } else if (event instanceof ServiceEvent.ServiceSubscribedEvent) {
        // 在某个客户端订阅服务时会触发ServiceSubscribedEvent事件
        // 此时会将服务的实例信息推送给这个客户端
        ServiceEvent.ServiceSubscribedEvent subscribedEvent = (ServiceEvent.ServiceSubscribedEvent) event;
        Service service = subscribedEvent.getService();
        delayTaskEngine.addTask(service, new PushDelayTask(service, PushConfig.getInstance().getPushTaskDelay(),
                subscribedEvent.getClientId()));
    }
}

在上述逻辑中,主要会监听两类事件,分别是ServiceChangedEventServiceSubscribedEvent,前者表明某个服务的实例信息发生了变更,比如服务有新的实例进行注册,或者服务有实例健康探测不过,此时会将服务的最新实例信息推送给这个服务的所有订阅者,后者则表明某个客户端订阅了某个服务,此时会将这个服务的实例信息推送给这个客户端。

这里注意到推送的逻辑使用了PushDelayTaskExecuteEngine任务引擎,这里解释一下其工作机制。

首先PushDelayTaskExecuteEngine 在其构造函数中会调用到其父类NacosDelayTaskExecuteEngine的构造函数,如下所示。

java 复制代码
public PushDelayTaskExecuteEngine(ClientManager clientManager, ClientServiceIndexesManager indexesManager,
                                  ServiceStorage serviceStorage, NamingMetadataManager metadataManager,
                                  PushExecutor pushExecutor, SwitchDomain switchDomain) {
    super(PushDelayTaskExecuteEngine.class.getSimpleName(), Loggers.PUSH);
    this.clientManager = clientManager;
    this.indexesManager = indexesManager;
    this.serviceStorage = serviceStorage;
    this.metadataManager = metadataManager;
    this.pushExecutor = pushExecutor;
    this.switchDomain = switchDomain;
    // 如果没有为任务配置专门的执行器
    // 则任务的执行由该默认执行器来完成
    setDefaultTaskProcessor(new PushDelayTaskProcessor(this));
}

public NacosDelayTaskExecuteEngine(String name, Logger logger) {
    this(name, 32, logger, 100L);
}

public NacosDelayTaskExecuteEngine(String name, int initCapacity, Logger logger, long processInterval) {
    super(logger);
    tasks = new ConcurrentHashMap<>(initCapacity);
    processingExecutor = ExecutorFactory.newSingleScheduledExecutorService(new NameThreadFactory(name));
    // 在这里会启动一个定时任务来周期性的执行ProcessRunnable
    processingExecutor
            .scheduleWithFixedDelay(new ProcessRunnable(), processInterval, processInterval, TimeUnit.MILLISECONDS);
}

也就是在PushDelayTaskExecuteEngine 引擎创建出来的时候,就会启动一个定时任务来周期性的执行ProcessRunnable ,可以理解为ProcessRunnablerun() 方法会被周期性的调用,那么跟进一下run() 方法,如下所示。

java 复制代码
@Override
public void run() {
    try {
        processTasks();
    } catch (Throwable e) {
        getEngineLog().error(e.toString(), e);
    }
}

protected void processTasks() {
    // 从tasks中拿到所有要执行的任务的键
    // tasks是存放任务的Map
    Collection<Object> keys = getAllTaskKeys();
    for (Object taskKey : keys) {
        // 从tasks中将每一个要执行的任务获取出来
        // 同时需要将任务从tasks中移除
        // 也就是一个任务只执行一次
        AbstractDelayTask task = removeTask(taskKey);
        if (null == task) {
            continue;
        }
        // 获取执行器
        // 通常获取出来是PushDelayTaskProcessor
        NacosTaskProcessor processor = getProcessor(taskKey);
        if (null == processor) {
            getEngineLog().error("processor not found for task, so discarded. " + task);
            continue;
        }
        try {
            // 执行任务
            if (!processor.process(task)) {
                retryFailedTask(taskKey, task);
            }
        } catch (Throwable e) {
            getEngineLog().error("Nacos task execute error ", e);
            retryFailedTask(taskKey, task);
        }
    }
}

上述逻辑其实就是会周期性的去消费tasks 中的任务来执行,并且一个任务仅执行一次。那么现在问题就是tasks 中的任务从哪里来的呢,回到上面的NamingSubscriberServiceV2ImplonEvent() 方法。

java 复制代码
@Override
public void onEvent(Event event) {
    if (!upgradeJudgement.isUseGrpcFeatures()) {
        return;
    }
    if (event instanceof ServiceEvent.ServiceChangedEvent) {
        ServiceEvent.ServiceChangedEvent serviceChangedEvent = (ServiceEvent.ServiceChangedEvent) event;
        Service service = serviceChangedEvent.getService();
        // 这里会往tasks中添加PushDelayTask
        delayTaskEngine.addTask(service, new PushDelayTask(service, PushConfig.getInstance().getPushTaskDelay()));
    } else if (event instanceof ServiceEvent.ServiceSubscribedEvent) {
        ServiceEvent.ServiceSubscribedEvent subscribedEvent = (ServiceEvent.ServiceSubscribedEvent) event;
        Service service = subscribedEvent.getService();
        // 这里会往tasks中添加PushDelayTask
        delayTaskEngine.addTask(service, new PushDelayTask(service, PushConfig.getInstance().getPushTaskDelay(),
                subscribedEvent.getClientId()));
    }
}

也就是监听到ServiceChangedEventServiceSubscribedEvent 时,就会添加推送任务PushDelayTasktasks 中,同时又有一个线程在周期性的消费tasks 中的任务来执行,这就是PushDelayTaskExecuteEngine 任务引擎的一个工作机制,这么设计的好处就是逻辑之间高度解耦,坏处就是看代码费眼睛。

最后分析一下每一个PushDelayTask 是怎么被PushDelayTaskProcessor 执行的,跟进其process() 方法,如下所示。

java 复制代码
@Override
public boolean process(NacosTask task) {
    PushDelayTask pushDelayTask = (PushDelayTask) task;
    Service service = pushDelayTask.getService();
    // 将pushDelayTask封装成PushExecuteTask
    // 然后调用异步线程来执行PushExecuteTask
    // 最终调用到PushExecuteTask的run()方法
    NamingExecuteTaskDispatcher.getInstance()
            .dispatchAndExecuteTask(service, new PushExecuteTask(service, executeEngine, pushDelayTask));
    return true;
}

// PushExecuteTask的run()方法
@Override
public void run() {
    try {
        PushDataWrapper wrapper = generatePushData();
        for (String each : getTargetClientIds()) {
            Client client = delayTaskEngine.getClientManager().getClient(each);
            if (null == client) {
                continue;
            }
            Subscriber subscriber = delayTaskEngine.getClientManager().getClient(each).getSubscriber(service);
            // 这里最终会调用到PushExecutorRpcImpl的doPushWithCallback()方法
            delayTaskEngine.getPushExecutor().doPushWithCallback(each, subscriber, wrapper,
                    new NamingPushCallback(each, subscriber, wrapper.getOriginalData(), delayTask.isPushToAll()));
        }
    } catch (Exception e) {
        Loggers.PUSH.error("Push task for service" + service.getGroupedServiceName() + " execute failed ", e);
        delayTaskEngine.addTask(service, new PushDelayTask(service, 1000L));
    }
}

// PushExecutorRpcImpl的doPushWithCallback()方法
@Override
public void doPushWithCallback(String clientId, Subscriber subscriber, PushDataWrapper data, PushCallBack callBack) {
    // 最终向客户端发送NotifySubscriberRequest来推送服务实例信息
    pushService.pushWithCallback(clientId, NotifySubscriberRequest.buildNotifySubscriberRequest(getServiceInfo(data, subscriber)),
            callBack, GlobalExecutor.getCallbackExecutor());
}

上述方法最终就是构建出NotifySubscriberRequest 来将服务实例信息发送给客户端,而客户端处理NotifySubscriberRequest 的是NamingPushRequestHandler,对应处理逻辑如下所示。

java 复制代码
@Override
public Response requestReply(Request request) {
    if (request instanceof NotifySubscriberRequest) {
        NotifySubscriberRequest notifyResponse = (NotifySubscriberRequest) request;
        // 服务实例信息缓存到ServiceInfoHolder中
        serviceInfoHolder.processServiceInfo(notifyResponse.getServiceInfo());
        return new NotifySubscriberResponse();
    }
    return null;
}

客户端收到服务实例推送消息后,会将服务实例最新信息更新到ServiceInfoHolder缓存中。

至此服务端处理服务订阅的逻辑分析完毕,流程稍微有点长,这里进行一个小结。

  1. 服务端收到 SubscribeServiceRequest 请求后会发布 ClientSubscribeServiceEvent 事件
  2. ClientServiceIndexesManager 监听 ClientSubscribeServiceEvent 事件并处理 。这里会做两件事情,首先建立起客户端与订阅的服务的映射关系,然后发布ServiceSubscribedEvent事件;
  3. NamingSubscriberServiceV2Impl 监听 ServiceSubscribedEvent 事件并处理 。这里会将服务的实例信息推送给客户端,做法是创建推送任务PushDelayTask 并提交给PushDelayTaskExecuteEngine任务引擎;
  4. PushDelayTaskExecuteEngine 任务引擎最终会创建 NotifySubscriberRequest 将服务实例信息发送给客户端
  5. 客户端的 NamingPushRequestHandler 会将最新服务实例信息更新到 ServiceInfoHolder 缓存

补充一点,就是NamingSubscriberServiceV2Impl 同时也会监听ServiceChangedEvent事件,该事件表示对应服务的实例信息发生了变更,此时就需要将变更的消息推送给服务的所有订阅者。

三. 客户端定时拉取服务

回到第一节,客户端向服务端发起订阅的逻辑在NamingClientProxyDelegatesubscribe() 方法中,再看下这部分代码。

java 复制代码
@Override
public ServiceInfo subscribe(String serviceName, String groupName, String clusters) throws NacosException {
    NAMING_LOGGER.info("[SUBSCRIBE-SERVICE] service:{}, group:{}, clusters:{} ", serviceName, groupName, clusters);
    String serviceNameWithGroup = NamingUtils.getGroupedName(serviceName, groupName);
    String serviceKey = ServiceInfo.getKey(serviceNameWithGroup, clusters);
    // 在这里创建拉取服务的定时任务
    serviceInfoUpdateService.scheduleUpdateIfAbsent(serviceName, groupName, clusters);
    ServiceInfo result = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
    if (null == result || !isSubscribed(serviceName, groupName, clusters)) {
        result = grpcClientProxy.subscribe(serviceName, groupName, clusters);
    }
    serviceInfoHolder.processServiceInfo(result);
    return result;
}

在客户端向服务端订阅服务前,客户端还会针对该服务创建一个定时拉取的任务,继续跟进一下ServiceInfoUpdateServicescheduleUpdateIfAbsent() 方法,如下所示。

java 复制代码
public void scheduleUpdateIfAbsent(String serviceName, String groupName, String clusters) {
    String serviceKey = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, groupName), clusters);
    if (futureMap.get(serviceKey) != null) {
        return;
    }
    synchronized (futureMap) {
        if (futureMap.get(serviceKey) != null) {
            return;
        }

        // 这里的定时任务就是UpdateTask
        ScheduledFuture<?> future = addTask(new UpdateTask(serviceName, groupName, clusters));
        futureMap.put(serviceKey, future);
    }
}

上述方法中创建出来的定时任务就是UpdateTask ,因此定时拉取服务实例信息的逻辑就在UpdateTaskrun() 方法中。

java 复制代码
@Override
public void run() {
    long delayTime = DEFAULT_DELAY;

    try {
        if (!changeNotifier.isSubscribed(groupName, serviceName, clusters) && !futureMap.containsKey(
                serviceKey)) {
            NAMING_LOGGER.info("update task is stopped, service:{}, clusters:{}", groupedServiceName, clusters);
            isCancel = true;
            return;
        }

        // 先从缓存中获取服务实例信息
        ServiceInfo serviceObj = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
        if (serviceObj == null) {
            // 获取不到则向服务端查询
            serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);
            // 放到缓存中
            serviceInfoHolder.processServiceInfo(serviceObj);
            lastRefTime = serviceObj.getLastRefTime();
            return;
        }

        // lastRefTime默认是Long的最大值
        if (serviceObj.getLastRefTime() <= lastRefTime) {
            // 因此这里的逻辑一般都会执行到
            // 所以缓存中就算有服务实例信息还是要向服务端查询一次
            serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);
            serviceInfoHolder.processServiceInfo(serviceObj);
        }
        lastRefTime = serviceObj.getLastRefTime();
        if (CollectionUtils.isEmpty(serviceObj.getHosts())) {
            incFailCount();
            return;
        }
        // delayTime = 1000 * 6
        delayTime = serviceObj.getCacheMillis() * DEFAULT_UPDATE_CACHE_TIME_MULTIPLE;
        // 重置failCount次数为0
        resetFailCount();
    } catch (Throwable e) {
        // 定时拉取服务实例过程中如果发生异常
        // 则failCount加1
        // 这里failCount最多只能增加到6
        incFailCount();
        NAMING_LOGGER.warn("[NA] failed to update serviceName: {}", groupedServiceName, e);
    } finally {
        if (!isCancel) {
            // 定时任务的执行周期一开始是6秒
            // 然后依次按照2的failCount幂次方递增
            // 最多增加到60秒
            // 这里是一个衰减重试的过程
            executor.schedule(this, Math.min(delayTime << failCount, DEFAULT_DELAY * 60),
                    TimeUnit.MILLISECONDS);
        }
    }
}

上述方法就是客户端定时拉取服务实例信息的整个逻辑,这里小结如下。

  1. 定时任务触发后就会向服务端拉取服务实例信息并更新缓存
  2. 如果定时任务执行成功则 failCount 重置为 0
  3. 如果定时任务执行失败则 failCount 1
  4. 定时任务的执行周期是一个衰减重试的过程 。一开始周期为6s ,如果定时任务触发时发生异常,则周期依次按照2failCount 幂次方递增,但最多增加到60s

总结

详细总结就是第一节第二节第三节的小结汇总,这里不再重复给出。


总结不易,如果本文对你有帮助,烦请点赞,收藏加关注,谢谢帅气漂亮的你。

相关推荐
豪宇刘1 小时前
MyBatis的面试题以及详细解答二
java·servlet·tomcat
秋恬意1 小时前
Mybatis能执行一对一、一对多的关联查询吗?都有哪些实现方式,以及它们之间的区别
java·数据库·mybatis
刘大辉在路上1 小时前
突发!!!GitLab停止为中国大陆、港澳地区提供服务,60天内需迁移账号否则将被删除
git·后端·gitlab·版本管理·源代码管理
FF在路上2 小时前
Knife4j调试实体类传参扁平化模式修改:default-flat-param-object: true
java·开发语言
真的很上进2 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
众拾达人3 小时前
Android自动化测试实战 Java篇 主流工具 框架 脚本
android·java·开发语言
皓木.3 小时前
Mybatis-Plus
java·开发语言
不良人天码星3 小时前
lombok插件不生效
java·开发语言·intellij-idea
守护者1703 小时前
JAVA学习-练习试用Java实现“使用Arrays.toString方法将数组转换为字符串并打印出来”
java·学习
源码哥_博纳软云3 小时前
JAVA同城服务场馆门店预约系统支持H5小程序APP源码
java·开发语言·微信小程序·小程序·微信公众平台