发布日期

第 36 讲:热路径优化:V8 / RSC / Streaming 视角

从 V8 JIT、RSC payload 压缩、Streaming 分块三个视角优化热路径

第 35 讲讲了"应用层"性能:bundle / TTFB / LCP / INP。本讲反过来从框架内部视角看:服务端的每个请求都在做什么、哪些操作进了 hot path、V8 是怎么把 JS 编译成机器码、Next.js 自己怎么写代码避免 deopt。读完本讲,你不仅能优化自己的业务,还能给 Next.js 主仓库提性能 PR。

学习目标

  1. 知道 V8 的 4 层 JIT 编译:Ignition → Sparkplug → Maglev → Turbofan。
  2. 理解 hidden class / inline cache(IC) / megamorphic 三个概念,能识别真实业务里的反模式。
  3. 看懂 RSC stream 的内部反序列化路径,知道哪些操作便宜哪些贵。
  4. --trace-opt / --trace-deopt / --prof 排查 Next.js 自身 hot path。
  5. 列出 8 个 Next.js 内部的 V8 友好实践,并能在 review business code 时识别同类问题。

本讲深度参考 .claude/skills/v8-jit/SKILL.md,可结合阅读。

一、为什么写 server 也要懂 V8

浏览器端代码每个 tab 跑一会儿就关;server 端代码24×7 跑同一份,每个请求都重新跑 base-server / app-render,hot path 上的一次 deopt 累计起来 = 每秒数千次 deopt。

举几个真实数据(next.js 主仓库 PR 中的数字):

  • RequestContext 构造函数里的条件初始化改成定值初始化 → 同接口 P99 -8%
  • headers 从 plain object 改成 Map → 高频 fetch 路径 -3%
  • 把 stream-utils 里一个 megamorphic 函数拆成两个分支 → renderToString -5%

如果你做的是 SaaS、电商、新闻这类高 QPS 站,framework 层的 1% = 业务层的 10%

二、V8 的 4 层 JIT

JavaScript 源代码
Parser
ASTBytecode
Ignition(interpreter)  ← 函数首次执行
    ↓ 调用次数累计
Sparkplug(无优化的快编译器)
    ↓ 仍然热
Maglev(中级优化器)
    ↓ 极热
Turbofan(最大优化)

关键点:

  • 越靠后的 tier 越快,但编译有开销,所以"冷函数没必要编译"
  • Turbofan 是推测式优化:它假设"这个函数总是被同样形状的对象调用",万一假设破坏 → deopt → 回退到 Ignition
  • 你的代码风格直接影响 V8 能否把假设固化下来

三、Hidden Class(Shape / Map)

V8 给每个对象贴一个隐藏的"shape"。同样属性、同样添加顺序的对象 share 同一个 shape。

// ✅ 两个对象同 shape
const a = { x: 1, y: 2 };
const b = { x: 3, y: 4 };

// ❌ 不同 shape:添加顺序不同
const c = {};
c.x = 1;
c.y = 2;
const d = {};
d.y = 4;
d.x = 3;

// ❌ 不同 shape:一个多一个少
const e = { x: 1, y: 2, z: 3 };

为什么重要?V8 在访问 obj.x 时,根据 shape 编译出"直接读偏移 offset 8" 的机器码(IC = Inline Cache)。如果同一段代码遇到 5 种不同 shape,IC 变成 megamorphic → 退化成 hashmap 查询,慢 5-10×。

Next.js 内部例子

packages/next/src/server/base-server.ts 里的 BaseNextRequest

  • 所有字段在 constructor 里初始化(不管是否实际有值)
  • 不会运行时 delete request.cached
  • 不会条件性挂 request.someExtra = ...

这样保证每个请求对象的 shape 一致,hot path 上 req.url / req.method 访问都是 IC monomorphic。

业务代码反例

// ❌ 反模式
function buildContext(req, opts) {
  const ctx = { url: req.url };
  if (opts.tracing) ctx.traceId = makeTraceId(); // shape fork
  if (opts.user) ctx.user = req.user; // shape fork
  return ctx;
}

buildContext 调用 1000 次可能产生 4 种 shape({url}/{url,traceId}/{url,user}/{url,traceId,user})→ 调用方对 ctx.url 的访问 IC 退化。

修复:

// ✅ 总是初始化所有字段
function buildContext(req, opts) {
  return {
    url: req.url,
    traceId: opts.tracing ? makeTraceId() : null,
    user: opts.user ? req.user : null,
  };
}

或更激进,用 class:

class RequestContext {
  url: string;
  traceId: string | null;
  user: User | null;
  constructor(req: Req, opts: Opts) {
    this.url = req.url;
    this.traceId = opts.tracing ? makeTraceId() : null;
    this.user = opts.user ? req.user : null;
  }
}

