前言
标题里的"PHP 队列原理"这个说法本身需要修正一下:PHP 语言层面并不存在"队列"这个类型,也没有内置的消息队列。PHP 只有数组,以及 SPL(Standard PHP Library,标准 PHP 库)提供的SplQueue/SplDoublyLinkedList。真正意义上的队列,要么是进程内的数据结构,要么是外部组件(Redis、RabbitMQ)提供的服务。所以本文讲的是"用 PHP 的数据结构实现队列,并用队列解决写文件这个问题",而不是某种 PHP 语言特性。
第二个要澄清的点:很多人以为"写文件不需要队列,file_put_contents()一行就完事了"。在低频场景下确实如此。但当写入频率上来了,或者多个进程要往同一个文件里追加时,直接写会暴露三个问题——频繁的系统调用(每次都是 open/write/close)、多进程写入互相交错、写入失败时无法重试。队列在这里的价值不是"更快",而是把"什么时候写"和"写什么"解耦:调用方只管把内容放进队列然后立刻返回,真正的落盘由消费者按自己的节奏批量完成。
一、队列的基本结构:为什么不用array_shift()
队列(Queue)只有三个基本操作:入队(enqueue)、出队(dequeue)、看队头(peek)。约束是"先进先出"(FIFO,First 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.3+,SplQueue 属于 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 SplQueue<int, 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()在空队列上会抛RuntimeException("Can't 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 队列即可,上层的"生产者入队 + 消费者批量落盘"结构完全不用变。