文章

Buffer与Stream深度解析

Buffer与Stream深度解析

一句话概括

Buffer 是 Node.js 处理二进制数据的核心机制,Stream 是基于事件驱动的高效数据处理模式,两者组合构成了 Node.js 处理大文件、网络传输和实时数据流的基石,本文深入剖析 Buffer 的内存分配、操作 API 和 Stream 的四种类型、背压机制以及管道读写原理。

背景与意义

JavaScript 在浏览器环境中最初只处理文本数据,没有原生的二进制数据处理能力。但当 JavaScript 走出浏览器、进入 Node.js 的后端世界后,情况发生了根本变化——文件 I/O、TCP 流、加密操作、图片处理……无一不与二进制数据打交道。

然而,JavaScript 的标准类型系统里缺少处理二进制数据的机制。于是 Node.js 引入了 Buffer 类,让开发者可以直接操作内存中的二进制数据。同时,面对网络流和文件流这种「数据像水一样源源不断到来」的场景,传统的「全部读入内存再处理」模式显然不可行——如果文件有 5GB,内存根本放不下。Stream 则解决了这个问题,它允许数据边到达边处理,内存消耗恒定,与数据总量无关。

对于任何一个 Node.js 后端开发者来说,Buffer 和 Stream 都是无法绕过的核心技术。

概念与定义

Buffer:Node.js 提供的一个全局类,用于在 V8 堆外部分配固定大小的原始二进制内存空间。每个 Buffer 实例代表一段连续的内存区域,单位是字节(byte)。

Stream:一个抽象的接口,用于处理流式数据。Node.js 中有四种类型的流:Readable(可读)、Writable(可写)、Duplex(可读可写)和 Transform(在读写过程中可以修改数据)。

Chunk:数据流的片段,流将数据切分为多个 chunk 依次处理。每个 chunk 通常是一个 Buffer 对象。

Backpressure(背压):当数据生产速度大于消费速度时,流内部触发的缓冲和流速调节机制。

Pipe(管道):将 Readable 流的输出直接连接到 Writable 流的输入的方法,readable.pipe(writable)

核心知识点拆解

1. Buffer 的创建、分配与操作

Buffer 在 V8 堆外分配内存,不受 V8 垃圾回收影响,但需要手动管理生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * Buffer 的多种创建方式
 */

// 方式 1:Buffer.from() - 从现有数据创建
const buf1 = Buffer.from('Hello Node.js', 'utf8');
// <Buffer 48 65 6c 6c 6f 20 4e 6f 64 65 2e 6a 73>
// H   e   l   l   o      N   o   d   e  .   j   s
console.log(buf1); // <Buffer 48 65 6c 6c 6f 20 4e 6f 64 65 2e 6a 73>

const buf2 = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // 从字节数组
console.log(buf2.toString()); // 'Hello'

const buf3 = Buffer.from(buf1); // 拷贝新的 Buffer
console.log(buf3 === buf1); // false - 是独立的副本

// 方式 2:Buffer.alloc() - 分配并初始化为 0(安全)
const buf4 = Buffer.alloc(10); // 分配 10 个字节,全部初始化为 0x00
console.log(buf4); // <Buffer 00 00 00 00 00 00 00 00 00 00>

// 方式 3:Buffer.allocUnsafe() - 分配但不初始化(更快,但可能包含旧数据)
const buf5 = Buffer.allocUnsafe(1024); // 分配 1KB,含敏感旧数据的风险
// 建议:填满数据后再使用,或使用 .fill() 初始化
buf5.fill(0); // 安全处理

/**
 * Buffer 的常用操作
 */

// 读写操作
const buf = Buffer.alloc(8);
buf.writeUInt16BE(0x1234, 0);  // 写 2 字节大端序
buf.writeUInt32LE(0x56789ABC, 2); // 写 4 字节小端序
buf.writeInt8(-1, 6); // 写 1 字节带符号整数
console.log(buf);

// 读取
console.log(buf.readUInt16BE(0)); // 0x1234
console.log(buf.readUInt32LE(2)); // 0x56789ABC

