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 时:
- 实例化 → 依赖注入 →
BeanPostProcessor.postProcessAfterInitialization() NacosValueAnnotationBeanPostProcessor扫描@NacosValue字段- 将 bean 实例强引用追加到
placeholderNacosValueTargetMap - 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_simbert、titleVector_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 打开,查看:
NacosValueAnnotationBeanPostProcessor→placeholderNacosValueTargetMap的 retained heap- 按 retained heap 排序的 Top 10 对象:应能看到大量
SearchThreeOptimizeRank、VideoI2iVectorRecallBuilder等 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 区分配压力 | 下周 |