# 文件系统操作 fs
30 秒速记
- 三套写法:回调
fs.readFile、同步fs.readFileSync、Promise版fs/promises;新代码优先Promise+await - 同步
API会卡住事件循环,只放在启动阶段或一次性脚本里 - 别先
access再操作:检查和使用之间文件可能变了(TOCTOU),直接操作再按err.code处理 - 大文件用流,
readFile会把整个文件塞进内存 - 重要文件写临时文件再
rename,避免写到一半进程挂掉留下半个文件
fs 是 Node.js 操作文件的模块,同一个功能一般有回调、同步、Promise 三种写法,新代码我默认用 fs/promises 加 await。 同步写法最直观,但它执行期间整个进程什么都干不了,在 HTTP 服务里读一个大文件,所有请求都得等着,所以只放在启动读配置、一次性脚本这种地方。另外两条工程经验:别先 fs.access 判断再去读,直接读并处理 ENOENT;写配置这类重要文件先写到临时文件再 rename 过去,这样进程中途崩了也不会留下半截文件。
理解「文件系统操作 fs」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 filesystem 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
fs 模块的接口很多,但大部分都是同一个操作的三种写法,先把这三种分清楚:
const fs = require('fs');
const fsp = require('fs/promises');
// 1. 同步:直观,但执行期间整个进程卡住
const a = fs.readFileSync('./a.txt', 'utf8');
// 2. 回调:错误优先,第一个参数是 err
fs.readFile('./a.txt', 'utf8', (err, data) => {
if (err) return console.error(err.code); // 比如 ENOENT
console.log(data);
});
// 3. Promise:新代码首选,配合 async/await
async function main() {
try {
const data = await fsp.readFile('./a.txt', 'utf8');
console.log(data);
} catch (err) {
if (err.code === 'ENOENT') console.log('文件不存在');
else throw err;
}
}
main();
选型上可以简单记:服务运行期间一律用异步(Promise 优先),同步写法只用在启动阶段和命令行脚本里。
几个常见错误码要认识:ENOENT 文件或目录不存在,EACCES / EPERM 没权限,EISDIR 把目录当文件读了,EEXIST 文件已存在,EMFILE 打开的文件太多。按错误码分支处理,比提前用 access 检查更可靠,因为检查完到真正操作之间,文件状态随时可能被别的进程改掉。
写重要文件时用「临时文件 + rename」:
async function writeAtomic(file, content) {
const tmp = file + '.' + process.pid + '.tmp';
await fsp.writeFile(tmp, content);
await fsp.rename(tmp, file); // 同一文件系统内是原子替换
}
面试官追问
追问 1服务里每个请求都 readFileSync 读一次模板,QPS 一高延迟就上去了,为什么?
同步读取期间事件循环被卡住,其他请求只能排队。启动时读一次缓存到内存,或者改成 await fs.readFile。
追问 2先 fs.existsSync(p) 判断,再 readFile,有什么问题?
两步之间文件可能被删或被换,判断结果就不准了,还多了一次系统调用。直接读,catch 里判断 err.code === 'ENOENT'。
追问 3写 config.json 时进程被 kill,重启后读出来是半个 JSON,怎么避免?
先写到同目录的 config.json.tmp,写完再 fs.rename 覆盖过去。同一个文件系统内的 rename 是原子的,读到的要么是旧文件要么是新文件。
追问 4回调版、Promise 版、同步版性能有差别吗?
回调和 Promise 版底层都走 libuv 线程池,差别很小;同步版单次可能更快,但会阻塞整个进程。选型看场景,不看这点速度差。
追问 5一次性 Promise.all 打开几万个文件,报 EMFILE,怎么办?
文件描述符超过系统上限了。用 p-limit 之类控制并发,比如同时只开 100 个。
# 文件读取
30 秒速记
- 小文件一次读完:
await fs.readFile(p, 'utf8'),不传编码拿到的是Buffer - 大文件用流:
fs.createReadStream每次读一块(默认64KB),内存占用稳定 readFile有上限(约2GB),超了直接报ERR_FS_FILE_TOO_LARGE- 按行处理用
readline+ 流;流要处理error事件,用pipeline自动收尾 - 同步读取只放启动阶段或脚本
读文件看大小选方式:小文件 readFile 一次读进内存,大文件用 createReadStream 一块一块读。 readFile 写起来最简单,但几百 MB 的日志一次读进来,内存直接飙上去,并发几个请求进程就可能被 OOM 干掉。流每次只读一块,读完处理完再读下一块,内存基本是平的。按行处理大文件我一般用 readline 配流,用 for await 逐行读;要把文件转发给响应或者压缩,就用 stream.pipeline,它出错时会帮你把两头的流都关掉。
理解「文件读取」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 filesystem 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
读文件主要就两种方式,区别在于内存里同一时刻放多少数据:
const fs = require('fs');
const fsp = require('fs/promises');
const readline = require('readline');
const { pipeline } = require('stream/promises');
const zlib = require('zlib');
// 1. 小文件:一次读完
const text = await fsp.readFile('./config.json', 'utf8');
// 2. 大文件按行处理:内存只占一块缓冲区
const rl = readline.createInterface({
input: fs.createReadStream('./access.log'),
crlfDelay: Infinity,
});
let count = 0;
for await (const line of rl) {
if (line.includes(' 500 ')) count++;
}
console.log('500 错误行数', count);
// 3. 大文件转存/压缩:pipeline 负责背压和出错时关闭两端
await pipeline(
fs.createReadStream('./access.log'),
zlib.createGzip(),
fs.createWriteStream('./access.log.gz'),
);
(上面的顶层 await 需要在 ESM 或 async 函数里运行。)
一个直观的类比:readFile 是把整桶水一次倒进杯子,杯子不够大就溢出;流是接一根水管,接一点用一点。fs.createReadStream 默认每块 64KB,可以用 highWaterMark 调整。
还有两个细节。第一,流一定要处理错误,单独用 .pipe() 时某一端出错另一端不会自动关闭,文件描述符会泄漏,所以优先用 pipeline。第二,readFile 不适合读持续增长的文件(比如正在写的日志),它只读调用那一刻的内容,要跟踪新内容得用流配合 fs.watch 或者专门的 tail 库。
面试官追问
追问 1fs.readFile(p) 打印出来是 <Buffer 68 65 ...>,怎么拿字符串?
传编码:readFile(p, 'utf8'),或者拿到 Buffer 后 data.toString('utf8')。
追问 2下载接口用 readFile 读 1GB 的文件再 res.end,内存爆了,怎么改?
改成
