ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node.js异步写入的三种写法:从回调到流式,搞定Express日志性能

Node.js异步写入的三种写法:从回调到流式,搞定Express日志性能 这次我们来看一个很实用的 Node.js 小知识点异步写入的三种写法。这个主题看起来简单但很多人在 Express.js 项目里写日志、导数据、处理批量任务时才发现同步写入会把整个事件循环卡住页面请求瞬间变慢。教程的核心不是概念多复杂而是你能不能在实际项目里写出不阻塞、不丢数据、扛得住高频请求的写入代码。本文会从三种常用写法讲起fs.appendFile回调风格、fs.promises配合async/await、createWriteStream流式写入然后把它落到 Express.js 的场景里做一个请求日志中间件和批量日志队列最后给出性能观察方法和常见问题排查清单。适合刚开始学 Node.js 的读者也适合已经写过 Express 接口、但日志和文件写入还停留在writeFileSync阶段的开发者。1. Node.js 异步写入三种写法核心速览特性项写法一回调式写法二Promise async/await写法三Stream 流式核心 APIfs.appendFile/fs.writeFilefs.promises.appendFile/fs.promises.writeFilefs.createWriteStream代码风格回调嵌套适合简单场景线性阅读最容易维护事件驱动适合高频持续写入是否阻塞事件循环不阻塞不阻塞不阻塞高频批量写入一般每条消息发起一次系统调用一般也可批量拼接后一次写入推荐内部有缓冲区能合并写入错误处理callback 参数判断try/catcherror 事件 write 返回值适用场景低频日志、一次性写入接口内需要等待写入结果的场景访问日志、审计日志、长连接数据落盘Node.js 版本要求所有版本Node.js 14 更稳定所有版本从表里能直接看出结论如果你只是偶尔写一行配置或一条日志用fs.promises最舒服如果要长期高频写访问日志应该用createWriteStream回调式写法虽然老但在老项目里还是会遇到所以也要能看懂。2. 适用场景为什么 Express.js 项目里要用异步写入先明确一个核心问题Node.js 是单线程事件循环模型同步文件写入会占用线程导致后续请求排队。你可以在一个 Express 接口里写一行fs.writeFileSync试试压测时就能看到响应时间明显上升。对于日志记录这种低频操作可能感觉不明显但一旦进入批量任务、导出报表、写入大量审计数据同步写入就会成为性能瓶颈。因此异步写入在 Express.js 项目里最常见的用途是请求访问日志每个请求记录 IP、路径、状态码、耗时。业务操作审计用户登录、下单、删除数据等关键操作落盘。批量数据导出从数据库查数据写入 CSV 或 JSON 文件。定时任务结果记录爬虫抓取结果、同步任务执行记录。这与很多其他语言中的“异步批量日志”思路一致Node.js 里同样可以通过“先缓存、再批量落盘”来降低磁盘压力。本文后面会给出一个简单的批量日志队列实现方便你在真实项目里直接改。3. 环境准备与前置条件动手之前先把环境确认好Node.js建议 16 LTS 或更高版本fs.promises在低版本下也能用但 14 更稳定。npmNode.js 安装后会自带。项目目录新建一个async-write-demo目录用来放测试代码。Express.js如果做接口测试需要安装express。初始化项目mkdir async-write-demo cd async-write-demo npm init -y npm install express创建目录结构async-write-demo/ ├── package.json ├── logs/ # 日志输出目录 └── app.js # Express 入口文件建议把logs目录单独管理不要和源码混在一起后面做日志轮转和清理也方便。如果项目里已经有现成的 Express 应用直接复制对应函数到你的工具模块里即可。4. 写法一fs.appendFile 回调式异步写入这是 Node.js 里最常见的入门写法也是老项目中存量代码最多的写法。核心是传入一个回调函数写入操作完成后由系统调用回调。创建一个write-callback.jsconst fs require(fs); const path require(path); // 日志目录不存在时先创建 const logDir path.join(__dirname, logs); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFile path.join(logDir, callback.log); /** * 使用 fs.appendFile 异步写入 * appendFile 表示追加写入文件不存在会自动创建 */ function writeLogCallback(message) { const line [${new Date().toISOString()}] ${message}\n; fs.appendFile(logFile, line, utf8, (err) { if (err) { console.error(写入日志失败, err); return; } console.log(写入成功, message); }); } // 连续写入三条 writeLogCallback(第一条回调式日志); writeLogCallback(第二条回调式日志); writeLogCallback(第三条回调式日志);运行node write-callback.js看logs/callback.log文件内容应该是三条追加进去的日志。这里的关键点fs.appendFile是异步 API不会阻塞事件循环。回调函数的第一个参数是错误对象必须判断。多次调用appendFile时Node.js 会按顺序排队写入但回调完成顺序不保证完全等于调用顺序高频场景下可能出现乱序需要靠时间戳或序号补偿。这种写法的缺点是代码嵌套变多后不好维护。你在 Express 中间件里连续写多个日志时回调会越套越深。5. 写法二fs.promises async/await 异步写入如果你已经习惯了async/await这种写法最顺手。Node.js 从 10.x 开始提供了fs.promises模块把文件操作包装成了 Promise配合try/catch可以写出非常干净的代码。创建write-async.jsconst fs require(fs/promises); const path require(path); const logFile path.join(__dirname, logs, async.log); /** * 使用 fs.promises.appendFile 异步写入 * 返回 Promise可以 await */ async function writeLogAsync(message) { const line [${new Date().toISOString()}] ${message}\n; try { await fs.appendFile(logFile, line, utf8); console.log(写入成功, message); } catch (err) { console.error(写入日志失败, err); throw err; } } async function main() { await writeLogAsync(第一条 async/await 日志); await writeLogAsync(第二条 async/await 日志); await writeLogAsync(第三条 async/await 日志); } main();运行node write-async.js这种写法的优势代码是线性的没有回调嵌套。如果想等待写入完成再返回响应可以await写入结果。错误处理集中到try/catch里逻辑清晰。在 Express.js 接口里如果你希望在日志写入成功后再返回结果给客户端用这种写法非常合适。例如app.post(/api/user, async (req, res) { // 处理业务逻辑 await fs.appendFile(logFile, 创建用户${userId}\n, utf8); res.status(201).json({ code: 0, message: 创建成功 }); });注意接口请求量高的时候每次请求都await写入可能会增加响应耗时因为磁盘写入需要时间。这种情况下不建议在请求链路里等待日志写入完成可以改成后面的流式写入或者只在关键业务点记录重要日志。6. 写法三createWriteStream 流式异步写入第三种写法是服务端高频日志场景里最推荐的。fs.createWriteStream会创建一个可写流数据先进入内部缓冲区再由系统批量写入磁盘减少了系统调用次数在高频写入时性能优势非常明显。创建write-stream.jsconst fs require(fs); const path require(path); const logFile path.join(__dirname, logs, stream.log); // 创建可写流flags: a 表示追加模式 const logStream fs.createWriteStream(logFile, { flags: a, encoding: utf8, highWaterMark: 16 * 1024 // 缓冲区大小 16KB }); /** * 通过流写入日志 */ function writeLogStream(message) { const line [${new Date().toISOString()}] ${message}\n; const canContinue logStream.write(line); // 背压处理当内部缓冲区已满时write 会返回 false if (!canContinue) { console.log(日志写入速度过快缓冲区已满正在等待排空...); } } // 模拟高频写入 for (let i 1; i 100; i) { writeLogStream(第 ${i} 条流式日志); } // 程序结束前关闭流 // 如果不关闭流会持续存在进程可能不会退出 logStream.end(() { console.log(日志流已关闭所有数据已写入); });运行node write-stream.js这段代码里有一个重要概念叫背压backpressure。write()方法返回false表示缓冲区已满此时如果继续写入数据会滞留在内存中极端情况下可能导致内存溢出。对于普通日志场景简单打印提示即可更严谨的做法是监听drain事件等缓冲区排空后再继续写入// 更好的背压处理示例 function writeLogWithBackpressure(message) { const line [${new Date().toISOString()}] ${message}\n; if (!logStream.write(line)) { logStream.once(drain, () { console.log(缓冲区已排空继续写入); }); } }流式写入最适合访问日志这种“只写不等”的场景不需要关心写入结果只要数据进缓冲区就行。Node.js 内部会在下一轮事件循环中把缓冲区数据刷到磁盘。7. 实战Express.js 异步日志中间件与 API前面三种写法分开看都简单真正体现价值的是把它组合到 Express 项目里。下面实现一个请求日志中间件使用流式写入记录每次访问的 IP、路径、状态码和耗时。创建app.jsconst express require(express); const fs require(fs); const path require(path); const crypto require(crypto); const app express(); app.use(express.json()); // 确保日志目录存在 const logDir path.join(__dirname, logs); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } // 创建可写流用于记录访问日志 const accessLogStream fs.createWriteStream(path.join(logDir, access.log), { flags: a, encoding: utf8 }); // 请求日志中间件 app.use((req, res, next) { const requestId crypto.randomUUID(); const startTime Date.now(); // 在响应结束时记录日志 res.on(finish, () { const duration Date.now() - startTime; const logLine ${new Date().toISOString()} ${req.ip} ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms ${requestId}\n; // 使用流式写入不阻塞响应 accessLogStream.write(logLine); }); next(); }); // 测试接口首页 app.get(/, (req, res) { res.json({ message: Hello async write }); }); // 测试接口模拟耗时业务 app.get(/slow, (req, res) { setTimeout(() { res.json({ message: slow response }); }, 500); }); // 批量日志接口一次写入多条 app.post(/api/logs, (req, res) { const { messages [], level info } req.body; if (!Array.isArray(messages) || messages.length 0) { return res.status(400).json({ error: messages must be a non-empty array }); } const lines messages.map((msg) { return ${new Date().toISOString()} [${level}] ${msg}\n; }); // 需要等待写入完成的场景可以使用 fs.promises fs.promises.appendFile(path.join(logDir, business.log), lines.join(), utf8) .then(() { res.json({ ok: true, count: messages.length }); }) .catch((err) { console.error(批量写入失败, err); res.status(500).json({ error: write failed }); }); }); app.listen(3000, () { console.log(服务已启动 http://localhost:3000); });启动服务node app.js然后访问测试curl http://localhost:3000/ curl http://localhost:3000/slow查看logs/access.log每次访问都会追加一条记录包含耗时和请求 ID。这个中间件的价值在于日志写入完全异步不会拖慢接口响应。500ms 延迟的慢接口日志记录本身不会额外增加响应时间。再测试批量写入接口curl -X POST http://localhost:3000/api/logs \ -H Content-Type: application/json \ -d {messages:[订单创建,订单支付,订单发货],level:info}返回{ok:true,count:3}同时logs/business.log里会出现三条带时间戳的日志。8. 接口 API 与批量任务队列写入设计上面的POST /api/logs是一次接口调用写入多条日志但还有另一种常见场景调用方持续高频发送单条日志服务端不能每条都立刻落盘否则磁盘压力很大。这时候需要做一个批量日志队列把消息先缓存到内存达到一定数量或时间后再批量写入。下面实现一个最简单的BatchLogger// batch-logger.js const fs require(fs); const path require(path); const logFile path.join(__dirname, logs, batch.log); class BatchLogger { /** * param {Object} options * param {string} options.filePath 日志文件路径 * param {number} options.maxSize 缓冲消息条数达到后触发刷盘 * param {number} options.flushInterval 最大刷盘间隔单位 ms */ constructor(options) { this.cache []; this.maxSize options.maxSize || 100; this.flushInterval options.flushInterval || 5000; this.filePath options.filePath || logFile; // 使用流式写入批量 flush 时更高效 this.logStream fs.createWriteStream(this.filePath, { flags: a, encoding: utf8 }); // 定时刷盘防止消息长时间积压在内存 this.timer setInterval(() this.flush(), this.flushInterval); // 定时器不阻止进程退出 this.timer.unref(); } /** * 添加一条日志到队列 */ log(message, level info) { const line ${new Date().toISOString()} [${level}] ${message}\n; this.cache.push(line); // 达到上限立即刷盘 if (this.cache.length this.maxSize) { this.flush(); } } /** * 将缓存中的消息批量写入文件 */ flush() { if (this.cache.length 0) { return; } const chunk this.cache.join(); this.cache []; // write 返回 false 表示缓冲区满为简化示例这里不阻塞 this.logStream.write(chunk); console.log([batch] 已写入 ${chunk.length} 字节); } /** * 程序结束时调用确保缓存数据落盘 */ close() { clearInterval(this.timer); this.flush(); this.logStream.end(); } } module.exports BatchLogger;模拟高频写入// batch-test.js const BatchLogger require(./batch-logger); const logger new BatchLogger({ filePath: require(path).join(__dirname, logs, batch.log), maxSize: 50, flushInterval: 3000 }); // 模拟 120 条业务日志 for (let i 1; i 120; i) { logger.log(业务消息 ${i}); } console.log(日志已进入队列等待批量刷盘...); // 3 秒后强制关闭防止进程挂住 setTimeout(() { logger.close(); console.log(日志器已关闭); }, 5000);运行node batch-test.js观察输出前面 100 条会在达到 50 条上限时触发两次批量写入剩下 20 条会在 3 秒定时器触发时写入最后close()时再补一次 flush。这个队列设计可以直接接到 Express 接口里const BatchLogger require(./batch-logger); const businessLogger new BatchLogger({ filePath: path.join(__dirname, logs, business-batch.log), maxSize: 100, flushInterval: 5000 }); app.post(/api/event, (req, res) { const { event } req.body; businessLogger.log(收到事件${event}); res.json({ ok: true }); });这样一来接口只需要把日志推到内存队列真正写磁盘的时机由队列统一控制高并发场景下的磁盘 I/O 次数大幅减少。9. 资源占用与性能观察这部分是很多人忽略的。异步写入虽然不阻塞事件循环但如果使用不当仍然会造成资源浪费或数据丢失。下面给出一个简单的同步和异步对比测试代码你在自己的机器上跑一下就能直观看出差距。创建benchmark.jsconst fsSync require(fs); const fsAsync require(fs/promises); const path require(path); const LOG_DIR path.join(__dirname, logs); if (!fsSync.existsSync(LOG_DIR)) { fsSync.mkdirSync(LOG_DIR, { recursive: true }); } const TOTAL 5000; // 同步逐个写入 function syncWrite() { const start Date.now(); for (let i 0; i TOTAL; i) { fsSync.appendFileSync(path.join(LOG_DIR, sync.log), ${i}\n, utf8); } return Date.now() - start; } // 异步批量写入拼接后一次写入 async function asyncBatchWrite() { const start Date.now(); const lines []; for (let i 0; i TOTAL; i) { lines.push(${i}\n); } await fsAsync.appendFile(path.join(LOG_DIR, async.log), lines.join(), utf8); return Date.now() - start; } // 流式写入 function streamWrite() { return new Promise((resolve) { const start Date.now(); const stream fsSync.createWriteStream(path.join(LOG_DIR, stream.log), { flags: a, encoding: utf8 }); for (let i 0; i TOTAL; i) { stream.write(${i}\n); } stream.end(() { resolve(Date.now() - start); }); }); } async function main() { console.log(写入条数${TOTAL}); const syncCost syncWrite(); console.log(同步逐个写入耗时${syncCost}ms); const asyncCost await asyncBatchWrite(); console.log(异步批量写入耗时${asyncCost}ms); const streamCost await streamWrite(); console.log(流式写入耗时${streamCost}ms); } main();运行node benchmark.js注意这个测试结果和操作系统、磁盘类型、Node.js 版本都有关系不同机器数字差异很大。但你一定会看到同步逐个写入明显更慢因为每次appendFileSync都是一次完整的系统调用而异步批量写入把 5000 条拼成一个字符串一次写完系统调用次数大幅减少。性能观察的几个关键点事件循环阻塞同步写入期间服务器的其他请求全部排队。可以用wrk或autocannon压测接口对比同步和异步两种日志写法的 QPS 差异。内存占用批量写入时lines.join()会在内存中生成一个大字符串。如果一次性拼接的数据量太大比如几十 MB会加大内存压力建议分批写入。背压流式写入时如果write()返回false说明 HTTP 请求堆积速度超过磁盘写入速度需要关注磁盘性能或增加消费者。文件句柄每次createWriteStream都会占用一个文件描述符长期运行的应用要避免频繁创建新的流最好在应用启动时创建一次进程退出时关闭。10. 常见问题与排查方法问题现象可能原因排查方式解决方案写入时报错ENOENT: no such file or directory日志目录不存在检查写入路径的目录是否存在写入前用fs.mkdirSync(dir, { recursive: true })创建目录写入时报错EACCES: permission denied当前用户对目录没有写权限检查 Linux 下的目录权限执行ls -l查看调整目录权限或把日志目录放到有写权限的位置异步写入后马上读文件内容为空写入尚未完成在回调或await之后再读取文件确认写入完成后再执行读操作或改用fs.promises并await高频写入时日志乱序多条appendFile并发执行回调节奏不确定查看日志里的时间戳使用单一流式写入或引入队列串行处理程序退出后部分日志丢失写入还在缓冲区时进程被强制结束检查createWriteStream是否调用了end()进程退出前调用stream.end()监听exit事件write()返回false后继续写入内存不断增加背压未被处理观察process.memoryUsage()和write()返回值监听drain事件暂停写入直到缓冲区排空fs.promises在低版本 Node.js 上报错Node.js 版本过低执行node -v查看版本升级到 Node.js 14或改用require(fs)的回调 API请求日志记录到res.on(finish)时拿不到完整响应体finish事件时机在响应发送完成后不包含响应体内容打印请求路径和状态码确认字段是否有值如果必须记录响应体使用拦截res.send的方式11. 最佳实践与使用建议写文件不是上线就不再管的操作尤其是日志类写入几周后磁盘会被写满或者日志目录权限出错导致程序崩溃。这里给出几条可以直接落地的建议。第一按时间分段写日志文件。可以使用类似access-2025-01-10.log的命名格式每天一个文件方便归档和删除。实现上可以每小时检查一次当前日期日期变化时关闭旧流、创建新流。第二做日志轮转。文件过大后读起来很痛苦处理方式就是按大小切割比如超过 100MB 自动生成新文件旧文件压缩保存。这块内容较多实践中建议直接用winston或pino这类成熟日志库它们内置了旋转、多级日志、格式化等服务端能力。第三批量写入要设置上限。内存队列不是无限大的当缓冲消息达到 1000 条或 5 秒没有刷盘时都该触发写入。要防止定时器只刷一部分、消息积压越来越多的情况。第四重要日志和普通访问日志分开存储。用户登录、订单支付这类关键事件建议单独落盘并且保持相对高的可靠性访问日志丢了影响不大可以接受批量异步写入。第五注意隐私合规。日志中如果包含用户手机号、IP 地址、请求体内容需要确认你的项目是否符合相关隐私法规。建议记录前做脱敏处理例如只保留 IP 前两段、手机号中间四位用星号代替。第六不要把日志写入放在接口响应关键路径上。除非业务要求“日志写入失败必须返回错误”否则尽量采用后台队列或流式写入保证客户端响应速度。12. 总结与下一步Node.js 异步写入的三种写法并不复杂回调式适合老项目维护fs.promises async/await适合需要等待写入结果的业务接口createWriteStream流式写入适合持续高频的日志场景。真正体现水平的地方在于如何组合它们Express 中间件里用流式写访问日志业务接口里用 Promise 写关键日志遇到高频上报事件时再套一层批量队列。建议你按顺序做三件事第一步把本文的app.js跑起来用 curl 模拟几次请求观察access.log的内容第二步把BatchLogger接入你自己的项目里替换掉原来的writeFileSync日志代码第三步用压测工具对比替换前后的接口响应时间你会对这个优化有更直观的感受。最容易踩的坑是批量写入队列忘了在进程退出时flush()导致最后一批日志丢失。其次是背压处理没做在高并发下内存不断上涨。这两个问题在真实项目里都很容易遇到提前做好处理可以省下不少排查时间。后续可以继续扩展的方向包括给日志队列增加消费者线程池、接入日志轮转、改用pino这类高性能日志框架、把日志同时写入本地文件和消息队列。这套异步写入的基础打牢之后再去看日志框架源码会更容易理解它们的设计思路。
返回列表