高并发下为什么要用 Stream?
这个问题问到了点子上!理解“Stream(流)”和“大对象(Large Object/Buffer)”的本质区别,是你从“会用Node”跨越到“精通Node”的分水岭。
用一句话回答核心原因:因为Node.js的内存有“天花板”(默认约1.4GB),而高并发下的请求是“无穷”的。用“大对象”是“把所有鸡蛋装进一个篮子”,而用“流”是“造一条传送带”。
我从四个维度来拆解,你一看就明白:
1. 内存占用:从“一次性爆仓”到“持续小步快跑”
-
大对象模式(错误):假设你要返回一个 500MB 的文件给用户。Node.js 必须调用
fs.readFileSync将 500MB 数据一次性全部读进内存(V8 堆内存)。此时,这 500MB 瞬间被独占。 -
后果:如果有 10 个并发请求,内存直接飙到 5GB,超出 V8 内存上限,进程直接崩溃(OOM - Out of Memory)。
-
流模式(正确):Stream 将 500MB 文件切分成无数个 16KB~64KB 的小块(Buffer/Chunk)。每次只往内存里放 16KB,发送完这一块,内存立即释放,再读取下一块。
-
结果:无论文件是 500MB 还是 5GB,内存占用恒定在几十 KB。请求再多(如 1000 个并发),内存也只是线性微增,永远不会爆仓。
2. 垃圾回收(GC):从“频繁卡顿”到“几乎无感知”
这是最容易被忽视的“隐形杀手”。
-
大对象模式:一次性分配 500MB 内存,V8 的垃圾回收器(GC)会将这些大对象标记为 “老生代(Old Space)”。为了回收这 500MB,V8 必须执行 全量垃圾回收(Full Mark-Compact)。这个过程会**暂停整个主线程(Stop-The-World)**长达 50ms~200ms。
-
连锁反应:在高并发下,GC 频繁卡顿,事件循环被阻塞,所有用户的请求延迟瞬间飙升(毛刺现象)。
-
流模式:每次只分配 16KB 的小对象,这些小对象通常在 “新生代(New Space)” 中就被快速回收(Scavenge 算法),单次回收耗时仅 不到 1ms。GC 压力几乎为零,事件循环永远丝滑。
3. 响应延迟(TTFB):从“等待全部”到“立即开始”
用户体验的差异极大。
-
大对象模式:服务器必须把文件完全读入内存并准备好后,才开始向客户端发送第一个字节(TTFB)。用户需要等待 500MB 全部加载到内存,可能耗时 2~3 秒,浏览器才出现下载框。
-
流模式:读取到第一个 16KB 数据块后,立刻通过管道(
pipe)发送给客户端。用户可能在 几十毫秒 后就能收到首字节,页面加载进度条迅速出现。这就是 “边读边发”。
4. 背压机制(Backpressure):自动调节“水龙头与水桶”
这是流(Stream)最智能的地方,也是大对象完全不具备的能力。
- 场景:假设服务器读取文件的速度是 100MB/s,但客户端的网络下载速度只有 1MB/s。
- 大对象模式:服务器不管不顾,先把 500MB 塞满内存,然后慢慢塞给网卡。内存一直被占用,直到发完为止。
- 流模式:流内置了
pipe()方法。当网卡(水桶)来不及发送时,流会自动告诉文件读取源(水龙头):“慢一点,先暂停读取!”;等网卡空闲了,再通知:“继续读!”。 - 结果:流速自动匹配,永远不会因为“生产者快、消费者慢”而导致内存积压。
代码对比(直观感受)
❌ 错误写法(大对象,内存杀手)
const http = require('http');
const fs = require('fs');
http.createServer((req, res) => {
// 读取整个 500MB 文件进内存,再发送
const data = fs.readFileSync('./bigfile.zip');
res.end(data);
}).listen(3000);
// 压测时 10 个并发直接内存暴涨,GC 卡顿
✅ 正确写法(流,内存恒定)
const http = require('http');
const fs = require('fs');
http.createServer((req, res) => {
// 创建读取流
const readStream = fs.createReadStream('./bigfile.zip');
// pipe 自动处理背压,内存占用恒定在 ~16KB
readStream.pipe(res);
}).listen(3000);
// 1000 个并发下,内存依然稳如泰山
唯一的例外(什么时候可以不用流?)
如果你的业务数据非常小(比如小于 1KB 的 JSON 响应),或者必须一次性获取全部数据才能计算(比如需要对整个 JSON 做 JSON.parse 聚合求和),那么用大对象是可以接受的。
但凡是涉及文件传输、数据库大字段查询、上游 API 大响应体转发,必须使用 Stream,这是生产环境的铁律。
如果你还想深入,我可以接着讲 Node.js 中四种流(Readable、Writable、Duplex、Transform)的实战应用,比如如何利用 Transform 流在数据发送给用户前进行实时 Gzip 压缩(边读边压缩边发送),或者如何在流中做 AES 加密而不占用额外内存。需要吗?