找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3884

积分

0

好友

533

主题
发表于 3 天前 | 查看: 21| 回复: 0

对于Nacos大家应该都不太陌生,出身阿里名声在外,能做动态服务发现、配置管理,是一个非常实用的工具。不过,一项技术用的人越多,面试中被深挖的概率也就越大。如果只停留在使用层面,面对一些底层原理性问题,可能就容易吃亏。

今天我们要讨论的就是一个经典的面试题:Nacos作为配置中心时,其配置数据的交互模式究竟是服务端推(Push)还是客户端主动拉(Pull)?

先抛出结论:是客户端主动拉(Pull)模型,但Nacos通过长轮询(Long Polling) 机制,实现了近乎实时的配置变更感知,达到了类似“推送”的效果。

Nacos配置中心Pull与Push交互模型示意图

接下来,我们将深入Nacos的源码,看看这套机制具体是如何设计和实现的。如果你对微服务架构下的配置管理感兴趣,这篇文章或许能给你一些启发。

配置中心的必要性

在深入Nacos之前,我们先简单回顾一下配置中心的由来。

简单来说,配置中心的核心作用就是对各类配置进行统一管理,当配置发生修改后,应用能够动态感知并生效,而无需重启应用。

传统项目中,配置大多以静态方式存在,比如写在应用内的ymlproperties文件中。如果想修改某个配置,通常需要重启应用才能生效。但在某些场景下,例如希望在运行时通过修改配置来实时控制某个功能的开关,频繁重启显然是无法接受的。

尤其在微服务架构下,应用服务被拆分成数十甚至上百个细粒度服务,每个服务都有自己特有或通用的配置。如果需要修改一个通用配置,难道要手动修改几百个服务的配置文件并一一重启吗?这显然不现实。因此,为了解决配置的动态管理和集中化问题,配置中心便应运而生。

微服务架构下的配置中心示意图

推 (Push) 与拉 (Pull) 模型浅析

客户端与配置中心进行数据交互的方式,本质上可以归结为两种:推(Push)和拉(Pull)。

推模型 (Push)
客户端与服务端建立一个TCP长连接。当服务端的配置数据发生变化时,立即通过这条长连接将新数据推送给客户端。

  • 优势:实时性高,数据一旦变更可立即送达客户端。对客户端而言逻辑简单,只需建立连接并等待接收数据,无需关心变更检测。
  • 弊端:长连接可能因为网络问题导致“假死”(连接状态正常但实际无法通信)。因此需要额外的心跳机制(KeepAlive)来维持连接活性,确保推送成功。

拉模型 (Pull)
客户端主动向服务端发起请求来拉取配置数据,最常见的方式是轮询,例如每隔3秒请求一次。

  • 优势:实现相对简单。
  • 弊端:无法保证数据的实时性。轮询间隔设置是门学问:间隔太短会对服务端造成巨大压力;间隔太长则会导致配置变更通知严重延迟。而且无论配置是否变更,客户端都会频繁发起请求,消耗资源。

长轮询:Pull模型的优化方案

开篇我们给出了答案,Nacos采用的是客户端主动拉(Pull)模型,但运用了长轮询(Long Polling) 的方式来优化传统的“短轮询”。

长轮询是什么?它和传统轮询(为了方便区分,我们称后者为“短轮询”)有何不同?

短轮询
无论服务端数据是否有变化,客户端都不停地发起请求获取数据。例如前端JS轮询订单支付状态。
这种方式的缺点很明显:由于配置数据不会频繁变更,持续发起无效请求会给服务端带来不必要的压力。同时,它也会造成数据推送的延迟。假设每10秒请求一次,如果在第11秒配置更新了,那么客户端感知到这个变更将延迟9秒,直到下一次请求。

短轮询交互示意图

长轮询
长轮询并非新技术,它是通过服务端控制响应客户端请求的返回时机,来减少客户端无效请求的一种优化手段。对于客户端而言,发起请求的方式与短轮询并无本质区别。
客户端发起请求后,服务端不会立即返回结果,而是将这个请求“挂起”一段时间。如果在这段时间内,服务端的数据发生了变更,则立即响应客户端;如果一直无变化,则等到预设的超时时间后,再响应此次请求。客户端收到响应(无论超时还是有变更)后,会立即重新发起一个新的长轮询请求,如此循环。

长轮询交互流程图

Nacos 核心概念与架构预览

