Problem
fs.writeFile不是原子操作。这是我将运行的示例程序strace
on:
#!/usr/bin/env node
const { writeFile, } = require('fs');
// nodejs won’t exit until the Promise completes.
new Promise(function (resolve, reject) {
writeFile('file.txt', 'content\n', function (err) {
if (err) {
reject(err);
} else {
resolve();
}
});
});
当我在下面运行时strace -f
并整理输出以仅显示来自writeFile
手术 (实际上跨越多个 IO 线程),我得到:
open("file.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 9
pwrite(9, "content\n", 8, 0) = 8
close(9) = 0
如你看到的,writeFile
分三步完成。
- 该文件是open()编辑。这是一个原子操作,使用提供的标志,可以在磁盘上创建一个空文件,或者如果文件存在,则截断它。截断文件是确保只有您编写的内容最终出现在文件中的简单方法。如果文件中存在现有数据并且该文件是longer与您随后写入文件的数据相比,额外的数据将保留。为了避免这种情况,你需要截断。
- 内容已写好。因为我写了这么短的字符串,所以这是用一个单一的pwrite()调用,但对于大量数据,我认为 NodeJS 可能一次只会写入一个块。
- 手柄已关闭。
My strace
每个步骤都发生在不同的节点 IO 线程上。这向我表明fs.writeFile()
实际上可能会按照以下方式实施fs.open(), fs.write(), and fs.close()。因此,nodejs 不会在任何级别上将这种复杂的操作视为原子操作,因为事实并非如此。因此,如果您的节点进程终止(即使是正常终止)而不等待操作完成,则操作可能会执行上述任何步骤。在你的情况下,你会看到你的进程在之后退出writeFile()
完成步骤 1 但在完成步骤 2 之前。
Solution
使用 POSIX 层以事务方式替换文件内容的常见模式是使用以下步骤:
- 将数据写入不同名称的文件,fsync()文件(请参阅“什么时候应该进行 fsync?”)“确保数据到达磁盘”), 进而
close()
it.
-
rename()(或者,在 Windows 上,MoveFileEx() with MOVEFILE_REPLACE_EXISTING) 将与要替换的文件名称不同的文件替换。
使用此算法,无论程序何时终止,目标文件都会更新或不更新。而且,更好的是,日志式(现代)文件系统将确保,只要您fsync()
在继续步骤 2 之前,先删除步骤 1 中的文件,这两个操作将按顺序发生。也就是说,如果您的程序执行步骤 1,然后执行步骤 2,但您拔掉了插头,那么当您启动时,您会发现文件系统处于以下状态之一:
- 两个步骤均未完成。原始文件是完整的(或者如果它以前从未存在过,则它不存在)。替换文件不存在(步骤 1)
writeFile()
算法,open()
,实际上从未成功),存在但空(步骤 1writeFile()
算法已完成),或存在一些数据(步骤 2writeFile()
算法部分完成)。
- 第一步完成了。原始文件是完整的(或者如果之前不存在它仍然不存在)。替换文件包含您想要的所有数据。
- 两个步骤都完成了。在原始文件的路径中,您现在可以访问所有替换数据,而不是空白文件。您在第一步中写入替换数据的路径不再存在。
使用此模式的代码可能如下所示:
const { writeFile, rename, } = require('fs');
function writeFileTransactional (path, content, cb) {
// The replacement file must be in the same directory as the
// destination because rename() does not work across device
// boundaries.
// This simple choice of replacement filename means that this
// function must never be called concurrently with itself for the
// same path value. Also, properly guarding against other
// processes trying to use the same temporary path would make this
// function more complicated. If that is a concern, a proper
// temporary file strategy should be used. However, this
// implementation ensures that any files left behind during an
// unclean termination will be cleaned up on a future run.
let temporaryPath = `${path}.new`;
writeFile(temporaryPath, content, function (err) {
if (err) {
return cb(err);
}
rename(temporaryPath, path, cb);
});
};
这基本上与您在任何语言/框架中针对相同问题使用的解决方案相同。