大家周末好,我是老王。
上周我们核心链路的一个账单服务突然收到 P1 告警:几台核心节点 CPU 使用率直奔 95%+,接口响应时间(P99)从原本的 40ms 直接掉到了 3s 以上。随之而来的就是上游支付网关的超时熔断。
这种场面,做过订单和支付的老同学肯定不陌生。今天老王就复盘一下这起典型的「临时大对象引发频繁 GC,进而拖垮 CPU」的排查全过程,顺便聊聊排查套路和治理思路。
一、 故障链路推导:为什么 GC 会让 CPU 飙高?
很多人一看到 CPU 飙高,第一反应是业务代码里写了死循环。但在 Java 应用里,还有一条极经典的因果链:
未做限制的深分页/全量查询 $\to$ 瞬间在堆中生成巨量临时大对象 $\to$ Eden 区瞬间打满,触发极高频率的 Young GC $\to$ Survivor 区容纳不下,对象提前晋升(Premature Promotion)至 Old 区 $\to$ Old 区迅速饱和,触发频繁并发/全局垃圾回收(CMS / G1 Mixed GC / Full GC) $\to$ 多个 GC 线程(Parallel GC Threads)全力抢占 CPU 时间片 $\to$ 业务线程被挂起或抢不到 CPU,整体 RT 飙高,CPU 打满。
这就是典型的“对象分配速率(Allocation Rate)失控反噬算力”。
二、 现场取证:三步锁定嫌疑范围
遇到这种突发告警,千万别盲目重启(留一台摘流机器保留现场),按以下三步抓出元凶:
1. 定位高 CPU 线程类型
先用 top -Hp <pid> 查看具体是哪个线程在疯狂吃 CPU:
# 找到 CPU 最高的线程 ID,比如 18293
printf "%x\n" 18293 # 转为十六进制:0x4775
# 导出线程栈查看
jstack 18292 | grep -A 20 0x4775
当时我们在现场看到,Top 5 的 CPU 线程全是类似这样的名字:
"VM Thread" os_prio=0 tid=0x... nid=0x4775 runnable 或者 "GC Thread#0"。
这就坐实了:CPU 资源是被 GC 线程吃掉的。
2. 观察 GC 行为与频率
用 jstat 观察当前的 GC 状态:
jstat -gcutil 18292 1000 10
监控输出令人心惊肉跳:
- Eden 区每秒从 0% 暴涨到 100%,Young GC 每秒 2~3 次;
- Old 区(O)从 40% 快速攀升到 90% 甚至 100%;
- FGC(Full GC)计数器在不断跳动,一次 STW(Stop The World)要花掉近 1.5 秒。
3. 抓取堆内存快照(Heap Dump)
既然是内存分配过快导致老年代被打穿,光看线程栈没用,必须抓 Dump 看看到底是什么对象在作怪。
由于服务已经做了流量摘除,我们直接线上执行:
jcmd 18292 GC.heap_dump /data/dumps/order_billing_leak.hprof
三、 堆转储分析:MAT 下的现形记
将约 4GB 的 Dump 文件拉回本地,扔进 Eclipse Memory Analyzer (MAT) 进行分析。
-
查看 Leak Suspects 报告: MAT 直接标出了最大嫌疑犯:一个
java.util.ArrayList独占了老年代近 68% 的内存,浅堆(Shallow Heap)只有 24B,但深堆(Retained Heap)高达 2.4GB。 -
追查 Dominator Tree(支配树): 展开该 List,发现里面平铺着成千上万个
BillingDetailDTO账单明细对象。 -
查看 GC Roots 路径: 通过
Path to GC Roots$\to$Exclude phantom/soft/weak references,清晰地看到了调用链路:BillingExportJobController$\to$BillingExportServiceImpl.generateExportData()。
真相大白:业务方开通了一个所谓“一键导出本月对账流水”的功能,前端没有强校验时间范围,某位商户运营勾选了过去半年的数据,并且后端把分页参数漏掉了。
四、 代码还原与重构治理
1. 翻车的典型代码
当时的逻辑大概长这样:
public List<BillingDetailDTO> generateExportData(ExportRequest request) {
// 坑点 1:Mapper 没传分页限制,底层是 SELECT * FROM t_billing WHERE ...
// 直接一次性拉取了 30w+ 条记录到内存中
List<BillingDO> rawList = billingMapper.queryByDateRange(request.getStartTime(), request.getEndTime());
// 坑点 2:在堆内存中做密集的对象转换与拼接,瞬间产生翻倍的临时对象
return rawList.stream().map(billingDO -> {
BillingDetailDTO dto = new BillingDetailDTO();
BeanUtils.copyProperties(billingDO, dto);
dto.setFormattedAmount(CurrencyUtil.format(billingDO.getAmount()));
return dto;
}).collect(Collectors.toList());
}
这 30 万条记录从 MySQL 驱动的底层 Buffer 读入,反序列化成 BillingDO,紧接着通过 Stream map 生成同等数量的 BillingDetailDTO。短时间内,堆内存承受了数十万个对象的暴击,Survivor 区瞬间被打穿,老年代直接被塞死。
2. 治理方案:从「全量载入」到「游标流式处理」
治理临时大对象,核心原则只有一条:化整为零,不给它在内存中抱团的机会。
针对这种大批量读和导出的场景,正确的做法是利用 MyBatis 的游标(Cursor)或分批 Fetch,结合流式写输出(比如流式写入 HTTP Response 或直接写入 OSS 临时文件):
// 治理后:基于 Cursor + 分块刷盘,将内存常驻对象压降到极低水平
public void exportByStream(ExportRequest request, OutputStream outputStream) {
// 强制校验查询区间跨度,严防大跨度扫描
validateQueryRange(request.getStartTime(), request.getEndTime());
try (Cursor<BillingDO> cursor = billingMapper.scanByDateRange(request.getStartTime(), request.getEndTime());
ExcelWriter writer = EasyExcel.write(outputStream, BillingDetailDTO.class).build()) {
List<BillingDetailDTO> batchBuffer = new ArrayList<>(1000);
for (BillingDO billingDO : cursor) {
batchBuffer.add(convertToDTO(billingDO));
// 每凑满 1000 条,刷盘并释放内存引用
if (batchBuffer.size() >= 1000) {
writer.write(batchBuffer, sheet);
batchBuffer.clear(); // 及时切断 GC Root,让对象在 Eden 区即生即灭
}
}
if (!batchBuffer.isEmpty()) {
writer.write(batchBuffer, sheet);
batchBuffer.clear();
}
}
}
改造后,老年代几乎看不到波动,年轻代产生的极少数 DTO 在几次常规 Young GC 中就被回收了,CPU 使用率瞬间回落到了 15% 以下。
五、 老王的复盘与架构思考
每次踩坑都是系统健壮性演进的契机。关于临时大对象的治理,我有几条在订单与支付系统摸爬滚打出来的沉淀建议:
- 别迷信“加内存就完事了”: 很多人看到 GC 频繁就想给 Pod 扩容(从 4G 加到 8G 甚至 16G)。在没有治理大对象的前提下,加内存往往只会推迟 Full GC 的发生时间,但会成倍拉长单次 STW 的耗时,在金融交易系统里,这往往是致命的。
- 永远在入口处做防御性约束:
任何不带分页(
LIMIT)或者允许用户自选时间跨度的批量接口,都是悬在生产环境上的达摩克利斯之剑。必须在 Controller 层或网关层强加限制(例如单次导出上限不能超过 7 天或 5000 条)。 - 警惕第三方组件的隐式大对象:
比如 JSON 序列化工具(Jackson / Fastjson)在反序列化超大报文时产生的超大
char[]数组;或者下载文件时使用ByteArrayOutputStream盲目全量读入。遇到这类场景,优先采用流式(Stream-based)读写。 - 关注分配速率(Allocation Rate)监控: 平时大家盯 CPU 和堆内存百分比,其实单位时间的内存分配速率是一个非常好的先验指标。如果服务在非压测时段 Allocation Rate 异常飙高,哪怕还没触发 Full GC,也说明底层已经有“坏味道”的代码在作祟了。
分布式链路里,最怕的不是明枪(逻辑报错),而是这种暗箭(悄悄吃满资源的暗黑 SQL 和无节制对象创建)。代码写得再优雅,也得敬畏底层的那片堆内存。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


