一个排查过的人都不会忘的场景:服务把审计日志写进文件,write 每次返回都正常,进程 check 也不报错。然后机器掉电重启,最后十几秒的日志全没了。
这不是文件系统的 bug。write 的语义从来就不是「写到磁盘」,而是「写进了内核的页缓存」。搞清楚这条边界,能避免一大批「数据明明写了为什么会丢」的争论。
write() 返回成功代表什么?
man 手册在 NOTES 里有一句非常直接的话:
A successful return from write() does not make any guarantee that data has been committed to disk.
这句话是整篇的地基。返回值的含义是「内核已经接收了这批数据」,仅此而已。数据此刻躺在内存的 page cache 里,标记为 dirty,等着被回写。
想让数据真正落盘,唯一的办法是显式调用同步接口。man 手册紧接着给出了那个唯一的答案——调用 fsync。
所以完整的持久化链路是三层:write 到内核缓冲、fsync 让内核刷盘、磁盘控制器确认。缺任何一层,掉电都可能丢。
脏页是什么时候被写回去的?
内核不会等脏页攒到极限才动手。它有两个阈值,一个软一个硬。
软阈值是 dirty_background_ratio,默认 10%。脏页达到总内存的这个比例时,后台 flusher 线程开始异步回写。注意是后台线程动手,写入的进程不受影响,继续跑。
硬阈值是 dirty_ratio,默认 20%。超过这条线,产生脏页的进程自己会被拉去同步回写,表现为写入突然变慢甚至卡住。
内核文档对两者的区分是:
the background kernel flusher threads will start writing out dirty data.
a process which is generating disk writes will itself start writing out dirty data.
一个容易踩的坑是这两个值的来源。内核文档的 sysctl 章节没有写默认值,只解释了含义。默认的 10 和 20 在内核源码的 page-writeback.c 里定义。所以想确认你机器上的实际值,不要查文档,直接读 /proc/sys/vm/ 下的对应文件。
| 参数 | 默认值 | 触发者 | 触发后行为 |
|---|---|---|---|
dirty_background_ratio | 10% | 内核 flusher 线程 | 异步回写,写入者无感 |
dirty_ratio | 20% | 产生脏页的进程 | 同步回写,写入被阻塞 |
dirty_expire_centisecs | 3000 | 定时回写 | 超过 30 秒的脏页被刷 |
dirty_writeback_centisecs | 500 | 回写线程周期 | 每 5 秒唤醒一次 |
表中后两个参数说明了另一件事:即使脏页量很低,超过 30 秒也会被刷。所以「我不 fsync 也不会丢太多」这个侥幸,最多撑 30 秒,而且这 30 秒不受你控制。
free 显示内存快没了要紧吗?
大多数情况下不要紧。
free 输出里的 buff/cache 那一列,包含了 page cache。它看起来吃掉了大量内存,但性质是「可回收」——当应用需要内存时,内核会优先丢弃干净的缓存页。
真正该看的是 available 列。man 手册对 MemAvailable 的定义是:
An estimate of how much memory is available for starting new applications, without swapping.
这里要诚实一点:官方并没有「free 数值低是正常的」这种原句,这个结论是从 Cached 和 MemAvailable 的定义推出来的。但推得很稳:既然 cache 可回收,那么「free 小了」本身不构成内存不足的证据。
判断内存是否真的紧张,看两个信号:available 持续低于总量的 10%,以及 swap 在增长。只盯着 free 那一列,是把自己吓一跳。
O_DIRECT 能绕过 page cache 吗?
这是本篇最想纠正的一条措辞。
中文资料里普遍的说法是「O_DIRECT 绕过 page cache」。官方措辞不是这样:
Try to minimize cache effects of the I/O to and from this file.
是 minimize,尽量减少,不是 bypass 绕过。
这个差别不是文字游戏。同一个手册页还说明了两件事:数据确实直接在用户缓冲区和磁盘之间传输,但未对齐时会回退到缓冲 I/O。也就是说,如果你没满足对齐要求,O_DIRECT 悄悄退化成了普通 I/O,你的「绕过」根本没发生,而且不会有任何报错。
对齐要求本身是:
The O_DIRECT flag may impose alignment restrictions on the length and address of user-space buffers and the file offset of I/Os.
具体到数值:早期的要求是文件系统块大小的整数倍,2.6 内核之后放宽到逻辑块大小,通常是 512 字节。不满足会返回 EINVAL,或者静默走缓冲路径。前者还算好的,后者最难查。
所以准确的说法是:O_DIRECT 尽量减少页缓存的影响,但不保证完全绕过,且在未对齐时会退化。
fsync 之后就绝对安全了吗?
不绝对,还有一个目录项的问题。
fsync 保证的是这个文件的数据和元数据落盘。但如果文件是新建的,它的目录项(也就是「这个文件名存在」这件事)属于目录的元数据,不在文件的 fsync 范围内。手册里说得很清楚:
Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk.
结果是:文件内容安全了,但掉电后可能找不到这个文件。对于「先写临时文件再 rename」这种原子替换模式,这是致命的——rename 本身也可能没落盘。
正确做法是在 rename 之后,对父目录的 fd 也调一次 fsync:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int write_atomic(const char *dir, const char *name, const char *buf, size_t len) {
char tmp[512], dst[512];
snprintf(tmp, sizeof(tmp), "%s/.tmp.XXXXXX", dir);
snprintf(dst, sizeof(dst), "%s/%s", dir, name);
int fd = open(tmp, O_CREAT | O_WRONLY | O_TRUNC, 0644);
if (fd < 0) return -1;
if (write(fd, buf, len) != (ssize_t)len) { close(fd); return -1; }
if (fsync(fd) != 0) { close(fd); return -1; } /* 数据落盘 */
close(fd);
if (rename(tmp, dst) != 0) return -1;
int dfd = open(dir, O_DIRECTORY | O_RDONLY);
if (dfd < 0) return -1;
if (fsync(dfd) != 0) { close(dfd); return -1; } /* 目录项落盘 */
close(dfd);
return 0;
}
少了最后那几行,这套「原子写入」只在进程崩溃时原子,掉电时不保证。这个细节在各类「如何安全写文件」的教程里经常被省略。
drop_caches 能解决内存问题吗?
不能,而且官方不建议。
use outside of a testing or debugging environment is not recommended.
这句话后面还跟了一条更关键的说明:这个操作是非破坏性的,不会释放脏页。
This is a non-destructive operation and will not free any dirty objects.
所以 drop_caches 之后,脏页依然在,内存并没有真正腾出来。它清掉的只是干净的缓存页——而那些页本来就是内核在内存紧张时会自动回收的。手动清等于做了一件内核本来就会做的事,还顺便让后续读操作全部退化成磁盘读。
真正需要它的场景只有一种:压测前后想要一个一致的缓存基线。生产环境上按这个按钮,通常只是在掩盖真正的问题。
怎么判断数据到底安全没有
把问题拆成两个,答案就清楚了。
第一个问题:进程崩溃会丢数据吗?答案是不会,因为数据已经在内核里了,进程死了内核还在。这类场景只需要正常的 write。
第二个问题:机器掉电会丢数据吗?答案是会,除非 fsync 过。而且要连目录一起 fsync,否则文件可能「有内容但没名字」。
按这个二分法,很多争论其实是在争不同的问题。说「我 write 完就返回了,怎么会丢」的人,问的是第一个;说「不 fsync 一定会丢」的人,答的是第二个。两边都对,只是没对齐场景。
工程上的取舍也很清楚:fsync 的代价是一次磁盘确认,通常在毫秒级,机械盘上更久。对于能容忍丢几秒数据的日志,用 dirty_expire_centisecs 的 30 秒兜底就够了;对于账目、审计、元数据这类不能丢的,必须每条同步,别指望阈值。
最后回到那句原文:a successful return from write() does not make any guarantee that data has been committed to disk。这句话不需要记,只要记住它否定的是什么——它否定的不是「写入成功」,而是「写入持久」。这两件事在内存和磁盘之间隔着一次 fsync。