- 发布日期
第 36 讲:热路径优化:V8 / RSC / Streaming 视角
从 V8 JIT、RSC payload 压缩、Streaming 分块三个视角优化热路径
第 35 讲讲了"应用层"性能:bundle / TTFB / LCP / INP。本讲反过来从框架内部视角看:服务端的每个请求都在做什么、哪些操作进了 hot path、V8 是怎么把 JS 编译成机器码、Next.js 自己怎么写代码避免 deopt。读完本讲,你不仅能优化自己的业务,还能给 Next.js 主仓库提性能 PR。
学习目标
- 知道 V8 的 4 层 JIT 编译:Ignition → Sparkplug → Maglev → Turbofan。
- 理解 hidden class / inline cache(IC) / megamorphic 三个概念,能识别真实业务里的反模式。
- 看懂 RSC stream 的内部反序列化路径,知道哪些操作便宜哪些贵。
- 用
--trace-opt/--trace-deopt/--prof排查 Next.js 自身 hot path。 - 列出 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
AST → Bytecode
↓ 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 数 | 性能 |
|---|---|---|
| Monomorphic | 1 | 最快,IC 直接 inline |
| Polymorphic | 2-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
修复方法:
- 不同类型不同函数:
function getUserId(u) {
return u.id;
}
function getPostId(p) {
return p.id;
}
- 用 class instanceof:
class Entity {
id: number;
constructor(id: number) {
this.id = id;
}
}
class User extends Entity {}
class Post extends Entity {}
// 所有都继承 Entity → 同基类 shape
- 抽提公共字段到固定位置:所有 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 object | packages/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.enqueue | packages/next/src/server/app-render/use-flight-response.ts |
| 6. Memo cache 用专门 LRU class | packages/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 |
| 偶发 spike | event loop block;clinic bubbleprof |
| OOM | back-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 复用 fnapp/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
十五、本讲小结
- V8 是推测式优化:你的代码越"可预测",越能跑在 Turbofan tier。
- Hidden class 一致性是头号原则:constructor 初始化全部字段,不动态加属性。
- Monomorphic call site 才能 IC inline:避免一个函数被 5+ 种 shape 调用。
- 数组 packed 而非 holey:用 push 而非赋值。
- Hot loop 不 try/catch、不创闭包、不 join 数组拼字符串。
- 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。