四、Monomorphic / Polymorphic / Megamorphic

一个函数调用点(call site)看到的对象 shape 数量决定它属于哪类:

类型shape 数性能
Monomorphic1最快,IC 直接 inline
Polymorphic2-4还能 IC,稍慢
Megamorphic≥ 5退化到 hashmap,慢 5-10×

反例:通用 dispatcher

// ❌ 这个函数被调用时 obj 可能是 N 种 shape
function getId(obj) {
  return obj.id;
}

getId(user);
getId(post);
getId(comment);
getId(product);
getId(order); // 5+ → megamorphic

修复方法:

  1. 不同类型不同函数
function getUserId(u) {
  return u.id;
}
function getPostId(p) {
  return p.id;
}
  1. 用 class instanceof
class Entity {
  id: number;
  constructor(id: number) {
    this.id = id;
  }
}
class User extends Entity {}
class Post extends Entity {}
// 所有都继承 Entity → 同基类 shape
  1. 抽提公共字段到固定位置:所有 entity 都长成 { id, type, data }

五、Closure & Allocation

每次函数调用都创建 closure + allocation 在 server hot path 是大忌。

// ❌ 每次请求都创建一个新 fn
function handleRequest(req) {
  const log = (msg) => console.log(`[${req.url}]`, msg); // 新闭包
  doWork(log);
  doMore(log);
}

频繁创建闭包 → 频繁 GC → P99 抖动。改成:

function doWork(req, msg) {
  console.log(`[${req.url}]`, msg);
}

或用全局 logger + AsyncLocalStorage 传 context(第 34 讲讲的 instrumentation 就这么做)。

类似的反例

// ❌ 每次请求 new Array → 触发 hidden class transition
async function render(req) {
  const headers = [];
  for (const [k, v] of Object.entries(req.headers)) {
    headers.push([k, v]);
  }
}

如果 headers 大部分情况固定结构,预分配:

const headers = new Array(estimatedSize);
let i = 0;
for (const [k, v] of Object.entries(req.headers)) {
  headers[i++] = [k, v];
}

六、Holey vs Packed Array

V8 区分:

  • Packed[1, 2, 3] 无空洞
  • Holey[1, , 3]arr[100] = 1(中间没填)

Packed 比 Holey 快 2-3×。所以:

// ❌
const arr = [];
arr[0] = "a";
arr[1] = "b";
arr[5] = "c"; // arr[2..4] 是 holes → holey

// ✅
const arr = ["a", "b"];
arr.push("c");

更隐蔽的反例:

const items = new Array(1000); // ❌ holey from birth
for (let i = 0; i < 1000; i++) items[i] = i;

// ✅
const items: number[] = [];
for (let i = 0; i < 1000; i++) items.push(i);

七、Number 优化

V8 用 SMI(Small Integer)表示 -2^30..2^30-1 的整数,不分配,直接 inline 在指针里。

混 SMI 和 Double 会触发 box/unbox:

// ❌ 数组中混入 NaN/Infinity → 退化到 PACKED_DOUBLE
const ratings = [4, 5, 3, NaN, 5];

// ✅ 用 -1 或 null 表示 missing
const ratings = [4, 5, 3, -1, 5];

Math.floor(a / b) 在两侧都是 SMI 时极快;混入 string / object 立刻 deopt。

八、Try/Catch 的 deopt 历史包袱

V8 老版本 try/catch 内函数无法优化(已修复,但 try 块里的 throw/catch 仍会触发某些 deopt)。建议:

  • 主 hot loop 内不要 try/catch
  • 把 try/catch 提取成单独函数包在外层

Next.js 内部 hot path 都这样写:

// 来自 packages/next/src/server/base-server.ts 的模式
try {
  return await this.handleRequestImpl(req, res, parsedUrl)
} catch (err) {
  return this.renderError(err, req, res, ...)
}

handleRequestImpl 是真正 hot 的;外层 try 不阻碍内层优化。

九、RSC Stream 的反序列化热路径

第 16 讲讲过 RSC payload 是 M: J: S: 等行式协议。每个请求服务端 render 时调 renderToReadableStream这是 hot path

里面的优化点:

// packages/next/src/server/app-render/ 内部模式
function writeRow(stream, type, id, data) {
  stream.write(`${type}${id}:${JSON.stringify(data)}\n`);
}

为什么不写成 stream.write([type, id, JSON.stringify(data)].join(''))?因为:

  • [a, b, c].join('') 临时创建 array + 调函数 + GC
  • 模板字符串 + concat 是 V8 内联 fast path
  • string concat 在 V8 里是 cons string,常量时间

业务代码同理:

// ❌
const log = ["[", timestamp, "]", " ", msg, "\n"].join("");

// ✅
const log = `[${timestamp}] ${msg}\n`;

