✨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

总结

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


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

相关推荐
葫芦和十三6 分钟前
图解 MongoDB 22|读写关注:持久性与一致性的档位选择
后端·mongodb·agent
葫芦和十三7 小时前
图解 MongoDB 21|选举与 failover:Primary 是怎么选出来的
后端·mongodb·agent
GetcharZp7 小时前
26k Star 开源内网穿透神器 NetBird,一分钟实现全球设备互联!
后端
考虑考虑8 小时前
Mybatis实现批量插入
java·后端·mybatis
咖啡八杯8 小时前
GoF设计模式——中介者模式
java·后端·spring·设计模式
fanly119 小时前
Surging AI Agent 完整产品介绍
微服务·microservice
lizhongxuan11 小时前
多Agent之间的区别
后端
青石路12 小时前
记一次多JDK版本问题的排查,一坑套一坑,差点没爬上来
java
杨充13 小时前
1.面向对象设计思想
后端
IT_陈寒13 小时前
Java的Date类又坑了我一次,改用时间戳真香
前端·人工智能·后端