Administrator
发布于 2026-02-21 / 291 阅读
7

一次线上 AI 服务的 Full GC 排查

凌晨两点的告警: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 次/秒
请求 P99280ms8.6s(大量超时)
分配速率520 MB/s2.4 GB/s

「锯齿消失」这个细节很关键。正常的 Old 区曲线是锯齿形——缓慢上涨,Mixed GC 后回落。现在变成持续上涨到顶再被 Full GC 一次性清掉,说明并发标记跟不上对象晋升速度,Mixed GC 根本来不及触发。

还有一点:分配速率从 520 MB/s 涨到 2.4 GB/s,涨了 4.6 倍。但 QPS 只涨了 1.3 倍(从 340 到 450)。所以不是流量问题,是单次请求的内存开销变大了

什么变了

我第一反应是查发布记录。前一天的变更有:

  1. 知识库扩容,单租户文档数从 800 涨到 3200 篇;
  2. 召回策略改了,topK 从 8 提到 20,加了 rerank;
  3. 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:

对象ShallowRetained实例数
float[1536]3.4 GB3.4 GB571,203
byte[](Jackson 缓冲)1.1 GB1.1 GB18,442
char[]0.6 GB0.7 GB2,103,884
ConcurrentHashMap$Node0.3 GB0.4 GB4,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 rerank38%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/s410 MB/s
Old 区峰值5.8 GB1.4 GB
Full GC每 12 分钟0(观察 7 天)
Mixed GC 频率基本没触发0.3 次/分钟
Young GC P9968ms19ms
请求 P998.6s240ms
常驻堆(向量部分)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 排查》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考