凌晨两点的告警:Old 区 12 分钟涨满
2 月 18 号凌晨 2 点 07 分,值班电话响了。监控看板上,我们的智能问答服务实例 qa-svc-7d9f 的老年代曲线是一条近乎垂直的直线:从 1.2 GB 到 5.8 GB 用了 12 分钟,然后 Full GC,STW 4.7 秒,接着又是一条垂直线。
这个服务跑了四个月,之前一直很稳。这篇是完整的排查记录。
先看现场
监控数据(事发前 30 分钟):
| 指标 | 正常时 | 故障时 |
|---|---|---|
| 堆使用(6G 上限) | 2.1 GB 锯齿 | 5.8 GB 峰值,锯齿消失 |
| Full GC 频率 | 0 | 每 12~14 分钟 |
| Full GC 耗时 | — | 4.2~5.1s |
| Young GC 频率 | 0.8 次/秒 | 3.4 次/秒 |
| 请求 P99 | 280ms | 8.6s(大量超时) |
| 分配速率 | 520 MB/s | 2.4 GB/s |
「锯齿消失」这个细节很关键。正常的 Old 区曲线是锯齿形——缓慢上涨,Mixed GC 后回落。现在变成持续上涨到顶再被 Full GC 一次性清掉,说明并发标记跟不上对象晋升速度,Mixed GC 根本来不及触发。
还有一点:分配速率从 520 MB/s 涨到 2.4 GB/s,涨了 4.6 倍。但 QPS 只涨了 1.3 倍(从 340 到 450)。所以不是流量问题,是单次请求的内存开销变大了。
什么变了
我第一反应是查发布记录。前一天的变更有:
- 知识库扩容,单租户文档数从 800 涨到 3200 篇;
- 召回策略改了,topK 从 8 提到 20,加了 rerank;
- embedding 维度从 1024 换成了 1536(换了新的 embedding 模型)。
第三条嫌疑最大。1536 维的 float[] 是 6144 字节,而 G1 在 6G 堆下 region 是 4 MB,一半是 2 MB,6144 字节还不到 humongous 阈值,不算大问题。但架不住量。
先抓一份堆转储看看。这里有个坑:直接在故障实例上 dump 会 STW 十几秒,把服务打死。我们的做法是先摘流量:
# 1. 从注册中心摘掉(Nacos)
curl -X PUT "http://nacos:8848/nacos/v1/ns/instance?serviceName=qa-svc&ip=10.2.3.41&port=8080&enabled=false"
# 2. 等 20 秒让存量请求处理完
# 3. 摘掉之后再 dump,此时 STW 不影响用户
jcmd 12847 GC.heap_dump /data/dump/20260218.hprof
# Heap dump file created [5,921,447,102 bytes in 41.283 secs]
另外在摘流量之前,先抓了 JFR,这个更轻(对运行中的实例影响 <2%):
jcmd 12847 JFR.start name=oom-prof duration=90s settings=profile filename=/data/oom.jfr
MAT 的结果:两个大户
堆转储 5.9 GB,MAT 打开后 dominator tree:
| 对象 | Shallow | Retained | 实例数 |
|---|---|---|---|
float[1536] | 3.4 GB | 3.4 GB | 571,203 |
byte[](Jackson 缓冲) | 1.1 GB | 1.1 GB | 18,442 |
char[] | 0.6 GB | 0.7 GB | 2,103,884 |
ConcurrentHashMap$Node | 0.3 GB | 0.4 GB | 4,821,003 |
57 万个 float[1536],3.4 GB。按单次请求算,我们设计上最多同时持有几百个向量才对。
追引用链:
float[1536]
← EmbeddingRecord.vector
← VectorCache.cache (ConcurrentHashMap<String, EmbeddingRecord>)
← VectorCache.INSTANCE (static)
GC root: static field
是这个本地向量缓存。代码是这么写的:
@Component
public class VectorCache {
// 这里就是问题:没有上限,没有过期,直接 static 常驻
private static final Map<String, float[]> CACHE = new ConcurrentHashMap<>();
public float[] get(String docId, String text) {
return CACHE.computeIfAbsent(docId,
k -> embeddingClient.embed(text));
}
}
知识库扩容前是 800 篇 × 20 个租户 = 1.6 万条,1.6 万 × 6 KB ≈ 98 MB,完全没问题,所以四个月没人发现。扩容后是 3200 × 20 = 6.4 万条,6.4 万 × 6 KB = 393 MB。再加上分片——每篇文档平均切成 12 个片段,每个片段一个向量,实际是 3200 × 12 × 20 = 76.8 万条,76.8 万 × 6 KB = 4.7 GB。
MAT 里看到 57 万个是还没装满的状态,服务在装满之前就崩了。
这里我有个判断失误要记下来:我看到「向量缓存 98 MB」的时候觉得完全 OK,就放过了。当时没有算「分片数 × 租户数」这个乘数,只算了「文档数 × 租户数」。分片系数是 12,直接把估值放大了一个数量级。
第二个问题:Jackson 的 byte[] 也是常驻的
1.1 GB 的 byte[] 来自 Jackson。我们调用 embedding 服务时,一次性传 512 条文本:
// 批量 embedding:512 条文本 × 平均 1.4 KB = 720 KB 的请求体
BatchEmbedRequest req = new BatchEmbedRequest(texts); // texts 是 List<String>
byte[] body = objectMapper.writeValueAsBytes(req); // 720 KB 的 byte[]
HttpResponse resp = httpClient.post(EMBED_URL, body);
问题在于 Jackson 内部的 ByteQuadsCanonicalizer 会缓存字段名,而 ObjectMapper 的序列化缓冲区 SegmentedStringWriter/ByteArrayBuilder 是按峰值增长的。我们每次批量 512 条,缓冲区涨到 720 KB 后就一直保持这个大小,ObjectMapper 是单例,缓冲区跟着单例一直活着。
1.8 万个 byte[] 实例,平均 60 KB,是各个 ObjectMapper 和 HTTP 客户端各自的缓冲区。这个数字看着不大,但都是 Old 区常驻。
第三个问题:临时大对象
分配速率 2.4 GB/s 里,有一部分是真正的临时对象。JFR 的分配火焰图显示:
| 分配点 | 占比 | 说明 |
|---|---|---|
float[1536] from rerank | 38% | rerank 阶段给 20 个候选各自构造向量矩阵 |
byte[] Jackson 序列化 | 24% | 批量 embedding 请求体 |
String 拼接 | 19% | prompt 里塞了 20 个召回片段 |
| 其他 | 19% |
rerank 那段代码是这样的:
// 问题代码:为每个候选单独构造一个二维矩阵
public List<Scored> rerank(String query, List<Candidate> candidates) {
List<Scored> out = new ArrayList<>();
for (Candidate c : candidates) {
float[][] batch = new float[1][]; // 每条一个 batch
batch[0] = loadVector(c.docId()); // 6 KB
float score = model.score(embed(query), batch);
out.add(new Scored(c, score));
}
return out;
}
20 个候选就是 20 次 6 KB 的分配,而且是循环里分配,全部进 Young 区。QPS 450、每请求 20 次 = 9000 次/秒 × 6 KB = 54 MB/s 纯向量分配。加上 query 向量每次都重新算(没缓存),更多。
更糟的是 embed(query) 写在循环里,同一个 query 被 embedding 了 20 遍。这是个低级 bug,之前 topK=8 时不明显,topK 提到 20 后被放大了 2.5 倍。
修复
三个问题,三个改法。
向量缓存:外置 + 有界
堆内放几十万个 float[] 是设计错误。我们改成两层:热点向量放堆外 ByteBuffer,冷数据放 Redis,堆内只留 LRU 索引。
@Component
public class VectorCache {
// 堆外:最多 8 万条 × 1536 维 × 4 字节 = 491 MB,一次性分配,不进 GC
private static final int MAX_VECTORS = 80_000;
private static final int DIM = 1536;
private final ByteBuffer store =
ByteBuffer.allocateDirect((long) MAX_VECTORS * DIM * 4)
.order(ByteOrder.LITTLE_ENDIAN);
// 堆内只留 id → slot 的映射,一个有界 LRU
private final Cache<String, Integer> slotIndex = Caffeine.newBuilder()
.maximumSize(80_000)
.expireAfterAccess(Duration.ofHours(6))
.build();
private final AtomicInteger cursor = new AtomicInteger();
public float[] get(String chunkId, Supplier<float[]> loader) {
Integer slot = slotIndex.get(chunkId, k -> {
int s = cursor.getAndUpdate(i -> (i + 1) % MAX_VECTORS);
float[] v = loader.get();
store.position((long) s * DIM * 4);
((FloatBuffer) store.slice().limit(DIM)).put(v);
return s;
});
return read(slot);
}
private float[] read(int slot) {
float[] v = new float[DIM];
store.position((long) slot * DIM * 4);
((FloatBuffer) store.slice().limit(DIM)).get(v);
return v; // 短期对象,Young 区回收
}
}
堆占用从 4.7 GB 的常驻降到接近 0(堆内只有 8 万个 Integer 的 LRU,约 20 MB)。代价是每次读取一次堆外拷贝,实测 2.3 微秒,相对 280ms 的 P99 可以忽略。
顺便加了个启动时的容量校验,防止下次再犯:
@PostConstruct
void check() {
long need = chunkCount * DIM * 4L;
if (need > (long) MAX_VECTORS * DIM * 4) {
throw new IllegalStateException(
"向量容量不足:需要 " + need / 1024 / 1024 + " MB," +
"堆外只分配了 " + (long) MAX_VECTORS * DIM * 4 / 1024 / 1024 + " MB");
}
}
Jackson:限制批量大小,复用缓冲
批量从 512 降到 64,并且显式复用序列化器:
private static final int BATCH = 64; // 从 512 降下来
// 分批,避免大对象
for (int i = 0; i < texts.size(); i += BATCH) {
List<String> sub = texts.subList(i, Math.min(i + BATCH, texts.size()));
doEmbed(sub);
}
单请求体从 720 KB 降到 90 KB,缓冲区峰值跟着降。同时配置了 Jackson 的缓冲区回收:
JsonFactory factory = JsonFactory.builder()
.recyclerPool(JsonRecyclerPools.defaultPool()) // 复用 BufferRecycler
.build();
ObjectMapper mapper = new ObjectMapper(factory);
rerank:query 向量提取 + 批量打分
public List<Scored> rerank(String query, List<Candidate> candidates) {
float[] qv = queryVectorCache.get(query, () -> embeddingClient.embed(query));
// 一次性构造 batch 矩阵,复用
float[][] batch = new float[candidates.size()][];
for (int i = 0; i < candidates.size(); i++) {
batch[i] = vectorCache.get(candidates.get(i).chunkId());
}
float[] scores = model.scoreBatch(qv, batch); // 一次调用
List<Scored> out = new ArrayList<>(candidates.size());
for (int i = 0; i < candidates.size(); i++) {
out.add(new Scored(candidates.get(i), scores[i]));
}
return out;
}
query 向量加了缓存后,重复 embedding 从 20 次/请求降到 0(命中率 91%,客服场景问题重复度很高)。
结果
| 指标 | 故障时 | 修复后(同 QPS 450) |
|---|---|---|
| 分配速率 | 2.4 GB/s | 410 MB/s |
| Old 区峰值 | 5.8 GB | 1.4 GB |
| Full GC | 每 12 分钟 | 0(观察 7 天) |
| Mixed GC 频率 | 基本没触发 | 0.3 次/分钟 |
| Young GC P99 | 68ms | 19ms |
| 请求 P99 | 8.6s | 240ms |
| 常驻堆(向量部分) | 4.7 GB(理论值) | 堆外 491 MB + 堆内 20 MB |
P99 从 8.6 秒降到 240ms,比故障前的 280ms 还好一点,主要是 query 向量缓存和批量打分带来的收益。
事后的监控补漏
故障处理完的第二天,我们复盘为什么是「凌晨两点被电话叫醒」而不是「提前预警」。查了一下监控配置,发现问题不少:
| 该有的告警 | 当时有没有 | 补了什么 |
|---|---|---|
| Old 区增长速率 | 没有 | 5 分钟增长率 > 80 MB 告警 |
| Full GC 频率 | 有,但阈值 30 分钟 3 次 | 改成 10 分钟 1 次 |
| STW 单次耗时 | 没有 | 单次 > 1 秒即告警 |
| 分配速率 | 没有 | 基线 600 MB/s,超 1.5 倍告警 |
| 向量缓存条目数 | 没有 | 新增,上限 8 万的 80% 告警 |
「Old 区增长速率」这一条最能提前发现问题。回顾当时的曲线,从 1.2 GB 涨到 3 GB 用了 4 分钟,如果配了这个告警(5 分钟增长 80 MB),我们会在故障发生前 6 分钟就收到通知——那时候 Old 区才刚开始加速,大概率还能撑到人工介入。
另外我们给所有常驻缓存加了一个容量自检,在启动时和定时巡检时跑:
@Scheduled(cron = "0 0 */4 * * ?")
public void auditCaches() {
cacheRegistry.all().forEach(c -> {
long entries = c.estimatedSize();
long bytes = c.estimatedBytes();
log.info("cache {} entries={} approxBytes={}", c.name(), entries, bytes);
if (bytes > c.warnThresholdBytes()) {
alert.warn("缓存 {} 占用 {} MB,接近上限", c.name(), bytes / 1024 / 1024);
}
});
}
这个巡检上线三周,又抓到另一个服务的一个无界缓存(历史会话摘要缓存,没有 TTL)。它当时已经积了 1.8 GB,只是那个服务堆比较大还没出问题。这类问题在没有巡检之前,只能等它自己爆炸。
先到这
《一次线上 AI 服务的 Full GC 排查》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。