对于Nacos大家应该都不太陌生,出身阿里名声在外,能做动态服务发现、配置管理,是一个非常实用的工具。不过,一项技术用的人越多,面试中被深挖的概率也就越大。如果只停留在使用层面,面对一些底层原理性问题,可能就容易吃亏。
今天我们要讨论的就是一个经典的面试题:Nacos作为配置中心时,其配置数据的交互模式究竟是服务端推(Push)还是客户端主动拉(Pull)?
先抛出结论:是客户端主动拉(Pull)模型,但Nacos通过长轮询(Long Polling) 机制,实现了近乎实时的配置变更感知,达到了类似“推送”的效果。

接下来,我们将深入Nacos的源码,看看这套机制具体是如何设计和实现的。如果你对微服务架构下的配置管理感兴趣,这篇文章或许能给你一些启发。
配置中心的必要性
在深入Nacos之前,我们先简单回顾一下配置中心的由来。
简单来说,配置中心的核心作用就是对各类配置进行统一管理,当配置发生修改后,应用能够动态感知并生效,而无需重启应用。
传统项目中,配置大多以静态方式存在,比如写在应用内的yml或properties文件中。如果想修改某个配置,通常需要重启应用才能生效。但在某些场景下,例如希望在运行时通过修改配置来实时控制某个功能的开关,频繁重启显然是无法接受的。
尤其在微服务架构下,应用服务被拆分成数十甚至上百个细粒度服务,每个服务都有自己特有或通用的配置。如果需要修改一个通用配置,难道要手动修改几百个服务的配置文件并一一重启吗?这显然不现实。因此,为了解决配置的动态管理和集中化问题,配置中心便应运而生。

推 (Push) 与拉 (Pull) 模型浅析
客户端与配置中心进行数据交互的方式,本质上可以归结为两种:推(Push)和拉(Pull)。
推模型 (Push)
客户端与服务端建立一个TCP长连接。当服务端的配置数据发生变化时,立即通过这条长连接将新数据推送给客户端。
- 优势:实时性高,数据一旦变更可立即送达客户端。对客户端而言逻辑简单,只需建立连接并等待接收数据,无需关心变更检测。
- 弊端:长连接可能因为网络问题导致“假死”(连接状态正常但实际无法通信)。因此需要额外的心跳机制(KeepAlive)来维持连接活性,确保推送成功。
拉模型 (Pull)
客户端主动向服务端发起请求来拉取配置数据,最常见的方式是轮询,例如每隔3秒请求一次。
- 优势:实现相对简单。
- 弊端:无法保证数据的实时性。轮询间隔设置是门学问:间隔太短会对服务端造成巨大压力;间隔太长则会导致配置变更通知严重延迟。而且无论配置是否变更,客户端都会频繁发起请求,消耗资源。
长轮询:Pull模型的优化方案
开篇我们给出了答案,Nacos采用的是客户端主动拉(Pull)模型,但运用了长轮询(Long Polling) 的方式来优化传统的“短轮询”。
长轮询是什么?它和传统轮询(为了方便区分,我们称后者为“短轮询”)有何不同?
短轮询
无论服务端数据是否有变化,客户端都不停地发起请求获取数据。例如前端JS轮询订单支付状态。
这种方式的缺点很明显:由于配置数据不会频繁变更,持续发起无效请求会给服务端带来不必要的压力。同时,它也会造成数据推送的延迟。假设每10秒请求一次,如果在第11秒配置更新了,那么客户端感知到这个变更将延迟9秒,直到下一次请求。

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

Nacos 核心概念与架构预览
为了方便后续的演示和源码理解,我们先快速过一下Nacos配置中心的几个核心概念和整体架构。
核心概念
Nacos配置管理围绕三个核心概念:dataId、group、namespace,它们是一种层级关系。

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

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

- 配置发布:客户端或控制台通过HTTP请求将配置数据注册/更新到服务端,服务端将配置持久化到数据库(如MySQL)。
- 配置拉取与监听:客户端拉取配置数据,并为感兴趣的
dataId注册监听,发起长轮询请求。
- 长轮询处理:服务端收到长轮询请求后,检查对应配置的MD5值。若无变化,则将请求挂起(默认最多29.5秒);若有变化或超时,则立即响应。
- 本地快照:为减轻服务端压力和保证客户端可用性,拉取到的配置数据会在客户端本地保存一份快照文件,客户端会优先读取本地快照。
本文基于 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是由dataId, group, tenant(租户/命名空间)拼接而成的字符串groupKey;value是一个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();
}
}

3. CacheData 结构解析
CacheData是客户端非常核心的一个类。除了包含dataId、group、tenant、content等基础属性外,还有几个关键属性:
listeners: 一个CopyOnWriteArrayList<ManagerListenerWrap>,存储了对该dataId注册的所有监听器包装类。
md5: 根据content计算出的MD5值,用于快速比对配置是否发生变化。
lastCallMd5 (在ManagerListenerWrap中): 记录上一次通知监听器时配置的MD5值。这是判断配置是否发生变更的核心依据。
在添加监听器addListener()时,会将当前CacheData的md5值赋给ManagerListenerWrap的lastCallMd5属性。

public void addListener(Listener listener) {
ManagerListenerWrap wrap =
(listener instanceof AbstractConfigChangeListener) ? new ManagerListenerWrap(listener, md5, content)
: new ManagerListenerWrap(listener, md5);
}
4. 变更检测与通知
客户端如何感知服务端的数据变更呢?奥秘在于一个后台任务。
在ClientWorker的构造函数中,启动了一个单线程的调度线程池,它不断地轮询cacheMap中的所有CacheData对象。

在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回调给业务方。

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()。

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

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() 来输出响应。

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 事件。

一旦上述 publishConfig 触发了事件,这个订阅者的 onEvent 方法就会被调用。它会创建一个 DataChangeTask,并立即执行。
DataChangeTask 的 run() 方法逻辑很清晰:遍历全局的 allSubs 队列,找到所有监听了这个变更配置(groupKey)的 ClientLongPolling 任务。对于每一个找到的任务,将其从队列中移除(避免超时任务再次执行),然后调用 clientSub.sendResponse() 方法,将变更的 groupKey 列表立即返回给对应的客户端。

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

至此,整个“客户端拉(Pull)-服务端长轮询(Long Polling)-变更事件驱动(Event)”的配置交互流程就形成了一个完美的闭环。
总结
Nacos配置中心通过客户端主动发起长轮询请求(Pull模型) 结合服务端请求挂起与事件驱动的机制,巧妙地实现了配置变更的准实时推送效果。这种设计既避免了纯Push模型长连接维护的复杂性,又克服了纯短轮询Pull模型的延迟与资源浪费问题。
本文仅揭示了Nacos配置中心交互模型的冰山一角,其背后还有集群数据同步、读写分离、容灾降级等大量精妙设计。对于开发者而言,阅读优秀的开源实战项目源码是提升技术深度的绝佳途径。Nacos的源码风格相对朴实,逻辑清晰,非常适合作为学习分布式系统中间件的起点。下次面试官再问你“是Push还是Pull”时,相信你一定能从容地给出让人印象深刻的答案。如果你想了解更多关于Java微服务生态的技术细节,可以关注云栈社区,这里汇聚了许多开发者的实践与思考。