服务端运行时编程
# 12.Node.js 运行时与流式编程
📍 上接第 11 篇《Web API 网络与存储架构》。浏览器端的数据流已打通。本文进入 JS 的另一主场——Node.js 服务端。libuv 的六阶事件循环和浏览器有什么不同?Stream 背压怎么破?Buffer 和 Uint8Array 到底什么关系?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. libuv 事件
- 4. nextTick
- 5. Stream流
- 6. 背压机制详解
- 7. Buffer本质
- 8. Cluster
- 9. 同构代码设计与双
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一个静默 OOM
先看一段在生产环境跑了大半年的代码——一个日志转发服务,负责把上游服务的日志实时写入文件:
// log_forwarder.js —— 日志转发器
const fs = require('fs');
const net = require('net');
const writer = fs.createWriteStream('./forwarded.log', { flags: 'a' });
const server = net.createServer(socket => {
socket.on('data', chunk => {
writer.write(chunk); // ← 直接写,不检查返回值
});
});
server.listen(9999, () => console.log('Forwarder on :9999'));
2
3
4
5
6
7
8
9
10
11
现象:
- 日常流量(几十 MB/h):稳定运行 6 个月
- 双十一当天(上游瞬时 20 GB/h):进程 RSS 从 80 MB 飙到 1.8 GB,被 OOM Killer 杀掉
- 日志文件里没有任何错误——不是代码显式抛了异常,是系统直接 SIGKILL
直觉怀疑:是不是 chunk 太大、内存里积了太多引用?加了 --max-old-space-size=512——一样崩,只是晚崩了 30 秒。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是
socket.on('data')触发了 GC 压力?——用--trace-gc看,minor GC 确实密集,但每次回收量正常,不是根因。 - 假设 2:是不是
fs.WriteStream自己缓冲了太多数据?——在writer.write()后打印writer.writableLength,发现它从 0 一路涨到 1.2 GB,从来没降过。 - 假设 3:为什么缓冲区只涨不降?——因为
write()返回false时我们还在继续写。WriteStream内部的缓冲区超过了highWaterMark,但代码没停手——数据像洪水一样灌进去。 - 假设 4:那为什么日常流量没问题?——因为日常流量下
write()几乎总是返回true,缓冲区来及写磁盘——"刚好够用"掩盖了"设计缺陷"。
这不是 fs 模块的 bug——这是**没有处理背压(backpressure)**的坑。
# 1.3 我们要回答什么
这一段事故里至少藏着 6 个原理点:
① libuv 事件循环:fs.write 的回调什么时候被执行? → 第 3 章
② nextTick 优先级:为什么"递归 nextTick"能饿死整个循环? → 第 4 章
③ Stream 背压:write()→false→drain 这条链怎么来的? → 第 5~6 章
④ Buffer 本质:WriteStream 内部缓冲区到底是什么数据结构? → 第 7 章
⑤ 多进程 / 多线程:Cluster 怎么分摊这个服务的负载? → 第 8 章
⑥ 同构代码:如果你把这条逻辑写进 SSR 组件,两边有什么差异? → 第 9 章
2
3
4
5
6
本篇路线:
架构总图(第 2 章)
↓
libuv 事件循环(第 3 章)──→ 解开"回调什么时候执行"
↓
nextTick vs Promise(第 4 章)──→ 解开"优先级怎么排的"
↓
Stream 四类型 + 背压(第 5~6 章)──→ 解开"write() 为什么返回 false"
↓
Buffer 本质(第 7 章)──→ 解开"Uint8Array 子类 + 内存池"
↓
Cluster / Worker Threads(第 8 章)──→ 解开"怎么利用多核"
↓
同构代码(第 9 章)──→ 解开"同一份代码怎么跑两边"
↓
综合案例(第 10 章)──→ 案例彻底剖开
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:上接浏览器 API 篇、下接模块系统篇。读完本篇后,你能画出"从 JS 代码到 OS 系统调用"的完整数据流路线图——每一个
setTimeout、每一次write()、每一块Buffer,都知道它在哪、怎么活、怎么死。
# 2. 架构全景概览
# 2.1 Node.js
┌──────────────────────────────────────────────────────────┐
│ Node.js 全景架构 │
├──────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────┐ │
│ │ JS 层(用户代码) │ │
│ │ fs.readFile / http.createServer / crypto.xxx │ │
│ └──────────────────────┬─────────────────────────────┘ │
│ │ Node.js C++ Binding (node_file.cc│
│ │ / node_http_parser.cc / ...) │
│ ┌──────────────────────▼─────────────────────────────┐ │
│ │ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ V8 (JS 引擎) │ │ libuv (事件循环)│ │ │
│ │ │ + Ignition │ │ + 线程池 │ │ │
│ │ │ + TurboFan │ │ + epoll/kqueue │ │ │
│ │ │ + Orinoco GC │ │ + TCP/UDP/FS │ │ │
│ │ └─────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ OpenSSL (TLS) │ zlib │ http-parser │ │
│ │ c-ares (DNS) │ nghttp2 │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼─────────────────────────────┐ │
│ │ 操作系统(Linux/macOS/Windows) │ │
│ │ epoll / kqueue / IOCP │ 文件系统 │ 网络栈 │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
每一层职责:
| 层级 | 组件 | 职责 |
|---|---|---|
| JS 层 | 用户代码 / npm 包 | 业务逻辑 |
| Binding 层 | node_file.cc 等 C++ 胶水 | 把 JS 调用翻译成 C++ API |
| 引擎层 | V8 | 编译 + 执行 JS(Ignition 解释器 → TurboFan JIT) |
| 事件循环层 | libuv | 跨平台异步 I/O 抽象(epoll/kqueue/IOCP 统一封装) |
| 系统库层 | OpenSSL / zlib 等 | 专项功能(TLS、压缩、HTTP 解析) |
| OS 层 | 内核 | 最终的系统调用 |
# 2.2 单线程 JS
疑惑:Node.js 说自己是"单线程",那一个耗时 5 秒的 fs.readFile 怎么没把整个服务卡住?
论证:
JS 代码确实在一个线程上执行(V8 的主线程)。但 fs.readFile 这类操作的实际 I/O 工作不是在主线程上完成的——libuv 把任务投递到了独立的 C++ 线程池(默认 4 个线程,可通过 UV_THREADPOOL_SIZE 调整)。
JS 主线程(V8) libuv 线程池(C++)
┌────────────────┐ ┌─────────────────────┐
│ fs.readFile() │ ──投递──→ │ 线程 1: 读文件 │
│ ↓(不阻塞) │ │ 线程 2: 空闲 │
│ 继续执行下一行 │ │ 线程 3: 压缩 │
│ ... │ │ 线程 4: DNS 解析 │
│ ... │ └──────────┬──────────┘
│ │ ←──回调── │
│ onRead(err, │ 文件读完后回到这里 │
│ data) { ... }│ │
└────────────────┘
2
3
4
5
6
7
8
9
10
11
关键事实:
- 线程池只处理文件 I/O、DNS 解析、加密/压缩等 CPU 密集的同步操作
- 网络 I/O(TCP/UDP)不走线程池——它们用 OS 原生的非阻塞 API(epoll/kqueue/IOCP),在主线程上直接轮询
- 这就解释了为什么"Node.js 能支撑上万并发连接"——网络 I/O 没有线程切换开销,一个线程轮询所有 socket
结论:单线程指的是"JS 执行层面"单线程——异步 I/O 本身是多线程的。这种模型把"编写代码的简单性"(不用想锁、竞态)和"执行效率"(真正的异步 I/O)结合到一起。
# 2.3 为什么不是"浏览
疑惑:很多开发者以为 Node 的事件循环是"去掉了渲染步骤的浏览器事件循环"。真的吗?
论证:
浏览器事件循环(HTML Spec §8.1.4):
┌─────────────────────────────────────────┐
│ 1. 取一个 macroTask(setTimeout / 事件)│
│ 2. 执行它 │
│ 3. 清空所有 microTask(Promise.then) │
│ 4. 渲染(如需要) │
│ 5. 回到 1 │
└─────────────────────────────────────────┘
2
3
4
5
6
7
Node.js event loop(libuv 的 uv_run):
┌──────────────────────────────────────────────────────┐
│ 1. timers → 执行到期的 setTimeout/setInterval│
│ 2. pending → 执行上轮延迟的 I/O 回调 │
│ 3. idle/prepare → libuv 内部(用户代码不接触) │
│ 4. poll → 等待并执行 I/O 回调(核心阶段) │
│ 5. check → 执行 setImmediate 回调 │
│ 6. close → 执行 close 事件的回调 │
│ 7. 回到 1(如果还有活跃句柄) │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
本质差异:
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 规范约束 | HTML 规范(§8.6 定时器要求嵌套≥5层夹持到 4ms) | libuv 源码,不受 HTML 约束 |
| 阶段划分 | 只有一个 macroTask → microTask → render 循环 | 六个独立阶段,每阶段处理不同异步源 |
| nextTick | 不存在 | 在阶段之间执行,优先级比 Promise 高 |
| setImmediate | 不存在 | 固定在第 5 阶段(check)执行 |
| 渲染管线 | 有(requestAnimationFrame) | 无 |
| 关闭回调 | 无单独阶段 | 有专门的 close 阶段 |
结论:两者不是"删减版 vs 完整版"的关系——它们的设计目标不同。浏览器的循环围绕渲染帧设计(有了 rAF、Layout、Paint),Node 的循环围绕I/O 完成设计(有了 poll、check)。setTimeout(fn, 0) 在两边的延迟差异不是 bug,是两个调度器在各自的设计约束下做出的行为。
# 3. libuv 事件
# 3.1 timers
┌──────────────────────────────────────────────────────────────────┐
│ libuv 事件循环六阶段(单轮) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 启动(或上一轮结束) │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 1.timers │ 遍历最小堆,执行所有到期的 setTimeout/setInterval │
│ └────┬─────┘ │
│ │ ↕ nextTick 队列在每阶段之间被清空(见第 4 章) │
│ ▼ │
│ ┌──────────┐ │
│ │ 2.pending│ 执行上一轮 poll 阶段收集到的"延迟 I/O 回调" │
│ │ │ (主要是系统级错误回调,如 TCP ECONNREFUSED) │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 3.idle/ │ libuv 内部使用,用户代码不可见 │
│ │ prepare │ 各平台 epoll/kqueue 事件轮询前的准备工作 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────┐ │
│ │ 4. poll(核心阶段) │ │
│ │ │ │
│ │ ┌─ 有 I/O 事件就绪? ──→ 执行回调 │ │
│ │ ├─ 无 I/O,但有待处理 timer? │ │
│ │ │ ──→ 超时返回(进入 check) │ │
│ │ ├─ 无 I/O,无 timer,有 setImmediate? │ │
│ │ │ ──→ 立即返回(进入 check) │ │
│ │ └─ 什么都没有? ──→ 阻塞等待(主线程休眠)│ │
│ └──────────────────┬────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 5.check │ 执行所有 setImmediate(fn) 注册的回调 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 6.close │ 执行 socket.on('close') / stream.destroy() 回调 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ 还有活跃句柄/请求? │ │
│ │ YES → 回到 1.timers │ │
│ │ NO → 进程退出 │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 3.2 每阶段的职责与回
| 阶段 | 执行什么 | 对应 API | 回调来源 |
|---|---|---|---|
| timers | 到期的定时器回调 | setTimeout / setInterval | 内层最小堆(binary heap),按到期时间排序 |
| pending | 上轮 poll 延迟的系统错误回调 | TCP ECONNREFUSED、ECONNRESET 等 | 操作系统返回错误码时入队 |
| poll | I/O 完成回调(核心) | fs.readFile / http.get / net.connect | epoll/kqueue 返回的 ready events |
| check | setImmediate 回调 | setImmediate(fn) | uv_check_t handle |
| close | 关闭事件回调 | socket.on('close') / stream.destroy() | uv_close() 触发的 close callback |
关键细节——timer 的精度问题:
// 你以为的:100ms 准时触发
setTimeout(fn, 100);
// 实际的:>= 100ms,且与 poll 阶段有关
// libuv 在 timers 阶段检查的是"now >= timer.deadline"
// 如果 poll 阶段卡了很久(在处理大量 I/O 回调),timer 的实际延迟 = 设定值 + poll 耗时
2
3
4
5
6
# 3.3 poll 阶段的
疑惑:poll 阶段一直有 I/O 事件来,会不会永远到不了 check 阶段,导致 setImmediate 永远不执行?
论证:
libuv 在 poll 阶段内部有一个 "回调上限"保护:
// libuv/src/unix/core.c —— uv__io_poll() 简化版
int uv__io_poll(uv_loop_t* loop, int timeout) {
// ...
for (;;) {
// epoll_wait / kevent 等待 I/O 事件(带 timeout)
nevents = epoll_pwait(loop->backend_fd, events, maxevents, timeout, ...);
for (i = 0; i < nevents; i++) {
// 执行 I/O 回调
w->cb(loop, w, events[i].events);
}
// ⚠️ 关键:每轮执行完后检查是否有 setImmediate 在等着
if (uv__has_active_handles(loop, UV_CHECK)) {
// 有 check 阶段任务 → 不再等,退出 poll 回到主循环
break;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
保护机制:
- 如果有
setImmediate在 check 等着,poll 不会"贪"——执行若干轮 I/O 回调后主动退出 - 这防止了"永远在 poll 里转圈,check 饿死"的情况
- 如果 poll 阶段真的没有 I/O 事件可执行——
epoll_wait会阻塞等待(timeout 由最近的 timer 到期时间决定),此时主线程空闲
结论:timer 和 I/O 回调可能在 poll 被打断,但 check(setImmediate)永远不会被饿死——libuv 在 poll 的内层循环里主动检查并退出。真正会被饿死的是那些"一直有 I/O 涌入时的 timer"——但 timer 在下一轮开头的 timers 阶段,而 poll 最多超时退出。
# 3.4 浏览器 vs N
疑惑:setTimeout(fn, 0) 在 Chrome 里实际延迟可能到 4.7ms,在 Node 里却可以 1.2ms——这个差异从哪来?
论证:
这是 HTML 规范 §8.6 的硬性要求(浏览器必须遵守):
HTML Standard §8.6 Timers:
"If nesting level is greater than 5, and timeout is less than 4ms,
then set timeout to 4ms."
2
3
Chrome 的 Blink 渲染引擎对这一条的实现:
// Chromium 源码简化版:third_party/blink/renderer/core/frame/dom_timer.cc
static const int kMaxTimerNestingLevel = 5;
static const base::TimeDelta kMinimumInterval = base::Milliseconds(4);
2
3
而 libuv 的 timer 实现没有这个约束——它直接读 uv_timer_t->timeout 和 uv_now(loop) 比较,到期的就执行,不设最小间隔。
┌─────────────────── 4ms 夹持触发条件 ───────────────────┐
│ │
│ 浏览器(Chrome): │
│ setTimeout(fn, 0) │
│ 第 1~5 次:延迟 ~1ms(无夹持) │
│ 第 6 次起:嵌套层级 > 5 → 强制抬高到 4ms │
│ → 实际延迟 ~4.7ms(4ms + 调度开销) │
│ │
│ Node.js(libuv): │
│ setTimeout(fn, 0) │
│ 任何嵌套层级:libuv 按实际到期判断 │
│ → 实际延迟 ~1.2ms(只受事件循环遍历开销影响) │
│ │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
结论:浏览器的 4ms 夹持是为了防止嵌套定时器锁死渲染(给了渲染管线一个"最小窗口")。Node 没有渲染管线,所以不需要这个约束——libuv 的 timer 精度只受 poll 阶段带 I/O 回调的耗时影响。
# 4. nextTick
# 4.1 nextTick
疑惑:为什么 process.nextTick(fn) 比 Promise.resolve().then(fn) 先执行?
论证:
先看代码输出顺序:
// test_priority.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('1. timeout'), 0);
setImmediate(() => console.log('2. immediate'));
process.nextTick(() => console.log('3. nextTick'));
Promise.resolve().then(() => console.log('4. promise'));
});
// 输出:
// 3. nextTick
// 4. promise
// 2. immediate (或 1. timeout —— 取决于 poll 之后先到 check 还是 timer)
// 1. timeout (或 2. immediate)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
nextTick 的执行时机不是"比 Promise 快",而是"执行位置不同":
┌──────────────────────────────────────────────┐
│ poll 阶段执行完一个 I/O 回调后 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 清空 nextTick 队列(process.nextTick)│ │
│ └─────────────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 清空 microTask 队列(Promise.then) │ │
│ └─────────────────┬───────────────────┘ │
│ ▼ │
│ 进入下一阶段(check / close / timers) │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
Node 的 nextTick 队列在每个阶段之间被清空,而 Promise 微任务在同一阶段内的每个回调之后被清空。
# 4.2 Promise.
更精确的时序:
setTimeout(() => {
console.log('1. timeout callback start');
process.nextTick(() => console.log('2. nextTick'));
Promise.resolve()
.then(() => {
console.log('3. promise 1');
process.nextTick(() => console.log('4. nextTick in promise'));
});
console.log('5. timeout callback end');
}, 0);
// 输出:
// 1. timeout callback start
// 5. timeout callback end
// 2. nextTick ← 阶段之间:所有 nextTick 先跑
// 3. promise 1
// 4. nextTick in promise ← Promise 里的 nextTick 也要等当前阶段结束!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
规则总结:
| 队列 | 执行时机 | 是否会饿死事件循环 |
|---|---|---|
nextTick | 每阶段之间(即 poll→check、check→close 等过渡时) | 会(如果递归注册新 nextTick) |
Promise.then | 当前回调执行完后立刻清空 | 通常不会(V8 有限制) |
# 4.3 nextTick
// ⚠️ 危险:递归 nextTick 会饿死事件循环
function starve() {
process.nextTick(starve); // 每次 nextTick 都在下一阶段前插队
}
starve();
setTimeout(() => console.log('never runs'), 0); // ← 永远不会执行
// 原因:nextTick 在每阶段之间清空 → 如果递归注册新 nextTick
// → nextTick 队列永远不为空 → 永远到不了下一阶段
2
3
4
5
6
7
8
9
10
Node 的防护:Node 对 process.nextTick 有递归深度上限(process.maxTickDepth,默认 1000)。达到上限后,Node 会强制把新的 nextTick 推迟到下一轮事件循环——防止彻底的饿死。但这个上限只是"软上限",不能完全依赖。
为什么要有 nextTick? 它的设计初衷是提供一种"比 Promise 更早"的异步机制——用于需要在当前操作完成后、任何其他异步操作之前执行的回调(比如:在触发 error 事件前确保 error listener 已注册)。
# 4.4 完整优先级图谱
┌────────────────────────────────────────────────────────────┐
│ Node.js 异步回调执行优先级 │
├────────────────────────────────────────────────────────────┤
│ │
│ 同步代码(最高优先级,无悬念) │
│ │ │
│ ▼ │
│ process.nextTick() 队列 │
│ 在每个阶段之间被清空 │
│ │ │
│ ▼ │
│ Promise.then / async/await / queueMicrotask() │
│ 在同一阶段内的回调执行完后清空 │
│ │ │
│ ▼ │
│ 下一阶段(按照 libuv 六阶段顺序) │
│ timers → pending → poll → check → close │
│ │ │
│ ├── 如果 poll 中有 I/O 就绪 │
│ │ └→ 执行 I/O 回调 → 回到上述 nextTick/Promise 清空 │
│ │ │
│ ├── 如果 poll 中超时(最近 timer 到期) │
│ │ └→ 回到 timers 阶段 │
│ │ │
│ └── 如果有 setImmediate 在 check 等着 │
│ └→ 进入 check 阶段 │
│ │
└────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
一句话记忆:同步 > nextTick > Promise > 各阶段回调,其中 setTimeout vs setImmediate 谁先跑取决于你从哪个阶段出发(非 I/O 回调里 setTimeout 可能先跑;I/O 回调里 setImmediate 必定先跑)。
# 5. Stream流
# 5.1 Readable
Stream 不是新技术——它来自 Unix 哲学的"管道"思想。Node 把这套抽象成四个类。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Readable │ pipe │ Transform │ pipe │ Writable │
│ (可读流) │ ───→ │ (转换流) │ ───→ │ (可写流) │
└─────────────┘ └─────────────┘ └─────────────┘
↑ │
│ ┌─────────────┐ │
└──────────│ Duplex │───────────────┘
可同时读/写 │ (双工流) │ 可同时读/写
└─────────────┘
2
3
4
5
6
7
8
9
疑惑:Readable 有两种模式——流动模式和暂停模式。默认哪个?什么时候切换?
论证:
const { Readable } = require('stream');
const fs = require('fs');
// -- 暂停模式(默认创建时就是暂停模式)--
const rs1 = fs.createReadStream('large.log', { highWaterMark: 64 * 1024 });
rs1.on('readable', () => {
let chunk;
while ((chunk = rs1.read()) !== null) {
// 手动拉取——你自己控制节奏
console.log('Paused mode:', chunk.length);
}
});
// -- 流动模式(一旦监听了 'data' 事件就自动切换)--
const rs2 = fs.createReadStream('large.log');
rs2.on('data', chunk => {
// 数据自动推送——你控制不了节奏
console.log('Flowing mode:', chunk.length);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
模式切换规则:
暂停模式 流动模式
│ │
├── .resume() ───→ │
├── .on('data') ──→ │
├── .pipe(writable) ──→ │
│ │
│ ←── .pause() ───┤
│ ←── .unpipe() ──┤
2
3
4
5
6
7
8
内部实现:两种模式对应 readable._readableState.flowing 这个布尔值。流动模式下,read() 被自动调用并把数据 push 给 data 事件;暂停模式下,你不调用 read() 就永远阻塞。
结论:暂停模式让你控制读取速度(相当于给下游装了阀门);流动模式让数据自动到(但容易失控)。生产代码优先暂停模式(或直接用 pipe()/pipeline() 让框架帮你控速)。
# 5.2 Writable
const { Writable } = require('stream');
const ws = new Writable({
highWaterMark: 16 * 1024, // 16 KB 水位线
write(chunk, encoding, callback) {
// 模拟慢速写入:每次 10ms
setTimeout(() => {
console.log('Wrote:', chunk.length, 'buffered:', ws.writableLength);
callback();
}, 10);
}
});
// 快速写入 100 个 1KB chunk → 共 100KB → 远超高水位线
for (let i = 0; i < 100; i++) {
const ok = ws.write(Buffer.alloc(1024));
if (!ok) console.log(`Write #${i}: backpressure!`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
内部缓冲区的生命周期:
ws.write(chunk)
│
├── writableLength < highWaterMark ?
│ YES → chunk 直接交给 _write(),返回 true
│ NO → chunk 进入内部缓冲队列,返回 false
│
▼
_write(chunk, enc, cb) 被调用(异步)
│
▼
cb() 被调用 → 缓冲区被消费一部分
│
├── writableLength < highWaterMark ?
│ YES → 触发 'drain' 事件
│ NO → 继续等待更多 cb() 被调用
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 5.3 Duplex
| 类型 | 读 | 写 | 内部实现 | 典型例子 |
|---|---|---|---|---|
| Readable | ✅ | ❌ | _read() | fs.createReadStream / process.stdin |
| Writable | ❌ | ✅ | _write() | fs.createWriteStream / process.stdout |
| Duplex | ✅ | ✅ | _read() + _write() | net.Socket / tls.TLSSocket |
| Transform | ✅ | ✅ | _transform() + _flush() | zlib.createGzip() / crypto.createCipher() |
Duplex vs Transform 的关键区别:
- Duplex 的读和写是独立的——你写进去的 chunk 和读出来的 chunk 可以完全不同(比如
net.Socket:写的是你发的数据,读的是对方发的数据) - Transform 的读和写是因果相关的——写进去的 chunk 经过
_transform()变成读出来的 chunk(比如 gzip 压缩:写入原始数据,读出压缩数据)
# 5.4 objectMo
疑惑:Stream 默认传的是 Buffer/string,怎么传 JS 对象?
论证:
const { Transform } = require('stream');
// 默认模式:传输 Buffer/string
const byteTransform = new Transform({
transform(chunk, encoding, cb) {
// chunk 是 Buffer
cb(null, chunk.toString().toUpperCase());
}
});
// objectMode:传输任意 JS 对象
const objectParser = new Transform({
readableObjectMode: true, // 读出的是 JS 对象
writableObjectMode: true, // 写入的是 JS 对象
highWaterMark: 16, // 对象模式下:16 个对象(不是 16 字节!)
transform(obj, encoding, cb) {
// obj 是真正的 JS 对象,不需要 toString()
cb(null, { ...obj, processed: true });
}
});
objectParser.write({ id: 1, data: 'hello' });
objectParser.write({ id: 2, data: 'world' });
objectParser.on('data', obj => {
console.log(obj); // { id: 1, data: 'hello', processed: true }
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
objectMode 关键差异:
| 维度 | 字节模式(默认) | objectMode |
|---|---|---|
| chunk 类型 | Buffer / string / Uint8Array | 任意 JS 值(除 null) |
highWaterMark 单位 | 字节数(如 16384 = 16 KB) | 对象个数(如 16 = 放 16 个对象就触发背压) |
read(n) 的 n 含义 | 读取 n 字节 | 读取 n 个对象 |
结论:objectMode 在消息队列、JSON 解析/序列化管道、ETL 数据管道中大量使用。但要注意背压单位变成了对象个数——一个大对象就可能撑爆内存,需要额外关注。
# 6. 背压机制详解
# 6.1 write()
回到第 1 章的日志转发器崩溃——根因就在 write() 的返回值被忽视了。
const fs = require('fs');
const writer = fs.createWriteStream('./log');
// ❌ 错误:不检查返回值
function writeUnsafe(data) {
writer.write(data);
}
// ✅ 正确:尊重背压信号
function writeSafe(data) {
if (!writer.write(data)) {
// → false:内部缓冲区满了,下游(磁盘)处理不过来
console.log('⚠️ Backpressure: buffer at', writer.writableLength);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
write() 返回值的语义:
write(chunk) → true
└→ chunk 被 accept,直接交给 _write() → 内存不增长(或微增)
write(chunk) → false
└→ chunk 被放入内部缓冲队列(writableBuffer)
└→ 队列大小 = writableLength(可读属性)
└→ 队列增长速度 > 消费者速度 → 内存持续增长 → OOM
2
3
4
5
6
7
内部缓冲区是什么:WriteStream 内部维护一个链表——每个节点是一块待写入的 Buffer。writableLength 是链表中所有 chunk 的字节数之和。这个链表没有上限——你可以往里塞任意多数据(直到进程内存炸了)。
# 6.2 drain 事件
疑惑:收到 false 之后怎么办?什么时候可以继续写?
论证:
function writeWithBackpressure(data, writer, cb) {
if (!writer.write(data)) {
// 暂停写入,等待下游排空
writer.once('drain', () => {
console.log('✅ Drain: buffer emptied, can write again');
cb();
});
} else {
cb();
}
}
2
3
4
5
6
7
8
9
10
11
drain 事件的触发时机:
write(chunk) → false write(chunk) → false
│ │
▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ chunk 1 │ → │ chunk 2 │ → │ chunk 3 │→│ chunk 4 │ ← writableLength > highWaterMark
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │
▼ _write() 逐个消费 │
┌─────────┐ ┌─────────┐ │
│ ✅ done │ │ ✅ done │ │
└─────────┘ └─────────┘ │
│ │
▼ ▼
writableLength < highWaterMark → 触发 'drain' 事件
2
3
4
5
6
7
8
9
10
11
12
13
14
不处理 drain 的后果(就是第 1 章事故的根因):
// 上游速度 20 GB/h(~5.5 MB/s)
// 磁盘写入速度 ~200 MB/s(SSD)→ 看起来够快
// 但 SSD 会遇到"写放大"——随机写比顺序写慢 10 倍
// 写放大时有效速度降到 ~20 MB/s
// 5.5 MB/s(入)> 20 MB/s(出)? 不,这次够了
// 但再来一个峰值 → 50 MB/s(入)> 20 MB/s(出)
// → 每秒积压 30 MB → 1 分钟积压 1.8 GB → OOM
2
3
4
5
6
7
结论:背压不是理论上的极端情况——它是任何一个上下游速度失配的系统中都会发生的事。"日常够用"掩盖的设计缺陷,在峰值流量下会原形毕露。
# 6.3 pipe()
// pipe() 源码简化版(核心逻辑):
Readable.prototype.pipe = function(dest) {
// 1. 进入流动模式
this.on('data', chunk => {
// 2. 写入下游
const ok = dest.write(chunk);
// 3. write() 返回 false → 暂停上游
if (!ok) {
this.pause();
// 4. 等下游排空 → 恢复上游
dest.once('drain', () => this.resume());
}
});
// 5. 上游结束 → 关闭下游
this.on('end', () => dest.end());
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pipe() 自动做了五件事:
- 把上游切换到流动模式(
.on('data')) - 每次写入检查
write()返回值 - 返回
false时暂停上游(.pause()) - 下游排空后恢复上游(
.resume()) - 上游结束时关闭下游(
.end())
为什么还是要懂原理:pipe() 处理了背压,但不处理错误传播——上游出错,下游不会自动被销毁。
# 6.4 pipeline
const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');
// ❌ pipe():错误不会自动传播
fs.createReadStream('bad_file') // 文件不存在 → error
.pipe(zlib.createGzip()) // ← 不会收到错误通知!继续等着(泄漏)
.pipe(fs.createWriteStream('out.gz'));
// ✅ pipeline():任一环节出错,整个管道被销毁
pipeline(
fs.createReadStream('bad_file'),
zlib.createGzip(),
fs.createWriteStream('out.gz'),
(err) => {
if (err) {
console.error('Pipeline failed:', err.message);
// 此时三个 stream 都已被自动 destroy()
}
}
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
对比表:
pipe() | pipeline() | |
|---|---|---|
| 背压处理 | ✅ 自动 | ✅ 自动 |
| 错误传播 | ❌ 需手动绑定每个 stream 的 error 事件 | ✅ 自动传播、自动销毁所有 stream |
| 回调完成信号 | ❌ 需手动监听 finish/end/close | ✅ callback(err) |
| Node 版本 | 所有版本 | v10+ |
| 推不推荐 | ❌ 不推荐(遗留 API) | ✅ 始终用 pipeline() |
结论:pipeline() 是 pipe() 的完整替代——它不仅自动处理背压,还自动传播错误、自动销毁管道中所有 stream。在 Node 10+ 中,永远用 pipeline() 而不是 pipe()。
# 7. Buffer本质
# 7.1 Uint8Arr
疑惑:为什么 Node 有 Buffer 这个独立类型?ES6 不是已经有 Uint8Array 了吗?
论证:
// Buffer 确实是 Uint8Array 的子类
console.log(Buffer.prototype instanceof Uint8Array); // true
console.log(Buffer.alloc(4) instanceof Uint8Array); // true
// 但它有自己的内存分配策略
const b1 = Buffer.alloc(8); // 0 填充,安全
const b2 = Buffer.allocUnsafe(8); // 不填充,快(可能含旧内存数据)
const b3 = Buffer.from('hello'); // 从字符串/数组创建
2
3
4
5
6
7
8
内存分配策略——slab 预分配:
┌─────────────────────────────────────────────────────┐
│ Node.js Buffer 内存池(slab allocation) │
├─────────────────────────────────────────────────────┤
│ │
│ 小 Buffer (< 4KB / Buffer.poolSize): │
│ ┌────────────────────────────────────────────┐ │
│ │ 8 KB slab 内存池 │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ 64B │ │ 256B │ │ 1KB │ │ 512B │ ... │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ │ 从 slab 切分 → 减少 GC 压力 │ │
│ └────────────────────────────────────────────┘ │
│ │
│ 大 Buffer (>= 4KB): │
│ ┌────────────────────────────────────────────┐ │
│ │ 直接走堆分配(malloc / new Uint8Array) │ │
│ │ 不在 slab 池中管理 │ │
│ └────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键参数:Buffer.poolSize(默认 8192 = 8 KB),小 Buffer 从这个池中分配。这个池是在第一次使用 Buffer.alloc() 或 Buffer.from() 时懒加载创建的。
Buffer.allocUnsafe(size) 的实现(极简版):
// Node 源码简化:lib/buffer.js
Buffer.allocUnsafe = function allocUnsafe(size) {
if (size <= Buffer.poolSize >>> 1) {
// 小 Buffer:从 slab 内存池里切一块
return pool.allocate(size);
}
// 大 Buffer:直接创建新的 Uint8Array
return new FastBuffer(size);
};
2
3
4
5
6
7
8
9
为什么要 slab 预分配:Node.js 内部大量使用短生命周期的 Buffer(每次网络请求读一块数据、每次文件操作读一块)。如果每个 Buffer 都走一次 malloc—free,会产生大量堆碎片和 GC 压力。slab 池让 Buffer 分配变成"从已分配的 8 KB 区里标记一块已用"——O(1) 分配,且释放后不需要立刻还给 OS。
# 7.2 Buffer.a
疑惑:allocUnsafe 比 alloc 快多少?值得冒风险吗?
论证:
const { performance } = require('perf_hooks');
// 测试 1:小 Buffer(1KB)分配 10 万次
let start = performance.now();
for (let i = 0; i < 100000; i++) Buffer.alloc(1024);
console.log('alloc (safe):', performance.now() - start, 'ms');
start = performance.now();
for (let i = 0; i < 100000; i++) Buffer.allocUnsafe(1024);
console.log('allocUnsafe (fast):', performance.now() - start, 'ms');
// 典型结果:
// alloc (safe): ~45 ms
// allocUnsafe (fast): ~8 ms → 约 5.6x 快
2
3
4
5
6
7
8
9
10
11
12
13
14
allocUnsafe 的风险:返回的 Buffer 没有清零——内存中可能有上一次被释放的数据残留:
const risky = Buffer.allocUnsafe(16);
console.log(risky.toString('hex'));
// 输出:可能是 "deadbeef0000..." 也可能是前一个请求的 JWT Token 或密码
2
3
安全矩阵:
| 方法 | 清零 | 速度 | 风险 | 适用场景 |
|---|---|---|---|---|
Buffer.alloc(n) | ✅ 全部写 0 | 慢 | 无 | 默认选择 |
Buffer.allocUnsafe(n) | ❌ | 快(~5x) | 可能暴露旧内存数据 | 你立刻用新数据完全覆盖它 |
Buffer.allocUnsafeSlow(n) | ❌ | 快 | 同 allocUnsafe,但不走 slab 池 | 需要 Buffer 不被池管理(如传给 C++ addon) |
什么时候可以用 allocUnsafe:
// ✅ 安全:立刻被新数据完全覆盖
const buf = Buffer.allocUnsafe(1024);
fs.readSync(fd, buf, 0, 1024); // 整块被文件数据覆盖
socket.read(buf); // 整块被网络数据覆盖
// ❌ 不安全:只部分覆盖
const buf = Buffer.allocUnsafe(1024);
fs.readSync(fd, buf, 0, 512); // 只覆盖前 512 字节,后 512 还是旧数据
buf.toString(); // ⚠️ 后 512 字节泄露了之前的敏感数据!
2
3
4
5
6
7
8
9
结论:永远默认用 Buffer.alloc()。allocUnsafe 只在性能关键热路径上,且你100% 确定会立刻用新数据完全覆盖整个 Buffer的情况下使用。这是一个"用安全性换性能"的 tradeoff——大部分场景不值得。
# 7.3 与浏览器 Typ
疑惑:ES6 的 Uint8Array 做二进制操作已经够用了,Node 为什么还要维护一个独立的 Buffer 类?
论证:
这是历史与功能叠加的结果。2009 年 Node.js 诞生时,JavaScript 没有 TypedArray——处理二进制数据的唯一方式是用字符串(又慢又不安全)。Ryan Dahl 写了 Buffer 类塞进 Node。2015 年 ES6 引入 TypedArray 后,Node 把 Buffer 改成了 Uint8Array 的子类——但保留了额外的专有 API。
Buffer 独有的能力:
// 1. 处理字节序(几乎只在网络协议/文件格式中需要)
const buf = Buffer.from([0x00, 0x00, 0x01, 0x02]);
buf.readUInt32BE(0); // → 258(大端序) Uint8Array 没有
buf.readUInt32LE(0); // → 33619968(小端序) Uint8Array 没有
// 2. slice 共享内存(不拷贝)——Uint8Array.slice 是拷贝
const buf1 = Buffer.alloc(1024);
const buf2 = buf1.slice(0, 512); // buf2 和 buf1 共享底层 ArrayBuffer
buf1[0] = 0xFF;
console.log(buf2[0]); // 0xFF —— 修改相互可见
// 3. 编码转换(Node 专有)
Buffer.from('你好', 'utf8');
Buffer.from('48656c6c6f', 'hex');
Buffer.from('SGVsbG8=', 'base64');
// Uint8Array 需要走 TextEncoder/TextDecoder
// 4. 零拷贝从 Buffer 到 string(Buffer.toString 比 TextDecoder 快)
const str = buf.toString('utf8'); // 内部优化,绕过 TextDecoder 的开销
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
对比矩阵:
| 能力 | Buffer (Node) | Uint8Array (浏览器 + Node) |
|---|---|---|
| 继承关系 | Uint8Array 的子类 | 独立(TypedArray 子类) |
| 内存池 | slab 池 (≤4KB 走池) | 无(每次 new 都独立分配) |
slice() | 共享内存(零拷贝) | 拷贝(ES6 规范要求) |
| 字节序读写 | ✅ readUInt16BE/LE 等 30+ 方法 | ❌ 需手动位移 |
| 编码转换 | ✅ Buffer.from(str, 'utf8') / buf.toString('hex') | ✅ TextEncoder/TextDecoder(浏览器 API) |
| 跨平台安全 | 仅 Node | ✅ Node + 浏览器 |
| 零填充 | alloc 填充 0 / allocUnsafe 不填 | 部分环境填充 0,部分不保证 |
什么时候用哪个:
- 纯 Node.js 后端:Buffer 是首选——它有更丰富的 API、更快的字符串转换、内存池
- 同构代码(SSR/SSG):用
Uint8Array/TextEncoder/TextDecoder——它们在浏览器和 Node 都能跑 - npm 包:如果不确定下游是浏览器还是 Node,用
Uint8Array;如果只面向 Node,用Buffer
结论:Buffer 是 Node.js 的"遗产优势"——它在 TypedArray 诞生之前就已存在,积累了大量专门为服务端二进制处理优化的 API。如今它成了 Uint8Array 的子类,但保留了自己的特色(内存池、slice 共享、字节序读写)。在同构代码里用标准 API;在纯 Node 代码里用 Buffer 能更快。
# 8. Cluster
# 8.1 Cluster
疑惑:Node 是单线程的,要怎么利用服务器的 16 核 CPU?
论证:Cluster 模块不是让你写并发代码——它是把同一个进程复制 N 份,每份跑在独立核心上。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// 自动重启:某个 worker 崩了 → 立刻 fork 一个新的
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
cluster.fork();
});
} else {
// 每个 Worker 都运行同一个 HTTP 服务器
http.createServer((req, res) => {
res.writeHead(200);
res.end(`Hello from worker ${process.pid}\n`);
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
端口共享的底层原理:
┌──────────────────────────────────────────────────┐
│ Cluster 端口共享机制(简化) │
├──────────────────────────────────────────────────┤
│ │
│ Master (PID 1000) │
│ ┌────────────────────────────────────┐ │
│ │ net.createServer() → listen(8000) │ │
│ │ 拿到 socket fd = 42 │ │
│ └──────────────┬─────────────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Worker 1 Worker 2 Worker 3 │
│ (PID 1001) (PID 1002) (PID 1003) │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ fd:42 │ │ fd:42 │ │ fd:42 │ ← 同一个 fd │
│ │ accept │ │ accept │ │ accept │ 内核负责分发 │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ 用户请求 → 内核 TCP 栈 → 负载均衡分给 Worker │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
关键细节:
- Master 进程负责
listen(),拿到 socket 的文件描述符 - Worker 进程通过 IPC 拿到同一个 fd
- 多个 Worker 对同一个 fd 调用
accept()——内核负责把连接分发到不同的 Worker(不是 Node 自己做的) - 分发策略是操作系统决定的:Linux 采用**"惊群 → 只有一个 Worker 拿到连接"(SO_REUSEPORT 之前的默认行为)或内核级轮转分发**(SO_REUSEPORT)
平滑重启(zero-downtime restart):
// PM2 的 reload 简化原理:
// 1. Master 收到 SIGHUP 信号
// 2. fork 新 Worker(带着新代码)
// 3. 新 Worker 启动完成 → 发 'listening' 消息给 Master
// 4. Master 发 'shutdown' 给老 Worker → 老 Worker 不再 accept 新连接
// 5. 老 Worker 处理完现有连接 → 优雅退出
// 这期间:新请求由新 Worker 处理,老请求在旧 Worker 中完成 → 零停机
2
3
4
5
6
7
# 8.2 Worker
疑惑:Worker Threads 和 child_process 都能"开新的",到底选哪个?
论证——核心差异是内存模型:
child_process(进程) Worker Threads(线程)
┌─────────────────┐ ┌─────────────────────────┐
│ 进程 A │ │ 进程 │
│ ┌─────────────┐│ │ ┌────────┬────────┐ │
│ │ 独立内存空间 ││ │ │ 主线程 │Worker │ │
│ │ 堆/栈/代码 ││ │ │ 堆/栈 │线程 1 │ │
│ └─────────────┘│ │ │ │ 堆/栈 │ │
│ │ IPC │ │ └────────┴────────┘ │
│ ┌─────▼───────┐│ │ ┌──────────────────┐ │
│ │ 进程 B ││ │ │ SharedArrayBuffer│ │
│ │ ┌─────────┐││ │ │ ┌────┬────┬───┐│ │
│ │ │独立内存 │││ │ │ │data│data│... ││ │
│ │ │堆/栈/代码│││ │ │ └────┴────┴───┘│ │
│ │ └─────────┘││ │ │ 共享内存区域 │ │
│ └─────────────┘│ │ └──────────────────┘ │
└─────────────────┘ └─────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 维度 | Worker Threads | child_process (fork) |
|---|---|---|
| 内存模型 | 共享 SharedArrayBuffer | 独立(IPC 序列化通信) |
| 启动开销 | 轻(~2 MB 虚拟空间) | 重(~30 MB + 独立 V8 实例) |
| 通信方式 | postMessage / SharedArrayBuffer / MessageChannel | IPC(pipe / socket)/ 序列化 |
| 大数据传输 | 零拷贝(Transferable objects / SharedArrayBuffer) | 必须序列化(JSON/clone) |
| 隔离性 | 低(共享内存有竞态风险) | 高(完全独立,一个崩不影响另一个) |
| 沙箱安全 | 低(同进程,可访问 process) | 高(独立进程,可用 --experimental-permission) |
| 适用场景 | CPU 密集计算(图像处理、加密、压缩) | 启动子程序、沙箱执行不受信任代码 |
选型决策树:
需要"多跑一核"?
│
├─ 任务类型是 CPU 密集(大量计算)?
│ ├─ 输入/输出数据大(>100 MB)?
│ │ └→ Worker Threads + SharedArrayBuffer
│ │ (避免序列化开销)
│ └─ 输入/输出数据小?
│ ├→ Worker Threads(轻量,快速)
│
├─ 需要沙箱隔离(不可信代码)?
│ └→ child_process(独立进程)
│
└─ 需要利用多核处理 HTTP 请求?
└→ Cluster(进程级,与 PM2 兼容)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 8.3 child_pr
exec | spawn | fork | |
|---|---|---|---|
| 缓冲输出 | ✅ 全缓冲在内存(默认最大 1MB→maxBuffer) | ❌ 流式输出(stdout/stderr 是可读流) | ❌ 流式输出 |
| 默认 shell | /bin/sh | 无(直接 execve) | 无(直接 execve) |
| IPC 通道 | ❌ | ❌ | ✅(内置 process.send) |
| 用途 | 简单一次性命令(ls -la) | 长时间进程、大数据输出 | Node 子进程(需 IPC) |
| 风险 | 大输出会 OOM(全缓冲在内存) | 安全 | 安全 |
const { exec, spawn, fork } = require('child_process');
// ❌ exec:数据量大时危险——整个 stdout 被缓冲在内存
exec('cat 10gb_log.txt', (err, stdout, stderr) => {
// stdout 是 10 GB 的字符串 → 💥 OOM
});
// ✅ spawn:流式处理——内存恒定
const child = spawn('cat', ['10gb_log.txt']);
child.stdout.on('data', chunk => {
console.log(`Got ${chunk.length} bytes`);
// 每块 64KB,随到随处理——不会 OOM
});
// ✅ fork:Node 子进程 + 内置 IPC
const worker = fork('./worker.js');
worker.on('message', msg => console.log('From worker:', msg));
worker.send({ task: 'compute', data: [1, 2, 3] });
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 9. 同构代码设计与双
# 9.1 Node 18
疑惑:一段包含 fetch() 的代码,怎么在浏览器和 Node 都能跑?
论证:Node.js 从 18 版本起逐步支持浏览器标准 API——不再需要 node-fetch 等 polyfill。这不是把浏览器的实现搬过来,而是在 Node 内部用 C++ 实现(底层走 undici/libuv)与浏览器行为保持一致的 API。
// 以下代码在浏览器和 Node 18+ 都能运行(无需 import/polyfill)
const response = await fetch('https://api.github.com/repos/nodejs/node');
const data = await response.json();
console.log(data.stargazers_count);
// 在 Node 18 之前:需要 import 'node-fetch' 且 API 有细微差异
// 在 Node 18+ 之后:直接用 globalThis.fetch
2
3
4
5
6
7
Node.js Web API 对齐表:
| API | Node 版本 | 浏览器支持 | 底层实现 |
|---|---|---|---|
fetch() | 18+ (稳定) / 17.5 (实验) | ✅ | undici (HTTP/1.1 客户端) + libuv |
URL / URLSearchParams | 10+ | ✅ | whatwg-url (C++ 绑定) |
AbortController / AbortSignal | 15+ | ✅ | C++ 内建 |
crypto.subtle (Web Crypto API) | 15+ | ✅ | OpenSSL 绑定 |
Blob | 16+ | ✅ | C++ 内建 |
File | 20+ | ✅ | Blob 子类 |
performance (Performance API) | 8.5+ | ✅ | libuv uv_hrtime() |
TextEncoder / TextDecoder | 11+ | ✅ | V8 内建 (ICU) |
EventTarget | 14.5+ | ✅ | C++ 内建 |
structuredClone | 17+ | ✅ | V8 内建 |
ReadableStream / WritableStream | 18+ | ✅ | Web Streams API (底层可兼容 Node Stream) |
双端统一的代码:
// ✅ 以下代码在任何现代运行时都能跑(浏览器 + Node 18+ + Deno + Bun)
async function fetchUser(id) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`/api/users/${id}`, {
signal: controller.signal,
});
return await res.json();
} finally {
clearTimeout(timeout);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 9.2 SSR 的双端渲
一个 React/Vue 组件在 SSR 中经历的双端生命周期:
┌─────────────────── 服务端(Node.js)───────────────────┐
│ │
│ 1. 解析路由 → 匹配组件 │
│ 2. 执行组件的 getServerSideProps / asyncData │
│ ├── 在 Node 端发起 fetch/数据库查询 │
│ └── 拿到数据,塞进组件 props │
│ 3. 执行组件的 render(): │
│ ReactDOMServer.renderToString(<App />) │
│ └── 注:window/document 不存在 → 跳过 useEffect │
│ 4. 输出:HTML 字符串 + <script>脱水数据</script> │
│ │
└──────────────────────┬──────────────────────────────────┘
│ HTTP 响应(HTML 字符串)
▼
┌─────────────────── 客户端(浏览器)──────────────────────┐
│ │
│ 5. 浏览器接收 HTML → 立即渲染首屏(白屏时间 < 100ms) │
│ 6. 加载 JS bundle │
│ 7. ReactDOM.hydrate(<App />, container): │
│ ├── 复用已有的 DOM 节点(不重新创建) │
│ ├── 绑定事件监听器(onClick / onChange) │
│ └── 执行 useEffect / componentDidMount │
│ 8. 此时:window/document 存在 → 访问 DOM API 的代码执行 │
│ │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
关键挑战——条件导入隔离:
// ❌ 这段代码会在 Node 端崩掉(没有 document)
const isMobile = document.documentElement.clientWidth < 768;
// ✅ 方案 1:延迟到客户端执行(useEffect)
import { useEffect, useState } from 'react';
function Component() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768); // 只在浏览器执行
}, []);
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}
// ✅ 方案 2:动态 import(Next.js 的 'use client' 指令)
// ✅ 方案 3:框架的条件导入(Nuxt 的 <ClientOnly> 包裹)
// ✅ 方案 4:在 getServerSideProps 中通过 User-Agent 判断设备类型
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 9.3 最常见的 5 个
| 差异点 | 浏览器 | Node.js | 如何兼容 |
|---|---|---|---|
| 全局对象 | window | global | globalThis(Node 12+ / 所有现代浏览器) |
| 模块系统 | ESM 原生(<script type="module">) | CJS 默认(require)+ ESM(.mjs / "type": "module") | 同构包双导出(main + module/exports) |
setTimeout 4ms | ✅ 嵌套≥5 层夹持 | ❌ 无夹持 | 不依赖精度,用 setImmediate 语义时用 setImmediate(Node)或 queueMicrotask(双端) |
__dirname / __filename | ❌ | ✅ | ESM 下用 import.meta.url(Node 12+) |
Buffer | ❌(有 Uint8Array) | ✅(Uint8Array 子类) | 同构代码用 Uint8Array / TextEncoder / TextDecoder |
globalThis 是双端的统一入口:
// 不需要判断环境
const root = globalThis;
// Node: globalThis === global
// 浏览器: globalThis === window
// Web Worker: globalThis === self
2
3
4
5
6
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的日志转发器崩溃——六个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
① libuv 事件循环:fs.write 的回调什么时候执行? | 第 3 章:写入完成后,回调在 poll 阶段被执行(线程池完成 I/O → 通知 libuv → poll 轮询到 → 执行回调) |
| ② nextTick 优先级:为什么"递归 nextTick"能饿死整个循环? | 第 4 章:nextTick 在每阶段之间执行——递归注册意味着每次"阶段之间"都有新任务,形成"永远到不了下一阶段"的闭环 |
③ Stream 背压:write()→false→drain 这条链怎么来的? | 第 5~6 章:write() 把 chunk 放入 WriteStream 内部链表,链表总大小超过 highWaterMark 时返回 false;链表消费到低于高水位线时触发 drain |
④ Buffer 本质:WriteStream 内部缓冲区是什么? | 第 7 章:是 Buffer 对象的链表。每个从 slab 池或堆分配。未被消费的 chunk 都在这个链表里——链表只增不减 = 内存只涨不降 = OOM 的前奏 |
| ⑤ 多进程:Cluster 怎么分摊这个服务的负载? | 第 8 章:Master 监听端口,Worker 共享 socket fd,内核负责把新连接分发给 Worker |
| ⑥ 同构:如果你把这条逻辑写进 SSR 组件? | 第 9 章:fs.createWriteStream 在浏览器端不存在——需要在双端各自提供接口的"适配层" |
修复方案(按代价从小到大):
方案 A:手动处理背压(最小改动)
// 改一行 → 检查返回值 + 暂停 socket
socket.on('data', chunk => {
if (!writer.write(chunk)) {
socket.pause(); // ← 暂停上游
writer.once('drain', () => socket.resume()); // ← 排空后恢复
}
});
2
3
4
5
6
7
代价:代码多两行;收益:内存从 1.8 GB 降到 ~50 MB。
方案 B:用 pipeline() 取代手写(推荐)
const { pipeline } = require('stream');
server.on('connection', socket => {
pipeline(socket, writer, err => {
if (err) console.error('Pipeline failed:', err);
});
});
2
3
4
5
6
7
代价:彻底改掉手动 on('data');收益:背压 + 错误传播 + 自动销毁全部由框架负责。
方案 C:加 Cluster 分摊流量(规模化后)
const cluster = require('cluster');
if (cluster.isMaster) {
for (let i = 0; i < require('os').cpus().length; i++) cluster.fork();
} else {
// 原来的 server + pipeline 逻辑
}
2
3
4
5
6
代价:需要管理多进程的生命周期;收益:4 个 Worker 各扛 1/4 流量 → 每个 Worker 写入压力降到 1/4 → 背压触发概率降为 1/4。
生产建议:方案 B(pipeline)是基础——先正确地"流",再考虑方案 C(Cluster)分摊。
# 10.2 用 Stream
const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');
const { Transform } = require('stream');
// 自定义 Transform:只保留 ERROR 和 FATAL 行,同时统计各级别数量
const filterAndCount = new Transform({
transform(chunk, enc, cb) {
const lines = chunk.toString().split('\n');
const errors = [];
for (const line of lines) {
if (line.includes('ERROR')) { errors.push(line); this.errorCount++; }
else if (line.includes('FATAL')) { errors.push(line); this.fatalCount++; }
else if (line.includes('WARN')) this.warnCount++;
else if (line.includes('INFO')) this.infoCount++;
}
if (errors.length > 0) cb(null, errors.join('\n') + '\n');
else cb(); // 跳过大段 INFO——减少输出体积
},
// 初始化计数器
errorCount: 0, fatalCount: 0, warnCount: 0, infoCount: 0
});
// 管道:1GB server.log → 过滤 → 压缩 → 写入
pipeline(
fs.createReadStream('server.log', { highWaterMark: 64 * 1024 }),
filterAndCount,
zlib.createGzip(),
fs.createWriteStream('errors_only.log.gz'),
(err) => {
if (err) {
console.error('Pipeline failed:', err);
} else {
console.log('Done! Stats:',
`ERROR=${filterAndCount.errorCount}`,
`FATAL=${filterAndCount.fatalCount}`,
`WARN=${filterAndCount.warnCount}`,
`INFO=${filterAndCount.infoCount}`
);
}
}
);
// 这套管道处理 1GB 文件,内存占用 < 100 MB
// 因为数据是一块一块"流"过去的——任何时候内存中只保存 ~64KB(highWaterMark)
// 对比:fs.readFile('server.log') 会把整个 1GB 读进内存 → 1GB RSS 起步
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
内存轨迹对比:
fs.readFile (1GB) pipeline(流式处理)
RSS │ RSS
1.2 GB │ ╱────── │
1.0 GB │ ╱ │
800 MB │ ╱ │
600 MB │ ╱ │
400 MB │╱ │ ───────────── ~80 MB ──────────
200 MB │ │ ╱ ╲
0 └────────────── t └────────────────────────────────── t
读入 处理 写入 随时都在"吃→处理→吐",内存恒定
2
3
4
5
6
7
8
9
10
# 10.3 设计哲学回扣
哲学一·「libuv 的事件循环是对 OS 的适配层」
浏览器的事件循环被 HTML 规范约束(渲染管线、4ms 夹持、每帧 16.67ms 预算)。Node.js 的事件循环由 libuv 驱动——它直接面对 OS 的 I/O 模型:Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP。同一个 setTimeout——浏览器和 Node 跑在不同的调度器上,不同的约束之下。 这不是"谁更好"的问题,而是:浏览器的循环必须为"一帧一帧画给用户看"服务;Node 的循环必须为"一个线程服务 10000 个 socket"服务。调度器的设计,由应用场景决定。
哲学二·「背压是流式架构的诚实信号」
write()→false 不是错误——它是下游在说"我处理不过来"。不处理这个信号 = 无视物理限制(内存不是无限的,磁盘不是无限快的)。流式编程的核心不是"怎么传数据",而是"怎么在传数据的每一步都对下游保持诚实"。 pipeline() 把这个诚实从"开发者手动管理"变成了"框架自动保证"——每一块数据进入管道时,下游都有明确的"我吃完了"信号,上游才继续投喂。这本质上是一个分布式反压算法——只是发生在一个进程内。
哲学三·「同构不是共享代码——是共享契约」
SSR 组件在 Node 和浏览器两端运行——不是因为代码一样(document 在 Node 端不存在,Buffer 在浏览器端不存在),而是因为它们遵循同一个渲染契约:Node 端产出的 HTML 字符串结构和浏览器端 hydrate 的 DOM 结构一一对应。同构的本质不是"代码能同时跑",而是"两个运行时对同一个输入的输出收敛到同一个结果"。 这个契约是现代前端框架(React/Next.js, Vue/Nuxt)的根基——它让"首屏在服务端渲染"和"后续交互在客户端运行"共享同一套组件逻辑。
哲学四·「Buffer 是历史的资产,不是历史的包袱」
Buffer 诞生于一个 TypedArray 不存在的年代。当 ES6 引入 Uint8Array 后,Node 没有废弃 Buffer——而是让它成为 Uint8Array 的子类,同时保留了自己的特色(内存池、slice 共享、字节序读写)。这是一种务实的取舍:向下兼容所有已有 npm 包的 Buffer API,同时向上对齐 ECMAScript 标准。 在同构代码里用标准 API;在纯 Node 代码里用 Buffer 更快——两种选择都对,取决于你是否需要"跑在浏览器里"这个约束。
# 10.4 速查表
libuv 六阶段:
| 阶段 | 回调类型 | 典型 API | 饿死风险 |
|---|---|---|---|
| timers | 定时器到期 | setTimeout / setInterval | 如果 poll 一直繁忙,timer 延迟增加 |
| pending | 上轮延迟 I/O 错误 | TCP ECONNREFUSED / ECONNRESET | 低(通常极少回调) |
| poll | I/O 完成(核心) | fs.readFile / http.get / net.connect | 主动退出保护(有 setImmediate 在等) |
| check | setImmediate 回调 | setImmediate(fn) | 不会饿死(poll 主动退出保证) |
| close | 关闭事件 | socket.on('close') / stream.destroy() | 低(只在新 close 时触发) |
Stream 四类型:
| 类型 | 读 | 写 | 内部方法 | 典型场景 |
|---|---|---|---|---|
| Readable | ✅ | ❌ | _read(size) | fs.createReadStream / 网络下载 |
| Writable | ❌ | ✅ | _write(chunk, enc, cb) | fs.createWriteStream / HTTP 响应 |
| Duplex | ✅ | ✅ | _read + _write(独立) | net.Socket / tls.TLSSocket |
| Transform | ✅ | ✅ | _transform(chunk, enc, cb) + _flush(cb) | zlib.createGzip() / 过滤器 / 加密 |
Buffer 安全分配:
| 方法 | 清零 | 内存池 | 速度 | 安全性 |
|---|---|---|---|---|
Buffer.alloc(n) | ✅(零填充) | ✅(≤4KB 走 slab 池) | 慢 | 安全(默认选择) |
Buffer.allocUnsafe(n) | ❌ | ✅(≤4KB 走 slab 池) | 快 ~5x | ⚠️ 可能含旧内存数据 |
Buffer.allocUnsafeSlow(n) | ❌ | ❌(强制堆分配) | 快 | ⚠️ 仅供 C++ addon 场景 |
Buffer.from(string) | N/A(已有内容) | ✅(拷贝进 slab) | 中 | ✅ 安全 |
Buffer.from(arrayBuffer) | N/A(零拷贝共享) | ❌ | 极快 | ✅ 安全(共享底层内存) |
同构双端差异速查:
| 差异点 | 浏览器 | Node.js | 统一方式 |
|---|---|---|---|
| 全局对象 | window | global | globalThis |
| 模块系统 | ESM 原生 | CJS + ESM | 双导出(main + module/exports) |
| 4ms 夹持 | ✅ | ❌ | 不依赖精确延迟 |
__dirname | ❌ | ✅ | ESM 下用 import.meta.url |
Buffer | ❌ | ✅ | 同构代码用 Uint8Array |
60 秒诊断命令清单:
# 看事件循环活跃句柄(哪些东西让进程不退)
node --trace-event-categories node.perf --trace-event-file-pattern 'trace.json' server.js
# 看 Stream 背压状态(process.stdout / stderr 的缓冲)
node -e "console.log(process.stdout.writableLength)"
# 看 libuv 线程池并发度
UV_THREADPOOL_SIZE=8 node server.js
# 看 Buffer 内存池大小(默认 8KB)
node -e "console.log(Buffer.poolSize)"
# 看进程 RSS 趋势
watch -n 1 "ps -o pid,rss,comm -p \$(pgrep -f node)"
# 强制 GC(排查内存泄漏时的快照)
node --expose-gc -e "global.gc(); console.log(process.memoryUsage())"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
背压调试模板:
// 监控任意 Writable Stream 的缓冲状态
function monitorBackpressure(name, writable) {
const interval = setInterval(() => {
const len = writable.writableLength;
if (len > 1024 * 1024) { // > 1MB
console.warn(`⚠️ ${name}: writableLength=${(len/1024/1024).toFixed(1)}MB`);
}
}, 1000);
writable.on('close', () => clearInterval(interval));
}
const writer = fs.createWriteStream('./log');
monitorBackpressure('log-writer', writer);
2
3
4
5
6
7
8
9
10
11
12
13
下一步:浏览器和 Node 都跑通了。但一个产品不是单文件——模块系统怎么组织?进入 13.模块系统双轨互操作。
📌 本文与 C++ 专栏的关系:C++ 专栏第 01 篇讲的是"进程地址空间——数据住在哪一段",本文讲的是"运行时——数据怎么流动"。读完后你会看到:Node.js 的 Buffer(JavaScript 层的 Uint8Array 子类)和 C++ 的堆/mmap(操作系统层的虚拟内存段)——它们之间隔了一个 V8 的堆、一层 libuv 的 slab 池、一次系统调用的边界。理解"数据从 JS 到 OS"的完整路径,是成为全栈工程师的核心能力。