《TypeScript高级编程》8.3 性能剖析与火焰图

凭直觉优化性能几乎总是错的。本节把「先测量、再优化」落成可执行的流程:用 --cpu-prof 与 --prof 采集 CPU 剖析、用自顶向下与自底向上两种视角读火焰图、用堆快照定位内存泄漏,并解释采样开销、JIT 预热、异步栈与 source map 四个让剖析失真的陷阱。读完你能独立定位热点函数并验证优化是否真的有效。

本节目标:把「先测量,再优化」变成一套可复现的操作流程。读完你会用 --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 各模式对照 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes