Node.js异步编辑JSON文件如何加锁避免并发写入冲突
问题场景
- 开发背景:基于Node.js开发Discord机器人,运行环境优先适配Linux
- 存储方案:使用内置
fs/promises模块,将每个公会(guild)的独立数据存储在data/<guild.id>.json路径下的独立JSON文件中 - 现有实现:已编写
setGuildData(guildID: string, dataToAssign: object)函数,逻辑为读取目标JSON文件、解析为fileData对象、通过Object.assign()合并新数据、字符串化后覆盖写入原文件
现存问题
多异步调用同时操作同一个JSON文件时会出现竞态条件,导致数据覆盖丢失,典型流程如下:
文件初始内容: {a:0, b:1} 调用1: 读取文件 调用2: 读取文件 调用1: 修改对象,插入{c:2} 调用2: 修改对象,插入{d:3} 调用1: 字符串化后写入{a:0, b:1, c:2} 调用2: 字符串化后写入{a:0, b:1, d:3} 最终文件内容: {a:0, b:1, d:3}
需求说明
- 对单个文件的读、数据处理、覆盖写入全流程执行期间,阻塞其他所有针对该文件的操作
- 后续操作必须基于前序操作写入完成后的最新文件内容执行,避免数据丢失
- 性能容忍度高:单文件最长阻塞百毫秒完全可接受,单公会峰值请求仅每分钟数次,无高并发压力
- 方案偏好:优先使用操作系统层面的临时文件锁方案,不优先考虑JS层全局队列实现
- 现有代码缺陷:当前实现使用
"w+"模式打开文件,打开瞬间就会清空文件内容,无法满足先读取、后写入的需求,且未加文件锁,无法阻止并发操作。原有代码如下:
const fsPromises = require('node:fs/promises'); const path = require('node:path') const dataPath = path.join(__dirname, 'data'); async function setGuildData(guildID, newData) { let fileHandle = await fsPromises.open(path.join(dataPath, `${guildID}.json`), "w+"); // 打开文件时直接清空内容,存在逻辑问题 let fileData; try { fileData = JSON.parse(await fileHandle.readFile()); // 读取并解析文件 } catch (err) { fileData = {}; // 解析失败则默认空对象 } await fileHandle.write(JSON.stringify(Object.assign(fileData, newData)), 0); // 合并数据后写入 await fileHandle.close(); // 关闭文件句柄 }
解决方案(Linux环境适配)
使用Node.js内置filehandle.lock()方法调用操作系统级别的劝告式文件锁,配合正确的文件打开模式即可实现需求,修正后代码如下:
const fsPromises = require('node:fs/promises'); const path = require('node:path'); const dataPath = path.join(__dirname, 'data'); // 初始化时确保数据目录存在,避免首次运行打开文件报错 (async () => { await fsPromises.mkdir(dataPath, { recursive: true }); })(); async function setGuildData(guildID, newData) { const filePath = path.join(dataPath, `${guildID}.json`); let fileHandle; // 使用r+模式打开:支持读写,不会打开即清空;文件不存在时先创建空文件 try { fileHandle = await fsPromises.open(filePath, 'r+'); } catch (err) { if (err.code === 'ENOENT') { const tempHandle = await fsPromises.open(filePath, 'w'); await tempHandle.close(); fileHandle = await fsPromises.open(filePath, 'r+'); } else { throw err; } } try { // 加排他写锁:锁持有期间其他进程/线程申请同文件锁会阻塞,直到锁释放 // Linux下底层调用flock系统调用实现劝告锁,属于OS级锁,比JS层队列更可靠 await fileHandle.lock('exclusive'); // 读取并解析文件内容 const fileBuffer = await fileHandle.readFile(); let fileData = {}; if (fileBuffer.length > 0) { try { fileData = JSON.parse(fileBuffer.toString()); } catch (parseErr) { fileData = {}; } } // 合并新数据 const updatedData = Object.assign(fileData, newData); const writeContent = JSON.stringify(updatedData); // 清空旧内容后写入新数据,避免新内容短于旧内容时残留尾部脏数据 await fileHandle.truncate(0); await fileHandle.write(writeContent, 0); } finally { // 无论流程是否报错,都释放锁、关闭文件句柄,避免锁泄漏和句柄泄漏 await fileHandle.unlock(); await fileHandle.close(); } }
方案说明
- 打开模式替换:弃用
"w+"模式,改用"r+"模式读写,不会在打开时清空文件,文件不存在时单独做创建逻辑即可 - 锁机制:使用Node.js原生支持的排他文件锁,Linux环境下直接调用操作系统能力实现,即使多进程操作同一份文件也能避免竞态
- 写入逻辑:写入前主动调用
truncate(0)清空旧内容,解决新旧内容长度不一致导致的脏数据问题 - 异常兜底:所有锁和文件句柄的释放逻辑放在
finally块中,不会因为流程报错导致资源泄漏 - 性能表现:单文件操作全流程耗时仅数毫秒到十数毫秒,完全满足百毫秒以内的性能容忍要求
注意:Linux下的劝告式文件锁需要所有操作该文件的程序都主动申请锁才能生效,如果你后续有其他脚本操作这些JSON文件,也需要对应加锁,否则锁不会强制阻止无锁的文件操作。如果所有读写操作都走这个封装的函数,就完全不会有竞态问题。
内容的提问来源于stack exchange,提问作者codemaker 4
相关产品推荐
相关产品推荐

