Nacos 源码深度畅游:Nacos 配置同步详解(下)

Nacos Client 客户端

在上文 Nacos 源码深度畅游:Nacos 配置同步详解(上) 中了解到,当配置发生变更时,Nacos Server 服务端会主动通知 Nacos Client 变更配置的内容,并且在通知失败后 Nacos Client 有主动拉取变更配置的机制保证配置的及时更新,接下来我们先看一下 Nacos Client 处理配置相关的流程图,之后再深入到源码之中:

Nacos Client 在接收到 Nacos Server 发送的配置变更请求 ConfigChangeNotifyRequest 后,会更新保存在内存中的 CacheData 配置对象的标志位,分别表示收到了配置变更请求和与服务端配置内容不一致,注意 此时并没有更新配置的内容 ,随后会在阻塞队列中添加一个元素。客户端在启动时,会创建一个 单线程的线程池并提交一个忙任务while (true)),首先这个任务会检查阻塞队列中是否有元素,如果有元素的话立即处理同步配置的任务,否则阻塞 5 秒后主动同步配置,这也就是 Nacos Client 的主动拉取配置内容的机制。在执行同步配置的任务时,会有以下步骤:

  1. checkLocalConfig: 检查本地配置文件(故障转移文件),需要注意的是:Nacos Client 的配置故障转移文件并不是由客户端程序自动创建和写入的,而是需要用户手动维护的应急机制,这样能避免配置的混乱,又提供了应急的机制
  2. checkListenerMd5: 检查配置的变更情况,如果有配置内容发生变更,则通知订阅了这些配置的监听器
  3. checkListenCache: 通过 gRPC 请求接口获取哪些配置发生了变更并获取对应的配置内容,更新到本地缓存对象 CacheData 中,并重置标志位,随后回调订阅了这些配置的监听器
  4. checkRemoveListenCache: 将已经失效的配置移除
  5. notifyListenConfig: 如果发生配置变更,则再触发一次同步配置的任务

Nacos Client 客户端处理配置变更的逻辑并不复杂,需要注意的是每次 Nacos Client 向 Nacos Server 读取的时候都会走缓存(磁盘文件和内存对象),因此 Nacos Client 和 Nacos Server 之间的遵循的是 AP 定理 ,遵循数据的 最终一致性 ,通过 Nacos Client 的定期重试来保证配置数据的更新,这样做的好处是 能够避免数据库成为性能瓶颈

Nacos Client 的主要流程如上所述,接下来为源码内容解析。


在 Nacos Client 启动时,它会执行 ConfigRpcTransportClient#initRpcClientHandler 方法来注册配置变更的处理器,这样在接收到服务端推送配置变更的请求时,会执行 ConfigRpcTransportClient#handleConfigChangeNotifyRequest 方法来处理请求,处理逻辑并不复杂,它会根据配置信息的唯一标识来查询配置信息的缓存数据,获取成功后会只是对缓存进行打标,而没有直接修改内存数据,并且向 BlockingQueue<Object> listenExecutebell 阻塞队列对象中添加了一个元素,随后处理服务端请求的流程就结束了,但是此时还并没有完成配置内容的变更:

java 复制代码
public class ClientWorker implements Closeable {
    
    public class ConfigRpcTransportClient extends ConfigTransportClient {

        private final BlockingQueue<Object> listenExecutebell = new ArrayBlockingQueue<>(1);
        
        private void initRpcClientHandler(final RpcClient rpcClientInner) {
            // 注册 配置变更 或 配置重同步处理器
            rpcClientInner.registerServerRequestHandler((request, connection) -> {
                // config change notify
                if (request instanceof ConfigChangeNotifyRequest) {
                    // 客户端处理服务端推送的配置变更通知请求
                    return handleConfigChangeNotifyRequest((ConfigChangeNotifyRequest) request,
                            rpcClientInner.getName());
                }
                return null;
            });
            
            // ...
        }

        ConfigChangeNotifyResponse handleConfigChangeNotifyRequest(ConfigChangeNotifyRequest configChangeNotifyRequest,
                                                                   String clientName) {
            // 构建配置唯一标识,查找对应的本地缓存数据
            String groupKey = GroupKey.getKeyTenant(configChangeNotifyRequest.getDataId(),
                    configChangeNotifyRequest.getGroup(), configChangeNotifyRequest.getTenant());

            // 获取对应的缓存数据
            CacheData cacheData = cacheMap.get().get(groupKey);
            if (cacheData != null) {
                synchronized (cacheData) {
                    // 标记配置状态变更,触发配置拉取和监听器通知
                    cacheData.getReceiveNotifyChanged().set(true);
                    // 标记与服务端不一致
                    cacheData.setConsistentWithServer(false);
                    // 重要:立即触发监听配置检查
                    notifyListenConfig();
                }

            }
            return new ConfigChangeNotifyResponse();
        }

        // 当有新的监听器添加或配置变更时或发生异常时,都会调用这个方法,那么在 startInternal 中的循环就会被唤醒
        @Override
        public void notifyListenConfig() {
            // 向阻塞队列中放入一个元素,唤醒 startInternal 中的循环
            listenExecutebell.offer(bellItem);
        }
    }
    
}

那么配置的内容何时变更呢?向阻塞队列 BlockingQueue<Object> listenExecutebell 中添加元素的作用是什么呢?看接下来的源码:

