文件系统是 Node.js 最常见的 I/O 来源,也是最容易写出"能跑但脆弱"代码的地方:阻塞事件循环、路径遍历漏洞、半成品文件、跨平台分隔符不一致……本文从 fs API 三形态讲到路径安全与原子写入,给你一套生产级文件处理方案。
1. fs API 三形态:同步、回调与 Promise
Node 的 fs 模块几乎每个方法都有三种形态,误用同步版本会阻塞整个事件循环:
| 形态 | 用法 | 适用场景 |
|---|---|---|
| 同步 | fs.readFileSync() | 进程启动时的配置读取,数量少 |
| 回调 | fs.readFile(path, cb) | 老代码,回调地狱风险 |
| Promise | fs/promises.readFile() | 现代默认选择 |
import { readFile, writeFile } from 'node:fs/promises';
// Promise 形态,不会阻塞事件循环
const data = await readFile('config.json', 'utf8');
await writeFile('out.txt', data, 'utf8');
铁律:请求处理路径上永远用
fs/promises。readFileSync一次同步读大文件,会让整个进程的并发请求一起卡住——这在压测里是立刻现形的性能杀手。
2. path 模块:跨平台的路径工程
2.1 分隔符与拼接
Windows 用 \、POSIX 用 /,手写字符串拼接必然踩坑。path.join 与 path.resolve 帮你处理:
import path from 'node:path';
path.join('app', 'config', 'db.json'); // app/config/db.json(按平台自适应)
path.resolve('app', '../config'); // /abs/path/config(相对 cwd 解析为绝对路径)
path.sep; // 当前平台分隔符:'/' 或 '\\'
path.delimiter; // 环境变量路径分隔符:':' 或 ';'
| API | 作用 | 与 join 的区别 |
|---|---|---|
path.join() | 拼接规范化 | 不解析 .. 的绝对基准 |
path.resolve() | 拼接并解析为绝对路径 | 以 .. 逐级上溯到根 |
path.basename() | 取文件名 | 不带目录 |
path.dirname() | 取目录名 | 不带文件名 |
path.extname() | 取扩展名 | 含点号,如 .json |
2.2 安全拼接:禁止字符串插值
// ✗ 危险:用户可注入 ../ 逃出目录
const p = `/uploads/${userInput}`;
// ✓ 安全:先 resolve 再校验前缀
const base = path.resolve('uploads');
const target = path.resolve(base, userInput);
if (!target.startsWith(base + path.sep)) throw new Error('非法路径');
一句话:跨平台路径永远走
path模块;涉及用户输入拼接时,先resolve再校验前缀,这是路径安全的第一步。
3. 大文件处理:流式读写与背压
readFile 会把整个文件载入内存——一个 2GB 文件会直接吃光进程内存。大文件必须用流:
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// pipeline 自动处理背压:读太快时写流会暂停读取
await pipeline(
createReadStream('big.log'),
createWriteStream('big-copy.log')
);
3.1 读大文件逐行处理
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({ input: createReadStream('access.log'), crlfDelay: Infinity });
for await (const line of rl) {
// 逐行处理,内存占用恒定为 O(1)
}
3.2 背压机制
流的 readable 数据若消费跟不上,pipe/pipeline 会自动暂停源端读取,防止内存暴涨。手写循环消费时要关注 read() 返回 null 时等待 readable 事件,别用 while 死循环。
一句话:“文件很大"的唯一正确姿势是流。
pipeline自动背压、readline逐行处理,内存恒定,这才是生产级大文件处理。
4. 目录监听:fs.watch 与场景
4.1 监听 API
import { watch } from 'node:fs';
const watcher = watch('uploads', { recursive: true }, (event, filename) => {
console.log(`${event}: ${filename}`);
});
// 用毕关闭,否则句柄泄漏
watcher.close();
4.2 使用注意
- 不可靠:不同平台事件语义不一致(rename/change),且可能丢事件;
- 生产建议:真正的文件同步/部署场景,推荐 chokidar(跨平台统一事件、防抖、原子性更好);
- watch 的是 inode:重命名后旧 watcher 可能失效,需 re-watch;
- 递归监听要显式开启,且仅部分平台支持。
一句话:
fs.watch适合开发期热重载等低风险场景;生产级监听(配置热更新、文件同步)用 chokidar,并做好重命名重挂。
5. 权限与安全:路径遍历防护
路径遍历(Path Traversal)是文件功能最常见的漏洞:用户传 ../../etc/passwd,程序拼接后读取了系统文件。
// 防御完整模板
import path from 'node:path';
import { realpath } from 'node:fs/promises';
async function safePath(root, relative) {
const base = path.resolve(root);
const target = path.resolve(base, relative);
// 1) 前缀校验:必须落在 base 之下
if (!target.startsWith(base + path.sep)) {
throw new Error('路径越界');
}
// 2) realpath 消解符号链接:防 symlink 逃逸
const real = await realpath(target);
if (!real.startsWith(base + path.sep)) {
throw new Error('符号链接逃逸');
}
return target;
}
5.1 其他安全要点
| 风险 | 对策 |
|---|---|
| 路径遍历 | resolve + 前缀校验 + realpath 防软链 |
| 上传文件名注入 | 用服务端生成的随机名,别信任原文件名 |
| 目录权限过宽 | chmod 收敛,上传目录禁止执行位 |
| 临时文件竞争 | 用 fs.mkdtemp 建独立临时目录 |
| 编码绕过 | 先 decodeURIComponent 再校验 |
一句话:路径安全 = 白名单根目录 + resolve 前缀校验 + realpath 防软链逃逸三层;上传场景永远服务端改名,别把用户文件名当路径用。
6. 临时文件与原子写入
6.1 原子写入:先写临时文件再 rename
直接 writeFile 到目标文件,进程崩溃会留下半截文件。正确姿势是写临时文件、fsync 后 rename(同目录下 rename 是原子操作):
import { mkdtemp, rename, writeFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
async function atomicWrite(file, content) {
const dir = await mkdtemp(path.join(os.tmpdir(), 'app-'));
const tmp = path.join(dir, 'part');
await writeFile(tmp, content);
await rename(tmp, file); // 原子替换
await rm(dir, { recursive: true, force: true });
}
6.2 临时目录
- 系统临时目录用
os.tmpdir(); - 共享临时目录要
mkdtemp建唯一子目录,避免多进程互踩; - 用完即删,必要时注册
process.on('exit')兜底清理。
一句话:任何"写文件可能被打断"的场景都用"临时文件 + rename"的原子写入;临时文件放唯一
mkdtemp目录里,写日志、写配置、做缓存落地都受益。
7. 磁盘 I/O 性能与工程实践
7.1 减少 I/O 次数
- 合并小写入:批量攒到一定量再落盘(如日志缓冲 100 条刷一次);
- 复用连接与句柄:文件句柄是稀缺资源,用完
close; - 异步并发写:用
Promise.all并行写多个小文件,别一个个 await。
// 并行写多个文件
await Promise.all(files.map((f) => writeFile(f.path, f.data)));
7.2 文件系统布局建议
data/
uploads/ # 用户上传(不可执行、定期清理)
tmp/ # 临时/中间产物
logs/ # 日志(轮转)
config/ # 配置文件(只读挂载)
7.3 常用性能指标
| 指标 | 关注点 |
|---|---|
| 磁盘 I/O 队列 | 过多同步写会阻塞事件循环 |
| 句柄泄漏 | lsof 看打开文件数持续增长 |
| 碎片化大文件 | 影响顺序读吞吐 |
| 大目录遍历 | 数千文件的目录,find 类操作慢 |
一句话:文件工程的性能核心是少 I/O、并发 I/O、流式 I/O;目录按用途分层,句柄随用随关,别让文件系统成为隐藏瓶颈。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 请求路径用 readFileSync | 并发请求集体卡死 | 一律 fs/promises |
| 手拼路径 | Windows 下全挂 | 用 path.join/resolve |
| 直接写目标文件 | 崩溃留半截文件 | 临时文件 + rename 原子写 |
| 不校验路径前缀 | 路径遍历漏洞 | resolve + startsWith + realpath |
| 大文件 readFile | 内存暴涨 OOM | createReadStream + pipeline |
| watch 不 close | 句柄泄漏、内存增长 | 用完 close / chokidar |
| 复用临时目录 | 多进程互踩文件 | mkdtemp 唯一子目录 |
9. 总结
| 环节 | 要点 |
|---|---|
| API 形态 | 生产用 fs/promises,启动期可用同步 |
| 路径 | 全走 path 模块,禁止字符串插值 |
| 大文件 | 流 + pipeline 背压,内存恒定 |
| 监听 | 低风险用 fs.watch,生产用 chokidar |
| 安全 | 前缀校验 + realpath + 服务端改名 |
| 原子写 | 临时文件 + rename,防半截文件 |
| 性能 | 少 I/O、并发写、句柄随用随关 |
一句话记住:文件系统代码的正确姿势 = 异步 API + path 规范化 + 流式大文件 + 原子写入 + 路径白名单校验。这五条写进代码规范,文件相关的线上事故能消失一大半。
延伸阅读
- Node.js Stream 流式编程与 Buffer — 流、管道与背压的完整体系
- Node.js 安全实践与生产部署 — 上传安全与部署加固
- Node.js 核心架构与运行时 — 事件循环与 I/O 模型底层
- Node.js 异步编程与并发模型 — 并发文件处理的正确姿势
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。