CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Node.js 后端入门/课时/Streams 基础
RawBlame
课时目录12 节

Node.js 后端入门

  1. 01Node.js 是什么
  2. 02运行时与 process
  3. 03模块与 npm
  4. 04文件系统与 path
  5. 05http 模块
  6. 06Express 入门
  7. 07事件循环与异步
  8. 08环境变量与配置
  9. 09Streams 基础
  10. 10错误处理
  11. 11测试 node:test
  12. 12综合练习与选型

Streams 基础

Readable/Writable、管道、大文件处理直觉。

30 分钟

Streams 基础

这一课你将能做到

  1. 用 createReadStream / createWriteStream 复制大文件,并解释为什么 readFile 会爆内存
  2. 用 pipeline(而非 pipe)安全地串联流,并用 Transform 流做实时 gzip 压缩
  3. 说清背压(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 版内存几乎不动。

讲清楚要点

  1. 流 = 分块处理 + 恒定内存:chunk 默认 64KB(可调 highWaterMark)。凡是「源大小不可控」(用户上传、日志、网络响应)的场景,一律优先流。
  2. 四种流各司其职:Readable 读、Writable 写、Duplex 双向、Transform 读写且加工(gzip/加密/行拆分)。HTTP 的 req 是 Readable、res 是 Writable——第 5 课收 body 就是在消费一个流。
  3. 背压是流安全的核心:写端缓冲满 → 暂停读端 → 缓冲空(drain)→ 恢复。手动 data/write 组合没有节流,pipe 与 pipeline 内置。
  4. pipeline 优于 pipe:pipe 出错时其他环节不会自动关闭(错误不传播、资源泄漏);pipeline 任一环节失败会销毁全部环节并把错误交给你,还返回 Promise 可 await。
  5. 事件三件套: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 处理上传文件/日志内存正比文件大小,大文件 OOMcreateReadStream 分块处理
手动 data/write 不做背压写不过来时内存无限堆积用 pipe/pipeline 内置节流
生产代码用 pipe 且不挂 error出错时写流泄漏、错误被吞stream/promises 的 pipeline
消费流后既不 data 也不 pause流静止不动,程序「卡住」流必须被消费:挂 data、接 pipe,或显式 pause
src.on('end') 里认为复制已完成数据可能还在写缓冲里等 dest 的 finish 事件
对二进制流 chunk + '' 拼字符串隐式 toString,多字节字符可能截断乱码统一 setEncoding 或用 string_decoder

卡点急救

  1. 程序什么都不输出也不退出:流创建了但没人消费。检查是否挂了 data 监听、是否 pipe/pipeline 接到了下游;不需要时显式 stream.destroy()。
  2. ERR_STREAM_PREMATURE_CLOSE:管道中某个流提前销毁了。检查上游 read 流是否出错(挂 error 监听看日志)、文件是否被外部删除。
  3. 复制出来的文件比原文件大/小:大——可能手动 write 又调用了 end 前重复写;小——end 过早,缓冲没刷完。用 pipeline 一步到位可避免手写时序问题。
  4. 中文乱码出现在行首:chunk 边界切断了多字节字符。整行处理换 readline;自定义协议用 string_decoder.StringDecoder。
  5. 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)流动处理,内存占用恒定,且可与转换(压缩/加密)实时串联。

自测三问

  1. 读流的事件 end 和写流的事件 finish 分别在什么时刻触发?
  2. 手写 data+write 复制文件有什么隐患?pipe 如何解决?
  3. 「读文件 → gzip 压缩 → 写出」用 pipeline 怎么写(三个参数)?

小结一句话

流用「分块 + 背压 + 管道」把大文件的内存问题变成常数级:读靠 createReadStream、加工靠 Transform、生产级串联只认 pipeline。

延伸

  • Node.js 官方文档 Stream
  • Node.js 官方指南:背压与流
  • 下一课:错误处理——同步异常、异步错误、全局兜底,把「崩溃」变成「可控的失败」。
加载本节练习…
← 上一课环境变量与配置下一课 →错误处理