java 复制代码
public class ClientWorker implements Closeable {

    public class ConfigRpcTransportClient extends ConfigTransportClient {

        private ExecutorService listenExecutor;

        private final BlockingQueue<Object> listenExecutebell = new ArrayBlockingQueue<>(1);
        
        @Override
        public void startInternal() {
            listenExecutor =
                    Executors.newSingleThreadExecutor(new NameThreadFactory("com.alibaba.nacos.client.listen-executor"));
            listenExecutor.submit(() -> {
                // 忙任务
                while (!listenExecutor.isShutdown() && !listenExecutor.isTerminated()) {
                    try {
                        // 阻塞等待通知,最多等待5秒
                        listenExecutebell.poll(5L, TimeUnit.SECONDS);
                        if (listenExecutor.isShutdown() || listenExecutor.isTerminated()) {
                            continue;
                        }
                        // 执行配置监听检查
                        executeConfigListen();
                    } catch (Throwable e) {
                        LOGGER.error("[rpc listen execute] [rpc listen] exception", e);
                        try {
                            Thread.sleep(50L);
                        } catch (InterruptedException interruptedException) {
                            // ignore
                        }
                        // 出现异常时重新通知监听配置
                        notifyListenConfig();
                    }
                }
            });
        }
    }

}

在 Nacos Client 启动时会调用 ConfigRpcTransportClient#startInternal 方法,创建一个 ExecutorService listenExecutor 线程池(这段逻辑是我在 ISSUE #13822 中修改过后的),这个线程池只有一个线程,这个线程会处理一个"忙"任务:循环检查配置是否发生变更,直到线程池关闭。BlockingQueue<Object> listenExecutebell 是阻塞队列,当配置变更时添加元素,会立即触发 executeConfigListen 配置检查的逻辑,否则会 阻塞等待 5 秒,也就是说,即使在配置没有发生变更的情况下,也会触发配置的检查操作 。现在我们明白了阻塞队列 BlockingQueue<Object> listenExecutebell 的作用,接下来我们继续看一下 executeConfigListen 执行配置检查的逻辑:

java 复制代码
public class ClientWorker implements Closeable {

    public class ConfigRpcTransportClient extends ConfigTransportClient {

        private long lastAllSyncTime = System.currentTimeMillis();

        /**
         * 3 minutes to check all listen cache keys.
         */
        private static final long ALL_SYNC_INTERNAL = 3 * 60 * 1000L;
        
        @Override
        public void executeConfigListen() throws NacosException {
            // 按 taskId 分组监听缓存和移除监听缓存
            Map<String, List<CacheData>> listenCachesMap = new HashMap<>(16);
            Map<String, List<CacheData>> removeListenCachesMap = new HashMap<>(16);
            long now = System.currentTimeMillis();
            // 每3分钟进行一次全量同步检查
            boolean needAllSync = now - lastAllSyncTime >= ALL_SYNC_INTERNAL;
            // 遍历所有缓存配置,按状态分类
            for (CacheData cache : cacheMap.get().values()) {
                synchronized (cache) {
                    // 1. 检查本地配置(故障转移文件)
                    checkLocalConfig(cache);

                    // 这个变量在收到服务端推送配置变更的请求时会被修改为 false,否则为 true
                    if (cache.isConsistentWithServer()) {
                        // 2. 检查配置变更,如果发生变更便通知各个监听器
                        cache.checkListenerMd5();
                        // 如果与服务端配置一致且不需要全量同步,则跳过这个配置缓存接下来的逻辑
                        if (!needAllSync) {
                            continue;
                        }
                    }

                    // 如果使用本地配置信息,跳过处理
                    if (cache.isUseLocalConfigInfo()) {
                        continue;
                    }

                    // 根据缓存状态分类处理
                    if (!cache.isDiscard()) {
                        // 需要监听的配置
                        List<CacheData> cacheDatas = listenCachesMap.computeIfAbsent(String.valueOf(cache.getTaskId()),
                                k -> new LinkedList<>());
                        cacheDatas.add(cache);
                    } else {
                        // 需要移除监听的配置
                        List<CacheData> cacheDatas = removeListenCachesMap.computeIfAbsent(
                                String.valueOf(cache.getTaskId()), k -> new LinkedList<>());
                        cacheDatas.add(cache);
                    }
                }

            }

            // 3. 执行监听检查,返回是否有变更,执行时会从服务端查询最新配置内容并触发监听器回调
            boolean hasChangedKeys = checkListenCache(listenCachesMap);

            // 4. 执行移除 discard 的配置
            checkRemoveListenCache(removeListenCachesMap);

            if (needAllSync) {
                lastAllSyncTime = now;
            }

            // 5. 如果有变更,重新通知监听配置(形成循环),再立即处理一遍上述逻辑
            if (hasChangedKeys) {
                notifyListenConfig();
            }
        }

