前言标题里的PHP 队列原理这个说法本身需要修正一下PHP 语言层面并不存在队列这个类型也没有内置的消息队列。PHP 只有数组以及 SPLStandard PHP Library标准 PHP 库提供的SplQueue/SplDoublyLinkedList。真正意义上的队列要么是进程内的数据结构要么是外部组件Redis、RabbitMQ提供的服务。所以本文讲的是用 PHP 的数据结构实现队列并用队列解决写文件这个问题而不是某种 PHP 语言特性。第二个要澄清的点很多人以为写文件不需要队列file_put_contents()一行就完事了。在低频场景下确实如此。但当写入频率上来了或者多个进程要往同一个文件里追加时直接写会暴露三个问题——频繁的系统调用每次都是 open/write/close、多进程写入互相交错、写入失败时无法重试。队列在这里的价值不是更快而是把什么时候写和写什么解耦调用方只管把内容放进队列然后立刻返回真正的落盘由消费者按自己的节奏批量完成。一、队列的基本结构为什么不用array_shift()队列Queue只有三个基本操作入队enqueue、出队dequeue、看队头peek。约束是先进先出FIFOFirst In First Out。PHP 数组当然可以模拟队列?php// 反例不推荐用数组做队列$queue [];$queue[] a; // 入队$queue[] b;$item array_shift($queue); // 出队得到 a这段代码逻辑上是对的但array_shift()有一个隐藏成本它会重建整个数组的数字索引。元素越多出队一次的开销越大。对于一个只有几百条日志的缓冲队列这无所谓但如果队列长期保持在几千条以上出队就变成了 O(n) 的操作。SPL 提供的SplQueue基于双向链表入队和出队都是 O(1)?php// 适用于 PHP 5.3SplQueue 属于 SPL默认启用$queue new SplQueue();$queue-enqueue(a); // 等价于 push$queue-enqueue(b);$queue-dequeue(); // a等价于 shift$queue-isEmpty(); // false$queue-count(); // 1$queue-bottom(); // a队头$queue-top(); // b队尾SplQueue默认就是 FIFO 迭代顺序不需要额外设置。由于它是对象在函数之间传递的是引用语义——传进函数里enqueue的内容函数外也能看到。这既方便不用引用也危险容易在无意中改到别处的队列。二、为什么写文件值得排队假设你有一个请求处理流程每个请求都要往访问日志里追加一行。直接写的代码如下file_put_contents($logFile, $line . \n, FILE_APPEND);它的问题在于每一次调用都是一组 open / write / close 系统调用。高 QPS 下这些调用的总量非常可观而它们本可以合并成一次。没有批量能力。1000 条日志本可以一次写出去现在被拆成 1000 次。没有错误处理位置。写失败时file_put_contents()返回false很多代码不检查日志就静默丢了。多进程场景下无法协调。多个 PHP-FPM worker 同时追加写写序不确定。把写操作放进队列后调用方变成入队消费者变成攒够 N 条或等够 M 秒就批量落盘。这样系统调用被摊薄出错也有统一的处理点。这就是基于队列的写文件的核心思路。三、完整案例带缓冲的队列写文件器下面这个类把上面几点都落到了实处。它是纯 PHP 实现不依赖任何扩展或外部服务可以直接php demo.php运行。?php// 适用于 PHP 7.4使用了 typed properties 与箭头函数final class BufferedFileWriter{/** var SplQueueint, string */private SplQueue $queue;/** var resource|null 常开的文件句柄避免每次 open/close */private $handle null;private int $pending 0;public function __construct(private string $path,private int $threshold 100) {$this-queue new SplQueue();}/** 入队一行内容 */public function write(string $line): void{$this-queue-enqueue($line);$this-pending;if ($this-pending $this-threshold) {$this-flush();}}/** 把队列里已有的内容一次性落盘 */public function flush(): void{if ($this-queue-isEmpty()) {return;}$buffer [];// 用 isEmpty() 做循环条件绝不能用 dequeue() 的返回值判断while (!$this-queue-isEmpty()) {$buffer[] $this-queue-dequeue();}$handle $this-getHandle();$payload implode(PHP_EOL, $buffer) . PHP_EOL;// 循环写fwrite 可能只写入一部分必须检查返回值$offset 0;$total strlen($payload);while ($offset $total) {$written fwrite($handle, substr($payload, $offset));if ($written false || $written 0) {throw new RuntimeException(写入失败: {$this-path});}$offset $written;}// PHP 8.1 才有 fsync()老版本可以省略这一步if (function_exists(fsync)) {fsync($handle);}$this-pending 0;}/** return resource */private function getHandle(){if ($this-handle null) {// ab 追加模式 二进制安全与 fopen 的 a 区别在于不做换行转换$handle fopen($this-path, ab);if ($handle false) {throw new RuntimeException(无法打开文件: {$this-path});}$this-handle $handle;}return $this-handle;}public function close(): void{if ($this-handle ! null) {$this-flush();fclose($this-handle);$this-handle null;}}public function __destruct(){// 析构函数里绝不能让异常逃逸抛出去会变成致命错误try {$this-close();} catch (Throwable $e) {error_log(BufferedFileWriter 收尾失败: . $e-getMessage());}}}?php// demo.php —— 使用示例require __DIR__ . /BufferedFileWriter.php;$writer new BufferedFileWriter(__DIR__ . /access.log, 3);$writer-write(2026-01-01 10:00:00 GET /index.php);$writer-write(2026-01-01 10:00:01 GET /list.php);$writer-write(2026-01-01 10:00:02 POST /save.php); // 达到阈值 3自动落盘一次$writer-write(2026-01-01 10:00:03 GET /detail.php);$writer-close(); // 显式收尾把剩下 1 条写出去这个案例里有几个必须逐行确认的点while (!$this-queue-isEmpty())而不是while ($this-queue-dequeue())。SplQueue::dequeue()在空队列上会抛RuntimeExceptionCant dequeue from an empty datastructure用它当循环条件最后一次循环必然抛异常。fwrite()的返回值是实际写入的字节数可能小于你给的长度。上面的写法用substr($payload, $offset)循环推进是唯一正确的写法。注意substr()的参数顺序是substr($string, $offset)——字符串在前、偏移在后。fsync()是 PHP 8.1 才加入的。它把 PHP 层的缓冲刷进操作系统的页缓存并要求落盘。在 8.1 之前没有对应函数如果对持久性要求高只能退回到每次写完fflush()fclose()或者用ab打开后用fclose()让操作系统去管。代码里用function_exists()做了兼容。__destruct()里包了try/catch。析构函数里抛异常极其危险尤其在脚本关闭阶段——那时已无法正常捕获会变成致命错误。四、多进程写同一个文件上面的案例是单进程的。如果是多进程多个 PHP-FPM worker、或者多个 CLI 进程情况会复杂一层。file_put_contents()的第三个参数加上FILE_APPEND | LOCK_EX可以让打开 定位到末尾 写入这一步带独占锁?php// 适用于 PHP 5.3// FILE_APPEND追加不覆盖LOCK_EX写入期间取独占锁$bytes file_put_contents(__DIR__ . /app.log,$line . PHP_EOL,FILE_APPEND | LOCK_EX);if ($bytes false) {// 一定要检查返回值否则写入失败会被静默吞掉error_log(日志写入失败);}关于LOCK_EX需要说清楚它保证什么、不保证什么它保证的是同一时刻只有一个进程持有这个文件的锁从而避免两个进程的写入内容在同一段字节区间里互相覆盖。它不保证写入原子的完整语义。在多数本地文件系统上O_APPEND模式的写入对小于一个块的内容是原子的但这属于平台行为不是 PHP 或 POSIX 层面的跨平台保证。NFS、部分网络文件系统上的表现可能不同。它不会让写入变快。锁本身就是开销。如果要绝对严格的顺序和可靠性正确做法是回到队列所有进程只负责往队列里投递由一个专门的消费者进程负责写文件。这正是队列存在的意义——把并发写的协调问题转换成单写者问题。另一个常见需求是日志轮转rotation。轮转时不要直接unlink()正在写的文件而要先把access.log重命名为access.log.1再让写者重新打开新文件。rename()在同一个文件系统内是原子操作这一点是可以依赖的。常见坑点❌ 用array_shift()从一个大数组里反复出队✅ 改用SplQueue。array_shift()会重建数字索引数组越长越慢SplQueue基于双向链表出队是 O(1)。❌while ($item $queue-dequeue())作为遍历条件✅ 空队列上SplQueue::dequeue()抛RuntimeException最后一次循环必炸。用while (!$queue-isEmpty())。❌ 认为fwrite()一定把数据全写完了✅fwrite()返回实际写入的字节数可能小于请求长度。必须循环写并累加偏移量直到写完全部内容。❌ 调用file_put_contents()后不检查返回值✅ 磁盘满、权限不足、路径不存在时它返回false并触发警告。生产代码必须判断返回值写失败要记日志或走重试。❌ 在__destruct()里直接throw✅ 析构函数中的异常在脚本关闭阶段几乎无法被捕获会变成致命错误。应当try/catch后error_log()只做尽力而为的清理。❌ 把fsync()当成所有版本都能用的函数✅fsync()和fdatasync()都是 PHP 8.1 才加入的。低版本要兼容就得用function_exists()判断或者接受写进操作系统缓存即可的语义。❌ 多进程直接file_put_contents($f, $line, FILE_APPEND)且不加锁✅ 加上LOCK_EX能避免大部分交错写入。但更根本的解法是用队列把并发写收敛成单写者——锁是缓解队列是消除。❌ 用include引入队列类文件文件不存在时继续跑✅ 核心类用require类文件缺失属于部署错误应该在第一时间以致命错误暴露而不是等到new BufferedFileWriter()时提示类不存在。总结要点结论队列的本质FIFO 的入队/出队/看队头PHP 无内置消息队列只有进程内的SplQueue为什么不用数组array_shift()会重建索引O(n)SplQueue是 O(1)写文件为什么要排队合并系统调用、统一错误处理、把并发写收敛为单写者队列缓冲的触发点达到条数阈值flush()或显式close()/ 析构兜底写文件的关键写法fwrite()循环写、检查返回值、fsync()PHP 8.1多进程写同一文件FILE_APPEND \析构函数纪律不抛异常只做尽力而为的清理结论队列在这个场景里解决的问题不是性能而是把不确定的写入时机变成可控的批量落盘。用SplQueue做缓冲、用阈值触发flush()、用循环fwrite()保证写完整、用析构兜底防止数据滞留内存——这四件事做到位一个几十行的纯 PHP 类就足以支撑相当规模的日志写入。等到需要跨进程、跨机器时再把SplQueue换成 Redis 队列即可上层的生产者入队 消费者批量落盘结构完全不用变。