前端开发··1 阅读·预计 12 分钟

Node.js 流式处理的三个反模式:别再让内存成为瓶颈

前言

不夸张地说,90% 的 Node.js 后端开发者在处理文件上传、日志聚合、数据导出时都有过一个共同的疑问:"为什么我的服务处理 500MB 的 CSV 文件会 OOM?我明明用了 Stream 啊。"

答案很残酷:你只是"写"了 Stream,但没有真正"流"起来。

这篇文章拆解三个最常见的流式处理反模式,每一个都来自真实的生产事故。

反模式一:读完再写——假装在流

最常见的"假流"代码:

// ❌ 反模式:看似用了 createReadStream,实际上全读进内存
const fs = require('fs');

async function transformFile(input, output) {
  const chunks = [];
  const stream = fs.createReadStream(input);
  
  for await (const chunk of stream) {
    chunks.push(chunk);  // 全堆在内存里
  }
  
  const allData = Buffer.concat(chunks);
  const transformed = allData.toString()
    .split('\n')
    .map(line => line.toUpperCase())
    .join('\n');
  
  fs.writeFileSync(output, transformed);
}

这段代码的问题:for await 虽然逐块读取,但 chunks.push 把每一块都留在了内存。500MB 文件 = 500MB 内存占用,完全不"流"。

正确做法:

// ✅ 真正流式:pipe + Transform
const { Transform } = require('stream');
const { pipeline } = require('stream/promises');

async function transformFile(input, output) {
  const upperCaseTransform = new Transform({
    transform(chunk, encoding, callback) {
      callback(null, chunk.toString().toUpperCase());
    }
  });

  await pipeline(
    fs.createReadStream(input),
    upperCaseTransform,
    fs.createWriteStream(output)
  );
}

关键差异:

方式内存占用处理 1GB 文件
for await + push~1GBOOM
pipeline + Transform~64KB(默认 highWaterMark)稳定

反模式二:忽视背压——上游洪水,下游旱地

背压(Backpressure)是 Stream 的核心机制。当消费者速度跟不上生产者时,Stream 会自动"捏闸"。但手工 data 事件会绕开这个机制:

// ❌ 反模式:data 事件无视背压
const readStream = fs.createReadStream('large.log');
const writeStream = fs.createWriteStream('output.log');

readStream.on('data', (chunk) => {
  // writeStream.write 返回 false 表示缓冲区满了
  // 但 data 事件照样轰炸,完全不减速
  writeStream.write(processChunk(chunk));
});

readStream.on('end', () => {
  console.log('done, maybe with OOM');
});

这个问题在生产中的表现是:处理小文件正常,文件一大就 OOM。因为 data 事件不管 write() 返回 false,继续推送数据。

正确做法:pipe 或 pipeline 自动处理背压:

// ✅ pipeline 内置背压管理
const { pipeline } = require('stream/promises');
const { Transform } = require('stream');

const processTransform = new Transform({
  transform(chunk, encoding, callback) {
    // 模拟耗时处理
    setTimeout(() => {
      callback(null, processChunk(chunk));
    }, 10);
  }
});

await pipeline(
  fs.createReadStream('large.log'),
  processTransform,
  fs.createWriteStream('output.log')
);
// pipeline 会在 writeStream 缓冲区满时自动暂停 readStream

如果你必须手动处理,用 readable + read() 配合 drain 事件:

// ✅ 手动背压管理
readStream.on('readable', () => {
  let chunk;
  while ((chunk = readStream.read()) !== null) {
    const canContinue = writeStream.write(processChunk(chunk));
    if (!canContinue) {
      // 缓冲区满了,暂停读取
      readStream.pause();
      writeStream.once('drain', () => readStream.resume());
      break;
    }
  }
});

反模式三:高水位开航母——默认配置踩坑

Stream 的 highWaterMark 默认值是 16KB(对于 fs.createReadStream 是 64KB)。这个值对大多数场景够用,但两个极端会让你翻车:

极端一:水位太低,IO 碎片化

// ❌ 读 100GB 的数据库 dump,64KB 一次
// 结果:150 万次系统调用,IO 开销巨大
fs.createReadStream('db_dump.sql')
  .pipe(transform)
  .pipe(fs.createWriteStream('output.sql'));

极端二:水位太高,内存吃紧

// ❌ 100MB 高水位,并发 10 个请求 = 1GB 内存
const stream = new Readable({
  highWaterMark: 100 * 1024 * 1024, // 100MB
  read() { /* ... */ }
});

工程化建议:

// ✅ 根据场景调整 highWaterMark
const { pipeline } = require('stream/promises');

// 大文件顺序处理 → 提高水位减少系统调用
const reader = fs.createReadStream('dump.sql', {
  highWaterMark: 1024 * 1024  // 1MB,系统调用减少 16 倍
});

// 高并发场景 → 降低水位控制内存
const concurrentReader = fs.createReadStream(file, {
  highWaterMark: 16 * 1024  // 16KB,配合背压稳定内存
});

await pipeline(reader, transform, writer);

一个经验法则:

  • 单任务大文件 → 1-4 MB highWaterMark
  • 并发任务 → 16-64 KB(默认值通常就对了)
  • 网络流 → 16KB(TCP 默认窗口的友好值)

别忘了错误处理

pipeline 相比 pipe 的最大优势是错误传播:

// ❌ pipe:错误不会自动传播
readStream.pipe(transform).pipe(writeStream);
readStream.on('error', handleErr);
transform.on('error', handleErr);  // 容易遗漏
writeStream.on('error', handleErr);

// ✅ pipeline:任一环节出错都会清理并 reject
const { pipeline } = require('stream/promises');
try {
  await pipeline(readStream, transform, writeStream);
} catch (err) {
  // 所有 stream 已自动销毁
  console.error('Pipeline failed:', err);
}

总结

反模式症状修复
读完再写大文件 OOMpipeline + Transform
忽视背压内存持续增长pipeline 或 readable + drain
高水位不当IO 慢或内存高按场景调 highWaterMark

Node.js Stream 的 API 设计很优雅,但"能用"和"用好"之间隔着一层对背压机制的理解。下次有人问你为什么服务 OOM 了,先看看他的 Stream 是不是真在"流"。

0 评论

评论区

登录 后参与评论