        /**
         * Checks and handles local configuration for a given CacheData object. This method evaluates the use of
         * failover files for local configuration storage and updates the CacheData accordingly.
         * 用于处理本地配置故障转移(failover)机制,用于 Nacos 的高可用场景,当客户端无法连接到 Nacos 服务器时,可以通过本地故障转移文件继续提供配置服务,确保应用程序的正常运行
         *
         * @param cacheData The CacheData object to be processed.
         */
        public void checkLocalConfig(CacheData cacheData) {
            final String dataId = cacheData.dataId;
            final String group = cacheData.group;
            final String tenant = cacheData.tenant;
            final String envName = cacheData.envName;

            // Check if a failover file exists for the specified dataId, group, and tenant.
            // 校验故障转移文件是否存在
            File file = LocalConfigInfoProcessor.getFailoverFile(envName, dataId, group, tenant);

            // If not using local config info and a failover file exists, load and use it.
            // 如果未使用本地配置信息且故障转移文件存在,加载并使用它。
            if (!cacheData.isUseLocalConfigInfo() && file.exists()) {
                String content = LocalConfigInfoProcessor.getFailover(envName, dataId, group, tenant);
                final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);
                // 变更为使用本地配置
                cacheData.setUseLocalConfigInfo(true);
                cacheData.setLocalConfigInfoVersion(file.lastModified());
                cacheData.setContent(content);
                LOGGER.warn("[{}] [failover-change] failover file created. dataId={}, group={}, tenant={}, md5={}",
                        envName, dataId, group, tenant, md5);
                return;
            }

            // If use local config info, but the failover file is deleted, switch back to server config.
            // 如果使用本地配置信息,但故障转移文件被删除,则切换回服务器配置
            if (cacheData.isUseLocalConfigInfo() && !file.exists()) {
                cacheData.setUseLocalConfigInfo(false);
                LOGGER.warn("[{}] [failover-change] failover file deleted. dataId={}, group={}, tenant={}", envName,
                        dataId, group, tenant);
                return;
            }

            // When the failover file content changes, indicating a change in local configuration.
            // 使用本地配置,文件存在且文件修改时间发生变更,表示本地配置发生了变化,需要更新本地配置信息
            if (cacheData.isUseLocalConfigInfo() && file.exists()
                    && cacheData.getLocalConfigInfoVersion() != file.lastModified()) {
                String content = LocalConfigInfoProcessor.getFailover(envName, dataId, group, tenant);
                final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);
                cacheData.setUseLocalConfigInfo(true);
                cacheData.setLocalConfigInfoVersion(file.lastModified());
                cacheData.setContent(content);
                LOGGER.warn("[{}] [failover-change] failover file changed. dataId={}, group={}, tenant={}, md5={}",
                        envName, dataId, group, tenant, md5);
            }
        }
    }

}

在这个方法中,第一个关注点是 checkLocalConfig 方法,这个方法用于 处理本地配置故障转移(failover) ,用于 Nacos 的 高可用 场景,当客户端无法连接到 Nacos 服务器时,可以通过本地故障转移文件继续提供配置服务,确保应用程序的正常运行,具体可以关注注释信息。需要注意的是:Nacos Client 的配置故障转移文件并不是由客户端程序自动创建和写入的,而是需要用户手动维护的应急机制,这样能避免配置的混乱,又提供了应急的机制。

CacheData#isConsistentWithServer 在接收到服务端推送配置变更时会被修改为 false,表示与服务端配置信息不一致,不一致的话它会执行 CacheData#checkListenerMd5 方法,这个方法的作用是检查内存中配置信息的 MD5 值与"监听器"中记录的 MD5 值是否一致,如果不一致的话,则"安全地"通知各个监听器:

监听器 ManagerListenerWrap:是指用户创建的订阅各个配置信息的监听器

java 复制代码
public class CacheData {

    /**
     * 监听器列表.
     */
    private final CopyOnWriteArrayList<ManagerListenerWrap> listeners;

    private volatile String md5;
    
    /**
     * 检查配置是否发生变更,遍历所有监听器,检查MD5是否变更,若变更则调用 safeNotifyListener 触发用户监听器
     */
    void checkListenerMd5() {
        for (ManagerListenerWrap wrap : listeners) {
            // 配置发生变更,触发监听器
            if (!md5.equals(wrap.lastCallMd5)) {
                safeNotifyListener(dataId, group, content, type, md5, encryptedDataKey, wrap);
            }
        }
    }

