如果你最近在 Node.js 项目里写文件,可能遇到过两种困惑:一种是直接用writeFileSync写完文件,接口响应明显变慢;另一种是换成writeFile之后,回调里忘了处理错误,日志数据静默丢了一部分。这两类问题,本质上都和“异步写入”有关。
本文是“5 分钟学编程”Express.js 系列的第 11 篇,主题非常聚焦:梳理 Node.js 异步写入的三种写法,并在 Express 接口中把它们串起来,完成一个带日志落盘功能的小项目。无论你是刚接触 Node.js 的新手,还是写过不少业务接口、但没系统研究过文件写入的开发者,都可以通过这篇文章建立一套清晰、可复用的异步写入方案。
1. 为什么我们需要关注 Node.js 异步写入
1.1 从一个真实场景说起
假设你正在开发一个 Express 接口,每次调用都要把请求信息写入本地日志文件。最偷懒的做法是这样:
const fs = require('fs'); fs.writeFileSync('./log.txt', 'some message', 'utf8');在并发量很低的时候,这段代码看起来没有任何问题。但一旦请求量上来,你会奇怪:为什么接口响应越来越慢?CPU 占用率也不算高,瓶颈在哪里?
问题出在writeFileSync是同步 API。Node.js 是单线程事件循环模型,当某个函数同步执行磁盘 I/O 时,事件循环会被卡住。在这段时间里,后面排队的所有请求都无法被处理,即使它们并不需要写文件。这就是常见的“一个慢 I/O 拖垮整个服务”的现象。
1.2 异步写入解决什么问题
Node.js 之所以适合做高并发 I/O 密集型应用,核心在于它默认采用异步非阻塞模型。所谓异步写入,就是调用方发起写文件操作后,不需要原地等待磁盘写完,而是先把控制权交还给事件循环,等文件写入完成后再通过回调、Promise 或async/await拿到结果。
这意味着:
- 写文件期间,其他请求仍然可以正常处理。
- 写文件失败时,我们仍然有明确的错误捕获机制。
- 代码可以继续保持顺序阅读,不需要把业务逻辑层层嵌套。
异步写入不是 Node.js 独有的概念,Java、C#、Python 中也都有类似机制。但 Node.js 因为默认单线程事件循环,把“阻塞事件循环”的代价放大了,所以在 Node.js 项目里尤其要重视这一点。
1.3 什么时候必须用异步写入
以下场景强烈建议使用异步写入:
- 在 HTTP 请求处理链路中写日志。
- 在中间件中记录审计信息。
- 上传文件的保存落盘。
- 定时任务中批量导出数据。
- 任何写入频率高于“每秒一次”的持久化操作。
同步写入并非完全不能用,它适合在 Node.js 进程启动阶段、脚本工具中执行一次性初始化。比如服务启动时创建目录、生成配置文件,这种低频操作使用同步 API 反而更直观。
2. 环境准备与项目初始化
在开始写代码之前,先把环境准备到位。
2.1 检查 Node.js 环境
本文示例基于 Node.js 18+ 的 LTS 环境,并使用 CommonJS 模块规范。你在本地执行以下命令,确认环境和包管理工具可用:
node -v npm -v如果提示找不到node,需要先下载并安装 Node.js。安装完成后建议把 npm 源切换为国内镜像,能明显加快之后的依赖安装速度:
npm config set registry https://registry.npmmirror.com版本方面不需要刻意追求最新版本。只要你的 Node.js 版本在 14 以上,fs.promises就已经可用;如果版本更老,也可以通过util.promisify(fs.writeFile)把回调包装成 Promise。本文统一使用现代写法,推荐使用 Node.js 18 或更高版本。
2.2 初始化项目
我们创建一个 demo 项目,用来演示 Node.js 异步写入的三种写法,并在 Express.js 中实现日志写入接口。
mkdir express-async-write-demo cd express-async-write-demo npm init -y npm install express成功后项目结构如下:
express-async-write-demo/ ├── node_modules/ ├── package.json ├── app.js └── logs/ # 启动后自动创建logs目录不需要手动创建,我会在代码里用自动创建的方式处理,这也是实际项目中的常见做法。
3. Node.js 异步写入的三种写法详解
Node.js 中异步写入文件主要围绕fs模块展开。常用的核心方法有两个:
fs.writeFile:覆盖写入。如果文件已存在,内容会被完全替换。fs.appendFile:追加写入。如果文件已存在,新内容会追加到文件末尾。
两者都有对应的 Promise 版本,即fs.promises.writeFile和fs.promises.appendFile。下面我以覆盖写为例,逐一演示三种异步写法。
3.1 写法一:回调函数
这是 Node.js 最原始、最底层的异步写法。fs.writeFile的签名如下:
fs.writeFile(file, data[, options], callback)其中callback会在文件写入完成或出错时被调用。回调函数的第一个参数是错误对象,如果写入成功则为null。
const fs = require('fs'); const content = '这是使用回调函数写入的内容\n'; fs.writeFile('example.txt', content, 'utf8', (err) => { if (err) { console.error('写入失败:', err); return; } console.log('写入成功'); });这段代码理解起来很直接:调用writeFile之后,Node.js 会把 I/O 请求交给底层线程池,主线程继续执行后续代码。当写入完成,事件循环会从队列中取出回调函数执行。
回调写法的优点是兼容性好,从 Node.js 最早的版本就已经存在。缺点是当你有多个连续异步操作时,很容易形成“回调地狱”:
fs.writeFile('a.txt', 'A', 'utf8', (err) => { fs.writeFile('b.txt', 'B', 'utf8', (err) => { fs.writeFile('c.txt', 'C', 'utf8', (err) => { // 再往下嵌套会越来越难看 }); }); });因此,在维护新项目时,我不推荐大量使用回调嵌套,除非是在维护老代码库。
3.2 写法二:Promise
从 Node.js 10 开始,fs模块提供了fs.promisesAPI,把文件操作封装成 Promise。这样我们可以使用链式调用来组织代码:
const fs = require('fs'); const content = '这是使用 Promise 写入的内容\n'; fs.promises .writeFile('example.txt', content, 'utf8') .then(() => { console.log('写入成功'); }) .catch((err) => { console.error('写入失败:', err); });与回调写法相比,Promise 写法的最大改进是解决了回调嵌套问题。多个连续写入可以这样写:
fs.promises .writeFile('a.txt', 'A', 'utf8') .then(() => fs.promises.writeFile('b.txt', 'B', 'utf8')) .then(() => fs.promises.writeFile('c.txt', 'C', 'utf8')) .then(() => { console.log('全部写入完成'); }) .catch((err) => { console.error('某个文件写入失败:', err); });任何一步出错,都会被末尾的catch捕获,不需要在每一步单独判断错误。这比回调写法清晰很多。
不过,如果业务逻辑中包含大量分支、循环、条件判断,单纯的 Promise 链依然会变得复杂。因此更推荐第三种写法。
3.3 写法三:async/await
async/await是 Promise 的语法糖,它让我们可以用近乎同步代码的方式书写异步逻辑,可读性最好。
const fs = require('fs'); async function saveFile() { try { await fs.promises.writeFile('example.txt', '这是使用 async/await 写入的内容\n', 'utf8'); console.log('写入成功'); } catch (err) { console.error('写入失败:', err); } } saveFile();这里的执行过程是:遇到await后,当前函数会暂停,但不会阻塞事件循环。当 Promise 完成,函数会从暂停位置继续执行。try/catch则可以捕获整个函数内部所有异步操作的异常。
连续写入时,代码会非常自然:
const fs = require('fs'); async function writeMultipleFiles() { try { await fs.promises.writeFile('a.txt', 'A', 'utf8'); await fs.promises.writeFile('b.txt', 'B', 'utf8'); await fs.promises.writeFile('c.txt', 'C', 'utf8'); console.log('全部写入完成'); } catch (err) { console.error('写入失败:', err); } } writeMultipleFiles();我自己的经验是:新项目里优先使用fs.promises配合async/await,既没有回调嵌套,又比 Promise 链更容易阅读和维护。
3.4 三种写法对比
| 写法 | 可读性 | 错误处理 | 适用场景 |
|---|---|---|---|
| 回调函数 | 低,嵌套后很难读 | 靠回调参数 err | 维护老项目、兼容低版本 |
| Promise | 中,链式调用清晰 | .catch 统一处理 | 简单的连续写入 |
| async/await | 高,类似同步代码 | try/catch 捕获 | 业务复杂、推荐使用 |
4. 在 Express 项目中实现日志异步写入实战
基础语法看完了,接下来进入本项目最核心的实战部分:在 Express.js 接口中实现日志异步写入。
4.1 需求设计
我们做一个简单的接口服务,提供三个接口,分别对应三种异步写入写法。每次请求接口时,服务会把请求时间、请求方法、请求路径、请求体写入到logs/request.log文件中。
接口设计如下:
| 接口 | 用途 |
|---|---|
| POST /api/log/callback | 使用回调方式写入日志 |
| POST /api/log/promise | 使用 Promise 方式写入日志 |
| POST /api/log/async | 使用 async/await 方式写入日志 |
这个设计会自然覆盖appendFile的场景,因为日志通常只在文件末尾追加,不会覆盖之前的内容。
4.2 编写 Express 应用
在项目根目录创建app.js,代码如下:
const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(express.json()); const LOG_DIR = path.join(__dirname, 'logs'); const LOG_FILE = path.join(LOG_DIR, 'request.log'); // 启动时自动创建日志目录,避免写入时出现 ENOENT if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } // 把请求对象格式化成一行日志 function formatLog(req) { return `${new Date().toISOString()} ${req.method} ${req.path} ${JSON.stringify(req.body)}\n`; } // 写法一:回调函数 app.post('/api/log/callback', (req, res) => { const line = formatLog(req); fs.appendFile(LOG_FILE, line, 'utf8', (err) => { if (err) { console.error('写入失败:', err); return res.status(500).json({ message: '写入失败' }); } res.json({ message: '写入成功', method: 'callback' }); }); }); // 写法二:Promise app.post('/api/log/promise', (req, res) => { const line = formatLog(req); fs.promises .appendFile(LOG_FILE, line, 'utf8') .then(() => { res.json({ message: '写入成功', method: 'promise' }); }) .catch((err) => { console.error('写入失败:', err); res.status(500).json({ message: '写入失败' }); }); }); // 写法三:async/await app.post('/api/log/async', async (req, res) => { try { const line = formatLog(req); await fs.promises.appendFile(LOG_FILE, line, 'utf8'); res.json({ message: '写入成功', method: 'async/await' }); } catch (err) { console.error('写入失败:', err); res.status(500).json({ message: '写入失败' }); } }); app.listen(3000, () => { console.log('服务已启动:http://localhost:3000'); });来逐段说明一下:
- 顶部引入
express、fs、path三个模块。path用来安全拼接路径,避免 Linux 和 Windows 分隔符差异。 LOG_DIR和LOG_FILE是写死示例路径。实际项目中建议从配置文件读取。- 启动时的
mkdirSync是同步操作,但只在进程启动时执行一次,不会影响运行期性能,所以这里可以接受。 formatLog是一个辅助函数,把请求信息拼成一行日志。JSON.stringify(req.body)允许请求体包含中文字符,写入时也会保留原文。- 三个接口分别用三种写法实现,核心逻辑完全一致。
4.3 运行服务
在终端执行:
node app.js看到下面输出就说明服务启动成功:
服务已启动:http://localhost:3000然后打开一个新终端,用curl分别调用三个接口:
curl -X POST http://localhost:3000/api/log/callback \ -H "Content-Type: application/json" \ -d '{"user":"csdn","action":"callback"}'curl -X POST http://localhost:3000/api/log/promise \ -H "Content-Type: application/json" \ -d '{"user":"csdn","action":"promise"}'curl -X POST http://localhost:3000/api/log/async \ -H "Content-Type: application/json" \ -d '{"user":"csdn","action":"async"}'预期依次返回:
{"message":"写入成功","method":"callback"}{"message":"写入成功","method":"promise"}{"message":"写入成功","method":"async/await"}此时查看日志文件:
cat logs/request.log你会看到类似下面的内容:
2025-01-15T10:23:11.102Z POST /api/log/callback {"user":"csdn","action":"callback"} 2025-01-15T10:23:12.203Z POST /api/log/promise {"user":"csdn","action":"promise"} 2025-01-15T10:23:13.304Z POST /api/log/async {"user":"csdn","action":"async"}三行日志顺序清晰,说明三种写法都成功执行了异步追加写入。
4.4 批量写入优化
在真实环境中,往往不是“一次请求写一行日志”,而是短时间内产生大量日志。如果每一条都单独触发一次appendFile,性能未必理想。更稳妥的做法是先在内存中攒一批,再一次性写入,这也是日志类库高效写入的底层思路。
下面的示例演示了把多条日志拼成一个大字符串,再使用async/await一次性追加:
const fs = require('fs'); async function writeLogBatch(logLines, file) { const content = logLines .map((line) => `${new Date().toISOString()} ${line}\n`) .join(''); await fs.promises.appendFile(file, content, 'utf8'); } writeLogBatch( ['用户登录', '查询订单', '用户退出'], './logs/batch.log' );这种批量写入方式,会把多次磁盘 I/O 合并成一次,在高并发场景下能显著降低 I/O 压力。
5. 常见问题与排查思路
在实际开发中,异步写入的报错和坑有不少。我把高频问题整理成表,方便你按图索骥。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| ENOENT: no such file or directory | 写入路径中的目录不存在 | 写入前用mkdirSync(dir, { recursive: true })创建目录 |
| 日志没有写入,也没有报错 | 回调中忘了处理 err | 所有回调 API 都必须检查第一个参数 |
| UnhandledPromiseRejection | Promise 没有 catch,或 await 没有 try/catch | 补全 catch,并考虑全局监听兜底 |
| 接口偶发 500,日志内容残缺 | 多个进程同时写同一个文件 | 单进程内使用 append 模式,分布式环境使用独立日志系统 |
| 文件中文乱码 | 写入或读取时编码不一致 | 统一指定utf8编码 |
| 接口卡顿严重 | 在请求链路中用了 writeFileSync | 替换为fs.promises异步 API |
5.1 ENOENT 错误
最常见的场景是:写入./logs/app.log,但项目里根本没有logs这个目录。修复方式是在启动阶段自动创建目录:
const path = require('path'); const fs = require('fs'); const logDir = path.join(__dirname, 'logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); }recursive: true的作用是,如果多级目录都不存在,会一次性创建完整目录链。
5.2 回调被遗忘
如果使用回调写法,漏掉err判断,就会出现“看似写完了,其实失败也没人知道”的情况。下面这种写法是不安全的:
fs.appendFile('app.log', 'data', 'utf8', () => { // 这里把 err 忽略了 });务必改成:
fs.appendFile('app.log', 'data', 'utf8', (err) => { if (err) { console.error(err); } });5.3 UnhandledPromiseRejection
Node.js 中,Promise 的 rejection 如果没有被捕获,会触发unhandledRejection事件。在较新的 Node.js 版本中,这会直接导致进程退出。
排查顺序如下:
- 检查是否所有
fs.promises方法都链式调用了.catch。 - 检查所有
await是否都处于try/catch中。 - 可以在启动入口统一增加兜底监听:
process.on('unhandledRejection', (reason) => { console.error('未处理的 Promise 异常:', reason); });但要记住,兜底监听只是“不崩溃”,真正的修复方向还是让每个 Promise 都有对应的处理分支。
5.4 并发写入同一文件
Node.js 单进程内,fs.appendFile的并发调用在大多数操作系统下是安全的,不会出现明显的交错覆盖。但如果你的服务开启了多进程,比如pm2启动多个实例,多个进程同时写同一个文件,可能出现日志错乱。
建议方案:
- 保持单进程写本地日志文件。
- 如果多进程实例必须写同一个日志,引入集中式日志服务,比如上传到日志平台。
- 用
fs.createWriteStream创建流,让多个写入通过流排队。
5.5 中文乱码问题
写入时指定utf8,读取和查看时也要保持同样的编码。尤其在使用 Windows 命令行时,默认终端编码如果不是 UTF-8,显示中文会乱码,但文件内容本身可能是正常的。可以用编辑器打开文件确认。
6. 最佳实践与工程建议
代码跑通只是第一步,工程上我们需要更稳定、更规范的做法。下面这份建议来自实际项目经验,可以直接用在自己的服务里。
6.1 新代码统一使用 fs.promises + async/await
除非是在维护老代码,否则新项目建议直接使用fs.promises。与回调写法相比,它天然支持try/catch异常处理;与 Promise 链相比,它的可读性更强。更重要的是,当业务逻辑变复杂时,async/await依然是容易维护的代码形态。
6.2 把日志写入封装成独立模块
不要在每个路由里直接fs.appendFile。更好的做法是封装一个日志类,统一管理日志目录、文件路径和写入格式。
下面是一个简单示例:
const fs = require('fs/promises'); const path = require('path'); class FileLogger { constructor(logDir) { this.logDir = logDir; this.logFile = path.join(logDir, 'app.log'); } async init() { await fs.mkdir(this.logDir, { recursive: true }); } async info(message) { await this._write('INFO', message); } async error(message) { await this._write('ERROR', message); } async _write(level, message) { const line = `${new Date().toISOString()} [${level}] ${message}\n`; await fs.appendFile(this.logFile, line, 'utf8'); } } module.exports = FileLogger;在 Express 中这样使用:
const FileLogger = require('./FileLogger'); const logger = new FileLogger('./logs'); async function main() { await logger.init(); await logger.info('服务启动'); } main();封装之后,如果需要切换日志策略,或者增加日志轮转,只需要改动这一个模块,业务代码完全不受影响。
6.3 高频写入优先考虑 Stream
如果日志量非常大,逐条appendFile的频繁打开、写入、关闭操作依然会造成性能损耗。更推荐的方案是使用fs.createWriteStream。
const fs = require('fs'); const stream = fs.createWriteStream('./logs/stream.log', { flags: 'a' }); stream.write(`${new Date().toISOString()} 第一条日志\n`); stream.write(`${new Date().toISOString()} 第二条日志\n`); stream.end(() => { console.log('全部写入完成'); });createWriteStream内部会维护一个写入队列,把多次小写入合并成较大的 I/O 操作,从而提升吞吐量。常见的日志库如winston、pino底层也大量使用流式写入。
不过流的错误处理和背压机制更复杂,适合对性能有明确要求的场景。对于日常业务日志,先用封装好的async/await写入完全够用。
6.4 错误处理与安全边界
写日志本身是辅助操作,但也不能因为辅助操作影响主业务流程。建议:
- 日志写入失败时,记录错误,但不要因为日志失败导致接口崩溃。
- 不要在日志中记录完整密码、身份证号、银行卡号等敏感信息。
- 写入文件的路径必须来自可控配置,避免拼接不可信输入造成路径穿越。
- 如果日志文件目录权限过宽,可能被其他进程读取或篡改,建议设置合理的文件权限。
6.5 考虑日志文件大小与轮转
无限增长的单个日志文件会带来两个问题:占用磁盘空间越来越大;定位问题时难以查找。工程上通常采用日志轮转,比如按天生成文件:
logs/ ├── app-2025-01-15.log ├── app-2025-01-16.log └── app-2025-01-17.log实现思路很简单:在计算日志文件路径时加入日期字段。这也是最轻量级的轮转方案。更复杂的可以交给专业日志库处理。
6.6 不要把目录结构写死在业务代码里
日志目录、日志文件名这些配置,建议放到环境变量或配置文件中。比如:
const logDir = process.env.LOG_DIR || path.join(__dirname, 'logs');这样在开发、测试、生产环境可以灵活切换,不需要改动代码逻辑。
7. 总结与下一步学习路线
这篇文章围绕 Node.js 异步写入,讲清了三件事:
fs.writeFile/fs.appendFile是异步写入的核心方法,写日志时优先使用追加模式。- 三种写法中,回调写法适合维护老代码,Promise 写法适合简单链式调用,
async/await写法最适合新项目。 - 在 Express 项目中,文件写入需要配合目录初始化、错误处理和批量写入,才能支撑真实流量。
把这个项目自己动手跑一遍,再把“启动时创建目录、每个接口异步写入、查看日志文件”这个流程完整走通,你就能理解 Node.js 事件循环对 I/O 操作的意义了。
接下来建议你继续学习这几个方向:
fs.createReadStream与fs.createWriteStream,理解流式读写和背压机制。winston或pino日志库,看它们如何解决日志分级、结构化和轮转问题。- Express 中间件机制,尝试把日志写入封装成全局中间件,让所有接口自动记录访问日志。
写文件是 Node.js 后端开发里非常基础的能力,但正因为基础,才更值得把每种写法和背后的原理吃透。希望你读完这篇文章后,能对异步写入有一个清晰、系统的认识,也欢迎在评论区分享你自己的踩坑经验。