本节目标:把「先测量,再优化」变成一套可复现的操作流程。读完你会用
--cpu-prof采集剖析数据、用两种视角读火焰图、用堆快照定位内存泄漏,并且知道采样开销、JIT 预热、异步栈、source map 这四个因素是如何让剖析结果失真的。
8.3 性能剖析与火焰图
前两节我们建立了运行时的心智模型:V8 靠类型反馈生成机器码(8.1 V8 类型反馈与 JIT ),类型擦除又决定了产物里究竟剩下什么(8.2 类型擦除后的运行时形态 )。但这两节讲的都是「机制」,还没回答工程里最实际的问题:我的代码到底慢在哪?
这个问题的答案不能靠读代码猜。人对热点的直觉准确率极低——真正吃掉 80% CPU 的,往往是某段你根本没注意的序列化代码,或者一个被误用的 Array.prototype.includes。
8.3.1 先测量,再优化
优化的第一步永远是建立可复现的基线。没有基线的优化等于赌博:你改了三处代码,程序快了,但不知道是哪一处起了作用,也不知道有没有把别处拖慢。
基线至少要包含三项:
| 项目 | 说明 |
|---|---|
| 输入规模 | 固定数据量,例如 10 万条记录 |
| 运行环境 | 同一台机器、同一 Node 版本、同一 --max-old-space-size |
| 指标口径 | 端到端耗时,还是单函数耗时,必须写明 |
最容易忽略的是预热。V8 需要一段时间才把热函数编译成机器码,直接测量第一次运行会把「解释执行 + 编译开销」算进去,结果毫无参考价值。
async function benchmark(fn: () => void, rounds = 10): Promise<void> {
for (let i = 0; i < 5; i++) fn(); // 预热,让 V8 完成优化
const times: number[] = [];
for (let i = 0; i < rounds; i++) {
const t0 = performance.now();
fn();
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
const median = times[Math.floor(rounds / 2)];
console.log(`median ${median.toFixed(2)} ms`);
}
注意这里取的是中位数而不是平均值——一次 GC 停顿就能把平均值彻底带偏。
8.3.2 三类剖析器
Node.js 生态里的剖析工具可以按原理分成三类,各有不可替代的场景:
| 类型 | 代表 | 原理 | 适合回答 |
|---|---|---|---|
| 采样式 | --cpu-prof、--prof | 周期性中断取样,记录当前调用栈 | 哪个函数吃 CPU |
| 插桩式 | performance.mark/measure、APM | 在代码里埋点,记录区间耗时 | 哪个业务阶段慢 |
| 分配式 | --heap-prof、堆快照 | 记录对象分配与保留关系 | 谁在占内存 |
采样式的最大优点是低开销:默认每毫秒采样一次,对吞吐的影响通常在个位数百分比。它的统计意义来自大数定律——采样点足够多时,某个函数出现的频率就逼近它的真实 CPU 占比。
插桩式精度更高但只覆盖你埋点的地方,无法发现「意料之外」的热点。真实排查流程一般是:先用采样找到可疑区域,再用插桩精确测量该区域。
8.3.3 用 –cpu-prof 采集 CPU 剖析
最省事的方式是直接给 Node 加参数:
node --cpu-prof --cpu-prof-dir=./prof app.js
运行结束后 ./prof 目录下会出现 CPU.<时间戳>.<pid>.0.cpuprofile。这是一个 JSON 文件,用 Chrome DevTools 的 Performance 面板导入即可查看火焰图。
采样频率可以调整,默认 1000 微秒(1kHz):
node --cpu-prof --cpu-prof-interval=100 app.js # 10kHz,精度更高,开销更大
更贴近生产环境的做法是按需触发:服务长时间运行时不能一直写盘,改用 node:inspector 在收到信号时开关剖析。
import { Session } from "node:inspector/promises";
import { writeFileSync } from "node:fs";
const session = new Session();
session.connect();
async function capture(ms: number, out: string): Promise<void> {
await session.post("Profiler.enable");
await session.post("Profiler.start");
await new Promise((r) => setTimeout(r, ms));
const { profile } = await session.post("Profiler.stop");
writeFileSync(out, JSON.stringify(profile));
}
await capture(3000, "cpu.cpuprofile");
capture(3000, ...) 表示「采样 3 秒」,这在线上排查短时抖动时非常实用。
如果不想依赖 DevTools,V8 自带的 tick processor 可以直接把原始日志转成文本报告:
node --prof app.js
node --prof-process isolate-*.log > profile.txt
[Summary]:
ticks total nonlib name
812 38.1% 42.3% JavaScript
104 4.9% 12.1% C++
61 2.9% 13.4% GC
这份摘要的价值在于先看大盘:如果 GC 占比异常高,说明瓶颈不是算法而是分配;如果 C++ 占比高,说明时间花在原生模块或 V8 内部。
8.3.4 剖析文件里到底有什么
理解 .cpuprofile 的结构,才能在工具不好用时自己写脚本分析。
{
"nodes": [
{
"id": 3,
"callFrame": { "functionName": "add", "url": "file:///app/dist/index.js", "lineNumber": 3 },
"hitCount": 812
}
],
"samples": [3, 3, 1, 3],
"timeDeltas": [1000, 1000, 1000, 1000]
}
| 字段 | 含义 |
|---|---|
nodes[].callFrame | 函数名、文件、行号 |
nodes[].hitCount | 该函数自身被采样到的次数(自耗时) |
samples | 按时间顺序排列的节点 id 序列 |
timeDeltas | 相邻样本的微秒间隔 |
hitCount 是核心指标:它统计的是自身耗时(self time),不包含它调用的子函数。这正是火焰图里「叶子节点宽度」的数据来源。
想直接算总耗时占比,把 hitCount 求和后做比即可:
import { readFileSync } from "node:fs";
const { nodes } = JSON.parse(readFileSync("cpu.cpuprofile", "utf8"));
const total = nodes.reduce((s: number, n: any) => s + (n.hitCount ?? 0), 0);
const top = nodes
.filter((n: any) => (n.hitCount ?? 0) > 0)
.sort((a: any, b: any) => b.hitCount - a.hitCount)
.slice(0, 10);
for (const n of top) {
const pct = ((n.hitCount / total) * 100).toFixed(1);
console.log(`${pct}% ${n.callFrame.functionName || "(anonymous)"}`);
}
38.1% add
12.4% (anonymous)
9.7% JSON.stringify
这一步的意义在于绕开图形界面:把分析脚本挂进 CI,每次压测后自动输出 Top 10 热点并对比上一次结果,性能回归就不再依赖人工盯图。
8.3.5 读火焰图:两种朝向
火焰图把调用栈画成矩形:横轴是样本占比(宽度即耗时),纵轴是调用深度。注意横轴不是时间轴,相邻矩形之间没有先后关系,这一点和时序图完全不同。
同一个剖析数据有两种读法:
| 视角 | 排列方式 | 回答的问题 |
|---|---|---|
| 自顶向下(Top-down) | 根在顶部,被调用者在下 | 「谁调用了这个慢函数」 |
| 自底向上(Bottom-up) | 根在底部,调用者在下方 | 「这个函数为什么被调用」 |
排查热点时通常先看自底向上:找到最宽的叶子节点,那就是自耗时最高的函数;再切回自顶向下,看它的调用者链,判断能否在更上层批量消除这次调用。
火焰图里有几个必须认识的特殊帧:
| 帧名 | 含义 | 处理方向 |
|---|---|---|
(idle) | 事件循环空闲 | 不是瓶颈,反而说明有优化空间 |
(program) | V8 内部与原生代码 | 检查原生模块、正则、字符串操作 |
(garbage collector) | GC 停顿 | 减少对象分配 |
(anonymous) | 匿名函数 | 结合 source map 定位真实位置 |
(idle) 占比高是一个容易误判的信号。它意味着 CPU 没跑满,瓶颈可能在 IO 或外部服务——这时候该去看分布式链路追踪,而不是继续优化 JavaScript。
8.3.6 定位瓶颈的四个信号
把读图经验收敛成四条可操作的判据:
| 信号 | 图形特征 | 结论 |
|---|---|---|
| 宽而平的叶子 | 单个矩形很宽,下面没有子节点 | 纯计算热点,优先优化算法 |
| 宽而深的链 | 同一调用链每层都宽 | 调用链过长,考虑合并或缓存 |
| GC 帧占比高 | (garbage collector) 明显 | 分配压力大,复用对象或改流式处理 |
| 深而窄的递归 | 一条细长的锯齿链 | 算法复杂度问题,先降阶再谈微优化 |
以「GC 占比高」为例,最常见的根因是在循环里创建大量短命对象:
// 反例:每轮都新建数组与对象
function sumScores(rows: Row[]): number {
let total = 0;
for (const row of rows) {
const scores = row.values.map((v) => ({ v })); // 每轮都分配
total += scores.reduce((s, x) => s + x.v, 0);
}
return total;
}
// 正例:不产生中间对象
function sumScores(rows: Row[]): number {
let total = 0;
for (const row of rows) {
for (const v of row.values) total += v;
}
return total;
}
两段代码语义完全一致,但后者把分配次数从 O(n) 降到 0。这种改动在火焰图上表现为 GC 帧变窄、目标函数变宽——总耗时下降但热点函数占比上升,这是优化生效的典型征兆。
关于编译期的同类成本(泛型实例化导致的 tsc 变慢),可以延伸阅读 3.1 类型实例化开销与测量
——运行期与编译期的剖析思路是一致的:先量化,再动手。
8.3.7 内存剖析:堆快照与分配剖析
CPU 之外,内存是第二类常见问题。表现通常是「服务跑几小时后内存持续上涨」,最终 OOM。
先看分配剖析,它回答「谁在分配」:
node --heap-prof --heap-prof-dir=./prof app.js
生成的 .heapprofile 同样可以在 DevTools 的 Memory 面板里看,它按分配点聚合,直接指出哪一行代码产生了最多字节。
要回答「谁在保留内存」(也就是真正的泄漏),需要堆快照。核心手法是「三快照法」:
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | 启动后拍快照 A | 基线 |
| 2 | 反复执行可疑操作 N 次 | 放大泄漏 |
| 3 | 再拍快照 B,对比 A → B | 找「只增不减」的对象 |
对比时看 Retained Size(保留大小)而不是 Shallow Size。保留大小表示「回收这个对象能连带释放多少内存」,它才反映真实占用。
一个典型的泄漏场景是闭包意外持有:
const handlers = new Map<string, () => void>();
function register(id: string): void {
const payload = Buffer.alloc(1024 * 1024); // 1 MB
handlers.set(id, () => payload.length); // 闭包永久持有 payload
}
handlers 只增不减,每个 payload 都被闭包引用,堆快照里会看到 1 MB 的 Buffer 按 id 数量线性增长。修复方式通常是显式 handlers.delete(id) 或用 WeakMap。想系统了解这类排查手法,可以延伸阅读 Node.js 内存泄漏剖析
。
8.3.8 异步栈:为什么栈顶总是 (anonymous)
Node.js 是异步运行时,CPU 剖析默认只看同步调用栈。一个 await 之后的回调在剖析器眼里是一个全新的栈根,于是你看到大量 (anonymous),完全不知道它由谁触发。
async function handle(req: Request): Promise<void> {
const user = await db.find(req.id); // 这里之后,栈就断了
await audit(user); // 剖析里显示为独立的 (anonymous)
}
三种缓解方式,按成本从低到高:
- 开启异步栈追踪:Node 默认已启用
--async-stack-traces,它让Error.stack能串起异步调用链,但不影响 CPU 剖析。 - 给匿名函数命名:
const fn = function handleUser() {}或对象方法简写,至少让火焰图上出现可辨认的名字。 - 用 AsyncLocalStorage 打点:把请求 id 贯穿异步链路,配合插桩式剖析还原「哪个请求走了哪条路径」。
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage<{ traceId: string }>();
function withTrace<T>(traceId: string, fn: () => T): T {
return als.run({ traceId }, fn);
}
// 在任意深层异步函数里都能取到,无需层层传参
const ctx = als.getStore();
这套机制是分布式追踪在 Node 侧的基础。当 (idle) 占比很高、CPU 剖析看不出问题时,真正的答案往往在链路追踪里,可以延伸阅读 持续剖析
与 后端性能优化与剖析
。
8.3.9 四个让剖析失真的陷阱
| 陷阱 | 表现 | 对策 |
|---|---|---|
| 采样开销 | 剖析时程序明显变慢 | 降低采样率;只在短窗口内采集 |
| JIT 预热 | 短跑数据全是解释执行 | 先预热,或跑够长时间 |
| 转译产物栈帧 | 行号指向 dist/ 甚至不存在的行 | 开 --enable-source-maps |
| 只看 CPU | 火焰图很干净但接口就是慢 | 补链路追踪,检查 IO 与下游 |
其中转译产物栈帧对 TypeScript 项目影响最大。用 tsx、ts-node 或 bundler 跑起来的代码,V8 看到的文件名与行号都是转译后的结果:
node --enable-source-maps dist/index.js
开启 source map 后,剖析输出里的 url 与 lineNumber 会映射回 .ts 源文件,火焰图上的函数名也能对应到真实的源码位置。没有这一步,你在火焰图上看到的行号可能落在编译生成的辅助函数里,排查效率会下降一个数量级。
最后一条经验:剖析工具只回答「哪里慢」,不回答「为什么慢」。拿到热点函数后,仍要回到代码与数据结构去推理。真正高效的循环是:剖析 → 提出假设 → 改一处 → 用同一套基线复测。想了解 Node.js 运行时层面的完整性能图景,可以延伸阅读 Node.js 性能调优指南 与 TypeScript 项目的 Node 性能剖析与火焰图 。
8.3.10 本节要点
- 优化前必须有可复现基线:固定输入、固定环境、明确指标、先预热。
- 采样剖析定位热点,插桩剖析量化业务阶段,两者配合使用。
.cpuprofile的hitCount是自耗时,火焰图宽度就是它的可视化。- 自底向上找最宽的叶子,自顶向下看调用者链。
(idle)高说明瓶颈在 IO;GC高说明分配压力大。- 内存问题看 Retained Size,用三快照法找「只增不减」的对象。
- 异步栈断裂与 source map 缺失是 TypeScript 项目里最常见的两个失真源。
小结
本节把「先测量,再优化」拆成了一条可复现的流水线:采集剖析数据、读火焰图找热点、用堆快照定位内存、再用同一套基线验证改动。核心心法只有一句——让数据决定改哪里,让复测决定改得对不对。
到这里,「运行时」这一章就结束了:我们从 V8 的类型反馈走到类型擦除后的产物形态,再走到用剖析器观测真实负载。下一章换一个战场,回到编译期最容易被忽视的一环:模块解析。moduleResolution 的几种模式为什么让人困惑、条件导出在打包器与 Node 之间有何差异,都会在 9.1 moduleResolution 各模式对照
里讲清楚。
阅读导航:上一节:8.2 类型擦除后的运行时形态 · 下一节:9.1 moduleResolution 各模式对照 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。