多进程 PHP 环境里做日志追加,看起来是个特别简单的事:file_put_contents($file, $line, FILE_APPEND) 一行搞定。但真放到多进程场景下跑一阵子,你会发现日志文件里出现半行、内容互相穿插、甚至直接丢数据。这个问题的本质,不是 PHP 语法层面的报错,而是文件 I/O 在内核态的并发语义没搞明白。
这篇文章不聊虚的,直接讲清楚 Append-Only 到底由谁保证、多进程写日志有哪些坑、怎么写才能兼顾性能和数据完整性,最后附上我实际踩过的坑和排查思路。适合正在做 PHP 长驻进程、Swoole 服务、多进程任务系统,或者任何需要审计日志、行为日志落盘的开发同学参考。
1. 日志写坏的本质:不是 PHP 的问题,是"多次 write"的问题
1.1 最常见的三个坑:半行日志、互相覆盖、文件被截断
先说现象。多进程同时写同一个日志文件,最典型的故障有三个:
第一个是半行日志。进程 A 正在写一行几百字节的日志,写到一半,进程 B 也往同一个文件描述符偏移量处写内容,结果 A 的后半段和 B 的内容混在一起,日志行从中间断裂。你 grep 出来的日志全是残片,没法解析。
第二个是日志行互相穿插。日志不是一整行出现,而是不同进程的片段轮流出现,看起来像这样:
code复制2025-06-12 10:00:01 [pid:123] order
2025-06-12 10:00:01 [pid:456] refund
created
因为 PHP 的 file_put_contents 或者 fwrite 在应用层是"一次调用",但在内核里可能被拆成多次 write() 系统调用,每次 write() 都有自己的偏移量更新,一旦并发,就会交叉。
第三个坑是文件被截断。某些业务需求要求先清空再写,比如"每次启动重新生成日志",于是代码里用了 fopen($file, 'w')。在多进程下,一个进程用 w 打开文件会立刻把文件长度清零,另一个进程正在写的内容全部丢失。这种情况比半行日志更隐蔽,因为函数没有报错,但数据没了。
1.2 你可能会想到 flock,但先别急
很多人第一反应是加锁:flock($fp, LOCK_EX),写之前加锁,写完解锁。这个方案在小并发下能用,但有两个绕不过去的问题。
问题一:锁不是强制的。 flock 是"协作锁",只能锁住同样主动调用 flock 的进程。如果你的代码里任何一处写日志走了别的路径,比如直接 file_put_contents、error_log、第三方库内部自己 open 写文件,那锁就形同虚设。多进程环境下,你往往不能保证每个写日志的入口都加了同一把锁。
问题二:锁的开销不小。 每个日志行都要加锁、解锁,这意味着所有写日志的进程在竞争同一个 inode 上的锁,日志量上来之后,锁等待本身就成了瓶颈。我一个项目里,4 个 worker 进程每秒写 2000 条日志,用了 flock 之后,CPU 反而涨了,因为大量时间花在锁等待和上下文切换上。
所以 flock 适合日志量很低、进程数很少的场景,比如 2~3 个进程、每秒几十条日志。它不是多进程高并发日志的答案。
1.3 Append-Only 的本质:O_APPEND 带来的原子偏移更新
真正可靠的做法,是依赖内核提供的 O_APPEND 语义。
当你用 fopen($file, 'ab') 打开文件时,PHP 底层会调用 open(2) 并传入 O_APPEND 标志。这个标志有一个关键承诺:每次 write() 系统调用写入数据时,内核会先把文件偏移量原子地移动到文件末尾,再执行写入,而且这个"移动到末尾 + 写入"的过程不会被其他写操作打断。
换句话说,对同一个文件,多个进程同时执行 write(),只要单次写入的数据量在合理范围内,OS 会保证这些 write() 是串行化完成的。这就是 Append-Only 日志的核心语义:只追加,不修改历史,不覆盖已有数据。
但要注意,O_APPEND 只保证 write() 这一层的原子性。如果你的 PHP 代码里一次日志写入被拆成多次 fwrite(),每一次都是独立的 write() 系统调用,那 O_APPEND 只能保证"每次 fwrite 的内容不会和其他进程交叉",但没法保证"一整行日志作为一个整体不被拆开"。如果你自己把一行日志拆成三次 fwrite,那并发下依然会互相穿插。
所以 Append-Only 的前提是:构造好完整的一行,然后一次 fwrite() 写完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个可靠的多进程日志写入方案:单行单写,交给内核
2.1 日志格式:一行一条,带足上下文
设计日志格式时,我强烈建议用一行一条 JSON,或者至少是带分隔符的 KV 文本,因为后续无论是
