【Node】操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

📅 发布时间:2026/7/9 13:15:41 👁️ 浏览次数:
【Node】操作磁盘文件底层原理:从「点外卖」到「厨房流水线」
Node.js 操作磁盘文件底层原理从「点外卖」到「厨房流水线」你以为fs.readFile是让 Node 帮你「拿一下文件」不其实是你下单 → 前台记单 → 后厨线程池做菜 → 做好了再叫你。这篇文章带你看看这份「外卖」是怎么从磁盘端到你手里的。一、先别急着写代码为什么你要关心「底层」很多人学 Node.js 的fs模块会背两句口诀就收工「用异步别用同步」「大文件用 Stream」。背完发现为什么我readFile读个 10GB 的日志直接 OOM为什么说 Node 是单线程但一堆文件操作时还是会「卡」fs.promises和fs.readFile回调版底层是不是同一套补充一句文件 I/O 本身是等磁盘不会占满 CPU你觉得「卡」多半是线程池被占满新任务在排队。要回答这些就得知道你的 JS 代码 → Node 的 C 绑定 → libuv → 操作系统 → 磁盘这条链上每一环在干什么。知道之后你选 API、调参数、排查性能问题都会心里有数——而不是靠「玄学调参」。所以这篇东西的目标很简单用尽量人话 一点幽默把 Node.js 操作磁盘文件的底层原理讲清楚顺便带上能跑的示例。二、从你敲下fs.readFile开始调用链长什么样你写的可能是constfsrequire(fs);fs.readFile(/tmp/hello.txt,(err,data){if(err)throwerr;console.log(data.toString());});在底层大概发生了这些事简化版JavaScript 层fs.readFile是 Node 内置模块fs上的方法实现里会做路径解析、编码处理、以及「把回调塞进某个流程里」。C 绑定层Node 的node_file.cc等JS 调用的其实是 C 里封装好的函数。这里会把路径、回调、选项等转成 C 能用的东西调 libuv 的 API发起「异步文件读请求」。libuv 层libuv 是 Node 用来抽象「异步 I/O」的 C 库跨平台Windows / Linux / macOS 都靠它。对文件 I/O它一般不会用 epoll/kqueue 这种「纯事件」机制而是把实际读文件的工作丢进「线程池」在池里某条线程里做阻塞式的 read。所以你以为的单线程只是 JS 执行单线程文件读写是在别的线程里阻塞地干的。操作系统 → 磁盘线程池里的线程调的就是 OS 的 read或类似系统调用由内核去和磁盘驱动、块设备打交道把数据从磁盘读到内核缓冲区再拷到用户态Node 的 Buffer。回到 JS读完后libuv 在某个时机下一次事件循环的 I/O 阶段把结果和你的回调塞回主线程于是你的(err, data) { ... }被调到了data就是那个 Buffer。一句话fs.readFile 你在 JS 里下单 → Node 通过 libuv 把「读文件」这个任务派给线程池 → 线程池里的线程阻塞地读磁盘 → 读完再通过事件循环把结果回传给 JS。所以「Node 单线程」指的是 JS 只在一个线程跑磁盘 I/O 并不在主线程上阻塞而是在线程池里。三、事件循环与 libuv谁在真正「干活」Node 的事件循环event loop是由 libuv 实现的。和文件相关的部分可以粗分为Poll 阶段等 I/O网络、部分原生异步 API 等。线程池完成回调文件 I/O 在池里做完后会在合适的阶段把「完成」事件插回事件循环从而执行你传的 callback 或 resolve Promise。所以主线程跑 JS 的那条只负责执行你的 JS、跑定时器、处理已完成 I/O 的回调不直接去读磁盘。真正摸磁盘的是 libuv 的线程池里那几条 worker 线程默认 4 个可配UV_THREADPOOL_SIZE。这就是为什么你写fs.readFileSync时主线程会阻塞因为同步 API 就是在主线程上直接调系统调用读文件而fs.readFile不会阻塞主线程因为读是在线程池里做的。四、线程池别被「单线程」三个字骗了默认情况下libuv 的线程池大小是4和你的 CPU 核数无关就是个固定值。所以同时发 10 个fs.readFile只有 4 个在「真·读磁盘」剩下 6 个在排队。线程池既管文件 I/O也管部分 crypto、部分 DNS 等所以文件多的时候你会感觉「怎么慢下来了」——因为池子被占满了。可以通过环境变量把池子调大建议不超过 CPU 数太多否则上下文切换会变多# 例如把线程池改成 8setUV_THREADPOOL_SIZE8# WindowsexportUV_THREADPOOL_SIZE8# Linux/macOS// 你可以自己试同时读多个文件看完成顺序constfsrequire(fs);constfiles[file1.txt,file2.txt,file3.txt,file4.txt,file5.txt];files.forEach((f,i){fs.readFile(f,()console.log(第${i1}个完成:${f}));});// 前 4 个往往先完成线程池只有 4第 5 个要等池里有空位五、Buffer内存里那块「黑板」Buffer是 Node 里表示「一块二进制数据」的类型本质是 V8 外的一块连续内存不经过 V8 堆的 GC由 Node 自己管理。文件读进来、网络收来的裸字节在 JS 里最常见的就是用 Buffer 拿着。fs.readFile的data就是 Buffer。你data.toString()是把这块内存按指定编码默认 UTF-8解码成字符串。大文件一次性readFile就是一次性在内存里开一块和文件一样大的 Buffer——所以 10GB 文件会直接 OOM和「底层」没关系就是设计如此。所以大文件不要用readFile用 Stream 或read(fd, buffer, offset, length, position)分段读。constfsrequire(fs);// 小文件没问题记得先判断 err否则文件不存在时 buf 为 undefinedfs.readFile(small.txt,(err,buf){if(err)returnconsole.error(err);console.log(Buffer.isBuffer(buf));// trueconsole.log(buf.length);// 字节数});// 大文件别这么干用 createReadStream// fs.readFile(huge.log, ...); // 可能 OOM六、文件描述符操作系统给你的「取餐号」文件描述符file descriptor, fd是操作系统里「打开的文件」的整数句柄。你open一个文件内核给你一个 fd比如 3、4、5后续 read/write 都用这个数字来指代「哪个打开的文件」。Node 里fs.open(path, flags, callback)会得到(err, fd)。fs.read(fd, buffer, offset, length, position, callback)表示从 fd 对应的文件里从position开始读length字节放进buffer的offset位置读完再回调。用完后要fs.close(fd)否则会占用内核资源可打开 fd 数量有限制。用 fd read可以自己实现「分段读大文件」constfsrequire(fs);functionreadInChunks(filePath,chunkSize64*1024){constbufferBuffer.alloc(chunkSize);letposition0;fs.open(filePath,r,(err,fd){if(err)returnconsole.error(err);functionreadNext(){fs.read(fd,buffer,0,chunkSize,position,(err,bytesRead){if(err)returnfs.close(fd,()console.error(err));if(bytesRead0)returnfs.close(fd,()console.log(读完了));console.log(读到${bytesRead}字节position${position});positionbytesRead;readNext();});}readNext();});}readInChunks(./some-big-file.log);这里就是「底层」用法自己控 Buffer、position、每次读多少不依赖readFile一次性装进内存。七、Stream别一口吞一口一口吃Stream流是「一块一块处理数据」的抽象不要求一次性把整个文件读进内存而是读一块、处理一块、再读下一块。fs.createReadStream(path)会打开文件并返回一个Readable 流。底层一般也是用 fd 多次read每次读满一块 Buffer默认 64KB可配通过data事件或read()推给你。流内部有highWaterMark内部缓冲超过这个值就暂停从底层拉数据避免内存爆掉。所以大文件用 ReadStream 管道或逐 chunk 处理就不会 OOM。constfsrequire(fs);// 大文件拷贝流式内存占用稳定functioncopyBigFile(src,dest){constreadStreamfs.createReadStream(src,{highWaterMark:64*1024});constwriteStreamfs.createWriteStream(dest,{highWaterMark:64*1024});readStream.pipe(writeStream);writeStream.on(finish,()console.log(拷贝完成));}// 边读边处理例如数行数letlines0;fs.createReadStream(huge.log).on(data,(chunk){for(leti0;ichunk.length;i)if(chunk[i]10)lines;}).on(end,()console.log(总行数:,lines));八、同步 vs 异步什么时候该用谁方式谁在干活阻塞主线程适用场景fs.readFile线程池否小文件、配置等fs.readFileSync主线程是启动时读配置、脚本createReadStream线程池 事件否大文件、日志fs.read(fd, ...)线程池否需要精细控制位置/块原则能异步就异步避免阻塞事件循环。只有在「进程刚启动、必须立刻拿到结果才能往下跑」的场景才考虑用 Sync例如读一个 config.json 再启动服务。九、新特性与最新知识点Promise、FileHandle、io_uring1.fs.promises与 async/awaitNode 内置了基于 Promise 的 fs API不用自己包一层constfsrequire(fs).promises;asyncfunctionmain(){try{constdataawaitfs.readFile(config.json,utf8);constconfigJSON.parse(data);console.log(config);}catch(e){console.error(e);}}main();底层和回调版是同一套都是走 libuv 线程池只是把 callback 换成了 Promise 的 resolve/reject。2.FileHandle长期持有 fd 的「句柄」fs.promises.open()返回的是FileHandle可以多次读/写再关闭适合「同一个文件反复读」constfsprequire(fs).promises;asyncfunctionreadHeadAndTail(path,headBytes100,tailBytes100){consthandleawaitfsp.open(path,r);conststatawaithandle.stat();constheadBuffer.alloc(headBytes);consttailBuffer.alloc(tailBytes);awaithandle.read(head,0,headBytes,0);if(stat.sizetailBytes){awaithandle.read(tail,0,tailBytes,stat.size-tailBytes);}awaithandle.close();return{head:head.toString(),tail:tail.toString()};}3. Linux 上的 io_uring了解即可从 libuv 1.45 起Linux 上部分文件 I/O 曾尝试用io_uring做更高性能的异步磁盘 I/O后来默认又改回线程池。若要用 io_uring需要在创建 event loop 时显式开启如UV_LOOP_USE_IO_URING_SQPOLL。对写业务代码的我们来说知道「文件 I/O 主要走线程池」就够了除非你在做极致性能调优。十、综合示例一个「带流式读 行解析」的日志处理器下面这段把「底层」和「实用」串起来用ReadStream读大日志按行切分、逐行处理不会把整个文件载入内存。constfsrequire(fs);constreadlinerequire(readline);asyncfunctionprocessLargeLog(filePath,onLine){conststreamfs.createReadStream(filePath,{highWaterMark:256*1024,// 256KB 一块});constrlreadline.createInterface({input:stream,crlfDelay:Infinity});forawait(constlineofrl){awaitonLine(line);// 你可以在这里做解析、写库、发 MQ 等}}// 使用示例只打印包含 ERROR 的行processLargeLog(./app.log,async(line){if(line.includes(ERROR))console.log(line);}).then(()console.log(处理完毕));这里用到的就是fs 的 ReadStream底层 fd 分块 read readline 按行消费既不会 OOM又符合「流式」的思维方式。十一、小结一张「外卖流程图」收尾你调fs.readFile/createReadStream等 →Node fs 模块JS↓C 绑定调libuv↓libuv把文件 I/O 丢给线程池默认 4 个 worker↓线程池里阻塞式 read→内核 → 磁盘↓读到的数据放进Buffer完成后通过事件循环把回调/Promise 推回主线程若是Stream则是多次「读一块 → 推一块」由highWaterMark等控制背压记住这几件事单线程指的是 JS文件 I/O 在 libuv 线程池里。大文件用 Stream 或 fd read别用readFile一把梭。Buffer是那块「装字节」的内存fd是操作系统给你的「取餐号」。新代码优先用fs.promises或FileHandle逻辑更清晰底层和回调版一致。如果你愿意再往深挖可以看Node 源码里的src/node_file.cc、lib/fs.jslibuv 文档里的 File system operations这样下次有人问「Node 读文件到底是同步还是异步」「为什么我读大文件会崩」你就能从事件循环讲到线程池、从 Buffer 讲到 Stream顺便用「外卖下单 → 后厨线程池 → 取餐号 fd」的比喻把对方讲懂。祝写 Node 少踩坑磁盘 I/O 稳如狗。