    // 安全地通知监听器,调用用户的 receiveConfigInfo 方法,完成配置变更通知
    private void safeNotifyListener(final String dataId, final String group, final String content, final String type,
                                    final String md5, final String encryptedDataKey, final ManagerListenerWrap listenerWrap) {
        final Listener listener = listenerWrap.listener;
        // 防止重复通知
        if (listenerWrap.inNotifying) {
            LOGGER.warn(
                    "[{}] [notify-currentSkip] dataId={}, group={},tenant={}, md5={}, listener={}, listener is not finish yet,will try next time.",
                    envName, dataId, group, tenant, md5, listener);
            return;
        }
        // 创建通知任务
        NotifyTask job = new NotifyTask() {

            @Override
            public void run() {
                long start = System.currentTimeMillis();
                ClassLoader myClassLoader = Thread.currentThread().getContextClassLoader();
                ClassLoader appClassLoader = listener.getClass().getClassLoader();
                ScheduledFuture<?> timeSchedule = null;

                try {
                    // 为 AbstractSharedListener 共享监听器填充上下文信息
                    if (listener instanceof AbstractSharedListener) {
                        AbstractSharedListener adapter = (AbstractSharedListener) listener;
                        adapter.fillContext(dataId, group);
                        LOGGER.info("[{}] [notify-context] dataId={}, group={},tenant={}, md5={}", envName, dataId,
                                group, tenant, md5);
                    }
                    // Before executing the callback, set the thread classloader to the classloader of
                    // the specific webapp to avoid exceptions or misuses when calling the spi interface in
                    // the callback method (this problem occurs only in multi-application deployment).
                    // 在执行回调之前,将线程类加载器设置为特定webapp的类加载器,以避免在回调方法中调用spi接口时出现异常或误用(此问题仅在多应用程序部署中发生)
                    Thread.currentThread().setContextClassLoader(appClassLoader);

                    ConfigResponse cr = new ConfigResponse();
                    cr.setDataId(dataId);
                    cr.setGroup(group);
                    cr.setContent(content);
                    cr.setEncryptedDataKey(encryptedDataKey);
                    configFilterChainManager.doFilter(null, cr);
                    String contentTmp = cr.getContent();
                    // 延期 60s 执行超时告警WARN任务
                    timeSchedule = getNotifyBlockMonitor().schedule(
                            new LongNotifyHandler(listener.getClass().getSimpleName(), dataId, group, tenant, md5,
                                    notifyWarnTimeout, Thread.currentThread()), notifyWarnTimeout,
                            TimeUnit.MILLISECONDS);
                    listenerWrap.inNotifying = true;
                    // 回调监听器的方法,告知用户接收到的配置信息
                    listener.receiveConfigInfo(contentTmp);
                    // compare lastContent and content
                    if (listener instanceof AbstractConfigChangeListener) {
                        // 变更监听器 AbstractConfigChangeListener 相关逻辑:变更配置信息并触发变更事件
                        Map<String, ConfigChangeItem> data = ConfigChangeHandler.getInstance()
                                .parseChangeData(listenerWrap.lastContent, contentTmp, type);
                        ConfigChangeEvent event = new ConfigChangeEvent(data);
                        ((AbstractConfigChangeListener) listener).receiveConfigChange(event);
                        listenerWrap.lastContent = contentTmp;
                    }

                    // 变更 MD5 信息
                    listenerWrap.lastCallMd5 = md5;
                    LOGGER.info(
                            "[{}] [notify-ok] dataId={}, group={},tenant={}, md5={}, listener={} ,job run cost={} millis.",
                            envName, dataId, group, tenant, md5, listener, (System.currentTimeMillis() - start));
                } catch (NacosException ex) {
                    LOGGER.error(
                            "[{}] [notify-error] dataId={}, group={},tenant={},md5={}, listener={} errCode={} errMsg={},stackTrace :{}",
                            envName, dataId, group, tenant, md5, listener, ex.getErrCode(), ex.getErrMsg(),
                            getTrace(ex.getStackTrace(), 3));
                } catch (Throwable t) {
                    LOGGER.error("[{}] [notify-error] dataId={}, group={},tenant={}, md5={}, listener={} tx={}",
                            envName, dataId, group, tenant, md5, listener, getTrace(t.getStackTrace(), 3));
                } finally {
                    listenerWrap.inNotifying = false;
                    Thread.currentThread().setContextClassLoader(myClassLoader);
                    // 及时通知完成就取消定时任务
                    if (timeSchedule != null) {
                        timeSchedule.cancel(true);
                    }
                }
            }
        };

        // 执行任务:如果配置了线程池,则异步执行,否则同步执行
        try {
            if (null != listener.getExecutor()) {
                LOGGER.info(
                        "[{}] [notify-listener] task submitted to user executor, dataId={}, group={},tenant={}, md5={}, listener={} ",
                        envName, dataId, group, tenant, md5, listener);
                job.async = true;
                listener.getExecutor().execute(job);
            } else {
                LOGGER.info(
                        "[{}] [notify-listener] task execute in nacos thread, dataId={}, group={},tenant={}, md5={}, listener={} ",
                        envName, dataId, group, tenant, md5, listener);
                job.run();
            }
        } catch (Throwable t) {
            LOGGER.error("[{}] [notify-listener-error] dataId={}, group={},tenant={}, md5={}, listener={} throwable={}",
                    envName, dataId, group, tenant, md5, listener, t.getCause());
        }
    }

}

在这个过程中,源码虽然很多,但是并不复杂,主要关注两项内容:

  1. 针对发生变更的配置会创建 NotifyTask 任务,用于监听器方法 receiveConfigInfo 告知变更后的配置信息,为了方便大家理解,以如下监听器为例:
java 复制代码
    @Test
void test() throws InterruptedException, NacosException {
    // 创建 NacosConfigService 客户端
    Properties properties = new Properties();
    properties.put(PropertyKeyConst.SERVER_ADDR, "127.0.0.1:8850");
    properties.put(PropertyKeyConst.CONFIG_LONG_POLL_TIMEOUT, "20000");
    properties.put(PropertyKeyConst.CONFIG_RETRY_TIME, "3000");
    properties.put(PropertyKeyConst.MAX_RETRY, "5");
    ConfigService configService = NacosFactory.createConfigService(properties);

    // 添加监听器
    configService.addListener("default_value", "DEFAULT_GROUP", new Listener() {
        @Override
        public Executor getExecutor() {
            return null;
        }

        // 监听器的回调方法
        @Override
        public void receiveConfigInfo(String configInfo) {
            System.out.println(configInfo);
        }
    });
}

