文件读写实战:流式分块复制大文件

本节目标

fs.readFile 会把整个文件读进内存再返回。对小文件无所谓;对几百 MB 乃至 GB 的文件,这是灾难。正确做法是用流:源头 createReadStream 按块读,终点 createWriteStream 按块写,pipe 在中间自动搬运——内存里永远只有"当前这一块"。

// 运行环境:Node.js 14+(会在当前目录生成测试文件,无需联网)
// 保存为 buf-l3.js,执行:node buf-l3.js
const fs = require('fs')
const path = require('path')

// 1) 先造一个 5MB 的测试文件(模拟"大文件")
const big = path.join(__dirname, 'sample-big.txt')
fs.writeFileSync(big, Buffer.alloc(5 * 1024 * 1024, 0x61)) // 5MB 全 'a'(0x61)

// 2) ❌ 错误示范:readFile 一次性读进内存
const t0 = Date.now()
const all = fs.readFileSync(big)
fs.writeFileSync(path.join(__dirname, 'copy-readfile.txt'), all)
console.log('readFile 内存:', (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1), 'MB')

// 3) ✅ 正确示范:流式分块复制(内存几乎不增长)
const t1 = Date.now()
const rs = fs.createReadStream(big, { highWaterMark: 64 * 1024 }) // 每块 64KB
const ws = fs.createWriteStream(path.join(__dirname, 'copy-stream.txt'))
rs.pipe(ws) // 自动按块读→写,无需手动监听 data
ws.on('finish', () => {  // finish:所有数据都已写入终点
  console.log('stream 耗时:', Date.now() - t1, 'ms')
  console.log('stream 内存:', (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1), 'MB')
  // 清理测试产物
  fs.unlinkSync(big)
  fs.unlinkSync(path.join(__dirname, 'copy-readfile.txt'))
  fs.unlinkSync(path.join(__dirname, 'copy-stream.txt'))
})

highWaterMark 是什么? 它是流"内部缓冲区的上限",单位字节。设为 64×1024 表示"每次最多攒 64KB 再交给下游"。值越小越省内存、但 I/O 次数略多;值越大吞吐高、但占内存。文件流默认 64KB,网络流默认 16KB,可按场景调。

逐行处理大文件:如果要对日志按行分析,配合 readline 模块即可,它内部也是流式按行吐出,不会把整文件读进内存。

名词解释

课后练习

  1. 把 highWaterMark 改成 1024(1KB)再跑,stream 内存 会明显变大还是变小?
    • 答案:基本不变(内存只和"当前缓冲区"有关,不随块大小线性增长);但 I/O 次数会变多、耗时略增。这正是流的优点——文件再大内存也稳。
  2. ws.on('finish') 和 ws.on('close') 有什么区别?
    • 答案:finish 表示"该写的都写完了"(数据已交给底层);close 表示"底层资源(如 fd)已释放"。通常复制完成用 finish 即可;要确保文件句柄关闭用 close。
  3. 为什么"逐行读大文件"也要用流而不是直接 readFile 后 split('\n')?
    • 答案:split('\n') 前必须先有完整字符串,等于把整文件读进内存;流式逐行(如 readline)边读边按行吐,内存恒定,才能处理超大日志。

总结

这一节把一个最常见的工程错误摆在了台面上:fs.readFile 的"方便"是有代价的——它假设文件能放进内存。可真实世界里,日志、导出、媒体文件动辄上 GB,"一次性读进内存"就是从根上错的设计。我建议你把"文件大小不确定 → 默认用流"当成一条肌肉记忆。但也要泼盆冷水:流不是银弹,小文件(几十 KB)用 readFile 反而更简单清晰,不必为了"看起来高级"硬上流。真正的判断力在于:当数据规模可能超过内存,或数据持续产生时,流是唯一合理的选择;否则简单优先。另外 highWaterMark 这个旋钮提醒我们——流的"块大小"是可调的,调它就调了"内存 vs I/O 次数"的权衡,这正是底层系统的乐趣所在。