// 编码转换
const chinese = Buffer.from('你好世界', 'utf8');
console.log(chinese.length); // 12 字节(3 字节 × 4 个汉字)
console.log(chinese.toString('utf8')); // '你好世界'
console.log(chinese.toString('base64')); // '5L2g5aW95LiW55WM'
console.log(Buffer.from('5L2g5aW95LiW55WM', 'base64').toString('utf8')); // '你好世界'

// 拼接与切割
const part1 = Buffer.from('Hello ');
const part2 = Buffer.from('World');
const combined = Buffer.concat([part1, part2]);
console.log(combined.toString()); // 'Hello World'

// 切片(共享内存!)
const slice = combined.slice(0, 5);
slice[0] = 0x68; // 修改 h(小写)
console.log(combined.toString()); // 'hello World' - 原 buffer 也被改了

关键理解buf.slice()TypedArray.subarray() 一样,返回的是同一段内存的不同视图,不是数据拷贝。修改 slice 会影响到原始 buffer。如果需要一个独立副本,使用 Buffer.from(buf.slice()) 进行拷贝。

2. Stream 的四种类型及其使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const { Readable, Writable, Duplex, Transform, PassThrough } = require('stream');
const fs = require('fs');
const zlib = require('zlib');

/**
 * 类型 1:Readable(可读流)
 * 数据来源:文件读取、HTTP 请求、用户输入等
 */
class CounterStream extends Readable {
  constructor(max = 10) {
    super({ objectMode: true }); // objectMode 允许推送非 Buffer 对象
    this.max = max;
    this.index = 1;
  }

  _read() {
    if (this.index <= this.max) {
      // 推送数据(push Buffer 或 对象)
      this.push({ count: this.index, timestamp: Date.now() });
      this.index++;
    } else {
      // 推 null 表示结束
      this.push(null);
    }
  }
}

// 使用可读流
const counter = new CounterStream(5);
counter.on('data', (chunk) => {
  console.log('读取到:', chunk);
});
counter.on('end', () => {
  console.log('流结束');
});

/**
 * 类型 2:Writable(可写流)
 * 数据目的地:文件写入、HTTP 响应、数据库等
 */
class AccumulatorStream extends Writable {
  constructor() {
    super({ objectMode: true });
    this.data = [];
  }

  _write(chunk, encoding, callback) {
    // 处理数据
    this.data.push(chunk);
    console.log('写入:', chunk);
    // 调用 callback 表示处理完成,准备接收下一个 chunk
    callback();
  }
}

// 使用可写流
const accumulator = new AccumulatorStream();
counter.pipe(accumulator);

/**
 * 类型 3:Transform(转换流)—— 最为常用
 * 在读写过程中修改数据,如:压缩、加密、格式转换
 */
class UpperCaseTransform extends Transform {
  constructor() {
    super({ objectMode: true });
  }

  _transform(chunk, encoding, callback) {
    // 对数据进行转换
    chunk.text = chunk.text.toUpperCase();
    // 推送转换后的数据
    this.push(chunk);
    callback();
  }
}

/**
 * 类型 4:Duplex(双工流)
 * 同时实现 Readable 和 Writable 接口
 * 如:TCP socket、zlib streams
 */
const duplex = new Duplex({
  read(size) {
    // 实现可读
  },
  write(chunk, encoding, callback) {
    // 实现可写
  }
});

3. pipe 管道与背压机制

pipe 是 Node.js 流最优雅的设计——它将数据的流向用管道符号串联起来,像一个 Unix 指令链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const fs = require('fs');
const zlib = require('zlib');
const crypto = require('crypto');

/**
 * pipe 链式操作示例
 * 功能:读取文件 → 加密 → 压缩 → 写入
 */