也就是说,用户可以自行创建各个不同的配置的监听器,当配置发生变更时,便能从 receiveConfigInfo 方法中获取到变更后的配置

  1. NotifyTask 中创建了 LongNotifyHandler 任务,这个任务会被延期 60s 执行,同时 NotifyTask 任务的 finally 代码块中有 LongNotifyHandler 任务的取消逻辑,也就是说:NotifyTask 任务只要执行的足够快(60s以内),这个任务将会被取消,接下来我们看一下 LongNotifyHandler 任务的逻辑:
java 复制代码
public class CacheData {
    class LongNotifyHandler implements Runnable {
        
        @Override
        public void run() {
            String blockTrace = getTrace(thread.getStackTrace(), 5);
            LOGGER.warn("[{}] [notify-block-monitor] dataId={}, group={},tenant={}, md5={}, "
                            + "receiveConfigInfo execute over {} mills,thread trace block : {}", envName, dataId, group, tenant,
                    md5, timeoutMills, blockTrace);
            NotifyCenter.publishEvent(
                    new ChangeNotifyBlockEvent(this.listenerClass, dataId, group, tenant, this.startTime,
                            System.currentTimeMillis(), blockTrace));
        }
    }
}

发现它会打印一行告警日志,提示某个配置的监听器通知被阻塞了,并且会触发一个 ChangeNotifyBlockEvent 事件(但是这个事件还没有特定的消费者),在日常开发中可以参考这种告警机制的写法。


接下来,我们再回到 ClientWorker#executeConfigListen 的逻辑中,执行完 CacheData#checkListenerMd5 方法后会检查是否使用本地故障转移配置文件,如果使用的话就不再处理后续逻辑,否则会将 CacheData 根据是否失效的状态进行分组,并对仍然有效的配置进行处理,执行 ConfigRpcTransportClient#checkListenCache 方法:

java 复制代码
public class ConfigRpcTransportClient extends ConfigTransportClient {

    private boolean checkListenCache(Map<String, List<CacheData>> listenCachesMap) throws NacosException {
        // 使用原子布尔值记录是否有配置发生变更,保证线程安全
        final AtomicBoolean hasChangedKeys = new AtomicBoolean(false);

        // 如果没有需要监听的缓存,直接返回false
        if (!listenCachesMap.isEmpty()) {
            List<Future> listenFutures = new ArrayList<>();
            for (Map.Entry<String, List<CacheData>> entry : listenCachesMap.entrySet()) {
                String taskId = entry.getKey();
                // 为每个 taskId 创建一个 RpcClient 客户端
                RpcClient rpcClient = ensureRpcClient(taskId);
                // 每个 taskId 专门分配一个线程数为 1 的线程池
                ExecutorService executorService = ensureSyncExecutor(taskId);
                Future future = executorService.submit(() -> {
                    List<CacheData> listenCaches = entry.getValue();
                    // 重置通知变更标识
                    for (CacheData cacheData : listenCaches) {
                        cacheData.getReceiveNotifyChanged().set(false);
                    }
                    // 将多个配置的监听请求合并为一个批量请求,提高网络效率
                    ConfigBatchListenRequest configChangeListenRequest = buildConfigRequest(listenCaches);
                    configChangeListenRequest.setListen(true);
                    try {
                        // 向 Nacos 服务端发送批量监听请求,检查配置是否有变更
                        ConfigChangeBatchListenResponse listenResponse = (ConfigChangeBatchListenResponse) requestProxy(
                                rpcClient, configChangeListenRequest);
                        if (listenResponse != null && listenResponse.isSuccess()) {
                            Set<String> changeKeys = new HashSet<>();

                            List<ConfigChangeBatchListenResponse.ConfigContext> changedConfigs = listenResponse.getChangedConfigs();
                            // 获取服务端返回的变更配置列表,并通知监听者
                            if (!CollectionUtils.isEmpty(changedConfigs)) {
                                hasChangedKeys.set(true);
                                for (ConfigChangeBatchListenResponse.ConfigContext changeConfig : changedConfigs) {
                                    // 构建配置的唯一标识key:dataId+group+tenant
                                    String changeKey = GroupKey.getKeyTenant(changeConfig.getDataId(),
                                            changeConfig.getGroup(), changeConfig.getTenant());
                                    changeKeys.add(changeKey);
                                    // 检查配置是否处于初始化状态,初始化状态的配置不需要通知监听器,避免重复通知
                                    boolean isInitializing = cacheMap.get().get(changeKey).isInitializing();
                                    // 刷新配置内容并检查MD5,触发监听器回调
                                    refreshContentAndCheck(rpcClient, changeKey, !isInitializing);
                                }

                            }

                            // ReceiveNotifyChanged 已经在方法开始时被重置为 false,这段逻辑解决的是被重置为 false 之后,
                            // 如果这期间仍有配置变更同步到 Nacos Client 时能一并处理
                            for (CacheData cacheData : listenCaches) {
                                if (cacheData.getReceiveNotifyChanged().get()) {
                                    String changeKey = GroupKey.getKeyTenant(cacheData.dataId, cacheData.group,
                                            cacheData.getTenant());
                                    if (!changeKeys.contains(changeKey)) {
                                        boolean isInitializing = cacheMap.get().get(changeKey).isInitializing();
                                        refreshContentAndCheck(rpcClient, changeKey, !isInitializing);
                                    }
                                }
                            }

                            // 若从服务端未获取到变更的配置,标记为与服务端一致
                            for (CacheData cacheData : listenCaches) {
                                cacheData.setInitializing(false);
                                String groupKey = GroupKey.getKeyTenant(cacheData.dataId, cacheData.group,
                                        cacheData.getTenant());
                                if (!changeKeys.contains(groupKey)) {
                                    synchronized (cacheData) {
                                        if (!cacheData.getReceiveNotifyChanged().get()) {
                                            cacheData.setConsistentWithServer(true);
                                        }
                                    }
                                }
                            }

                        }
                    } catch (Throwable e) {
                        // 发生异常的话进行重试
                        LOGGER.error("Execute listen config change error ", e);
                        try {
                            Thread.sleep(50L);
                        } catch (InterruptedException interruptedException) {
                            //ignore
                        }
                        // 重新触发监听检查
                        notifyListenConfig();
                    }
                });
                // 将异步任务添加到Future列表中
                listenFutures.add(future);
            }
            // 阻塞等待任务完成
            for (Future future : listenFutures) {
                try {
                    future.get();
                } catch (Throwable throwable) {
                    LOGGER.error("Async listen config change error ", throwable);
                }
            }

        }
        return hasChangedKeys.get();
    }
}