为了方便后续的演示和源码理解,我们先快速过一下Nacos配置中心的几个核心概念和整体架构。

核心概念
Nacos配置管理围绕三个核心概念:dataIdgroupnamespace,它们是一种层级关系。

Nacos配置层级:namespace > group > dataId

  • dataId:配置的最小单元,采用key-value结构。key通常是配置文件名(如application.yml),value则是整个文件的内容。Nacos支持TEXTJSONXMLYAML等多种格式。
  • group:用于对dataId进行分组管理。例如,同在dev环境下,但不同开发分支可能需要不同的配置,就可以用不同的group进行隔离。默认分组是DEFAULT_GROUP
  • namespace:用于隔离不同的环境,如devtestprod。默认所有的配置都在public命名空间下。

Nacos配置编辑界面示例

架构设计概览
下图简要描述了Nacos配置中心的核心交互流程:

Nacos配置中心架构流程图

  1. 配置发布:客户端或控制台通过HTTP请求将配置数据注册/更新到服务端,服务端将配置持久化到数据库(如MySQL)。
  2. 配置拉取与监听:客户端拉取配置数据,并为感兴趣的dataId注册监听,发起长轮询请求。
  3. 长轮询处理:服务端收到长轮询请求后,检查对应配置的MD5值。若无变化,则将请求挂起(默认最多29.5秒);若有变化或超时,则立即响应。
  4. 本地快照:为减轻服务端压力和保证客户端可用性,拉取到的配置数据会在客户端本地保存一份快照文件,客户端会优先读取本地快照。

本文基于 Nacos 2.0.1 版本源码进行分析。2.0之后的版本在通信和架构上有较多改动,与早期一些资料可能有所不同。
源码地址:https://github.com/alibaba/nacos/releases/tag/2.0.1

客户端源码解析:如何实现“拉”与“感知”

Nacos配置中心的客户端源码位于nacos-client模块,NacosConfigService是实现所有操作的核心入口。

在开始之前,需要了解一个贯穿客户端操作的核心数据结构——cacheMap。它是一个AtomicReference<Map<String, CacheData>>类型的原子引用,用于保证在多线程场景下的数据一致性。

/**
 * groupKey -> cacheData.
 */
private final AtomicReference<Map<String, CacheData>> cacheMap = new AtomicReference<Map<String, CacheData>>(new HashMap<>());

cacheMap是一个Map,其key是由dataIdgrouptenant(租户/命名空间)拼接而成的字符串groupKeyvalue是一个CacheData对象,每个被监听的dataId都会对应一个CacheData对象。

1. 获取配置
Nacos获取配置的基本逻辑是:先读本地快照,再请求远端。客户端会优先读取本地快照文件中的配置。如果本地文件不存在或内容为空,则通过HTTP GET请求从服务端拉取对应的dataId配置,并保存到本地快照中。请求默认重试3次,超时时间为3秒。

客户端获取配置流程图

获取配置主要有两个接口:getConfig()getConfigAndSignListener()

  • getConfig():仅发送普通的HTTP GET请求拉取配置。
  • getConfigAndSignListener():在拉取配置的基础上,增加了注册监听发起长轮询的操作(核心逻辑在addTenantListenersWithContent()方法中)。
@Override
public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
    return getConfigInner(namespace, dataId, group, timeoutMs);
}

@Override
public String getConfigAndSignListener(String dataId, String group, long timeoutMs, Listener listener)
        throws NacosException {
    String content = getConfig(dataId, group, timeoutMs);
    worker.addTenantListenersWithContent(dataId, group, content, Arrays.asList(listener));
    return content;
}

2. 注册监听与长轮询发起
我们来看关键的addTenantListenersWithContent()方法。客户端首先从cacheMap中获取dataId对应的CacheData对象,如果不存在,则调用addCacheDataIfAbsent()方法。这个方法会向服务端发起一个默认超时时间为30秒的长轮询请求(通过getServerConfig()),并将返回的配置内容设置到CacheData对象中,同时计算其MD5值。然后,通过cache.addListener(listener)将我们传入的监听器注册进去。

public void addTenantListenersWithContent(String dataId, String group, String content,
                                         List<? extends Listener> listeners) throws NacosException {
    group = blank2defaultGroup(group);
    String tenant = agent.getTenant();
    // 1、获取dataId对应的CacheData,如没有则向服务端发起长轮询请求获取配置
    CacheData cache = addCacheDataIfAbsent(dataId, group, tenant);
    synchronized (cache) {
        // 2、注册对dataId的数据变更监听
        cache.setContent(content);
        for (Listener listener : listeners) {
            cache.addListener(listener);
        }
        cache.setSyncWithServer(false);
        agent.notifyListenConfig();
    }
}

