Nacos 迁移引发 Full GC 问题深度分析

1 阅读6分钟

Nacos 迁移引发 Full GC 问题深度分析

文档背景:搜索服务 将配置从本地 application.yaml 迁移到 Nacos 后,生产环境运行一段时间后出现 Full GC 频繁,CPU 和内存飙升,接口 P99 超时严重。本文档结合生产 GC 日志(-search-prod-sbwj9_gc.log)与代码 Review 进行深度根因分析,并给出解决方案。


一、问题现象

1.1 生产故障时间线(来自 GC 日志)

时间事件
2026-06-03 16:23服务启动,JDK 21 G1GC,堆 8GB 固定,Old 区仅 1 region(4MB)
16:23 ~ 17:20Young GC 正常,Old 区快速增长至 309 regions(1236MB)
22:17首次 Evacuation Failure,堆使用 8033M,GC 无法移动对象
23:05 ~ 00:58EF 持续出现,约每 30 分钟一次,Old 区累计增长至 653 regions
01:12严重 Evacuation Failure,单次 STW 暂停 328ms
2026-06-04 08:35Old 区增至 890 regions(3560MB),占堆 43%
14:45Old 区急剧增至 1699 regions(6796MB),占堆 83%
16:15连环 Evacuation Failure 爆发(见下方详情)
16:17:16触发 Full GC(G1 Compaction Pause),STW 暂停 6.23 秒!

1.2 Full GC 爆发前的连环 Evacuation Failure(16:15~16:17)

GC(1472) Evacuation Failure  8151M->7748M  18ms
GC(1475) Evacuation Failure  8190M->7884M  23ms
GC(1476) Evacuation Failure  8180M->8144M  12ms   ← 几乎没回收
GC(1477) Evacuation Failure  8180M->8138M   6ms   ← 堆几乎全满
...连续多次 EF(约10次)...
GC(1494) Evacuation Failure  8187M->8187M   7ms   ← 完全没回收!
​
GC(1495) Pause Full (G1 Compaction Pause) 8187M->7475M(8192M) 6230.739ms
  • Full GC 停顿 6.2 秒,所有业务线程完全暂停,是 P99 接口超时的直接原因。
  • Full GC 后依然有 7475M 存活对象,说明 Old 区对象完全无法释放——这是内存泄漏的铁证。

1.3 Old 区单调增长趋势(内存泄漏特征)

16:23(启动)  Old:    1 regions  (    4 MB)
17:20          Old:  309 regions  ( 1236 MB)   1小时涨1232MB!
22:23          Old:  653 regions  ( 2612 MB)
03:16(次日)  Old:  721 regions  ( 2884 MB)
08:35          Old:  890 regions  ( 3560 MB)
14:45          Old: 1699 regions  ( 6796 MB)   严重
16:17          Old: 1878 regions  ( 7512 MB)   接近上限(2048 regions = 8192MB)

Mixed GC 每次仅能回收 1~2 个 Old region,说明 Old 区对象大量存活、无法被回收。这是内存泄漏而非正常晋升。

1.4 Humongous 大对象问题

GC 日志中频繁出现 G1 Humongous Allocation 触发 Concurrent Start,Humongous regions 峰值达 110+ regions(440MB+)。Humongous 对象(>2MB)直接分配到 Old 区,加速 Old 区填满。


二、@NacosValue autoRefreshed = true 的原理

2.1 Nacos SDK 的注入机制

@NacosValue(autoRefreshed = true) 由 Nacos Spring 扩展(nacos-spring-context)实现,核心原理:

Nacos 配置中心
    │
    ▼ 配置变更推送
NacosValueAnnotationBeanPostProcessorBeanPostProcessor)
    │  ─── 在每个 Bean 初始化后,扫描其 @NacosValue 字段
    │  ─── 将 bean 实例引用注册到内部 Map 中
    │
    ▼ 配置刷新时
    遍历 Map,通过反射将新值写入所有注册的 bean 字段

2.2 关键数据结构(Nacos SDK 源码)

// NacosValueAnnotationBeanPostProcessor 内部
Map<String, List<NacosValueTarget>> placeholderNacosValueTargetMap;
// key:   "${browser..result-rank.url}"
// value: List<{ Object beanInstance(强引用!), Field field }>

2.3 Prototype Bean 注册时机与问题根源