这是一个同步调用的方法,虽然比较长但是逻辑上并不难,首先它会将配置按照"任务"的维度分组、合并,这样可以减少对 Nacos Server 服务端的请求数量,合并完成后,每个任务都分配一个线程池去处理,处理时会先查询服务端有哪些配置发生了变更,获取到变更的配置后再逐一处理,调用 ConfigRpcTransportClient#refreshContentAndCheck 方法:

java 复制代码
public class ConfigRpcTransportClient extends ConfigTransportClient {
    private void refreshContentAndCheck(RpcClient rpcClient, String groupKey, boolean notify) {
        if (cacheMap.get() != null && cacheMap.get().containsKey(groupKey)) {
            CacheData cache = cacheMap.get().get(groupKey);
            refreshContentAndCheck(rpcClient, cache, notify);
        }
    }

    private void refreshContentAndCheck(RpcClient rpcClient, CacheData cacheData, boolean notify) {
        try {
            // 向服务端查询最新的配置内容,并写入 CacheData 中,并且记录在了本地文件中
            ConfigResponse response = this.queryConfigInner(rpcClient, cacheData.dataId, cacheData.group,
                    cacheData.tenant, requestTimeout, notify);
            cacheData.setEncryptedDataKey(response.getEncryptedDataKey());
            cacheData.setContent(response.getContent());
            if (null != response.getConfigType()) {
                cacheData.setType(response.getConfigType());
            }
            if (notify) {
                LOGGER.info("[{}] [data-received] dataId={}, group={}, tenant={}, md5={}, type={}", agent.getName(),
                        cacheData.dataId, cacheData.group, cacheData.tenant, cacheData.getMd5(),
                        response.getConfigType());
            }
            // 检查配置 MD5 变化并触发监听器回调通知应用程序
            cacheData.checkListenerMd5();
        } catch (Exception e) {
            LOGGER.error("refresh content and check md5 fail ,dataId={},group={},tenant={} ", cacheData.dataId,
                    cacheData.group, cacheData.tenant, e);
        }
    }
}

在其中的 ConfigRpcTransportClient#refreshContentAndCheck 方法中会向 Nacos 服务端查询配置信息,再完成本地配置的写入 ,随后执行 CacheData#checkListenerMd5 方法完成监听器的通知回调。在这里我有一个疑问:为什么不在批量查询哪些配置发生变更时一并将配置的内容返回呢?这样的网络开销是不是会更小一些?

后来我想了想,理论上可行,不过因为每次请求都是请求到 Nacos Server 的内存,所以多次请求也不会给 Nacos Server 造成太大的压力,网络开销也不大

ConfigRpcTransportClient#checkListenCache 方法如果在执行中抛出异常,那么会在 catch 代码块中执行 ConfigRpcTransportClient#notifyListenConfig 方法立即发起配置主动拉取的操作,避免配置内容更新不及时 。再回到 ClientWorker#executeConfigListen 的逻辑中,如果 ConfigRpcTransportClient#checkListenCache 方法返回 true 表示有配置发生变更,同样地也会执行一次 ConfigRpcTransportClient#notifyListenConfig 方法,这么做的原因可能是当有配置发生变更时,用户可能在这期间陆续修改了多个配置,再次触发配置拉取操作减少配置同步的延时。

现在我们已经对 Nacos Client 处理 Nacos Server 推送配置变更的处理和主动同步配置变更的逻辑比较了解了,现在我们还要注意下 Nacos Client 在同步配置过程中与 Nacos Server 的两次交互:批量查询配置信息是否发生变更查询单个配置的内容

首先我们来看批量查询配置信息时,服务端的处理逻辑:

