起因
前几天写了一篇文章,讲我们项目里遇到的一个配置刷新问题。简单说就是用了 @RefreshScope 之后,简单的字符串、数字能自动刷新,但复杂的嵌套 Map 就不行了。后来我们自己监听了 EnvironmentChangeEvent,手动用 ResolvableType 绑定才搞定。
写完那篇文章之后,突然就很好奇:
- Nacos 配置中心到底是怎么做到的? 我在 Nacos 控制台改个配置点发布,几秒钟后我的应用就自动生效了,这中间发生了什么?
- 是服务端推送还是客户端拉取? 如果是推送,服务端怎么知道哪些客户端需要通知?如果是拉取,客户端怎么知道配置变了?
- 为什么有时候感觉立刻就生效,有时候要等几秒? 这个延迟是怎么回事?
带着这些疑问,我决定自己动手写一个简化版的 Nacos 配置中心,看看能不能把这些机制都搞明白。
写之前先猜猜
我一开始以为 Nacos 用的是 WebSocket 或者 Server-Sent Events (SSE) 这种长连接方案。毕竟配置一变就要通知客户端,感觉上应该是服务端主动推送吧?
后来翻了翻资料才发现,人家用的是 HTTP 长轮询。
什么是长轮询?简单说就是:
- 客户端发一个 HTTP 请求给服务端
- 服务端不立刻返回,而是把这个请求 hold 住
- 如果配置变了,立刻返回变更信号
- 如果一直没变,就等到超时(比如 30 秒)再返回空结果
- 客户端收到响应后,等个几秒,再发下一轮请求
这个机制挺巧妙的,我们后面详细说。
长轮询 vs 长连接 vs 短连接
这里先把几个容易混淆的概念理清楚。
短连接:
- 发请求,立刻响应,连接关闭(或放回连接池)
- 就是普通的 REST API
长连接(WebSocket/SSE):
- 建立连接后一直保持
- 服务端可以随时推送数据
- 适合高频双向通信(聊天、游戏、股票行情)
长轮询:
- 还是 HTTP 请求,但服务端会 hold 住一段时间
- 本质上是"长时间的请求",不是"长时间的连接"
- 适合低频推送(配置变更、通知)
Nacos 为什么用长轮询而不是 WebSocket?我猜测主要是:
- 配置变更频率低,不需要 WebSocket 的高频能力
- HTTP 兼容性好,容易穿透各种代理和网关
- 实现简单,服务端压力小
动手搞一个简化版
我们这个项目叫 surfing-nacos-refresh,目标是实现:
- 客户端通过 HTTP 长轮询监听配置变更
- 服务端 hold 住请求,有变更时立刻返回
- 客户端拿到变更信号后,拉取最新配置
- 自动刷新 Spring 的 Environment
- 支持
@Value和@ConfigurationProperties热更新
整体架构
服务端:怎么 hold 住请求
服务端最核心的就是这个 /listener 接口,客户端会不断发请求过来,我们要把请求挂起,等配置变更了再返回。
java
@PostMapping("/v1/cs/configs/listener")
public void listener(
@RequestParam("Listening-Configs") String listeningConfigs,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
log.info("[Listener] 收到订阅: {}", listeningConfigs);
// 解析订阅串:dataId^2^group^2^md5^1
List<ConfigKey> keys = parseListeningConfigs(listeningConfigs);
// 先比较 md5,看看有没有变更
List<ConfigKey> changedKeys = new ArrayList<>();
for (ConfigKey key : keys) {
String currentMd5 = getConfigMd5(key.getDataId(), key.getGroup());
if (!Objects.equals(currentMd5, key.getMd5())) {
changedKeys.add(key);
}
}
// 有变更,立刻返回
if (!changedKeys.isEmpty()) {
log.info("[Listener] 检测到变更,立即返回: {}", changedKeys);
String result = buildChangedResponse(changedKeys);
response.getWriter().write(result);
return;
}
// 没变更,挂起请求
log.info("[Listener] 无变更,挂起请求");
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0); // 禁用容器默认超时
addLongPollingClient(keys, asyncContext);
}
这里有几个关键点:
1. 先比较后挂起
服务端不是盲目挂起所有请求,而是先对比客户端发过来的 md5 和当前配置的 md5。如果不一样,说明客户端的配置已经过期了,立刻返回变更信号。
只有 md5 一致的情况下,才会挂起请求。
2. AsyncContext
这是 Servlet 3.0 提供的异步处理能力。普通的 Servlet 请求会占用一个工作线程,如果要 hold 30 秒,那这个线程就得等 30 秒。用了 AsyncContext 之后,工作线程可以释放,请求被转移到异步线程池处理。
java
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0); // 这个很重要!
setTimeout(0) 是禁用容器的默认超时机制,我们要用自己的超时调度。
3. 挂起的细节
java
private void addLongPollingClient(List<ConfigKey> keys, AsyncContext asyncContext) {
ClientSubscription sub = new ClientSubscription(keys, asyncContext);
// 加到订阅列表
for (ConfigKey key : keys) {
subscribers.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(sub);
}
// 定时 29.5 秒后超时返回
ScheduledFuture<?> timeoutTask = scheduler.schedule(() -> {
try {
log.info("[LongPolling] 超时,返回空字符串");
asyncContext.getResponse().getWriter().write("");
asyncContext.complete();
} catch (Exception e) {
log.error("[LongPolling] 超时处理失败", e);
} finally {
removeSubscriber(sub); // 别忘了清理!
}
}, 29500, TimeUnit.MILLISECONDS);
sub.setTimeoutTask(timeoutTask);
}
这里用了一个 ScheduledExecutorService 来管理超时。29.5 秒后,如果配置还没变,就返回空字符串,然后 complete 这个 AsyncContext。
4. 配置发布时的通知
当有人调用 /configs 接口发布新配置时,我们要立刻通知所有在等待的客户端:
java
@PostMapping("/v1/cs/configs")
public String publishConfig(
@RequestParam("dataId") String dataId,
@RequestParam("group") String group,
@RequestParam("content") String content) {
// 保存配置
saveConfig(dataId, group, content);
// 通知所有订阅者
notifyConfigChange(dataId, group);
return "success";
}
private void notifyConfigChange(String dataId, String group) {
ConfigKey key = new ConfigKey(dataId, group);
List<ClientSubscription> subs = subscribers.get(key);
if (subs != null) {
log.info("[LongPolling] 通知 {} 个订阅者", subs.size());
for (ClientSubscription sub : subs) {
// 取消超时任务
sub.cancelTimeout();
// 返回变更信号
try {
String result = dataId + "^2^" + group + "^1";
sub.getAsyncContext().getResponse().getWriter().write(result);
sub.getAsyncContext().complete();
} catch (Exception e) {
log.error("[LongPolling] 通知失败", e);
}
// 清理订阅
removeSubscriber(sub);
}
subscribers.remove(key);
}
}
5. 订阅清理(重要!)
这个地方我当时踩了个坑。如果超时或者通知后不清理订阅,subscribers 这个 Map 会越来越大,而且会有很多已经 complete 的 AsyncContext 留在里面,再次通知时就会报错。
所以无论是超时还是通知,都要记得清理:
java
private void removeSubscriber(ClientSubscription sub) {
for (ConfigKey key : sub.getKeys()) {
List<ClientSubscription> subs = subscribers.get(key);
if (subs != null) {
subs.remove(sub);
if (subs.isEmpty()) {
subscribers.remove(key);
}
}
}
}
客户端:怎么发起长轮询
客户端这边用了一个定时器,每隔一段时间(retryInterval)就发起一次长轮询请求:
java
public void start() {
// 使用定时器,每 retryInterval 秒执行一次长轮询
executor.scheduleWithFixedDelay(
new LongPollingRunnable(),
0, // 立即开始
retryInterval, // 间隔时间(默认2秒)
TimeUnit.SECONDS
);
}
class LongPollingRunnable implements Runnable {
@Override
public void run() {
try {
// 构造订阅串
String listeningConfigs = buildListeningConfigs();
System.out.println("[LongPolling] request Listening-Configs=" + listeningConfigs);
RequestBody body = new FormBody.Builder()
.add("Listening-Configs", listeningConfigs)
.add("timeout", String.valueOf(timeout * 1000))
.build();
Request request = new Request.Builder()
.url(serverAddr + "/v1/cs/configs/listener")
.post(body)
.build();
// 这里会阻塞最多 30 秒
try (Response resp = httpClient.newCall(request).execute()) {
if (resp.isSuccessful()) {
String content = resp.body() != null ? resp.body().string() : "";
if (StringUtils.isNotEmpty(content)) {
// 有变更
System.out.println("[LongPolling] 检测到变更: " + content);
List<ConfigKey> changedKeys = parseChangedConfigs(content);
for (ConfigKey key : changedKeys) {
CacheData cd = getCacheData(key.dataId, key.group);
if (cd != null) {
fetchFromServer(cd); // 拉取最新配置
}
}
}
// 无变更的话,定时器会在 retryInterval 秒后自动触发下一轮
}
}
} catch (Exception e) {
// 出错了,定时器也会在 retryInterval 秒后重试
System.err.println("[LongPolling] 请求失败: " + e.getMessage());
}
}
}
这里有几个时间上的设计:
关键的时间关系:
- 服务端挂起超时:29.5 秒
- 客户端请求超时:30 秒(OkHttp readTimeout)
- 定时器触发间隔:2 秒(retryInterval)
这个设计很巧妙:
1. 为什么服务端是 29.5 秒?
因为要比客户端的 30 秒小一点,确保服务端先返回。如果服务端超时设置成 30 秒或更大,客户端可能先超时断开连接,服务端还在傻等。
2. 为什么用定时器而不是 while 循环?
用定时器(scheduleWithFixedDelay)的好处是:
- 每次任务执行完(包括30秒的阻塞等待),等固定间隔(2秒)后才开始下一轮
- 如果某次请求耗时长(比如网络慢),不会立刻发下一个请求
- 更容易控制并发,不会因为异常导致疯狂重试
对比 while 循环:
java
// while 循环需要手动 sleep
while (running) {
doLongPolling(); // 可能耗时30秒
Thread.sleep(2000); // 再等2秒
}
// 定时器自动处理
executor.scheduleWithFixedDelay(() -> {
doLongPolling(); // 可能耗时30秒
// 任务结束后,定时器自动等2秒再触发下一轮
}, 0, 2, TimeUnit.SECONDS);
3. 为什么间隔是 2 秒?
这是个缓冲时间,避免客户端连续不断地发请求。实际的循环是:
- 发请求 → 服务端 hold 29.5秒 → 返回 → 定时器等2秒 → 下一轮
所以如果配置一直不变,客户端的请求频率是:29.5秒 + 2秒 = 31.5秒一轮。
拉取配置
客户端收到变更信号后,要再发一个 GET 请求拉取完整配置:
java
private void handleConfigChange(String changedConfigs) {
// 解析变更的 dataId 和 group
String[] parts = changedConfigs.split("\\^1");
for (String changed : parts) {
String[] keyValue = changed.split("\\^2");
if (keyValue.length >= 2) {
String dataId = keyValue[0];
String group = keyValue[1];
// 拉取最新配置
String newContent = fetchConfig(dataId, group);
// 更新本地缓存
updateLocalCache(dataId, group, newContent);
// 触发刷新
receiveConfigInfo(newContent);
}
}
}
private String fetchConfig(String dataId, String group) throws IOException {
Request request = new Request.Builder()
.url(serverAddr + "/v1/cs/configs?dataId=" + dataId + "&group=" + group)
.get()
.build();
try (Response resp = httpClient.newCall(request).execute()) {
return resp.body().string();
}
}
这里是两阶段拉取:
- 长轮询只返回"哪些配置变了"
- 客户端再根据这个信号,去拉取具体内容
为什么不直接在长轮询里返回配置内容?因为:
- 长轮询的响应要尽量小,快速返回
- 配置内容可能很大,放在长轮询响应里会阻塞其他订阅者的通知
- 分离变更信号和内容拉取,职责更清晰
刷新 Spring Environment
拿到新配置后,要更新 Spring 的 Environment,然后触发刷新事件:
java
private void receiveConfigInfo(String content) {
try {
// 解析配置(支持 YAML 和 Properties)
Properties props = parseConfig(content);
// 更新 Environment
ConfigurableEnvironment env = applicationContext.getEnvironment();
MutablePropertySources sources = env.getPropertySources();
// 创建新的 PropertySource
PropertySource<?> propertySource = new PropertiesPropertySource(
"nacos-config", props
);
// 替换或添加
if (sources.contains("nacos-config")) {
sources.replace("nacos-config", propertySource);
} else {
sources.addFirst(propertySource);
}
// 发布事件
Set<String> keys = props.stringPropertyNames();
applicationContext.publishEvent(new EnvironmentChangeEvent(keys));
log.info("[Refresh] Environment 已更新,变更的 key: {}", keys);
} catch (Exception e) {
log.error("[Refresh] 配置刷新失败", e);
}
}
EnvironmentChangeEvent 是 Spring Cloud 提供的,@RefreshScope 的 Bean 会监听这个事件,然后销毁旧实例,等下次使用时重新创建。
支持 @Value 刷新(我们加的)
但是光靠 @RefreshScope 还不够,我们希望普通的 @Value 也能刷新。所以我们加了一个 @Refresh 注解:
java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Refresh {
}
然后写了一个监听器,专门处理带 @Refresh 注解的类:
java
@Component
public class ValueRefreshListener {
@EventListener
public void onEnvironmentChange(EnvironmentChangeEvent event) {
// 找到所有带 @Refresh 的 Bean
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Refresh.class);
for (Object bean : beans.values()) {
refreshValueFields(bean, event.getKeys());
}
}
private void refreshValueFields(Object bean, Set<String> changedKeys) {
Class<?> clazz = bean.getClass();
// 遍历所有字段
for (Field field : clazz.getDeclaredFields()) {
Value valueAnnotation = field.getAnnotation(Value.class);
if (valueAnnotation != null) {
String placeholder = valueAnnotation.value();
String key = extractKey(placeholder); // 提取 ${xxx} 里的 key
if (changedKeys.contains(key)) {
// 从 Environment 获取最新值
Object newValue = resolveValue(placeholder, field.getType());
// 反射设置
field.setAccessible(true);
try {
field.set(bean, newValue);
log.info("[Refresh] 更新字段: {}.{} = {}",
clazz.getSimpleName(), field.getName(), newValue);
} catch (IllegalAccessException e) {
log.error("[Refresh] 字段更新失败", e);
}
}
}
}
}
}
这样一来,只要在类上加个 @Refresh 注解,里面的 @Value 字段就能自动刷新了:
java
@Component
@Refresh // 加这个!
public class FeatureConfig {
@Value("${feature.new-algorithm:false}")
private boolean useNewAlgorithm;
@Value("${database.pool-size:10}")
private int poolSize;
}
对比 Nacos,我们做了什么
保留的:
- HTTP 长轮询机制(核心)
- 两阶段拉取(变更信号 + 配置内容)
- 先比较后挂起的逻辑
- 超时时间设计(29.5s < 30s)
简化的:
- 没有做服务发现(Nacos 2.x 用 gRPC)
- 没有做配置加密、权限控制
- 没有做灰度发布
- 没有做多数据中心同步
- 配置存储直接用内存(Nacos 用数据库)
增强的:
- 加了
@Refresh注解支持@Value刷新 - 这块 Nacos 本身是通过
@RefreshScope实现的,我们提供了另一种方案
踩过的坑
坑1:订阅清理
前面提到过,如果不清理订阅,subscribers 会越来越大。而且 AsyncContext complete 之后再调用会报错。我们的做法是:
- 超时时清理
- 通知时清理
- 异常时也要在 finally 里清理
坑2:超时时间
一开始我把服务端超时设成 30 秒,客户端也是 30 秒,结果经常出现客户端超时断开,服务端还在等。后来改成 29.5 秒就好了。
而且网关的超时要设得更大,我们用的 Nginx,proxy_read_timeout 设成了 60 秒,确保不会被中间层断开。
坑3:线程池
AsyncContext 会用到异步线程池,如果配置不当,高并发时会出现拒绝。我们用的是 Tomcat,在 application.yml 里加了:
yaml
server:
tomcat:
threads:
max: 200
min-spare: 10
max-connections: 10000
accept-count: 100
坑4:Environment 更新时机
有一次我们发现配置更新了,但 Bean 里的值还是旧的。后来发现是事件监听的顺序问题。EnvironmentChangeEvent 和 RefreshScopeRefreshedEvent 可能并发触发,如果你在两个事件里都做了操作,可能会有时序问题。
我们后来统一只监听 EnvironmentChangeEvent,问题就解决了。
整个流程串起来
最后画个完整的流程图,把服务端和客户端的交互都串起来:
性能和稳定性
我们在本地跑了压测,模拟 100 个客户端同时长轮询,服务端没啥压力。因为请求被挂起后,工作线程就释放了,不会占用资源。
配置发布时的通知也很快,基本上是毫秒级。我们测试了同时通知 100 个客户端,耗时不到 50ms。
当然这只是个 Demo,生产环境还要考虑:
- 配置持久化(数据库或文件)
- 多实例部署时的配置同步
- 客户端断线重连
- 配置版本管理
- 权限控制
但核心的长轮询机制已经跑通了,这是最重要的。
几个还没搞透的地方
关于泛型刷新:
前面那篇文章里提到的嵌套 Map 刷新问题,我在这个 Demo 里还没完全解决。如果你的配置类有复杂泛型,可能还是需要手动监听 EnvironmentChangeEvent,用 ResolvableType 绑定。
这块我还在研究,可能跟 Spring Boot 版本有关,也可能是 Binder 的实现细节。如果有大佬知道更好的方案,欢迎指点。
关于 @Async 方法:
如果你的服务里有 @Async 异步方法,或者自己创建的线程,这些地方拿到的配置对象可能不是最新的。因为 @RefreshScope 的代理只在 Spring 容器管理的调用链路上生效。
解决办法是在异步方法里重新获取 Bean,或者把配置值作为参数传进去。但这个我还没找到特别优雅的方案。
关于配置回滚:
如果新配置有问题,导致应用启动失败或者运行异常,怎么快速回滚?Nacos 有配置历史版本管理,我们这个 Demo 还没做。
这块可以考虑保留最近 N 个版本的配置,出问题时快速切回去。
写在最后
周五晚上下班回家就开始撸代码,搞到凌晨1点多。周六早上6点又爬起来继续干,前前后后投入了差不多10个小时。
但收获挺大的:
- 彻底搞懂了 Nacos 的长轮询机制
- 理解了
@RefreshScope的原理 - 知道了配置刷新的完整链路
- 还顺便学了 AsyncContext 的用法
最重要的是,这些知识不是看视频看文章能学到的。只有自己动手写一遍,调试一遍,踩坑一遍,才能真正理解。
之前看 Nacos 源码,总觉得代码太多,看着看着就迷糊了。现在自己写了个简化版,再去看源码,就清晰多了。哪些是核心逻辑,哪些是边界处理,一目了然。
项目地址
GitHub: surfing-nacos-refresh
完整项目包含:
- 服务端实现(配置存储、长轮询、推送通知)
- 客户端实现(长轮询、配置拉取、Environment刷新)
- Spring Boot Starter(开箱即用)
- 可运行的 Demo(本地启动就能测试)
代码已经完全开源,clone 下来就能跑。总共也就几百行代码,但该有的功能都有了。
为什么选择开源?
这是个学习项目,目的是理解原理,不是造轮子。既然是学习,就应该分享出来,让更多人受益。
生产环境还是建议用官方的 Nacos,人家经过了大规模验证,功能更完善。
如果觉得有帮助:
- 文章点个赞、收藏一下
- 有问题欢迎提 issue 或者评论区交流
周末写代码+写文章确实挺累的,被老婆嫌弃了好几次"又在敲代码"。但看到能帮到大家,也值了。
下一步计划
下一篇文章打算写"Nacos 配置中心的常见面试问题",把这次学到的知识点整理成面试题的形式,比如:
- 长轮询和长连接的区别?
- 为什么服务端超时是 29.5 秒?
- 配置刷新的完整流程是怎样的?
@RefreshScope的原理是什么?- 如何保证配置刷新的线程安全?
敬请期待。
最后再问一句:你们项目里用的配置中心是什么?遇到过配置刷新的问题吗?
欢迎在评论区分享你的经验,我们一起交流学习。如果有什么疑问,也可以留言,我都会回复的。
周末愉快!