function compressAndEncrypt(inputFile, outputFile, password) {
  const readStream = fs.createReadStream(inputFile);
  const writeStream = fs.createWriteStream(outputFile);
  
  // 创建加密转换流
  const cipher = crypto.createCipher('aes-256-cbc', password);
  
  // 创建压缩转换流
  const gzip = zlib.createGzip();
  
  // 管道链接:读取 → 加密 → 压缩 → 写入
  readStream
    .pipe(cipher)
    .pipe(gzip)
    .pipe(writeStream);
  
  return new Promise((resolve, reject) => {
    writeStream.on('finish', resolve);
    writeStream.on('error', reject);
  });
}

/**
 * 背压(Backpressure)的手动处理
 * 
 * 当数据生产速度 > 消费速度时,流内部通过
 * readable.push() 返回 false 来触发背压
 */
function backpressureDemo() {
  const readable = fs.createReadStream('/large/file.dat', {
    highWaterMark: 16 * 1024 // 每次读取 16KB(默认值)
  });
  
  const writable = fs.createWriteStream('/tmp/output.dat', {
    highWaterMark: 16 * 1024
  });
  
  // 手动处理背压的推荐写法
  readable.on('data', (chunk) => {
    // 写入数据
    const canContinue = writable.write(chunk);
    
    if (!canContinue) {
      // 消费端跟不上,暂停生产
      readable.pause();
      
      // 等待消费端排出(drain)
      writable.once('drain', () => {
        // 消费端处理完了,恢复生产
        readable.resume();
      });
    }
  });
  
  readable.on('end', () => {
    writable.end();
  });
  
  writable.on('finish', () => {
    console.log('所有数据已处理完成');
  });
}

pipe 的背压处理:这正是 pipe 方法替我们做的事——上面的手动背压代码,就是 readable.pipe(writable) 的内部实现。所以默认情况下使用 pipe 就够了。

4. 流的工作模式与 highWaterMark

Node.js 的可读流有两种模式:流动模式(Flowing)暂停模式(Paused)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const fs = require('fs');

/**
 * 流动模式(Flowing Mode)
 * 数据自动从底层系统读取并通过 'data' 事件推送
 * 触发方式:
 *   1. 添加 'data' 事件监听器
 *   2. 调用 readable.pipe()
 *   3. 调用 readable.resume()
 */
const flowingStream = fs.createReadStream('data.txt');
flowingStream.on('data', (chunk) => {
  // 数据自动推送
  console.log(`收到 ${chunk.length} 字节`);
});

/**
 * 暂停模式(Paused Mode)
 * 必须手动调用 readable.read() 读取数据
 * 触发 'readable' 事件表示有新数据可供读取
 */
const pausedStream = fs.createReadStream('data.txt');
pausedStream.on('readable', () => {
  let chunk;
  // 每次读取指定大小的数据
  while ((chunk = pausedStream.read(1024)) !== null) {
    console.log(`手动读取 ${chunk.length} 字节`);
  }
});

/**
 * highWaterMark —— 内部缓冲区大小
 * 决定每次底层读取的数据量和背压触发阈值
 */
const stream = fs.createReadStream('file.dat', {
  highWaterMark: 64 * 1024 // 64KB(默认 16KB)
});

const writer = fs.createWriteStream('output.dat', {
  highWaterMark: 16 * 1024 // 写缓冲区大小
});

// 流的内部缓冲区大小计算
// 可读流:highWaterMark 是每次从底层拉取的数据量
// 可写流:highWaterMark 是写入缓冲区水位线,超过此值 write() 返回 false

实战案例

案例一:高性能文件复制(对比不同方案)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const fs = require('fs');
const { pipeline } = require('stream/promises');

/**
 * 方案 1:一次性读取写入(❌ 大文件不可用)
 */
async function copyWithReadAll(src, dest) {
  const content = fs.readFileSync(src); // 全部读入内存
  fs.writeFileSync(dest, content);      // 全部写入磁盘
}

/**
 * 方案 2:流式复制 - 使用 pipeline(✅ 推荐)
 * pipeline 比 .pipe() 更安全,会自动处理销毁和错误
 */
async function copyWithStream(src, dest) {
  const readStream = fs.createReadStream(src);
  const writeStream = fs.createWriteStream(dest);
  
  await pipeline(readStream, writeStream);
  console.log('流式复制完成');
}