java 复制代码
@Component
public class ConfigChangeBatchListenRequestHandler
        extends RequestHandler<ConfigBatchListenRequest, ConfigChangeBatchListenResponse> {
    
    @Autowired
    private ConfigChangeListenContext configChangeListenContext;
    
    @Override
    @NamespaceValidation
    @TpsControl(pointName = "ConfigListen")
    @Secured(action = ActionTypes.READ, signType = SignType.CONFIG)
    @ExtractorManager.Extractor(rpcExtractor = ConfigBatchListenRequestParamExtractor.class)
    public ConfigChangeBatchListenResponse handle(ConfigBatchListenRequest configChangeListenRequest, RequestMeta meta)
            throws NacosException {
        // 获取连接ID,用于标识客户端连接
        String connectionId = StringPool.get(meta.getConnectionId());
        String tag = configChangeListenRequest.getHeader(Constants.VIPSERVER_TAG);
        ParamUtils.checkParam(tag);
        ConfigChangeBatchListenResponse configChangeBatchListenResponse = new ConfigChangeBatchListenResponse();
        // 处理批量监听请求中的每个配置项
        for (ConfigBatchListenRequest.ConfigListenContext listenContext : configChangeListenRequest.getConfigListenContexts()) {
            boolean isNeedTransferNamespace = NamespaceUtil.isNeedTransferNamespace(listenContext.getTenant());
            String namespaceId = NamespaceUtil.processNamespaceParameter(listenContext.getTenant());
            String groupKey = GroupKey2.getKey(listenContext.getDataId(), listenContext.getGroup(), namespaceId);
            groupKey = StringPool.get(groupKey);
            
            String md5 = StringPool.get(listenContext.getMd5());
            
            if (configChangeListenRequest.isListen()) {
                // 添加监听:将客户端连接与配置项关联
                configChangeListenContext.addListen(groupKey, md5, connectionId, isNeedTransferNamespace);
                // 检查配置是否已经发生变更
                boolean isUptoDate = ConfigCacheService.isUptodate(groupKey, md5, meta.getClientIp(), tag,
                        meta.getAppLabels());
                // 如果配置已变更,添加变更的配置信息
                if (!isUptoDate) {
                    configChangeBatchListenResponse.addChangeConfig(listenContext.getDataId(), listenContext.getGroup(),
                            listenContext.getTenant());
                }
            } else {
                // 移除监听:取消客户端连接与配置项的关联
                configChangeListenContext.removeListen(groupKey, connectionId);
            }
        }
        
        return configChangeBatchListenResponse;
        
    }
    
}

其中较为关键的逻辑是检查配置是否发生变更的 ConfigCacheService#isUptodate 方法,可见比较配置是否发生变更的 MD5 值是从本地缓存对象 CacheItem 中获取的:

java 复制代码
public class ConfigCacheService {
    public static boolean isUptodate(String groupKey, String md5, String ip, String tag,
                                     Map<String, String> appLabels) {
        String serverMd5 = ConfigCacheService.getContentMd5(groupKey, ip, tag, appLabels);
        return StringUtils.equals(md5, serverMd5);
    }

    public static String getContentMd5(String groupKey, String ip, String tag, Map<String, String> connLabels) {
        // JVM 本地内存对象
        CacheItem item = CACHE.get(groupKey);
        if (item == null) {
            return NULL;
        }
        if (connLabels == null && StringUtils.isNotBlank(ip)) {
            connLabels = new HashMap<>(4);
        }
        if (connLabels == null && StringUtils.isNotBlank(tag)) {
            connLabels = new HashMap<>(4);
        }

        if (StringUtils.isNotBlank(ip)) {
            connLabels.put(CLIENT_IP, ip);
        }
        if (StringUtils.isNotBlank(tag)) {
            connLabels.put(VIPSERVER_TAG, tag);
        }
        if (item.getSortConfigGrays() != null && connLabels != null && !connLabels.isEmpty()) {
            for (ConfigCacheGray entry : item.getSortConfigGrays()) {
                if (entry.match(connLabels)) {
                    return entry.getMd5();
                }
            }
        }
        String md5 = item.getConfigCache().getMd5();
        return md5 == null ? NULL : md5;
    }
}

这样做会有什么问题吗?在上文讲解 Nacos Server 变更配置的逻辑中,我们了解到数据库变更是同步的,但是内存和服务端本地 Dump 文件变更是异步的,这样就可能存在节点未完成缓存更新的场景,可能造成查询时实际配置变了(数据库变更)但通过接口查询结果显示配置没变的情况,所以在 Nacos Client 超时自动配置同步的逻辑也是能理解的了,这也反映了 Nacos Client 同步配置遵循的是数据的 最终一致性

其次,我们再看一下 Nacos Client 向 Nacos Server 查询单个配置的场景:

