Day 3 - Stream 与 Buffer:后端的水管与水桶
📦 接上回:服务员接单很快,可 2GB 的菜怎么端?
Day 1–2 我们搞清楚了:Node.js 的服务员(event loop)从不站着等,慢活外包、按铃收尾,一个人能接待成千上万桌客人。
但今天有个新麻烦:客人点了一份"读取 2GB 日志文件"。服务员动作再快,这道菜也根本端不上桌 —— 托盘(内存)只有那么大,一次性把 2GB 全读进来,进程直接被压垮。
这不是 Node 的问题,是所有后端的日常:
- 用户上传一个 500MB 的视频 → 不能全塞进内存再存盘;
- 从数据库导出 1000 万行生成 CSV → 不能一次查出全部;
- 代理转发一个下载请求 → 不能把整个文件先吞下来再吐出去。
📇 概念卡 #1:Buffer —— 标准周转箱
先问一个底层问题:文件、网络发给程序的到底是什么?不是文字,不是 JSON,是一串原始字节(byte)。"你好"两个字,在 UTF-8 编码下其实是 6 个字节。程序收到字节后,总得有个容器先装着,才能交给你处理 —— 这个容器就是 buffer。
Buffer(字节缓冲区):内存里一块固定大小的连续空间,专门临时存放"运输途中"的原始字节。它是 I/O 世界交接货物的标准容器:文件系统、网卡、你的代码,三方都用它倒手。
一句话记:buffer 是快递行业的标准周转箱 —— 全国统一尺寸,只认箱子不认内容;装满就运走,卸空接着用。它是运输中的临时停车位,不是仓库。
Node 里的 Buffer 三个脾气
const box = Buffer.alloc(4); // ① 固定大小:4 字节箱子,多装会被截断
const b = Buffer.from("你好"); // ② 按字节算:"你好"= 2 个字符 = 6 个字节
const part = b.subarray(0, 3); // ③ subarray/slice 是"隔板"不是"复印":
// 改 part 会改到 b 本身(共享同一块内存)
习题 3 会亲手验证这三个脾气。现在只要记住:chunk(流里的每一块数据)默认就是 Buffer —— 传送带上运的每个纸箱,都是这个标准周转箱。
📇 概念卡 #2:Stream —— 传送带,不是大卡车
Stream(流):把一份大数据拆成一串小块(chunk),一块一块地生产、一块一块地消费,每块到达时用事件(或返回值)通知你的 I/O 抽象。
一句话记:大卡车一次性拉走 vs 传送带一箱一箱运。流式处理的两大好处:内存恒定(桌上永远只放一盘菜)和即时开工(第一块到了就能处理,不用等全部到齐)。
两种读法的人生对比
| 大卡车:readFile | 传送带:createReadStream | |
|---|---|---|
| 内存占用 | ≈ 整个文件大小(2GB 文件 = 2GB 内存) | ≈ 恒定几十 MB(永远只有运输中的几箱) |
| 何时能开工 | 等全部读完,才开始处理 | 第一块到了就处理,边下边干活 |
| 适合场景 | 小文件(配置、模板,几 MB 以内) | 大文件 / 网络数据 / 不知道多大的数据 |
Node 里流怎么"通知"你
Node 的流是一个 EventEmitter,每运来一箱就按一次铃:
import { createReadStream } from "node:fs";
const stream = createReadStream("big.log");
for await (const chunk of stream) {
// 每到一个 chunk(一个 Buffer)就执行一次循环体
// 全程内存里只有"正在处理的这一块"
}
for await 是最现代的吃法;底层靠的是 data(来了一箱)、end(运完了)、error(出事了)三个事件。
🍽️ 四种流:出餐口 / 收盘口 / 对讲机 / 加工台
把餐厅想象成一条流水线,四种流各管一段。这个四分法语言无关,任何后端生态都认:
| 流的类型 | 餐厅角色 | 方向 | Node 例子 | 真实场景 |
|---|---|---|---|---|
| Readable 可读流 |
出餐口:只往外端 | 数据源 → 你的代码 | fs.createReadStream |
读文件、读 HTTP 请求体 |
| Writable 可写流 |
收盘口:只往里收 | 你的代码 → 目的地 | fs.createWriteStream |
写文件、写 HTTP 响应 |
| Duplex 双工流 |
对讲机:两头各说各的 | 双向,读写互不影响 | TCP net.Socket |
网络连接、WebSocket |
| Transform 转换流 |
加工台:边过边改 | 进来的每块,改完再出去 | zlib.createGzip() |
压缩、加密、转码 |
🚦 backpressure:下游吃得慢,上游必须会被喊停
现在思考一个所有流系统都绕不开的问题:上游送货快,下游吃得慢,多出来的货堆在哪?
答案:堆在内存里。读文件每秒 500MB,写磁盘每秒只能 100MB —— 每秒就有 400MB 积压在内存缓冲区。文件够大,内存照样爆。传送带并没有自动解决这个问题,它只是提供了喊停的机制。
Backpressure(背压):当消费方处理速度跟不上生产方时,由下游向上游传递"慢点 / 停一下"的信号,让上游暂停生产,防止数据在缓冲区无限堆积。
一句话记:快递分拣中心的红绿灯 —— 传送带堆满了,红灯亮起,装货员停手;分拣员清空了,绿灯(drain)亮起,继续装货。背压不是优化,是流式系统的保命机制。
Node 里的红绿灯长什么样
const ok = ws.write(chunk); // 返回 false = 红灯:缓冲区已超过水位线(highWaterMark)
if (!ok) {
await once(ws, "drain"); // drain 事件 = 绿灯:缓冲区排空,可以继续写了
}
highWaterMark(水位线):缓冲区的警戒线,默认 16KB。超过它,write 就亮红灯;- 无视红灯继续写:数据不会丢,但全堆在内存里 —— 习题 2 里 64MB 数据莽夫写法的缓冲区峰值就是 64MB,守规矩写法只有 64KB;
- 用
pipe的话,红灯绿灯全自动处理(下一节)。
request(n) 就是显式背压;Go 里 channel 写满自然阻塞也是背压。面试任何语言的流/消息队列问题,"背压怎么处理"都是必答题。
🔧 pipe 与 pipeline:把水管拧紧
手动管理 chunk 和红绿灯太啰嗦,Node 给了两根现成的水管接头:
pipe:自动接水 + 自动背压
createReadStream("big.log").pipe(createWriteStream("copy.log"));
一行搞定:每来一块自动写过去;下游亮了红灯,上游自动暂停;绿灯亮了自动恢复。
pipeline:pipe 的生产环境版(推荐)
import { pipeline } from "node:stream/promises";
await pipeline(
createReadStream("big.log"), // 出餐口
createGzip(), // 加工台
createWriteStream("big.log.gz") // 收盘口
);
pipeline 比 pipe 多解决两件要命的事:
- 错误传播:任何一段出错,异常会抛出来让你 catch(pipe 的错误会静默漏掉);
- 资源清理:出错时把整条流上所有段都销毁、释放句柄(pipe 会留下"半截水管"漏资源)。
规矩:练手用 pipe 理解原理,生产代码一律 pipeline。
🌍 跨语言对照(流式思想 everywhere)
今天学的不是"Node 的 stream API",是"流式 I/O"这个通用概念。换语言只是换名词:
| 通用概念 | Node.js(教具) | C# / .NET | Java | Go |
|---|---|---|---|---|
| 流抽象 | stream.Readable / Writable(对象 + 事件) | System.IO.Stream(一个基类身兼读写) |
InputStream / OutputStream;NIO 是 Channel |
io.Reader / io.Writer(两个小接口,组合出一切) |
| 字节缓冲区 | Buffer(固定大小,subarray 共享内存) |
byte[] / Memory<byte> / Span<byte> |
byte[] / NIO ByteBuffer |
[]byte(切片天然共享底层数组) |
| 分块读 | for await (chunk of stream) |
await stream.ReadAsync(buf) |
in.read(buf) 循环 |
r.Read(p) 循环 |
| 管道拼接 | pipeline(src, …, dst) |
src.CopyToAsync(dst) |
in.transferTo(out)(Java 9+) |
io.Copy(dst, src) |
| 背压信号 | 显式:write()===false → 等 drain |
隐式:await 写不完不返回,天然排队 |
隐式:阻塞写天然排队;Reactive Streams 用 request(n) 显式化 |
隐式:channel / 阻塞 Read 天然排队 |
📌 面试翻译练习:面试官问 "Node 的 backpressure 是什么?" 你可以答:"流式系统的流量自保机制 —— 下游跟不上时向上游发暂停信号。Node 因为是事件驱动才需要 write 返回值 + drain 这对显式信号;.NET/Java/Go 的阻塞或 await 写法是'写不完就等着',本质是同一件事的两种实现。" 一个概念,四个生态通用。
🛠️ 今日习题(约 30 min)
习题代码在 src/03-streams-buffer/,已写好可直接运行,带中文注释和预测填空。运行方式:npx tsx src/03-streams-buffer/ex1-readfile-vs-stream.ts
习题 1:大卡车 vs 传送带(动手,约 8 min)
文件:ex1-readfile-vs-stream.ts
同一个 200MB 文件,先用流式读、再用 readFile 读,对比 rss(常驻内存)变化。先在文件顶部写下你对两个数字的预测,再运行对比。想清楚:流式的峰值为什么不随文件大小涨?
习题 2:backpressure 红绿灯(动手,约 10 min)
文件:ex2-backpressure.ts
同样写 64MB:莽夫写法无视 write 返回值,守规矩写法红灯停绿灯行。观察两者缓冲区峰值差 1000 倍。回答注释里的思考题:如果 TOTAL 是 64GB,莽夫写法会发生什么?把答案写进 notes.md。
习题 3:Buffer 五连(动手,约 7 min)
文件:ex3-buffer-basics.ts
五个小实验,每个先写预测再运行:字节数 ≠ 字符数、固定大小截断、subarray 共享内存、concat 是复印、编码误读。③ 的"隔板"特性是笔试高频坑,务必亲手踩一次。
习题 4:概念卡默写(动笔不动键盘,约 5 min)
文件:notes.md(骨架已放好)
合上本文档,用中文回答两题(每题不超过 5 句话,必须用自己的话):
- 用生活比喻讲清 stream、buffer、backpressure 三者的关系(提示:传送带 / 周转箱 / 红绿灯)。
- 为什么生产环境推荐 pipeline 而不是手写 pipe 或 data 事件拼接?多解决了哪两个问题?
写完后回到第 3、5、6 节对答案,把说错的地方订正。
✅ 总结与自测
今天带走的 4 句话
- 大数据不能一次搬进内存:stream 是传送带(分块流着搬),buffer 是周转箱(装每一块的固定容器)。
- 流式处理两大好处:内存恒定、即时开工;四种流 = 出餐口 Readable、收盘口 Writable、对讲机 Duplex、加工台 Transform。
- 下游跟不上时,数据会堆在内存 —— backpressure 是流系统的保命机制:write 返回 false 是红灯,drain 是绿灯。
- 手动接水管练原理,生产代码用 pipeline:错误会传播、资源会清理。
自测清单(能打勾才算今天过关)
- 能用"周转箱 + 传送带"比喻讲清 buffer 和 stream 各自是什么
- 能说出流式处理的两大好处,并举两个真实后端场景
- 能解释 backpressure:数据堆在哪、红灯绿灯各是什么、highWaterMark 是什么
- 能说出 pipeline 比 pipe 多解决的两个问题
- 能把 stream 翻译成 .NET/Java/Go 的对应物(Stream / InputStream / io.Reader)