Skip to main content

Buffer 与 Stream

在 Node.js 中,处理文件、网络通信、音视频等底层 I/O 操作时,必定会绕不开 BufferStream。这也是高级前端/Node.js 工程师面试中极其重要的一环,主要考察你对内存管理大文件处理能力的理解。


1. Buffer (二进制缓冲区)

在 ES6 引入 TypedArray 之前,JavaScript 原生并没有读取或操作二进制数据流的机制。Node.js 为了处理 TCP 流或文件流,引入了 Buffer 类。

1.1 核心特性与内存分配

面试题:Buffer 是如何分配内存的?它受 V8 内存限制吗?

  • 堆外内存:Buffer 对象所占用的实际二进制数据内存,是直接由 Node.js 的 C++ 层面在系统物理内存中分配的,这部分被称为堆外内存。因此,它不受 V8 引擎默认的内存大小限制(32位系统约 0.7GB,64位约 1.4GB)。
  • 对象头在 V8 内:虽然实际数据在堆外,但代表这个 Buffer 的“壳子”(JavaScript 中的 Buffer 对象)是分配在 V8 的堆内存中的。
  • 垃圾回收机制:当 JS 里的 Buffer 对象(壳子)没有被任何变量引用时,V8 会将其垃圾回收。在这个对象被回收的同时,Node.js 底层也会一并释放掉它对应的堆外物理内存。
  • 8KB 池化分配池 (Slab 机制):为了高效管理内存,Node.js 内部采用 Slab 预分配机制。以 8KB 为界限,小于 8KB 的 Buffer 会在一个预先申请好的大内存块中进行切片分配;大于 8KB 的则直接向操作系统申请。

1.2 Buffer 常用操作

// 1. 创建 (不再推荐使用 new Buffer())
const buf1 = Buffer.alloc(10); // 分配一段长度为 10 且用 0 填充的 Buffer (安全,推荐)
const buf2 = Buffer.allocUnsafe(10); // 不初始化内存,可能包含旧数据 (速度快,但可能泄漏敏感信息)
const buf3 = Buffer.from("Hello"); // 从字符串创建

// 2. 拼接
const bufA = Buffer.from("Hello ");
const bufB = Buffer.from("World");
const bufC = Buffer.concat([bufA, bufB]); // 内部会计算总长度并重新分配一块大内存

// 3. 乱码问题
// 中文在 UTF-8 下通常占 3 个字节。如果一个中文字符被截断在两个 Buffer 之间,
// 直接 toString() 会出现乱码。通常使用 string_decoder 模块来解决截断问题。

2. Stream (流)

面试题:为什么处理大文件(如 2GB 的视频下载)必须用 Stream?如果用 fs.readFile 会怎样?

如果用 fs.readFile,Node.js 会试图把这 2GB 的文件内容一次性全部读入 V8 的堆外内存中,然后再调用回调函数。

  1. 内存撑爆 (OOM):如果同时有 10 个用户下载,服务器直接需要消耗 20GB 内存,服务直接崩溃。
  2. 响应极慢:必须等 2GB 全部读完,客户端才能收到第一个字节。

Stream 的优势:它采用的是“一段一段读取、一段一段处理”的模式(边读边写)。无论文件有多大,内存中只需要维持极小的一块缓冲(默认 64KB)。不仅内存占用极低,而且客户端可以立刻收到数据流。

2.1 Stream 的四大种类

  1. Readable(可读流):数据源。例如 fs.createReadStream、HTTP 请求中的 req
  2. Writable(可写流):数据目标。例如 fs.createWriteStream、HTTP 响应中的 res
  3. Duplex(双工流):既可读也可写。例如 TCP 连接的 net.Socket
  4. Transform(转换流):特殊的双工流,在读写过程中可以修改数据。例如压缩模块 zlib.createGzip() 或加密流。

2.2 管道与背压问题 (Backpressure) - 🌟 高频难点

高级面试题:什么是 Stream 的背压 (Backpressure) 问题?怎么解决?

现象与原因: 数据流动的过程中,由于可读流的读取速度(比如从极快的 SSD 读取文件)通常远远大于可写流的处理/写入速度(比如受限于网络带宽发送给客户端,或者写入缓慢的机械硬盘)。 如果可读流不停地向可写流中塞数据,数据就会疯狂积压在可写流的内存缓冲区中。当积压数据超过 highWaterMark(高水位线阈值,默认通常是 16KB 或 64KB)时,会导致 Node.js 进程的内存持续飙升,最终可能导致内存溢出。

解决方案(底层原理):

  1. 当可写流的缓冲区满了时,其 write() 方法会返回 false
  2. 此时,应该立刻调用可读流的 pause() 方法暂停读取数据。
  3. 等待可写流将缓冲区中的数据慢慢消费完毕,清空缓冲区后,它会触发一个 drain(抽干)事件。
  4. 监听到 drain 事件后,再调用可读流的 resume() 方法恢复读取。

工程实践中的解法: 在实际开发中,我们不需要手动写上面那一堆复杂的逻辑。Node.js 提供了现成的 API 帮我们完美处理了背压:

  1. 使用 .pipe() (传统解法)

    const readable = fs.createReadStream("big.mp4");
    const writable = fs.createWriteStream("copy.mp4");
    // pipe 内部自动封装了对 write() 返回值的判断和 drain 事件的监听
    readable.pipe(writable);
  2. 使用 pipelinestream/promises (现代解法,推荐) .pipe() 的一个致命缺点是:如果管道中间的某个流发生了错误,它不会自动销毁其他流,容易造成内存泄漏。现代 Node.js 更推荐使用 pipeline

    const { pipeline } = require("stream/promises");
    const fs = require("fs");
    const zlib = require("zlib");

    async function run() {
    try {
    await pipeline(
    fs.createReadStream("archive.tar"),
    zlib.createGzip(),
    fs.createWriteStream("archive.tar.gz"),
    );
    console.log("处理完成,自动处理背压,且任何环节出错都会自动清理");
    } catch (err) {
    console.error("流处理失败", err);
    }
    }
    run();

2.3 Stream 的两种读取模式

对于可读流(Readable),面试偶尔会问到它的两种工作模式:

  • 流动模式 (Flowing):数据自动从底层系统读取,并通过 data 事件源源不断地提供给应用程序。
    const rs = fs.createReadStream("file.txt");
    rs.on("data", (chunk) => console.log("收到数据", chunk.length));
  • 暂停模式 (Paused):默认状态。必须显式调用 stream.read() 才能主动从流中拉取数据包。
    const rs = fs.createReadStream("file.txt");
    rs.on("readable", () => {
    let chunk;
    while (null !== (chunk = rs.read())) {
    console.log("主动拉取数据", chunk.length);
    }
    });