/**
 * 方案 3:带进度报告的流式复制
 */
async function copyWithProgress(src, dest) {
  const { size } = fs.statSync(src);
  let bytesProcessed = 0;
  
  const readStream = fs.createReadStream(src);
  const writeStream = fs.createWriteStream(dest);
  
  // 进度报告流
  const progressStream = new Transform({
    transform(chunk, encoding, callback) {
      bytesProcessed += chunk.length;
      const percent = ((bytesProcessed / size) * 100).toFixed(1);
      process.stdout.write(`\r进度: ${percent}% (${bytesProcessed}/${size} 字节)`);
      this.push(chunk);
      callback();
    }
  });
  
  await pipeline(readStream, progressStream, writeStream);
  console.log('\n复制完成!');
}

案例二:CSV 文件的流式解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const fs = require('fs');
const { Transform } = require('stream');

/**
 * 流式 CSV 解析器
 * 边读取边解析,无需将整个文件加载到内存
 */
class CSVParser extends Transform {
  constructor(options = {}) {
    super({ objectMode: true, ...options });
    this.buffer = '';
    this.headers = null;
    this.rowCount = 0;
  }

  _transform(chunk, encoding, callback) {
    this.buffer += chunk.toString();
    
    const lines = this.buffer.split('\n');
    // 保留最后一段(可能不完整,等待下一个 chunk)
    this.buffer = lines.pop();
    
    for (const line of lines) {
      if (!line.trim()) continue;
      
      if (!this.headers) {
        // 第一行是表头
        this.headers = line.split(',').map(h => h.trim());
      } else {
        // 解析数据行
        const values = this.parseLine(line);
        if (values) {
          const row = {};
          this.headers.forEach((header, i) => {
            row[header] = values[i];
          });
          row._rowNumber = ++this.rowCount;
          this.push(row);
        }
      }
    }
    
    callback();
  }

  _flush(callback) {
    // 处理最后一个不完整行
    if (this.buffer.trim()) {
      const values = this.parseLine(this.buffer);
      if (values) {
        const row = {};
        this.headers.forEach((header, i) => {
          row[header] = values[i];
        });
        row._rowNumber = ++this.rowCount;
        this.push(row);
      }
    }
    callback();
  }

  parseLine(line) {
    // 简单 CSV 解析(不处理引号内逗号等复杂情况)
    return line.split(',').map(v => v.trim());
  }
}

// 使用示例:解析 1GB 的 CSV 文件
async function parseLargeCSV(filePath) {
  const readStream = fs.createReadStream(filePath);
  const parser = new CSVParser();
  
  readStream
    .pipe(parser)
    .on('data', (row) => {
      console.log(`第 ${row._rowNumber} 行:`, JSON.stringify(row));
    })
    .on('end', () => {
      console.log('CSV 解析完成');
    });
}

底层原理

Buffer 的内存分配机制

Node.js 采用内存池策略来分配 Buffer,减少系统调用次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Node.js 内部实现(简化)
// Buffer 并非直接调用 malloc,而是通过内存池管理

const poolSize = 8 * 1024; // 8KB 内存池
let poolOffset, allocPool;

function createPool() {
  allocPool = Buffer.allocUnsafe(poolSize);
  poolOffset = 0;
}

function allocate(size) {
  // 如果剩余空间不足,创建新池
  if (!allocPool || poolSize - poolOffset < size) {
    createPool();
  }
  
  // 从内存池中分配
  const start = poolOffset;
  poolOffset += size;
  
  // 返回内存池的切片
  return allocPool.slice(start, poolOffset);
}

// Buffer.from('hello') 内部会调用类似 allocate(5)
// 如果连续分配多个小 Buffer,它们在内存中是连续的!

关键要点

  • 小于 4KB(Node 9+ 为 8KB)的小 Buffer 从内存池分配
  • 大于等于 4KB 的大 Buffer 直接调用 malloc 单独分配
  • 这种双层分配策略平衡了内存碎片和系统调用开销

流的内部状态机

