首页 / 文章 / Node.js 流详解:解决内存不足导致的文件处理崩溃问题

Node.js 流详解:解决内存不足导致的文件处理崩溃问题

了解为何将整个文件加载到内存中会导致 Node.js 服务器崩溃,以及如何通过可读流、可写流、双向流和转换流结合背压机制来解决这一问题。

1993 词

想象一下,在一个平常而宁静的下午,某台生产服务器突然瘫痪了。

并没有流量激增,也没有大量用户同时访问。只有一人在使用该应用,他点击按钮试图导出一份大型报告。

几秒钟内,该进程就完全停止响应,控制台还显示了一条熟悉的错误信息:“JavaScript 堆内存不足。”

如果你们曾经遇到过这种错误,就知道它有多令人不安。

人们的自然反应是困惑:为何一个用户请求的单一文件就能让整个正在运行的应用崩溃?

这类事件其实是个很好的学习机会。它直接指向了每个后端开发者最终都必须理解的一个 Node.js 核心概念:

大多数初学者会犯的严重错误

当开发者刚开始接触 Node.js 时,他们通常会选择最简单的工具。

要读取磁盘中的文件,人们常常首先想到的是 fs.readFile()。它的使用方式很简单:传入文件路径,通过回调函数或 await 等待处理,就能得到文件的完整内容。

典型的使用方式如下:

import fs from 'node:fs/promises';
async function sendFile(filePath) {
  // Reading the entire file at once
  const bigData = await fs.readFile(filePath);
  return bigData;
}

只要文件大小不大,这种方法的性能就相当不错。50千字节的文本文件可以瞬间加载完毕,小型头像也没问题。

由于在本地测试时一切正常,人们很容易认为这段代码可以直接用于生产环境。

但现实往往并非如此。

为何一次性读取所有内容会失败

思考一下你的机器的RAM实际上是如何被使用的。当fs.readFile()被调用时,Node.js会逐字节将整个文件加载到内存中,然后再交还给你。

假设你的服务器仅为该应用分配了1千兆字节的RAM。

现在假设有个用户试图上传视频,或者请求一个900兆字节的原始日志文件。

对那个900兆字节的文件调用fs.readFile()会引发一系列连锁反应:

  • Node.js会立即向操作系统请求900兆字节的内存。
  • 随着可用内存减少,垃圾回收器不得不超负荷工作。
  • 如果另一个用户同时请求同一个文件,内存需求将上升至1800兆字节。
  • 服务器的内存预算会被耗尽,从而直接崩溃。

故障并非由文件损坏引起的,而是因为整个数据量被一次性吞下,而非逐步读取。

用简单的话解释什么是流?

暂且放下代码,想想现实中的类比。

假设你需要把水从一个大湖里引到后院的花园里。

你不会试图用一个巨大的桶把整个湖的水都舀起来再搬运过去——那重量实在太大,根本没人能举得动。

相反,你会接上一根花园水管。

水会以细小、连续的流形式通过这根管子:一部分从一端进入,沿着管道流动,然后从另一端流出浇灌土壤。

仅凭一根细管,你就能在一段时间内输送数百万升的水,而无需一次性搬动全部水量。

Node.js中的流就如同那根软管一样工作。

流不会一次性将整个文件加载到内存中,而是以称为的较小、易于处理的部分来读取文件。

默认情况下,一个块的容量约为64千字节。

Node.js会获取一个块,对其进行处理,然后将其发送到需要的地方,之后再从内存中释放它,接着处理下一个块。

这就是为什么服务器能够传输10吉字节的文件,而仅占用大约20到30兆字节的RAM。

Node.js中的四种流类型

Node.js提供了四种用于处理流式数据的基本组件。你不必立即掌握所有细节,但了解它们的名称还是很有必要的:

1. 可读流

可读流就是用于从中获取数据的流。

  • 示例包括从磁盘读取文件、接收传入的HTTP请求的正文,或从数据库查询中读取数据行。

2. 可写入流

可写入流是指向其中写入数据的流。

  • 示例包括将内容写入新文件、向浏览器发送响应,或通过网络套接字输出字节数据。

3. 双向流

双向流允许您同时执行两项操作:可以同时从中读取数据并向其写入数据。

  • 示例:如TCP套接字这样的网络连接,您可以通过同一连接发送数据并接收返回的数据。

4. 转换流

转换流是一种特殊的双向流,其功能是在数据传输过程中对其进行修改,而不仅仅是原封不动地传递。

  • 示例:在数据传输过程中将其压缩为.gzip格式,或在写入磁盘前对文本进行加密。

直观对比:代码示例

让我们通过一个具体场景来比较这两种方法。假设你正在构建一个简单的HTTP服务器,允许访问者下载大文件。

错误做法(高内存占用)

JavaScript

import http from 'node:http';
import fs from 'node:fs/promises';
const server = http.createServer(async (req, res) => {
  try {
    // We load the whole file into RAM first
    const fileData = await fs.readFile('./massive-dataset.csv');

    res.writeHead(200, { 'Content-Type': 'text/csv' });
    res.end(fileData);
  } catch (error) {
    res.writeHead(500);
    res.end('Something broke');
  }
});server.listen(3000);

如果massive-dataset.csv的大小恰好为2千兆字节,这段代码会在向客户端发送哪怕一个字节之前就试图将全部2千兆字节的数据存放在内存中。在大多数云托管环境中,这会导致进程立即崩溃。

更好做法(低内存占用)

现在让我们使用流来实现相同的下载功能:

JavaScript

