AI → Full-Stack
365 天全栈成长 · PHASE 1 · WEEK 1

Day 3 - Stream 与 Buffer:后端的水管与水桶

今日时长:3 小时(学习 50 min + 动手 100 min + 整理 30 min)· 主题:流式 I/O(Streaming)——大数据不能一次搬,要分块流着搬 · 练习语言:Node.js(它只是教具,"分块 + 缓冲 + 背压"这套思想才是主角)

📦 接上回:服务员接单很快,可 2GB 的菜怎么端?

Day 1–2 我们搞清楚了:Node.js 的服务员(event loop)从不站着等,慢活外包、按铃收尾,一个人能接待成千上万桌客人。

但今天有个新麻烦:客人点了一份"读取 2GB 日志文件"。服务员动作再快,这道菜也根本端不上桌 —— 托盘(内存)只有那么大,一次性把 2GB 全读进来,进程直接被压垮。

这不是 Node 的问题,是所有后端的日常:

  • 用户上传一个 500MB 的视频 → 不能全塞进内存再存盘;
  • 从数据库导出 1000 万行生成 CSV → 不能一次查出全部;
  • 代理转发一个下载请求 → 不能把整个文件先吞下来再吐出去。
💡 生活翻译: 搬家公司的衣柜太大,电梯装不下。笨办法是叫一辆巨型卡车整栋搬(一次性读进内存);聪明办法是拆成一个个纸箱,走传送带一箱一箱运(流式处理)。今天学的就是这条"传送带":Stream,和装纸箱的"标准周转箱":Buffer。

📇 概念卡 #1:Buffer —— 标准周转箱

先问一个底层问题:文件、网络发给程序的到底是什么?不是文字,不是 JSON,是一串原始字节(byte)。"你好"两个字,在 UTF-8 编码下其实是 6 个字节。程序收到字节后,总得有个容器先装着,才能交给你处理 —— 这个容器就是 buffer。

概念卡 #1(语言无关)

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 —— 传送带,不是大卡车

概念卡 #2(语言无关)

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() 压缩、加密、转码
💡 生活翻译: 一条完整的流水线可以串起来:出餐口(读文件)→ 加工台(gzip 压缩)→ 收盘口(写文件)。每一块数据自动流完全程 —— 这就是下一节要拧紧的"水管"。

🚦 backpressure:下游吃得慢,上游必须会被喊停

现在思考一个所有流系统都绕不开的问题:上游送货快,下游吃得慢,多出来的货堆在哪?

答案:堆在内存里。读文件每秒 500MB,写磁盘每秒只能 100MB —— 每秒就有 400MB 积压在内存缓冲区。文件够大,内存照样爆。传送带并没有自动解决这个问题,它只是提供了喊停的机制

概念卡 #3(语言无关,后端通用痛点)

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 的话,红灯绿灯全自动处理(下一节)。
⚠️ 为什么这是"全后端通用"的概念: TCP 的滑动窗口是背压;Kafka 的消费 lag 监控的是背压;.NET / Java 的 Reactive Streams 规范里 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 多解决两件要命的事:

  1. 错误传播:任何一段出错,异常会抛出来让你 catch(pipe 的错误会静默漏掉);
  2. 资源清理:出错时把整条流上所有段都销毁、释放句柄(pipe 会留下"半截水管"漏资源)。

规矩:练手用 pipe 理解原理,生产代码一律 pipeline。

🌍 跨语言对照(流式思想 everywhere)

今天学的不是"Node 的 stream API",是"流式 I/O"这个通用概念。换语言只是换名词:

通用概念Node.js(教具)C# / .NETJavaGo
流抽象 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 句话,必须用自己的话):

  1. 用生活比喻讲清 stream、buffer、backpressure 三者的关系(提示:传送带 / 周转箱 / 红绿灯)。
  2. 为什么生产环境推荐 pipeline 而不是手写 pipe 或 data 事件拼接?多解决了哪两个问题?

写完后回到第 3、5、6 节对答案,把说错的地方订正。

✅ 总结与自测

今天带走的 4 句话

  1. 大数据不能一次搬进内存:stream 是传送带(分块流着搬),buffer 是周转箱(装每一块的固定容器)。
  2. 流式处理两大好处:内存恒定即时开工;四种流 = 出餐口 Readable、收盘口 Writable、对讲机 Duplex、加工台 Transform。
  3. 下游跟不上时,数据会堆在内存 —— backpressure 是流系统的保命机制:write 返回 false 是红灯,drain 是绿灯。
  4. 手动接水管练原理,生产代码用 pipeline:错误会传播、资源会清理。

自测清单(能打勾才算今天过关)

  • 能用"周转箱 + 传送带"比喻讲清 buffer 和 stream 各自是什么
  • 能说出流式处理的两大好处,并举两个真实后端场景
  • 能解释 backpressure:数据堆在哪、红灯绿灯各是什么、highWaterMark 是什么
  • 能说出 pipeline 比 pipe 多解决的两个问题
  • 能把 stream 翻译成 .NET/Java/Go 的对应物(Stream / InputStream / io.Reader)