一个Listener泄漏干掉了32G内存:Nacos配置管理你不该碰的默认值
一个Listener泄漏干掉了32G内存:Nacos配置管理你不该碰的默认值
32G 内存的机器,Nacos 客户端吃了 18G
周一早上。巡检脚本报了一条我以为是误报的数字。
那台跑着订单服务的机器,物理内存 32G,Nacos 客户端的 Java 进程占了 18G。不是 Nacos 服务端——是客户端。一个本该用 200MB 的 SDK。
jmap -histo:live拉出来一看,堆里躺了24,731 个 ConfigService 实例。每个实例内部挂着一个独立的 HTTP 连接池、一个线程池、一个本地缓存。24,731 个。
代码里是这么写的:
@BeanpublicvoidinitConfigs(){// 每次配置变更,创建一个新的 ConfigService// 没关过旧的configs.forEach(config->{ConfigServicecs=NacosFactory.createConfigService(properties);cs.addListener(config.getDataId(),config.getGroup(),newListener(){@OverridepublicvoidreceiveConfigInfo(StringconfigInfo){refreshConfig(configInfo);}});});}addListener方法内部每调用一次就注册一个新 Listener,没人去removeListener。配置变更越频繁,堆里的 Listener 越多。半年跑了 2 万多个,内存一路涨到 18G。
这就是 Nacos 配置管理最常见的三个慢性病:监听器泄漏、长轮询堆积、缓存策略缺失。它们不致命——不会立即炸出堆栈——但会让你的系统像一只慢慢漏气的轮胎,直到某个深夜彻底瘪掉。
监听器:注册了就得注销
Nacos 的配置监听有两种写法。第一种是 Spring Cloud Alibaba 的注解式,第二种是 SDK 的原生 API。
注解式不会泄漏。Spring 容器帮你管生命周期,Bean 销毁时自动注销 Listener。
// 写法一:Spring 托管,不会泄漏@NacosConfigListener(dataId="order-service-prod.yml")publicvoidonConfigChange(Stringconfig){// Bean 销毁时 Spring 自动 removeListener}原生 API 泄漏,是因为你忘了关。
// 写法二:原生 SDK,容易泄漏ConfigServiceconfigService=NacosFactory.createConfigService(properties);configService.addListener("order-service-prod.yml","ORDER_GROUP",listener);// ... 业务代码 ...// 忘了调 configService.removeListener() 和 configService.shutDown()Nacos SDK 的 ConfigService 内部架构长这样:
一个 ConfigService 实例 = 一个线程池 + 一个连接池 + 一个本地缓存。2 万个实例 = 2 万个线程池 + 2 万个连接池。
怎么检查有没有泄漏
# 第一步:看有多少 ConfigService 实例jmap-histo:live<pid>|grepConfigService# 返回示例(如果数字很大,就是泄漏了):# 24731 ...ConfigService → 24731 个实例# 24731 ...ClientWorker → 每个实例带一个线程池# 第二步:看内存占比jmap-heap<pid>|grep-A5"Heap Usage"怎么修
@ComponentpublicclassConfigManagerimplementsDisposableBean{privatefinalList<ConfigService>services=newArrayList<>();publicvoidaddConfig(StringdataId,Stringgroup,Listenerlistener){try{Propertiesprops=newProperties();props.put("serverAddr","127.0.0.1:8848");ConfigServicecs=NacosFactory.createConfigService(props);cs.addListener(dataId,group,listener);services.add(cs);}catch(NacosExceptione){log.error("注册配置监听失败: {}",dataId,e);}}@Overridepublicvoiddestroy(){for(ConfigServicecs:services){try{cs.shutDown();// 关键:注销所有 Listener,关闭连接池}catch(NacosExceptione){log.warn("关闭 ConfigService 失败",e);}}services.clear();}}一个shutDown()就够了——它会注销这个实例下的所有 Listener,释放线程池和连接池。
长轮询:Hold 得越久越好吗
Nacos 1.x 的配置推送走 HTTP 长轮询。客户端发一个 GET 请求,服务端 Hold 住,有变更才返回。没有变更就一直 Hold 到超时,超时后客户端再发一次。
默认超时是 30 秒。这个数字影响三件事:
| 超时时间 | 配置变更延迟 | 服务端连接数 | 适用场景 |
|---|---|---|---|
| 10 秒 | 最多 10 秒 | 高(每 10 秒重建连接) | 配置频繁变动的核心业务 |
| 30 秒(默认) | 最多 30 秒 | 中 | 常规场景 |
| 60 秒 | 最多 60 秒 | 低 | 配置很少变的晚间任务 |
改超时时间不会改变配置变更的感知延迟——因为变更是 Push 的,不是等到超时才通知。超时只影响"没有变更时多久重建一次连接"。改短了无意义地增加连接开销,改长了没有实际收益。
# Nacos 服务端(1.x 或兼容模式) nacos.longpolling.timeout=30000 # 客户端侧(bootstrap.yml) spring: cloud: nacos: config: timeout: 30000 # 长轮询超时,默认 30000ms除非你的服务端 CPU 非常紧张、连接数是个瓶颈,否则默认 30 秒别动。
缓存:Nacos 服务端崩了你还剩什么
Nacos 客户端在本地文件系统存了两样东西:
- 配置内容:
$HOME/nacos/config/{namespace}/{group}/{dataId} - 上一次拉到的快照:
$HOME/nacos/config/snapshot-tenant/{namespace}/{group}/{dataId}
# 默认缓存目录ls~/nacos/config/# 结构:# nacos/config/# ├── fixed-localhost_8848_nacos/# │ └── snapshot-tenant/# │ └── public/# │ └── ORDER_GROUP/# │ └── order-service-prod.yml启动时如果连不上 Nacos,客户端会读这个本地缓存。
spring:cloud:nacos:config:# 修改缓存目录file-extension:ymlnamespace:prodgroup:ORDER_GROUP# 本地缓存策略naming-load-cache-at-start:true# 启动时加载本地缓存但这里有个容易忽略的点:如果服务端正常但配置被删了,本地缓存不会自动失效。客户端以为自己拿到的是最新配置,其实是上一次成功拉取的历史版本。
两种场景对比:
| 场景 | 服务端状态 | 客户端取的配置 | 安全吗 |
|---|---|---|---|
| 服务端正常 | 配置存在 | 从服务端拉取最新 | ✅ |
| 服务端正常 | 配置被误删 | 上次缓存的版本 | ⚠️ 可能是错的 |
| 服务端全挂 | 不可达 | 上次缓存的版本 | ✅ 兜底有用 |
| 服务端恢复 | 配置已回滚 | 等下一次长轮询刷新 | ✅ 自动修复 |
也就是说,缓存是兜底,不是永久缓存。服务端恢复后,下一次长轮询会自动拉回正确配置。
配置太多:当你把 Nacos 当成文件服务器
一家 SaaS 公司把每个租户的自定义配置都存进了一个 Data ID:
# tenant-config.yml —— 一个文件 12MBtenant_001:features:{...1500 行}whitelist:{...800 行}tenant_002:features:{...1500 行}whitelist:{...800 行}# ... 200 个租户每次随便改一个租户的白名单,200 个租户的客户端全部触发receiveConfigInfo,重新解析这 12MB 的 YAML。
两个问题:
- 网络传输:12MB × 200 个客户端 = 2.4GB 带宽
- YAML 解析:单个 12MB 的 YAML 解析耗时 200~400ms,期间阻塞配置刷新线程
拆分的收益远大于"统一管理"的便利:
# 拆成按租户粒度tenant-001-config.yml# 3KBtenant-002-config.yml# 3KB# ... 200 个 Data ID每个客户端只订阅自己租户的 Data ID。改租户 001 的配置,只推送给租户 001,其他 199 个不受影响。
拆分 vs 不拆分
| 维度 | 一个巨型文件 | 按粒度拆 N 个文件 |
|---|---|---|
| 配置变更影响面 | 全体客户端 | 只影响订阅了该文件的客户端 |
| 网络带宽 | N × 12MB | 只有订阅者收到 ~3KB |
| YAML 解析耗时 | 200~400ms | <5ms |
| 代码可维护性 | 一个人改全公司提心吊胆 | 隔离,改自己的 |
三个参数的最终建议
别在配置文件里动太多参数。Nacos 配置管理的默认值经过了生产验证,大多数场景不需要改。
| 参数 | 默认值 | 什么时候才需要改 |
|---|---|---|
| 长轮询超时 | 30000ms | 服务端连接数确实是瓶颈时 |
| 客户端缓存 | 默认开启 | 确认了服务端全部宕机后客户端无法启动 |
| Data ID 粒度 | 没限制 | 单个文件超过 500KB 时考虑拆分 |
真正需要你关注的不是调参,是这三件事:
- 每一个
addListener都有对应的shutDown——或者交给 Spring 管。 - 一个 Data ID 一个服务一个功能维度——别把 200 个租户塞进一个文件。
- 升级到 Nacos 2.x——gRPC 双向流原生解决长轮询的超时问题和连接数问题。1.x 调再多参数也不如升级本身。
你们遇到过一次配置变更影响所有服务的场景吗?评论区留个数字:1=改一个配置全公司抖三抖 2=拆分得好好的互不影响 3=还没上生产配置中心。顺便说一个你最想吐槽的 Nacos 配置管理问题。