addCacheDataIfAbsent方法中向服务端请求数据的代码截图

3. CacheData 结构解析
CacheData是客户端非常核心的一个类。除了包含dataIdgrouptenantcontent等基础属性外,还有几个关键属性:

  • listeners: 一个CopyOnWriteArrayList<ManagerListenerWrap>,存储了对该dataId注册的所有监听器包装类。
  • md5: 根据content计算出的MD5值,用于快速比对配置是否发生变化。
  • lastCallMd5 (在ManagerListenerWrap中): 记录上一次通知监听器时配置的MD5值。这是判断配置是否发生变更的核心依据

在添加监听器addListener()时,会将当前CacheDatamd5值赋给ManagerListenerWraplastCallMd5属性。

CacheData类部分源码,展示listeners, md5等属性

public void addListener(Listener listener) {
    ManagerListenerWrap wrap =
        (listener instanceof AbstractConfigChangeListener) ? new ManagerListenerWrap(listener, md5, content)
            : new ManagerListenerWrap(listener, md5);
}

4. 变更检测与通知
客户端如何感知服务端的数据变更呢?奥秘在于一个后台任务。
ClientWorker的构造函数中,启动了一个单线程的调度线程池,它不断地轮询cacheMap中的所有CacheData对象。

ClientWorker中启动轮询任务的代码截图

checkListenerMd5()方法中,它会遍历CacheData中的所有监听器包装ManagerListenerWrap,比较CacheData当前的md5值与监听器持有的lastCallMd5值是否一致。

如果不一致,说明服务端的配置已经发生了变更,则触发safeNotifyListener()方法进行通知。

void checkListenerMd5() {
    for (ManagerListenerWrap wrap : listeners) {
        if (!md5.equals(wrap.lastCallMd5)) {
            safeNotifyListener(dataId, group, content, type, md5, encryptedDataKey, wrap);
        }
    }
}

safeNotifyListener()方法会启动一个新线程,调用监听器的receiveConfigInfo()方法,将最新的配置内容content回调给业务方。

safeNotifyListener方法源码截图

5. 效果演示
我们可以写一个简单的Demo来验证这个流程。客户端获取配置并添加监听器,然后在代码中模拟服务端发布新配置。

public static void main(String[] args) throws NacosException, InterruptedException {
    String serverAddr = "localhost";
    String dataId = "test";
    String group = "DEFAULT_GROUP";
    Properties properties = new Properties();
    properties.put("serverAddr", serverAddr);
    ConfigService configService = NacosFactory.createConfigService(properties);
    String content = configService.getConfig(dataId, group, 5000);
    System.out.println(content);
    configService.addListener(dataId, group, new Listener() {
        @Override
        public void receiveConfigInfo(String configInfo) {
            System.out.println("数据变更 receive:" + configInfo);
        }
        @Override
        public Executor getExecutor() {
            return null;
        }
    });

    boolean isPublishOk = configService.publishConfig(dataId, group, "我是新配置内容~");
    System.out.println(isPublishOk);

    Thread.sleep(3000);
    content = configService.getConfig(dataId, group, 5000);
    System.out.println(content);
}

运行结果如下,可以看到,当通过publishConfig修改配置后,客户端的监听器几乎立刻收到了变更通知,完美实现了“配置变更,实时感知”的效果。

数据变更 receive:我是新配置内容~
true
我是新配置内容~

服务端源码解析:长轮询如何实现“挂起”与“触发”

服务端源码主要集中在nacos-config模块的ConfigController类中。我们重点关注长轮询的处理逻辑。

1. 接收长轮询请求
服务端对外提供监听接口 /v1/cs/configs/listener(POST请求)。这个方法的核心是调用 doPollingConfig()

ConfigController的listener接口源码

doPollingConfig() 方法中,服务端首先根据请求头 Long-Pulling-Timeout 判断是否支持长轮询。我们重点关注长轮询分支,它会调用 LongPollingService.addLongPollingClient()

doPollingConfig方法判断长轮询的代码