十、Streaming back-pressure

写 ReadableStream 时如果生产快于消费 → 缓冲内存增长 → OOM。

const stream = new ReadableStream({
  start(controller) {
    for (let i = 0; i < 1_000_000; i++) {
      controller.enqueue(makeChunk(i)); // ❌ 全塞进去
    }
    controller.close();
  },
});

正确做法:用 pull-based 模式,消费方调一次 pull 才生产一次。

let i = 0;
const stream = new ReadableStream({
  pull(controller) {
    if (i >= 1_000_000) return controller.close();
    controller.enqueue(makeChunk(i++));
  },
});

Next.js 自己的 RSC stream / Fizz stream 都是 pull-based。

十一、profiling 实战

11.1 --prof

node --prof packages/next/dist/bin/next start
# 跑一会儿
# 关掉
ls isolate-*-v8.log
node --prof-process isolate-*-v8.log > profile.txt

profile.txt 里看 [JavaScript] section,按 ticks 排序的 hot 函数。

11.2 --trace-opt --trace-deopt

node --trace-opt --trace-deopt packages/next/dist/bin/next start 2>&1 | grep deopt

输出形如:

[deoptimizing (DEOPT eager): begin 0x... <JSFunction render>
  reason: wrong map
  bailout type: Soft

reason: wrong map = hidden class 不一致 → 找对应函数看 shape fork。

11.3 Chrome DevTools for Node

node --inspect packages/next/dist/bin/next start

Chrome → chrome://inspect → 连上 → Performance 录一段。可以看 V8 优化/反优化标记。

11.4 clinic.js

npx clinic doctor -- node packages/next/dist/bin/next start
# 跑负载
# ctrl+c
# 出 HTML 报告

直观图:CPU / event loop / GC / I/O。

十二、Next.js 源码里的 8 个 V8 友好实践

实践源码位置
1. RequestContext / BaseNextRequest 在 constructor 初始化所有字段packages/next/src/server/base-http/
2. headers 用专门的 HeadersAdapter class 而非 plain objectpackages/next/src/server/web/spec-extension/adapters/headers.ts
3. AsyncLocalStorage 用专门的 store 类(固定 shape)packages/next/src/server/app-render/work-async-storage.external.ts
4. tagsManifest 用 Map 而非 plain object(动态 key 多)packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts
5. RSC writer 用 template string + 单次 controller.enqueuepackages/next/src/server/app-render/use-flight-response.ts
6. Memo cache 用专门 LRU classpackages/next/src/server/lib/lru-cache.ts
7. 类型分支提到独立函数(避免 megamorphic dispatch)各 route-modules
8. for-of 而非 forEach / map 在 hot loop大量出现于 base-server / app-render

十三、生产排障

现象排查
进程 RSS 持续增长clinic doctor → 看是否 closure 累积;用 heap snapshot 对比
P99 抖动大但 P50 稳定多半 GC 或 deopt;--trace-deopt 看
CPU 100% 但 QPS 不增找 hot loop;--prof 看 top function;可能 megamorphic
偶发 spikeevent loop block;clinic bubbleprof
OOMback-pressure 缺失 / cache 没上限 / closure 泄露

十四、配套 fixture

fixtures/lecture-36/ 提供一个 mini benchmark:

  • bench/shape.mjs:对比 monomorphic vs megamorphic 调用 1M 次的耗时
  • bench/array.mjs:packed vs holey array 遍历对比
  • bench/closure.mjs:每次创建 closure vs 复用 fn
  • app/api/work/route.ts:在 Next.js route handler 里跑一段 hot loop,用 node --trace-deopt pnpm start 观察

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-36
pnpm install

# 1. 跑独立 bench
node bench/shape.mjs
node bench/array.mjs

# 2. 在 Next.js 里跑(可选)
pnpm build && pnpm start
curl http://localhost:3036/api/work

十五、本讲小结

  1. V8 是推测式优化:你的代码越"可预测",越能跑在 Turbofan tier。
  2. Hidden class 一致性是头号原则:constructor 初始化全部字段,不动态加属性。
  3. Monomorphic call site 才能 IC inline:避免一个函数被 5+ 种 shape 调用。
  4. 数组 packed 而非 holey:用 push 而非赋值。
  5. Hot loop 不 try/catch、不创闭包、不 join 数组拼字符串
  6. Next.js 自己严格遵守这些规则,你的业务代码(尤其是 server 端高频路径)也应该。

下讲预告

第 37 讲《调试技巧大全:dev / Node inspector / source map / production debug》。会讲怎么进 dev server 单步、prod 容器里抓 trace、source map 配置怎么影响 stack、Server Component 的 stack 怎么看、用 __NEXT_SHOW_IGNORE_LISTED 看完整 framework frame。