Streams 基础
这一课你将能做到
- 用
createReadStream / createWriteStream 复制大文件,并解释为什么 readFile 会爆内存
- 用
pipeline(而非 pipe)安全地串联流,并用 Transform 流做实时 gzip 压缩
- 说清背压(backpressure)是什么、
pipe 的经典错误处理缺陷在哪
为什么要学
第 4 课埋过一个伏笔:readFile 把整个文件读进内存,2GB 的文件直接把进程干爆。**流(Stream)**人话:不一次吃完,而是「来一口吃一口」——数据像水流一样分块(chunk)经过你的代码,内存占用恒定,还能边收边处理。
Node 的很多核心能力都是流:HTTP 的 req/res 是流(第 5 课读过 data 事件)、标准输入输出是流(第 2 课)、数据库结果集、加密压缩管道都是流。面试里「背压」「pipe 和 pipeline 区别」是流相关的两道高频题。这课学完,你处理日志分析、大文件导出、文件上传就有了正确工具。
先建立图像
readFile 的做法(一次性):
[2GB 文件] ──全部载入──► [2GB 内存] ──► 处理 内存峰值 = 文件大小
流的做法(分块):
[2GB 文件] ──64KB──► [处理] ──64KB──► [写出] ──► ...
chunk 1 chunk 2 chunk 3 ... 内存峰值 ≈ 几 MB,恒定
四种流类型:
Readable 可读流 fs.createReadStream / http 的 req / process.stdin
Writable 可写流 fs.createWriteStream / http 的 res / process.stdout
Duplex 双工流 可读可写(两个独立通道) net.Socket
Transform 转换流 读进来加工再写出 zlib.createGzip()
类比:readFile 是「把整头牛搬进厨房」,流是「流水线上按块切、按块加工」。
跟着做
步骤 1:先感受「整读」的极限
新建 node-streams 目录,npm init -y。先造一个大文件(约 500MB,按需调整):
// make-big.js
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(__dirname, 'big.txt');
const fd = fs.openSync(file, 'w');
const line = 'hello stream world padding padding padding\n'; // 约 39 字节
for (let i = 0; i < 5_000_000; i++) { // 500 万行 ≈ 190MB,可自行加大
fs.writeSync(fd, line);
}
fs.closeSync(fd);
console.log('生成完毕:', (fs.statSync(file).size / 1024 / 1024).toFixed(0), 'MB');
node make-big.js
# 生成完毕: 190 MB
步骤 2:readFile vs 流,内存对比
// compare.js
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
function heapMB() {
return (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
}
async function copyByReadFile(src, dest) {
const before = heapMB();
const data = await fsp.readFile(src); // 整个文件进内存
await fsp.writeFile(dest, data);
console.log(`readFile 复制完成, 堆内存 ${before}MB → ${heapMB()}MB`);
}
async function copyByStream(src, dest) {
const before = heapMB();
await fsp.copyFile(src, dest); // 底层就是流式
console.log(`流式复制完成, 堆内存 ${before}MB → ${heapMB()}MB`);
}
async function main() {
const big = path.join(__dirname, 'big.txt');
await copyByReadFile(big, path.join(__dirname, 'copy1.bin'));
await copyByStream(big, path.join(__dirname, 'copy2.bin'));
}
main();
// readFile 复制完成, 堆内存 3MB → 193MB ← 文件多大,内存涨多少
// 流式复制完成, 堆内存 193MB → 194MB ← 几乎不变
结论直观:readFile 的内存正比于文件大小,流的内存是常数。文件到 GB 级时前者直接 heap out of memory。
步骤 3:手动串起一对流
// manual-copy.js
const fs = require('node:fs');
const path = require('node:path');
const src = fs.createReadStream(path.join(__dirname, 'big.txt'));
const dest = fs.createWriteStream(path.join(__dirname, 'copy3.bin'));
src.on('data', (chunk) => {
// 每次这里收到一块数据(默认 64KB)
dest.write(chunk);
});
src.on('end', () => {
dest.end(); // 通知写流:没有更多数据了
console.log('复制完成');
});
src.on('error', (err) => console.error('读失败:', err.message));
dest.on('error', (err) => console.error('写失败:', err.message));
node manual-copy.js
# 复制完成
data / end / error 是可读流三大事件。这段代码能跑,但藏着一个隐患——下一步揭穿它。
步骤 4:背压——写不过来了怎么办
手动版的问题:src 读得快、dest 磁盘写得慢,dest.write() 把来不及写的数据堆在内存里排队,大文件时内存照样膨胀。这就是背压问题。
改用 pipe,Node 自动处理「读太快就暂停,写完了再继续」:
// pipe-copy.js
const fs = require('node:fs');
const path = require('node:path');
const src = fs.createReadStream(path.join(__dirname, 'big.txt'));
const dest = fs.createWriteStream(path.join(__dirname, 'copy4.bin'));
src.pipe(dest); // 一行完成:读取 → 写出 + 自动背压控制 + 自动 end
src.on('end', () => console.log('复制完成'));
背压机制一句话:写流的缓冲区满了(write() 返回 false),就通知读流暂停;写空了(drain 事件)再恢复。pipe 内置了这套节流,手写版没有。
但 pipe 还有个著名缺陷:读流出错时它不会关闭写流,也不会把错误传过去——进程可能挂着半开状态的写流悄悄泄漏。所以生产代码用 pipeline。
步骤 5:pipeline——生产级管道
stream/promises 的 pipeline 干三件事:接好流、传错误、任何一环出错时销毁所有环节,并且返回 Promise 配合 async/await:
// pipeline-copy.js
const fsp = require('node:fs/promises');
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
async function main() {
const big = path.join(__dirname, 'big.txt');
// 例 1:复制
await pipeline(
fs.createReadStream(big),
fs.createWriteStream(path.join(__dirname, 'copy5.bin'))
);
console.log('复制完成');
// 例 2:边读边压缩(Transform 流登场)
const start = Date.now();
await pipeline(
fs.createReadStream(big),
zlib.createGzip(), // 实时 gzip,无需整文件
fs.createWriteStream(path.join(__dirname, 'big.txt.gz'))
);
const raw = (await fsp.stat(big)).size;
const gz = (await fsp.stat(path.join(__dirname, 'big.txt.gz'))).size;
console.log(`压缩完成: ${Math.round(raw/1024/1024)}MB → ${Math.round(gz/1024/1024)}MB, 耗时 ${Date.now()-start}ms`);
}
main().catch((err) => {
console.error('管道失败:', err.message);
process.exitCode = 1;
});
node pipeline-copy.js
# 复制完成
# 压缩完成: 190MB → 1MB, 耗时 1200ms(文本压缩率高)
pipeline(读流, Transform, 写流) 的中间可以插任意多个转换环节:压缩、加密、行拆分……数据一路「现场加工」流出去。
步骤 6:readline——按行处理文本流
// line-count.js
const fs = require('node:fs');
const path = require('node:path');
const readline = require('node:readline');
const { pipeline } = require('node:stream/promises');
async function main() {
const file = path.join(__dirname, 'big.txt');
const rl = readline.createInterface({
input: fs.createReadStream(file, 'utf8'),
crlfDelay: Infinity, // 兼容 \r\n
});
let lines = 0;
let contains = 0;
rl.on('line', (line) => {
lines++;
if (line.includes('stream')) contains++;
});
await new Promise((resolve) => rl.on('close', resolve));
console.log(`总行数: ${lines}, 含 stream 的行: ${contains}`);
}
main();
node line-count.js
# 总行数: 5000000, 含 stream 的行: 5000000
换成 readFile 拿这 190MB 去 split('\n'),内存要翻好几倍;readline 版内存几乎不动。
讲清楚要点
- 流 = 分块处理 + 恒定内存:chunk 默认 64KB(可调 highWaterMark)。凡是「源大小不可控」(用户上传、日志、网络响应)的场景,一律优先流。
- 四种流各司其职:Readable 读、Writable 写、Duplex 双向、Transform 读写且加工(gzip/加密/行拆分)。HTTP 的 req 是 Readable、res 是 Writable——第 5 课收 body 就是在消费一个流。
- 背压是流安全的核心:写端缓冲满 → 暂停读端 → 缓冲空(drain)→ 恢复。手动 data/write 组合没有节流,
pipe 与 pipeline 内置。
pipeline 优于 pipe:pipe 出错时其他环节不会自动关闭(错误不传播、资源泄漏);pipeline 任一环节失败会销毁全部环节并把错误交给你,还返回 Promise 可 await。
- 事件三件套:Readable 的
data(来块了)/ end(读完了)/ error;Writable 的 drain(缓冲清空)/ finish(全写完了)。error 不监听,异常就是 uncaughtException。
你可能会懵的地方
-
什么时候必须用流,什么时候 readFile 就够?
小文件(配置、模板,KB~MB 级)readFile 更简单直接。满足任一条件就上流:文件可能很大、来源是网络/用户上传、需要边收边处理(边下载边压缩)、要接管道转换。
-
chunk 到底是什么?
一小段 Buffer(二进制数据块),默认 64KB。处理文本时要 setEncoding('utf8') 或 chunk.toString('utf8')——但注意 UTF-8 多字节字符可能被切块切断,跨 chunk 拼接时用 string_decoder 模块或 readline 才安全。
-
pipe 到底错在哪?既然有坑为什么不废除?
pipe 的坑只在「错误处理」:正常路径它很好用。历史包袱让它保留,官方推荐新代码用 pipeline。看到 pipe + 手动挂错误监听的老代码,能看懂即可,新写一律 pipeline。
-
end 和 finish 有什么区别?
end 是读流的事件:没有更多数据可读了;finish 是写流的事件:所有数据都刷到底层了。复制完成要等写流的 finish,等读流的 end 只说明「都交给写流了」,还没落盘。
-
流能「倒回去重读」吗?
默认不能,读完就没了。要重读就重新 createReadStream,或用 readable.pause()/resume() 手动控速。需要随机访问(读中间某段)时,用 fd + read 指定 offset。
错法对照
| 错法 | 问题 | 正解 |
|---|
readFile 处理上传文件/日志 | 内存正比文件大小,大文件 OOM | createReadStream 分块处理 |
手动 data/write 不做背压 | 写不过来时内存无限堆积 | 用 pipe/pipeline 内置节流 |
生产代码用 pipe 且不挂 error | 出错时写流泄漏、错误被吞 | stream/promises 的 pipeline |
消费流后既不 data 也不 pause | 流静止不动,程序「卡住」 | 流必须被消费:挂 data、接 pipe,或显式 pause |
src.on('end') 里认为复制已完成 | 数据可能还在写缓冲里 | 等 dest 的 finish 事件 |
对二进制流 chunk + '' 拼字符串 | 隐式 toString,多字节字符可能截断乱码 | 统一 setEncoding 或用 string_decoder |
卡点急救
- 程序什么都不输出也不退出:流创建了但没人消费。检查是否挂了
data 监听、是否 pipe/pipeline 接到了下游;不需要时显式 stream.destroy()。
ERR_STREAM_PREMATURE_CLOSE:管道中某个流提前销毁了。检查上游 read 流是否出错(挂 error 监听看日志)、文件是否被外部删除。
- 复制出来的文件比原文件大/小:大——可能手动 write 又调用了 end 前重复写;小——end 过早,缓冲没刷完。用 pipeline 一步到位可避免手写时序问题。
- 中文乱码出现在行首:chunk 边界切断了多字节字符。整行处理换
readline;自定义协议用 string_decoder.StringDecoder。
EMFILE: too many open files:同时打开的文件句柄超限(并发开几百个读流)。用分批(每批 N 个)或 p-limit 控制并发数。
面试常问
-
什么是背压(backpressure)?Node 怎么处理?
读端生产速度快于写端消费速度时,数据堆积在写缓冲造成内存膨胀。Node 流通过 highWaterMark 判断:write() 返回 false 表示缓冲满,应暂停读(readable.pause());缓冲清空触发 drain 事件再恢复。pipe/pipeline 内置这套机制。
-
pipe 和 pipeline 的区别?
pipe 自动传递数据与背压,但错误不传播:一环出错其余环节不会自动销毁,可能资源泄漏;pipeline(stream/promises)在任一环节失败时销毁所有流并返回 Promise,能被 try/catch 接住,生产代码首选。
-
Node 有哪几种流?举例说明。
Readable(fs.createReadStream、http req、stdin)、Writable(createWriteStream、http res、stdout)、Duplex(net.Socket,读写独立)、Transform(zlib.createGzip、加密流,读写之间加工)。
-
为什么大文件处理必须用流?
readFile 将文件整体载入内存,内存峰值等于文件大小,GB 级文件直接 OOM;流以固定 chunk(默认 64KB)流动处理,内存占用恒定,且可与转换(压缩/加密)实时串联。
自测三问
- 读流的事件
end 和写流的事件 finish 分别在什么时刻触发?
- 手写
data+write 复制文件有什么隐患?pipe 如何解决?
- 「读文件 → gzip 压缩 → 写出」用 pipeline 怎么写(三个参数)?
小结一句话
流用「分块 + 背压 + 管道」把大文件的内存问题变成常数级:读靠 createReadStream、加工靠 Transform、生产级串联只认 pipeline。
延伸