java 复制代码
@Component
public class ConfigQueryRequestHandler extends RequestHandler<ConfigQueryRequest, ConfigQueryResponse> {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigQueryRequestHandler.class);

    private final ConfigQueryChainService configQueryChainService;

    public ConfigQueryRequestHandler(ConfigQueryChainService configQueryChainService) {
        this.configQueryChainService = configQueryChainService;
    }

    @Override
    @NamespaceValidation
    @TpsControl(pointName = "ConfigQuery")
    @Secured(action = ActionTypes.READ, signType = SignType.CONFIG)
    @ExtractorManager.Extractor(rpcExtractor = ConfigRequestParamExtractor.class)
    public ConfigQueryResponse handle(ConfigQueryRequest request, RequestMeta meta) throws NacosException {
        try {
            request.setTenant(NamespaceUtil.processNamespaceParameter(request.getTenant()));
            String dataId = request.getDataId();
            String group = request.getGroup();
            String tenant = request.getTenant();
            String groupKey = GroupKey2.getKey(dataId, group, tenant);
            boolean notify = request.isNotify();

            String requestIpApp = meta.getLabels().get(CLIENT_APPNAME_HEADER);
            String clientIp = meta.getClientIp();

            ConfigQueryChainRequest chainRequest = ConfigChainRequestExtractorService.getExtractor().extract(request, meta);
            // 执行查询逻辑
            ConfigQueryChainResponse chainResponse = configQueryChainService.handle(chainRequest);

            if (ResponseCode.FAIL.getCode() == chainResponse.getResultCode()) {
                return ConfigQueryResponse.buildFailResponse(ResponseCode.FAIL.getCode(), chainResponse.getMessage());
            }
            if (chainResponse.getStatus() == ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_NOT_FOUND) {
                return handlerConfigNotFound(request.getDataId(), request.getGroup(), request.getTenant(), requestIpApp,
                        clientIp, notify);
            }
            if (chainResponse.getStatus() == ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_QUERY_CONFLICT) {
                return handlerConfigConflict(clientIp, groupKey);
            }

            ConfigQueryResponse response = new ConfigQueryResponse();
            response.setMd5(chainResponse.getMd5());
            response.setEncryptedDataKey(chainResponse.getEncryptedDataKey());
            response.setContent(chainResponse.getContent());
            response.setContentType(chainResponse.getConfigType());
            response.setLastModified(chainResponse.getLastModified());
            // ...

            return response;

        } catch (Exception e) {
            LOGGER.error("Failed to handle grpc configuration query", e);
            return ConfigQueryResponse.buildFailResponse(ResponseCode.FAIL.getCode(), e.getMessage());
        }
    }
}

public class FormalHandler extends AbstractConfigQueryHandler {

    private static final String FORMAL_HANDLER = "formalHandler";

    @Override
    public String getName() {
        return FORMAL_HANDLER;
    }

    @Override
    public ConfigQueryChainResponse handle(ConfigQueryChainRequest request) throws IOException {
        ConfigQueryChainResponse response = new ConfigQueryChainResponse();

        String dataId = request.getDataId();
        String group = request.getGroup();
        String tenant = request.getTenant();

        // MD5 值来自 JVM 缓存对象
        CacheItem cacheItem = ConfigChainEntryHandler.getThreadLocalCacheItem();
        String md5 = cacheItem.getConfigCache().getMd5();
        // 配置的内容 Content 来自磁盘文件
        String content = ConfigDiskServiceFactory.getInstance().getContent(dataId, group, tenant);
        if (StringUtils.isBlank(content)) {
            response.setStatus(ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_NOT_FOUND);
            return response;
        }
        long lastModified = cacheItem.getConfigCache().getLastModifiedTs();
        String encryptedDataKey = cacheItem.getConfigCache().getEncryptedDataKey();
        String configType = cacheItem.getType();
        response.setContent(content);
        response.setMd5(md5);
        response.setLastModified(lastModified);
        response.setEncryptedDataKey(encryptedDataKey);
        response.setConfigType(configType);
        response.setStatus(ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_FOUND_FORMAL);

        return response;
    }
}

在这段逻辑中重点关注配置信息读取,可以发现配置的内容来自磁盘文件,配置的 MD5 值来自 JVM 的内存缓存对象,也就是说 Nacos Client 单次查询 Nacos Server 某个配置的内容同样也是不经过数据库的,那么这就 可能存在查询结果与数据库中实际的配置信息存在差异的情况 。很显然,在 Nacos Server 上进行配置的发布遵循 CP 定理保证线性一致性 ,但是 Nacos Client 来读取的时候会走缓存,因此 Nacos Client 和 Nacos Server 之间的遵循的是 AP 定理 ,遵循数据的 最终一致性 ,通过 Nacos Client 的定期重试来保证配置数据的更新,这样做的好处是 能够避免数据库成为性能瓶颈


巨人的肩膀

相关推荐
方圆想当图灵2 小时前
Nacos 源码深度畅游:Nacos 配置同步详解(上)
分布式·后端·github
小羊失眠啦.3 小时前
用 Rust 实现高性能并发下载器:从原理到实战
开发语言·后端·rust
lkbhua莱克瓦244 小时前
Java练习——数组练习
java·开发语言·笔记·github·学习方法
Slow菜鸟4 小时前
Java后端常用技术选型 |(四)微服务篇
java·分布式
Filotimo_4 小时前
SpringBoot3入门
java·spring boot·后端
闲人编程4 小时前
Python与大数据:使用PySpark处理海量数据
大数据·开发语言·分布式·python·spark·codecapsule·大规模
一 乐4 小时前
校园墙|校园社区|基于Java+vue的校园墙小程序系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·小程序
这儿有一堆花4 小时前
部署Cloudflare免费图床——免费开源强大
开源·github
golang学习记4 小时前
🍵 Go Queryx 入门指南:让数据库操作像喝奶茶一样丝滑!
后端