2. 挂起客户端请求
客户端默认设置的请求超时时间是30秒。但服务端在处理时,会“偷偷”减去一个固定延迟(默认500毫秒),将实际处理超时设置为 29.5秒。这样做的目的是为了抵消网络延迟和内部处理耗时,最大程度避免客户端因超时而中断连接。

服务端计算长轮询超时时间的代码

接着,服务端会立即检查客户端携带的配置MD5值(clientMd5Map)与服务端当前MD5是否一致(MD5Util.compareMd5)。如果不一致,说明配置已经变更,直接将变更的groupKey返回给客户端。

如果一致,说明配置未变更,此时服务端会创建一个 ClientLongPolling 任务(实现了 Runnable),并提交到一个延迟调度线程池,设定在 29.5秒后执行。同时,将这个任务添加到一个全局队列 allSubs 中。每个任务都持有一个 AsyncContext(Servlet 3.0的异步处理上下文),这使得请求可以被挂起,延迟响应。

AsyncContext 是 Servlet 3.0 的特性,支持异步处理。它允许容器线程在处理完请求后立即释放,业务逻辑在另外的线程中完成,完成后通过调用 asyncContext.complete() 来输出响应。

ClientLongPolling任务调度代码

3. 长轮询的两种结束方式
现在,客户端的请求被挂起了。它将在以下两种情况下被唤醒:

  • 情况A:超时。如果29.5秒内配置都没有变化,延迟任务到期执行。它会将这个 ClientLongPolling 任务从 allSubs 队列中移除,然后调用 asyncContext.complete() 返回一个空的响应(告知客户端无变更)。客户端收到响应后,会立即发起下一次长轮询请求。
  • 情况B:配置变更。如果在这29.5秒内,有配置发生了变更,服务端会主动找到并触发这个任务,立即响应客户端。

长轮询处理时序图

4. 配置变更如何触发挂起的请求?
当用户通过控制台或客户端调用 ConfigController.publishConfig() 发布/更新配置时,在持久化配置后,会触发一个内部事件:

ConfigChangePublisher.notifyConfigChange(new ConfigDataChangeEvent(false, dataId, group, tenant, time.getTime()));

发布配置时触发变更事件的代码

关键的连接点在于 LongPollingService。在其构造函数中,它注册了一个订阅者(Subscriber),专门监听 LocalDataChangeEvent 事件。

LongPollingService构造函数中注册事件订阅的代码

一旦上述 publishConfig 触发了事件,这个订阅者的 onEvent 方法就会被调用。它会创建一个 DataChangeTask,并立即执行。

DataChangeTaskrun() 方法逻辑很清晰:遍历全局的 allSubs 队列,找到所有监听了这个变更配置(groupKey)的 ClientLongPolling 任务。对于每一个找到的任务,将其从队列中移除(避免超时任务再次执行),然后调用 clientSub.sendResponse() 方法,将变更的 groupKey 列表立即返回给对应的客户端。

DataChangeTask遍历allSubs队列的代码

sendResponse() -> generateResponse() 中,最终会调用 asyncContext.complete() 来完成此次异步请求,将变更信息返回给客户端。

generateResponse方法中调用asyncContext.complete()

至此,整个“客户端拉(Pull)-服务端长轮询(Long Polling)-变更事件驱动(Event)”的配置交互流程就形成了一个完美的闭环。

总结

Nacos配置中心通过客户端主动发起长轮询请求(Pull模型) 结合服务端请求挂起与事件驱动的机制,巧妙地实现了配置变更的准实时推送效果。这种设计既避免了纯Push模型长连接维护的复杂性,又克服了纯短轮询Pull模型的延迟与资源浪费问题。

本文仅揭示了Nacos配置中心交互模型的冰山一角,其背后还有集群数据同步、读写分离、容灾降级等大量精妙设计。对于开发者而言,阅读优秀的开源实战项目源码是提升技术深度的绝佳途径。Nacos的源码风格相对朴实,逻辑清晰,非常适合作为学习分布式系统中间件的起点。下次面试官再问你“是Push还是Pull”时,相信你一定能从容地给出让人印象深刻的答案。如果你想了解更多关于Java微服务生态的技术细节,可以关注云栈社区,这里汇聚了许多开发者的实践与思考。




上一篇:Nacos集群AP架构解析:自研Distro一致性协议如何保证高可用
下一篇:Zookeeper、Eureka、Nacos等5大注册中心核心技术对比与选型指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-10 11:32 , Processed in 0.564577 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表