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

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:20 Young GC 正常,Old 区快速增长至 309 regions(1236MB)
22:17 首次 Evacuation Failure,堆使用 8033M,GC 无法移动对象
23:05 ~ 00:58 EF 持续出现,约每 30 分钟一次,Old 区累计增长至 653 regions
01:12 严重 Evacuation Failure,单次 STW 暂停 328ms
2026-06-04 08:35 Old 区增至 890 regions(3560MB),占堆 43%
14:45 Old 区急剧增至 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)

scss 复制代码
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 区单调增长趋势(内存泄漏特征)

yaml 复制代码
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)实现,核心原理:

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

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

javascript 复制代码
// 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 泄漏路径

scss 复制代码
每次 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 MB 57 min ~21 MB/min
17:20 → 22:23 +1376 MB 303 min ~4.5 MB/min
22:23 → 08:35 +948 MB 612 min ~1.5 MB/min
08:35 → 14:45 +3236 MB 370 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 字段数 影响等级
SearchThreeOptimizeRank 1(rerankUrl)
VideoI2iVectorRecallBuilder 5(ragTimeout, k, numCandidates, similarity, embeddingTimeout)
NewsI2iVectorRecallBuilder 5(同上)
VideoTitleVectorRecallBuilder 2(vectorStageTimeout, ragTimeout)
NewsTitleVectorRecallBuilder 2(同上)
HotTitleVectorRecallBuilder 2(同上)
SearchAbstractRecallBuilder 2(ragTimeout, browserTimeout)
IntentionResultLlmRecallBuilder 3(url, model, apikey)
IntentionDomainLlmRecallBuilder 3(url, model, apikey)
AiSearchDomainIntentionLlmRecallBuilder 3(url, model, apikey)
QueryListRecallBuilder 2(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 无效回收,是该结论的直接证据。

故障链路

markdown 复制代码
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:新建统一配置持有类

kotlin 复制代码
@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

less 复制代码
@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 分配频率:

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

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

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

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

ruby 复制代码
# 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 时)执行:

perl 复制代码
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 Failure 22: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 区分配压力 下周
相关推荐
Gatlin1 小时前
逆向学习:我为什么放着文档不看,直接读字节码
后端
我不是外星人1 小时前
一键还原任意网站,这套 Playwright + 多 Agent 工程流太猛了
前端·后端·ai编程
geovindu1 小时前
java: Singleton Pattern
java·开发语言·后端·单例模式·设计模式·创建型模式
Nturmoils1 小时前
JOIN 不是拼上就完事,多表查询这几个坑先跑一遍
数据库·后端
qzhqbb1 小时前
字节面试复盘2026/07/03
面试·职场和发展
IT_陈寒2 小时前
Vue的v-if和v-show,我竟然用错了这么久
前端·人工智能·后端
一路向北North2 小时前
Spring Security OAuth2.0(16):密码模式
java·后端·spring
大P哥阿豪2 小时前
Go panic & recover:深入Go语言的异常拯救之道
开发语言·后端·golang·go
IT_陈寒2 小时前
React状态更新延迟搞晕我,原来是闭包在捣鬼
前端·人工智能·后端