如果你有多个 PHP 进程写同一个日志文件,第一反应多半是:加锁。我曾经维护过一套用 supervisor 拉起来的多进程 PHP 消费队列脚本,8 个 worker 同时往一个日志文件里追加记录,线上的表现很典型——日志出现半截行、行与行互相拼贴、严重时还会丢记录。排查了两轮才反应过来,真正的问题不是“并发写该不该加锁”,而是我打开文件的方式根本不具备日志文件追加(Append-Only)语义。多进程 PHP 环境里,想要安全地往同一个文件写日志,最干净的做法不是自己维护锁,而是把“追加”这个动作交给操作系统去保证原子性。这篇文章就围绕这套方案展开,适合正在处理多进程日志、审计日志、或想搞懂 O_APPEND 底层原理的 PHPer 参考。
1. 日志损坏现场:先搞清楚多进程并发写为什么会坏
1.1 三个最常见的“坏日志”症状
我在排查线上问题时,把日志“写坏”的症状归成了三类,几乎每个踩坑的人都会遇到其中一种。
第一类是截断清空。某些代码为了“每次写最新日志”,习惯用 fopen($file, 'w') 来打开文件。如果是多个进程各自执行 fopen('w'),那么每次打开文件都会把文件长度清成 0,整个文件会变成几个进程的“拼图残骸”,日志行数远小于预期,甚至只剩最后一个进程写入的内容。更隐蔽的是有的代码用 file_put_contents($file, $log) 不加 FILE_APPEND,效果一样,每次都会先截断再写,多进程下日志基本等于没有。
第二类是半截行和内容拼贴。不少代码写日志喜欢分几次写,先写时间戳,再写 PID,再写消息内容和换行。比如:
php复制fwrite($fp, '[' . date('c') . '] ');
fwrite($fp, '[pid:' . getmypid() . '] ');
fwrite($fp, $message . "\n");
这 3 次 fwrite 在单进程里没问题,但在多进程里,进程 A 写完时间戳后未必能马上写完消息,进程 B 可能在这中间插入完整日志。最终文件里出现 [时间] 进程B的完整日志 [pid:123] 进程A的消息 这种行。
第三类是追尾式覆盖。有些开发者知道不能截断,于是用 fopen($file, 'r+') 打开文件,每次写之前先 fseek($fp, 0, SEEK_END) 把偏移量挪到文件末尾。问题在于,“把偏移量挪到末尾”和“真正执行 write”是两步操作。两个进程完全可能同时在末尾取到相同偏移量,后一个进程会把前一个进程刚写入的数据覆盖掉。这样日志文件内容不会有明显地语法损坏,但你会发现大量行数丢失,单看每一行都很正常,一统计数量差了很多。
1.2 为什么“先读后写”和“自己 seek”都不行
有人会问:那我先读文件末尾偏移量,再写入,行不行?答案是并发环境下不行。任何“先获取位置,再执行写入”的做法,中间都存在一个时间窗口。窗口内其他进程可能也在执行同样操作,于是两个进程拿到同一个“文件末尾位置”,后写的人就会覆盖先写的人。这也解释了为什么用 file_get_contents 读完整个文件再 file_put_contents 覆盖回去的做法在多进程下是灾难,本质上就是拿错位置再写。
用 r+ 模式加 fseek 的方式,我自己做过一次对照实验,8 个进程各写 2000 行,最终 wc -l 出来的数量有时只有 11000 行左右,被覆盖掉的记录超过三成。这类故障非常微妙,因为剩下的日志格式完全正常,没有任何乱码,很多人会误以为只是进程漏写了,排查方向直接跑偏。
1.3 “攒批写入”造成的伪顺序错乱
还有一类情况需要单独提一下,它不一定会损坏文件,但会让你觉得日志顺序完全对不上。PHP 写入普通文件时,语言层面的流是有缓冲的,默认缓冲区大小通常为 8192 字节。如果你持久持有文件句柄并频繁 fwrite 小日志,数据不会立刻进入内核,而是先攒在 PHP 用户态缓冲区里。缓冲区满或调用 fflush 时才真正触发一次系统调用。
在多进程下,这种缓冲会造成“时间折叠”:A 进程先调用 fwrite 写了一条 5 分钟前的日志,但因为缓冲区没满,它直到 5 分钟后才和后面几条日志一起落盘,结果这行“旧日志”反而出现在文件比较靠后的位置。这样日志内容本身没有交错,但时间线是乱的。有些人会把这个问题当成多进程并发写坏,其实是缓冲策略的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Append-Only 的内核承诺:O_APPEND 不是玄学
2.1 PHP 的 'a' 模式底层到底做了什么
PHP 里最基础的追加写法是:
php复制$fp = fopen('/var/log/foo.log', 'a');
'a' 模式在 Linux 底层等价于 open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666)。关键就在 O_APPEND 这个 flag 上。它做的事情不是简单的“打开文件后把偏移量设到末尾”,而是承诺:每次 write 之前,系统内核会把文件偏移量设置为当前文件末尾,并把“偏移量调整”和“写入操作”作为一个原子步骤完成。
这句话意味着两个甚至多个进程同时以追加模式写同一个文件时,不会出现两个进程同时取到同一个位置、然后互相覆盖的情况。每个 write 都能看到别人刚写入的数据,并接着新的文件末尾继续写。多进程并发追加日志的安全底座,就是这个 flag。
需要提醒的是,Linux 下 'a' 和 'ab' 没有区别,但如果你要考虑 Windows 环境,建议统一写成 'ab'。'b' 表示二进制模式,可以避免 Windows 平台把 \n 自动转换为 \r\n 导致日志格式错乱。跨平台项目里直接用 'ab' 是零成本的保险。
2.2 单次 write 的原子性边界和“加锁”的误会
很多人对 O_APPEND 有一个误解:以为只要用了 'a' 模式,无论写多少字节、无论怎么拆开写,文件都一定是干净的。这不对。
O_APPEND 保证的是“每次 write 不会覆盖已有数据”,并且对一个常规本地文件系统来说,一次完整的 write 系统调用不会被另一个进程的 write 从中间切碎。但如果你把一条日志拆成多个 fwrite 调用,那每次 fwrite 只是单独一次原子写,多次写之间依然可能插入其他进程的数据。
所以真正的工程规则是:一条完整的日志,应该通过一次 fwrite 写入,并且这条日志的字节数不要太大。我一般把单条日志控制在 4KB 以内。原因有三点:
- 日志行越短,越不容易遇到部分写(partial write)的情况。
- 单条日志用一次系统调用写完,跨进程互相穿插的概率天然为 0。
- 后续日志解析通常按行处理,单行过大本身就会拖垮检索和展示。
网上有人拿管道 PIPE_BUF 的 4KB 或