import http from 'node:http';
import fs from 'node:fs';
const server = http.createServer((req, res) => {
  // We create a readable stream
  const readStream = fs.createReadStream('./massive-dataset.csv');  res.writeHead(200, { 'Content-Type': 'text/csv' });  // We connect our read stream directly to the response
  readStream.pipe(res);  readStream.on('error', (err) => {
    res.writeHead(500);
    res.end('File not found or error reading');
  });
});server.listen(3000);

注意对 .pipe() 的调用吗?

这一次方法调用就能实现非常强大的功能——它将我们的文件读取流直接连接到输出的 HTTP 响应(res)中。

一旦磁盘传输出第一小块数据(比如 64 KB),Node.js 会立即将其发送给客户端,无需等待整个文件读完。在整个下载过程中,内存使用量都保持较低且稳定。

理解背压机制(交通堵塞问题)

在流处理中有一个每个开发者都应掌握的关键概念:背压

先回想一下花园水管的类比。

想象以每秒 100 升的速度向管道中注水,而出口阀门只允许每秒有 10 升的水流出。

管道内的压力会不断增大,如果管道强度不够,就会爆裂。

软件中也经常出现类似的问题。固态硬盘能够以每秒数百兆字节的速度传输数据,而下载你文件的人可能使用的是速度较慢的移动网络。

因此,如果 Node.js 从磁盘读取数据的速度快于客户端接收数据的速度,这些多余的数据会去往何处?

它们会堆积在服务器的 RAM 中,等待被发送。

如果不加以控制,这就会违背使用流式的初衷,因为内存占用会再次上升。

现代 Node.js 如何解决此问题

幸运的是,当前版本的 Node.js 已经为这个问题提供了内置解决方案:即来自 stream/promises 模块的 pipeline 函数。

与其依赖较旧的 .pipe() 方法,现代代码应优先使用 pipeline

JavaScript

import http from 'node:http';
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
const server = http.createServer(async (req, res) => {
  const readStream = fs.createReadStream('./massive-dataset.csv');  try {
    // pipeline handles backpressure and cleans up automatically
    await pipeline(readStream, res);
  } catch (error) {
    if (!res.headersSent) {
      res.writeHead(500);
      res.end('Transfer failed');
    }
  }
});server.listen(3000);

那么,为什么 pipeline.pipe() 更优呢?

  • 它能应对不同的处理速度:当客户端接收数据的速度较慢时,它会自动暂停可读流,直到客户端能够处理更多数据。
  • 它能优雅地处理错误:如果有人在下载过程中关闭浏览器,pipeline 会及时终止读取流并正确释放文件句柄,从而避免内存泄漏。

流式处理在现实场景中的优势

流式处理并不仅限于传输大型视频文件或巨大下载内容,它在各种日常生产场景中都能发挥作用:

  • 日志处理:扫描庞大的服务器日志以查找错误时无需将整个文件加载到内存中,可以逐行流式处理。
  • 图像与视频转换:当有人上传高分辨率照片时,可以直接将上传的文件输入到图像缩放工具中,无需先将其原始文件写入磁盘。
  • 数据库导出:在将数百万行数据导出为CSV文件时,可从数据库游标中分批获取数据,并在数据到达后立即直接发送给客户端。
  • 数据加密:在将敏感信息写入云存储时即时对其进行加密。

需避免的常见错误

即便那些理解流处理原理的开发者,也可能会遇到一些实际操作中的问题:

  • 跳过错误处理程序:旧版的流 API 不会自动传播错误。如果处理流程中的某个步骤抛出异常且没有对应的监听机制,整个进程都可能崩溃。建议继续使用 pipeline,或明确监听 'error' 事件。
  • 将流转换回缓冲区:人们很容易想把所有的 'data' 事件收集到数组中,再将其合并成一个大的字符串或缓冲区。但这样做会抵消你原本想要获得的内存优化效果。
  • 让资源保持打开状态:如果某个操作在执行过程中失败,必须确保所有已打开的文件描述符都能被正确关闭,而不能一直处于开放状态。

总结

当开发者刚开始接触编程时,他们往往将数据视为某种固定且完整的存在,就那样等待被使用,无论是整个文件、完整的数据库表,还是已生成的响应。

要专业地从事后端系统开发,就需要摒弃这种思维模式。

数据并不总是固定不变的实体。更多时候,它就像一条流动的河流

你无需试图截取整条河来与之交互,只需让它一点一点地流过即可。

一旦流式处理成为你的工具箱中的组成部分,大文件就不再令人畏惧。你的基础设施可以运行在更精简、成本更低的服务器上。应用程序对使用者的响应也会更加迅速。或许最重要的是,你可以放心,即使有意外大的2GB文件需要上传,也不会在半夜导致服务器崩溃。

相关阅读

  • Node.js 流技术深入解析:内存管理、背压机制与实际故障处理 — 了解 Node.js 流如何与 Web Streams 相互配合,通过真实测试数据查看内存节省效果,以及那些仅在高负载情况下才会出现的生产环境错误。
  • 理解 Node.js 流技术:它们真正解决的问题 — 了解 Node.js 流存在的意义,管道机制的内部运作原理,以及背压在高效处理大量数据时的实际作用。
  • 会话机制与JWT:如何选择合适的Node.js认证模型 — 了解在Node.js认证中会话机制与JWT的实际差异、各自的优缺点,以及如何做出选择而不后悔。
  • Node.js 26:Temporal API、Map更新操作及Undici 8详解 — 阐述Node.js 26在后台功能方面的重大变化,包括稳定的Temporal API、原生的Map更新方法、Undici 8带来的性能提升,以及升级前需了解的破坏性变更。