Node.js 的流基类内部维护了一个复杂的状态机,控制数据的流动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 流的核心状态
const STATE_MACHINE = {
  // 可读流状态
  readableFlowing: null,   // null=未开始, true=流动模式, false=暂停模式
  readableEncoding: null,  // 编码
  readableEnded: false,    // 是否已结束
  readableHighWaterMark: 16384, // 16KB 默认高水位
  
  // 可写流状态
  writableEnded: false,
  writableFinished: false,
  writableHighWaterMark: 16384,
  corked: 0,              // 软木塞计数
  
  // 通用状态
  errored: null,           // 错误状态
  destroyed: false,        // 是否已销毁
};

高频面试题解析

面试题1:为什么 Buffer 是全局变量而 Stream 需要 require?

设计上,Node.js 将 Buffer 设为全局变量是因为二进制数据处理贯穿 Node.js 所有异步 I/O 操作——任何回调都可能收到 Buffer。而 Stream 是一个高级抽象,不是所有模块都需要,因此放在 stream 模块中按需加载。

面试题2:pipeline 和 .pipe() 有什么区别?

1
2
3
4
5
6
7
8
9
10
11
12
const { pipeline } = require('stream/promises');

// .pipe() 的问题:不自动管理错误传播
readStream.pipe(gzip).pipe(writeStream);
// 如果 gzip 或 writeStream 出错,readStream 不会自动销毁
// 可能导致内存泄漏

// pipeline 自动处理:
// 1. 错误传播(所有流都被销毁)
// 2. 清理(关闭文件描述符)
// 3. 返回 Promise
await pipeline(readStream, gzip, writeStream);

面试题3:一个文件读取 Stream 如何知道何时读取数据?

可读流通过 fs.read() 系统调用读取文件数据,但「何时读」取决于工作模式:

  • 流动模式:添加 data 事件监听后,流自动读取并推送
  • 暂停模式:调用 readable.read() 时主动读取
  • 内部实现依赖 libuv 的事件循环,在 poll 阶段监听文件描述符的可读事件

面试题4:Readable 流的 objectMode 有什么影响?

默认情况下,流只处理 Buffer/String,在 objectMode 下可以处理任意 JavaScript 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { Transform } = require('stream');

// ❌ 非 objectMode 下推送对象会报错
const badStream = new Transform({
  transform(chunk, enc, cb) {
    this.push({ modified: true }); // Error: Invalid non-string/buffer chunk
    cb();
  }
});

// ✅ objectMode 下可以推送任何类型
const goodStream = new Transform({
  objectMode: true,
  transform(chunk, enc, cb) {
    this.push({ 
      original: chunk.toString(),
      length: chunk.length,
      timestamp: Date.now()
    });
    cb();
  }
});

面试题5:大文件读取为什么不用 readFileSync 而用 Stream?

  • fs.readFileSync 将整个文件加载到内存,对于大文件会消耗大量内存甚至 OOM
  • Stream 使用固定大小的缓冲区(默认 16KB/64KB),无论文件多大,内存消耗恒定
  • Stream 支持在数据到达时立即开始处理,无需等待整个文件加载完成

总结与扩展

Buffer 和 Stream 是 Node.js 处理数据的两种核心方式:Buffer 处理离散的二进制数据块,Stream 处理连续的数据流。

特性BufferStream
数据形态静态内存块动态数据流
内存使用一次性分配固定缓冲区
适用场景小数据、协议解析大数据、网络传输
操作方式索引/方法读写pipe/事件监听

扩展方向

  • TypedArray 与 Buffer:Node.js 的 Buffer 继承自 Uint8Array,两者可以在同一个应用中混用
  • fs.createReadStream 的内部实现:如何通过 fs.open + fs.read 逐块读取文件
  • HTTP 流式响应res 对象本身就是一个 WritableStream,可以通过 pipe 直接输出文件
  • 流式压缩/解压zlib.createGzip 返回 Transform Stream,可以无缝插入管道链
  • 自定义流的实现:继承 Readable/Writable/Transform 并实现 _read/_write/_transform 方法
本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量