
1. 问题现象内存曲线“温和”地失控先说结论这个问题的典型表象不是“性能瞬间劣化”而是“内存被慢慢吃掉”。我们有个订单服务Spring Boot Dubbo Nacos做注册中心和配置中心正常运行两周后JVM堆内存就开始出现一种很规律的阶梯式增长——每次发布配置、每次服务上下线堆内存就往上跳一截但GC之后又降不下来。一开始以为是业务代码里的缓存没清后来发现Full GC之后老年代依然高得离谱直到某次凌晨流量低峰直接OOM了。重启之后只能好两三天然后同样的曲线又起来。这个现象最迷惑人的地方在于CPU不高线程数看着也算正常接口响应没有明显变慢只有内存一个指标在“稳定”地恶化。如果你也遇到这种“不痛不痒但迟早出事”的问题大概率就是某个客户端的连接、监听器或者缓存没有随对象一起释放而Nacos客户端恰恰是这类问题的高发区。这个问题的排查难度不在“定位”而在“验证”。因为堆内存里的Nacos相关对象往往从名字上看都挺正常的——比如ClientWorker、HostReactor、NacosNamingService——真正重要的是它们的实例数量而不是它们占了多大空间。所以一次heap dump配上实例计数基本就能实锤。本文会完整走一遍我的排查和修复过程适合正在维护Spring Cloud Alibaba或Dubbo Nacos这套技术栈的同学参考也适合遇到过类似诡异内存增长但没找到根因的同行。2. 排查记录从怀疑业务代码到拿下Nacos客户端2.1 先排除业务层的“假内存泄漏”任何内存问题第一步都不该急着去怀疑框架先把业务代码可能的坑排除掉。我当时先做了三件事检查所有静态缓存比如Map、List有没有无界增长用jstat -gcutil观察GC后的老年代占用率。检查线程池的使用情况重点看线程数是不是随请求持续增长。用jmap -dump:live,formatb,fileheap.bin强制触发一次Full GC再dump堆确保dump里的对象都是“存活”的。在这三步后老年代占用还是很高并且java.lang.Thread数量稳定但对象数量异常这时候才把目标指向框架客户端。一个实用的判断技巧是如果堆里某个类的对象数量达到了几千甚至几万而业务逻辑根本不需要这么多实例那大概率就是框架客户端泄漏了。2.2 用MAT定位到Nacos客户端对象拿到heap.bin之后我用Eclipse MAT的Dominator Tree支配树按Retained Heap排序再配合List objects看具体类名发现在Shallow Heap和Retained Heap的排序里排在前面的除了业务对象就是一堆Nacos相关对象。具体是这么一组组合拳打开Dominator Tree搜索NacosConfigService和NacosNamingService统计实例数量。在Inspector面板里看每个实例的创建时间和关联线程。用Path to GC Roots追踪引用链看这些对象是被谁持有的。结果非常典型NacosConfigService有几十个实例每个实例都对应一个独立的ClientWorker线程ClientWorker内部又挂着cacheMap和listenerMap里面保存了配置内容和业务Listener对象。而这些Listener对象又间接引用了Spring容器里的Bean导致整个业务链路都无法被GC回收。这已经不是单纯的“对象泄漏”而是整个配置客户端生命周期没有统一管理。2.3 线程数为什么没爆但内存爆了有人可能会问如果一个线程池或一个客户端实例长期不被回收线程数应该也会涨啊怎么会内存先爆这里有一个容易被忽略的点Nacos客户端2.x版本大量使用定时任务调度但很多任务是在一个共享的ScheduledThreadPoolExecutor里跑的。也就是说线程数不一定会线性增长但任务队列和未执行的任务会持续堆积。尤其是配置监听和心跳上报这类高频任务每个ConfigService实例都会注册自己的定时任务线程池的BlockingQueue里塞满了等待执行的任务这部分占用的内存看起来不大但它会引用对应的客户端对象整条引用链一起泄漏。我当时用jstack抓线程确实没看到几百个Nacos线程那种夸张现象但heap dump里的ScheduledFuture和Runnable对象数量是正常的几十倍。所以排查Nacos客户端泄漏线程数不可靠对象实例数和任务队列大小才可靠。3. 根因剖析Nacos客户端到底把内存花在了哪3.1 配置中心侧ClientWorker与监听器引用的生命周期Nacos客户端的配置中心核心类叫ClientWorker。它做的事情可以简化理解成两件事启动一个长轮询线程去服务端拉取整个监听配置文件的最新内容。把配置内容缓存在本地cacheMap里同时维护一个listenerMap当配置变化时回调对应的Listener接口。问题就出在ClientWorker和业务Listener的绑定关系上。很多业务代码会这么写// 错误示例每次方法调用都新建一个监听器并注册到全局客户端 public void registerDynamicConfig(String dataId) { configService.addListener(dataId, GROUP, new Listener() { Override public void receiveConfigInfo(String configInfo) { doSomething(configInfo); } Override public Executor getExecutor() { return executor; } }); }这段代码的问题在于Listener匿名内部类会捕获this引用而this如果是某个Spring Bean或某个长生命周期组件那么ClientWorker.listenerMap就会一直持有这个Bean的引用。如果这个方法被频繁调用且该Bean的创建频率很高就会产生大量无法被回收的引用链。正确做法是把Listener注册为单例或者在不需要监听时显式调用removeListener。这个看起来是基础问题但实际线上业务中非常常见尤其是动态配置按租户或按业务模块拆分时很多人图省事在每个模块初始化时都注册一个新Listener导致泄漏面迅速扩大。3.2 注册中心侧服务缓存与服务信息holderNacos注册中心客户端的核心类是NacosNamingService它内部有一个HostReactor负责缓存服务实例列表。HostReactor里有一张MapString, ServiceInfo缓存ServiceInfo里保存了该服务的所有实例IP、端口、权重、元数据等。在正常用法下这张缓存Map是有上限的——服务数量有限每个服务一条记录。但如果你在业务代码里反复创建NacosNamingService实例或者用类似NamingFactory.createNamingService(properties)的方式每次动态创建那么每个实例都会有自己的HostReactor和缓存Map。当这些实例没有调用shutdown()方法时相关联的心跳线程BeatReactor也会持续运行每5秒向服务端发送心跳。一个NacosNamingService实例占用的内存可能不大但如果数量达到几百上千再加上每个实例内部缓存的ServiceInfo和BeatInfo内存增长就会非常明显。更麻烦的是这些实例之间互不可见每个实例都自认为自己是唯一的客户端服务端收到的心跳数量也会随之暴涨。3.3 2.x版本特有的gRPC连接与任务队列问题Nacos 2.x开始客户端与服务端的通信方式从HTTP长轮询升级为gRPC长连接。好处是性能更好、支持推送但坏处是每个客户端实例都会维护一套独立的gRPC连接和对应线程模型。具体来说2.x客户端内部会创建GrpcClient里面包含连接管理、重连调度。一个Connection对象关联独立的I/O线程和线程池。一个RpcClientStatus状态机负责连接状态流转。一个ServerCheckRequest等定时检测任务。如果你创建了多个NacosNamingService或NacosConfigService却没有正确关闭那么这些gRPC连接会一直存在每个连接相关的线程、缓冲区、超时任务全部留在JVM里。和1.x相比2.x的泄漏面积更大而且更隐蔽因为gRPC连接本身长得很像“正常资源”短时间看不到异常但一两个月后就会累积成内存黑洞。3.4 为什么会出现“publish nacos metadata failed”很多同事在排查时看到这样的堆栈会慌caused by: java.lang.RuntimeException: publish nacos metadata failed at org.apache.dubbo.metadata.store.nacos.NacosMetadataReport.storeMetadata(NacosMetadataReport.java:387)这个异常的核心并不是“元数据发布失败”本身而是背后的客户端状态。Dubbo把Nacos作为元数据中心时会通过一个统一的NacosMetadataReport向Nacos发布服务元数据。如果当前JVM里的Nacos客户端实例已经被shutdown()了或者gRPC连接已经断开而Spring容器里仍然保留着对NacosMetadataReport的引用那么每次服务发布、导出或刷新元数据时就会触发这个异常。这个异常为什么会和内存泄漏扯上关系因为发布失败后的重试逻辑通常不是我们想象的那种“失败一次就丢弃”而是会不断重建连接、重新提交任务。如果连接持续失败NacosMetadataReport内部的重试队列就会持续积压未完成的任务对象这些任务又持有业务服务元数据对象最终形成泄漏。所以遇到这类堆栈不要只盯着“为什么发布失败”要同时检查客户端实例的健康状态和重试任务数量。4. 解决方案与验证一步步把内存水位拉回来4.1 强制统一客户端实例禁止散装创建我做的第一项整改是把所有Nacos客户端实例收口到Spring容器里统一管理。具体做法全项目只保留一个ConfigService单例用Spring的Bean创建通过Autowired注入。不能随意调用NacosFactory.createConfigService()所有入口都走统一封装。在PreDestroy里显式调用configService.shutDown()和namingService.shutDown()。这是最基础但最关键的一步。很多泄漏的根源就是“每个模块自己拉一个客户端”只要把单例化做掉数量级就能从几十降到一个。Configuration public class NacosClientConfig { Bean(destroyMethod shutDown) public ConfigService nacosConfigService() throws NacosException { Properties properties new Properties(); properties.setProperty(PropertyKeyConst.SERVER_ADDR, 127.0.0.1:8848); properties.setProperty(PropertyKeyConst.NAMESPACE, order-service); return NacosFactory.createConfigService(properties); } Bean(destroyMethod shutDown) public NamingService nacosNamingService() throws NacosException { Properties properties new Properties(); properties.setProperty(PropertyKeyConst.SERVER_ADDR, 127.0.0.1:8848); properties.setProperty(PropertyKeyConst.NAMESPACE, order-service); return NacosFactory.createNamingService(properties); } }注意spring容器里Bean(destroyMethod shutDown)这个细节不用的话Spring默认销毁逻辑可能不生效老实例还是会在容器关闭时继续运行。4.2 监听器按生命周期注册业务方法不直接注册第二步是审查所有addListener调用强制要求Listener必须是无状态单例不能在匿名内部类里捕获业务对象。监听器注册时记录数据ID和分组在PreDestroy或模块关闭时执行removeListener。如果监听器内部确实需要业务上下文把上下文作为参数传入构造器而不是通过匿名类隐式捕获。在排查过程中我们遇到过一个很典型的例子某个定时任务每5分钟去Nacos拉一次配置然后顺手addListener美其名曰“实时更新”。实际上配置更新根本不需要反复注册监听注册一次就够了。定时任务本身没有持有监听的引用但ClientWorker持有导致ClientWorker引用的对象全部滞留。这就是“定时任务没问题但监听器把定时任务的结果对象给泄漏了”的场景。4.3 服务提供方的心跳线程与宿主信息检查对Provider服务还要重点检查心跳线程。Nacos客户端会为每个持久化实例启动一个BeatReactor线程默认每5秒发一次心跳。如果你在非持久化场景下误加了ephemeralfalse或者动态注册了多个实例心跳线程数量就会异常。检查技巧是jstack之后搜索nacos相关的线程名比如com.alibaba.nacos.client.naming.beat。如果线程数量远超预期说明NamingService实例存在大量未关闭。此时不要只改代码还要排查是否存在反射或框架自动创建的隐式实例比如某些版本的Spring Cloud Alibaba会在启动时自动创建一个NacosNamingService如果业务代码又手动建了一个就会造成双份对象。4.4 版本升级与关键参数调整我们对Nacos客户端版本做了升级从2.0.x升到2.3.2。这个版本在gRPC连接管理上做了不少优化尤其是连接断开后的重连策略和任务队列清理对内存泄漏的修复很有帮助。同时调整了几个关键参数# 配置客户端长轮询超时时间毫秒默认30000 -Dnacos.config.long-polling.timeout30000 # 配置客户端本地快照缓存目录避免堆内缓存过大 -Dnacos.naming.cache.dir/tmp/nacos/naming # 限制gRPC客户端线程池大小 -Dnacos.remote.client.grpc.pool.size4这几个参数不是万能的但至少能在不改造代码的情况下缓解部分压力。真正根治还是要回到单例化和监听器释放上。特别注意2.x版本中-Dnacos.remote.client.grpc.pool.size参数只对新建的连接生效已经存在的连接不会动态调整所以改完要重启应用。4.5 验证监控曲线和heap dump双重确认改造完成并重启后我连续观察了两周老年代占用率稳定在25%上下不再出现阶梯式上涨。jmap -dump再次抓堆ClientWorker和NacosNamingService实例数量都降到了个位数。gRPC连接数从几十个回到了正常的1-2个。之前出现的publish nacos metadata failed异常不再出现因为客户端状态正常了重试队列也不会积压。验证阶段有一个小技巧不要只等线上出问题才抓堆。可以在压测环境用JVM参数开启-XX:HeapDumpOnOutOfMemoryError然后把GC日志落到文件里通过GCViewer观察老年代增长趋势。如果发现GC后老年代不能回落到低位即使没有OOM也应该主动抓一份堆来做排查。5. 常见问题与排查技巧实录5.1 常见问题速查表现象可能原因排查方法解决方向老年代阶梯式增长ConfigService实例过多heap dump统计ClientWorker实例数统一客户端单例化正确shutdown心跳线程数量异常NamingService实例未关闭jstack搜索nacos.naming线程收口客户端创建入口gRPC连接堆积2.x客户端实例过多或连接未释放netstat查看8848端口连接数升级版本 统一线程池配置publish nacos metadata failed客户端已shutdown但Dubbo仍引用查看异常堆栈 检查任务队列保证客户端生命周期一致监听器回调不生效但内存涨Listener匿名内部类捕获业务对象查看listenerMap引用链监听器单例化 removeListener5.2 排查工具和命令清单我整理了一个可以直接抄的排查流程按顺序执行基本不会漏# 1. 查看GC情况关注老年代占用和Full GC次数 jstat -gcutil pid 5000 # 2. 抓线程快照看Nacos相关线程 jstack pid thread_dump.txt # 3. 抓堆快照分析实例数量和引用链 jmap -dump:live,formatb,fileheap.bin pid # 4. 查看针对8848端口的连接数统计 netstat -anp | grep 8848 | wc -l拿到thread_dump.txt后重点搜索这几个关键词com.alibaba.nacos.client.Worker配置长轮询线程。com.alibaba.nacos.client.naming.beat服务心跳线程。grpc-default-workgRPC工作线程。nacos-grpc-clientgRPC客户端线程。如果这些线程数量和你启动的服务数对不上就已经有初步嫌疑了。之后再用MAT的Path to GC Roots定位具体持有者。5.3 团队规范的几条硬规则这次排查之后我给团队定了几条硬规则现在分享出来基本可以预防90%以上的Nacos客户端泄漏问题所有Nacos客户端实例必须由Spring容器统一管理业务代码禁止直接new。addListener必须成对存在有注册就必须有移除不能只图一时方便。每次发版前检查堆内存趋势不要只看接口成功率内存曲线是稳定性的一等指标。升级Nacos客户端版本前先在灰度环境跑两周重点看老年代曲线和gRPC连接数。遇到publish nacos metadata failed这类异常先查客户端状态再查网络不要盲目加超时和重试重试越多泄漏越快。最后再分享一个小技巧如果你们项目里大量使用了Dubbo Nacos并且发现元数据发布失败和内存问题同时出现可以优先考虑把Dubbo的元数据中心独立成单独服务不要让每个Provider都直接持有Nacos客户端实例。这样能有效缩小泄漏面排查时也更容易定位。我在实际项目中踩过这个坑把元数据发布收敛到独立组件之后问题复杂度直接下降了一个量级。