Spring 每次创建 prototype bean 时:

  1. 实例化 → 依赖注入 → BeanPostProcessor.postProcessAfterInitialization()
  2. NacosValueAnnotationBeanPostProcessor 扫描 @NacosValue 字段
  3. 将 bean 实例强引用追加到 placeholderNacosValueTargetMap
  4. Prototype bean 生命周期结束,Spring 不回调销毁方法,也不从 Nacos 注册表移除

结果:每一个 prototype bean 实例都被 Nacos 的 Map 强引用,永远无法被 GC。


三、内存泄漏根因分析(结合 GC 日志数据验证)

3.1 泄漏路径

每次 HTTP 请求到达 SearchController.search()
    │
    ▼ SearchService.search()
    ├── applicationContext.getBean("SearchReq")      ← prototype bean 创建
    ├── applicationContext.getBean(InnerRequest.class)  ← prototype bean 创建
    └── graph.run() → DAG 执行以下 prototype beans:
        ├── SearchThreeOptimizeRank              ← @NacosValue rerankUrl
        ├── VideoTitleVectorRecallBuilder         ← @NacosValue 2个字段
        ├── NewsTitleVectorRecallBuilder          ← @NacosValue 2个字段
        ├── HotTitleVectorRecallBuilder           ← @NacosValue 2个字段
        ├── VideoI2iVectorRecallBuilder           ← @NacosValue 5个字段
        ├── NewsI2iVectorRecallBuilder            ← @NacosValue 5个字段
        ├── SearchAbstractRecallBuilder           ← @NacosValue 2个字段
        └── ~30+ 其他 RecallBuilder/Recall 类       ← 各含 @NacosValue 字段

每个 @NacosValue 字段 → Nacos 注册表追加一条强引用
请求结束 → bean 无法被 GC(Nacos Map 持有强引用)

3.2 GC 日志数据验证泄漏速率

时段Old 区增量时长泄漏速率
16:23 → 17:20+1232 MB57 min~21 MB/min
17:20 → 22:23+1376 MB303 min~4.5 MB/min
22:23 → 08:35+948 MB612 min~1.5 MB/min
08:35 → 14:45+3236 MB370 min~8.7 MB/min(流量高峰)

启动初期泄漏速率最高(21 MB/min),与服务刚上线流量集中、每次请求创建大量 prototype bean 完全吻合。夜间低峰泄漏速率降低,白天高峰再次飙升。

3.3 Mixed GC 无效的原因

G1 的 Mixed GC 用于回收 Old 区存活率低的 region。正常服务中,Old 区对象会随请求完成而死亡,Mixed GC 能有效回收。

本案例中,Old 区 prototype bean 实例被 Nacos 强引用,对象全部存活,Mixed GC 扫描后发现无可回收对象,回收量趋近于 0,无法阻止 Old 区持续增长。

3.4 受影响的 Prototype Bean 完整清单

以下类均存在 @Scope(PROTOTYPE) + @NacosValue(autoRefreshed=true) 组合,每次请求均会泄漏:

类名@NacosValue 字段数影响等级
SearchThreeOptimizeRank1(rerankUrl)
VideoI2iVectorRecallBuilder5(ragTimeout, k, numCandidates, similarity, embeddingTimeout)
NewsI2iVectorRecallBuilder5(同上)
VideoTitleVectorRecallBuilder2(vectorStageTimeout, ragTimeout)
NewsTitleVectorRecallBuilder2(同上)
HotTitleVectorRecallBuilder2(同上)
SearchAbstractRecallBuilder2(ragTimeout, browserTimeout)
IntentionResultLlmRecallBuilder3(url, model, apikey)
IntentionDomainLlmRecallBuilder3(url, model, apikey)
AiSearchDomainIntentionLlmRecallBuilder3(url, model, apikey)
QueryListRecallBuilder2(sugUrl, vectorStageTimeout)
SugGameRecallBuilder 等 30+ 个类各 1~2 个

3.5 为什么迁移前没有问题

