文件读写实战:流式分块复制大文件
本节目标
- 看清
fs.readFile一次性读内存的隐患,明白"大文件必须用流" - 用
fs.createReadStream+createWriteStream+pipe完成分块复制 - 用
highWaterMark控制每次读取的块大小,并实测内存占用差异
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 模块即可,它内部也是流式按行吐出,不会把整文件读进内存。
名词解释
- 文件描述符(fd):操作系统给"已打开文件"的一个整数编号。
fs.open返回的就是它;流的底层其实就在操作 fd,只是帮你封装好了按块读写。 - highWaterMark(高水位线):流内部缓冲区能攒的"最大字节数"。它决定了"一次读/写多大一块",是调优吞吐与内存的旋钮。
finish事件:可写流触发,表示所有数据都已交给底层写入完成(注意:不等于操作系统已落盘,落盘由系统异步完成)。- 流式复制:不把源文件整份载入,而是"源头吐一块、终点收一块"地搬运。无论文件多大,内存占用都只和块大小有关,与文件大小无关。
课后练习
- 把
highWaterMark改成1024(1KB)再跑,stream 内存会明显变大还是变小?- 答案:基本不变(内存只和"当前缓冲区"有关,不随块大小线性增长);但 I/O 次数会变多、耗时略增。这正是流的优点——文件再大内存也稳。
ws.on('finish')和ws.on('close')有什么区别?- 答案:
finish表示"该写的都写完了"(数据已交给底层);close表示"底层资源(如 fd)已释放"。通常复制完成用finish即可;要确保文件句柄关闭用close。
- 答案:
- 为什么"逐行读大文件"也要用流而不是直接
readFile后split('\n')?- 答案:
split('\n')前必须先有完整字符串,等于把整文件读进内存;流式逐行(如readline)边读边按行吐,内存恒定,才能处理超大日志。
- 答案:
总结
这一节把一个最常见的工程错误摆在了台面上:fs.readFile 的"方便"是有代价的——它假设文件能放进内存。可真实世界里,日志、导出、媒体文件动辄上 GB,"一次性读进内存"就是从根上错的设计。我建议你把"文件大小不确定 → 默认用流"当成一条肌肉记忆。但也要泼盆冷水:流不是银弹,小文件(几十 KB)用 readFile 反而更简单清晰,不必为了"看起来高级"硬上流。真正的判断力在于:当数据规模可能超过内存,或数据持续产生时,流是唯一合理的选择;否则简单优先。另外 highWaterMark 这个旋钮提醒我们——流的"块大小"是可调的,调它就调了"内存 vs I/O 次数"的权衡,这正是底层系统的乐趣所在。