对比项迁移前(@Value迁移后(@NacosValue autoRefreshed=true
注入时机Bean 创建时读一次,之后不维护引用注册 bean 引用到全局 Map,持续持有
prototype bean 能否被 GC✅ 可以,无外部强引用❌ 不能,Nacos Map 持有强引用
配置动态刷新❌ 不支持✅ 支持
内存泄漏有,每请求泄漏

四、Humongous 对象分析

GC 日志中频繁出现 Pause Young (Concurrent Start) (G1 Humongous Allocation),说明有 >2MB 的大对象频繁分配,触发 Concurrent Mark。

疑似来源(结合代码):

  • 向量数据titleVector_simberttitleVector_bgelarge 等 Embedding 向量(float 列表,1024 维 × 4 bytes = 4KB,不构成 Humongous)
  • 召回结果集:多路召回合并后的 List<ItemInfo>,每个 ItemInfo 含完整 VO 对象(含向量字段),多路汇总后可能超过 2MB
  • JSON 序列化GSON.toJson(itemListToCache) 序列化大结果集写入 Redis,产生大 String
  • CompletableFuture 链:DAG 并行执行时多个 future 链持有中间结果对象

Humongous 对象直接分配到 Old 区,与内存泄漏叠加,双重加速 Old 区填满。


五、分析结论

主因(确认)@NacosValue(autoRefreshed = true) + @Scope(PROTOTYPE) 组合导致系统性内存泄漏。GC 日志中 Old 区从启动到崩溃 24 小时单调递增、Mixed GC 无效回收,是该结论的直接证据。

故障链路

1. prototype bean 创建 → Nacos 强引用注册
2.     → Old 区持续增长(泄漏速率 ~1.5~21 MB/min)
3.     → Mixed GC 无效(对象全部存活)
4.     → Old 区占堆 >90%(7512MB/8192MB)
5.     → Evacuation Failure 连环触发
6.     → Full GC(G1 Compaction Pause,STW 6.23秒)
7.     → 业务线程全部暂停
8.     → P99 接口大量超时

受影响范围:50+ 个 prototype 类,其中 7 个核心 RecallBuilder/Rank 类单次请求共泄漏约 19 个 @NacosValue 注册条目。

触发时间规律:启动后约 68 小时 Old 区达到 50% 阈值开始触发 Concurrent Mark,约 1218 小时开始出现 Evacuation Failure,约 24 小时触发 Full GC。流量越大,爆发越快。


六、解决方案

方案一(推荐):将 @NacosValue 收敛到 Singleton 配置类

核心思路:prototype bean 不直接持有 @NacosValue 字段,改为注入 singleton 配置类获取值。singleton 只注册一次,彻底消除泄漏,动态刷新能力完全保留。

Step 1:新建统一配置持有类

@Slf4j
@Component  // 默认 singleton,全局只注册一次到 Nacos
public class SearchNacosConfig {
​
    // 原 SearchThreeOptimizeRank 中的字段
    @NacosValue(value = "${browser..result-rank.url}", autoRefreshed = true)
    private String rerankUrl;
​
    // 原各 RecallBuilder 中的超时字段
    @NacosValue(value = "${.search.timeout.vector-stage:250}", autoRefreshed = true)
    private long vectorStageTimeoutMs;
​
    @NacosValue(value = "${elasticsearch.rag-cluster-query-timeout-ms:1000}", autoRefreshed = true)
    private long ragClusterQueryTimeoutMs;
​
    @NacosValue(value = "${elasticsearch.browser-cluster-query-timeout-ms:350}", autoRefreshed = true)
    private long browserClusterQueryTimeoutMs;
​
    @NacosValue(value = "${.search.i2i.k:20}", autoRefreshed = true)
    private int i2iK;
​
    @NacosValue(value = "${.search.i2i.num-candidates:50}", autoRefreshed = true)
    private int i2iNumCandidates;
​
    @NacosValue(value = "${.search.i2i.similarity:0.7}", autoRefreshed = true)
    private double i2iSimilarity;
​
    @NacosValue(value = "${.search.i2i.embedding-timeout-ms:200}", autoRefreshed = true)
    private long i2iEmbeddingTimeoutMs;
​
    // ... 其余所有 @NacosValue 字段
​
    public String getRerankUrl() { return rerankUrl; }
    public long getVectorStageTimeoutMs() { return vectorStageTimeoutMs; }
    public long getRagClusterQueryTimeoutMs() { return ragClusterQueryTimeoutMs; }
    public long getBrowserClusterQueryTimeoutMs() { return browserClusterQueryTimeoutMs; }
    // ...
}

Step 2:修改 SearchThreeOptimizeRank

@Slf4j
@Component("SearchThreeOptimizeRank")
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class SearchThreeOptimizeRank extends AbstractRank {

    // ❌ 删除(会泄漏):
    // @NacosValue(value = "${browser..result-rank.url}", autoRefreshed = true)
    // private String rerankUrl;

    // ✅ 改为注入 singleton 配置类:
    @Resource
    private SearchNacosConfig nacosConfig;

    // 使用时:nacosConfig.getRerankUrl()
}

Step 3:对所有受影响的 prototype 类执行同样改造

所有含 @NacosValue 的 RecallBuilder / Recall / Rank prototype 类均按此方式改造,用 @Resource SearchNacosConfig nacosConfig 替换直接注解字段。

优点

  • 彻底消除内存泄漏,Old 区将恢复正常 GC 回收
  • 动态刷新完全保留(singleton 字段正常更新,所有 prototype 通过方法调用实时读取)
  • 改动范围清晰,逐类迁移,不影响业务逻辑

方案二(辅助):针对 Humongous 大对象优化

建议同步排查大对象来源,减少 Humongous 分配频率:

// 1. 召回结果集控制上限(各路召回已有 limit,确认执行到位)
// 例如 SearchThreeOptimizeRank.scoreSortMergeResults() 已有 limit(20),确保生效

// 2. Redis 缓存序列化前控制大小
// saveDataToCache 前检查 json 大小,超过阈值(如 1MB)跳过缓存
if (jsonStr.length() < 1024 * 1024) {
    redisUtils.set(...);
}
# 3. G1 HeapRegionSize 调大,提高 Humongous 门槛
# 当前 HeapRegionSize=4MB(Humongous 门槛 2MB),可改为:
-XX:G1HeapRegionSize=8m   # Humongous 门槛提升到 4MB

方案三(应急缓解):重启 + JVM 参数调整

不能根治,仅作紧急止血:

# 1. 立即重启 pod(清空泄漏的 Old 区)# 2. 开启 OOM 时自动 Heap Dump,下次复现时捕获现场
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heap-dump.hprof
​
# 3. 降低 IHOP 阈值,让 G1 更早启动 Concurrent Mark,争取更多回收时间
-XX:InitiatingHeapOccupancyPercent=35   # 当前默认约 45%# 4. 增加并发 GC 线程(当前 Concurrent Workers 仅 2,CPU 4核可调到 2~3)
-XX:ConcGCThreads=3

七、验证方案

7.1 修复前 Heap Dump 分析

在下次服务启动后约 2~4 小时(Old 区增长到约 500MB 时)执行:

jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

用 Eclipse MAT 打开,查看:

  • NacosValueAnnotationBeanPostProcessorplaceholderNacosValueTargetMap 的 retained heap
  • 按 retained heap 排序的 Top 10 对象:应能看到大量 SearchThreeOptimizeRankVideoI2iVectorRecallBuilder 等 prototype bean 实例堆积

7.2 修复后的预期 GC 表现

对比当前 GC 日志,修复后应观察到:

指标修复前(实测)修复后(预期)
Old 区增长趋势24h 从 4MB 增至 7512MB,无法回收趋于稳定,Mixed GC 可有效回收
Evacuation Failure22:17 起持续,约 30min 一次消失
Full GC运行约 24h 后触发,STW 6.23s消失
Mixed GC 回收量每次回收 1~2 个 Old region每次回收数十个 Old region
P99 延迟超时严重(6s+ STW)恢复正常

7.3 压测验证方法

修复上线后,施加持续 QPS 压测(建议与生产峰值 QPS 相同)持续 30 分钟:

  • 观察 Old 区是否保持平稳(不超过 20% 堆大小)
  • 确认无 Evacuation Failure、无 Full GC

八、优先级与实施计划

优先级动作说明时间
P0 立即重启 pod,临时恢复服务清空当前泄漏的 Old 区今日
P0 立即-XX:+HeapDumpOnOutOfMemoryError JVM 参数下次复现时自动抓取现场今日
P0 1天内按方案一改造 SearchThreeOptimizeRank 及 6 个高频 RecallBuilder覆盖最高频泄漏路径1天内
P1 本周全量排查 50+ prototype 类,完成所有 @NacosValue 迁移彻底消除泄漏本周
P1 本周执行压测验证,确认 Old 区不再单调增长验证修复有效性本周
P2 下周添加 JVM Old Gen 监控告警(Old Gen > 60% 预警,> 80% 紧急)提前发现类似问题下周
P2 下周排查 Humongous 大对象来源,优化召回结果集大小控制降低 Old 区分配压力下周