如何用 PHP 读取大文件(不杀死你的服务器)
作为 PHP 开发人员,我们通常不需要担心内存管理。 PHP 引擎在我们之后做了出色的清理工作,而短期执行上下文的 Web 服务器模型意味着即使是最草率的代码也不会产生持久的影响。
在极少数情况下,我们可能需要走出这个舒适的边界——比如当我们试图在我们可以创建的最小 VPS 上为大型项目运行 Composer 时,或者当我们需要在同样小的服务器上读取大文件时.
这是我们将在本教程中看到的后一个问题。
本教程的代码可以在 GitHub 上找到。
衡量成功
确保我们对代码进行任何改进的唯一方法是衡量一个糟糕的情况,然后在我们应用修复后将该衡量结果与另一个衡量结果进行比较。 换句话说,除非我们知道“解决方案”对我们有多大帮助(如果有的话),否则我们无法知道它是否真的是解决方案。
我们可以关心两个指标。 首先是 CPU 使用率。 我们想要处理的过程有多快或多慢? 其次是内存占用。 脚本执行需要多少内存? 这些通常成反比——这意味着我们可以以 CPU 使用为代价卸载内存使用,反之亦然。
在异步执行模型(如多进程或多线程 PHP 应用程序)中,CPU 和内存使用都是重要的考虑因素。 在传统的 PHP 架构中,当任何一个达到服务器的限制时,这些通常都会成为问题。
在 PHP 中测量 CPU 使用率是不切实际的。 如果那是您想要关注的领域,请考虑使用类似 top
, 在 Ubuntu 或 macOS 上。 对于 Windows,请考虑使用 Linux 子系统,这样您就可以使用 top
在Ubuntu中。
出于本教程的目的,我们将测量内存使用情况。 我们将看看“传统”脚本中使用了多少内存。 我们将实施一些优化策略并对其进行测量。 最后,我希望您能够做出明智的选择。
我们将用来查看使用了多少内存的方法是:
// formatBytes is taken from the php.net documentation
memory_get_peak_usage();
function formatBytes($bytes, $precision = 2) {
$units = array("b", "kb", "mb", "gb", "tb");
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . " " . $units[$pow];
}
我们将在脚本末尾使用这些函数,这样我们就可以看到哪个脚本一次使用了最多的内存。
我们有哪些选择?
我们可以采用多种方法来有效地读取文件。 但是我们也可以在两种可能的情况下使用它们。 我们可能希望同时读取和处理数据,输出处理后的数据或根据我们读取的内容执行其他操作。 我们还可能希望转换数据流,而无需真正需要访问数据。
让我们想象一下,对于第一个场景,我们希望能够读取一个文件并每 10,000 行创建单独的排队处理作业。 我们需要在内存中保留至少 10,000 行,并将它们传递给排队的作业管理器(无论采用何种形式)。
对于第二种情况,假设我们想要压缩一个特别大的 API 响应的内容。 我们不关心它说的是什么,但我们需要确保它以压缩形式备份。
在这两种情况下,我们都需要读取大文件。 首先,我们需要知道数据是什么。 第二,我们不关心数据是什么。 让我们探索这些选项……
逐行读取文件
有许多用于处理文件的函数。 让我们将一些组合成一个简单的文件阅读器:
// from memory.php
function formatBytes($bytes, $precision = 2) {
$units = array("b", "kb", "mb", "gb", "tb");
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . " " . $units[$pow];
}
print formatBytes(memory_get_peak_usage());
// from reading-files-line-by-line-1.php
function readTheFile($path) {
$lines = [];
$handle = fopen($path, "r");
while(!feof($handle)) {
$lines[] = trim(fgets($handle));
}
fclose($handle);
return $lines;
}
readTheFile("shakespeare.txt");
require "memory.php";
我们正在阅读包含莎士比亚全集的文本文件。 文本文件约为 5.5MB,峰值内存使用量为 12.8MB。 现在,让我们使用生成器来读取每一行:
// from reading-files-line-by-line-2.php
function readTheFile($path) {
$handle = fopen($path, "r");
while(!feof($handle)) {
yield trim(fgets($handle));
}
fclose($handle);
}
readTheFile("shakespeare.txt");
require "memory.php";
文本文件大小相同,但内存使用峰值为 393KB。 在我们对正在读取的数据进行处理之前,这没有任何意义。 每当我们看到两个空行时,也许我们可以将文档分成块。 像这样:
// from reading-files-line-by-line-3.php
$iterator = readTheFile("shakespeare.txt");
$buffer = "";
foreach ($iterator as $iteration) {
preg_match("/n{3}/", $buffer, $matches);
if (count($matches)) {
print ".";
$buffer = "";
} else {
$buffer .= $iteration . PHP_EOL;
}
}
require "memory.php";
猜猜我们现在使用了多少内存? 如果您知道,即使我们将文本文档分成 1,216 个块,我们仍然只使用 459KB 的内存,您会感到惊讶吗? 鉴于生成器的性质,我们将使用的最多内存是我们需要在迭代中存储最大文本块的内存。 在本例中,最大块为 101,985 个字符。
我已经写过关于使用生成器和 Nikita Popov 的 Iterator 库的性能提升的文章,所以如果您想了解更多信息,请查看!
生成器还有其他用途,但这一个明显适合大文件的高性能读取。 如果我们需要处理数据,生成器可能是最好的方法。
文件之间的管道
在我们不需要对数据进行操作的情况下,我们可以将文件数据从一个文件传递到另一个文件。 这通常称为管道(大概是因为除了两端之外我们看不到管道内部的内容……当然,只要它是不透明的!)。 我们可以通过使用流方法来实现这一点。 让我们先写一个脚本来从一个文件传输到另一个文件,这样我们就可以测量内存使用情况:
// from piping-files-1.php
file_put_contents(
"piping-files-1.txt", file_get_contents("shakespeare.txt")
);
require "memory.php";
不出所料,此脚本运行时使用的内存比它复制的文本文件略多。 那是因为它必须读取(并保存)内存中的文件内容,直到它写入新文件。 对于小文件,这可能没问题。 当我们开始使用更大的文件时,没有那么多……
让我们尝试从一个文件流式传输(或管道)到另一个文件:
// from piping-files-2.php
$handle1 = fopen("shakespeare.txt", "r");
$handle2 = fopen("piping-files-2.txt", "w");
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
require "memory.php";
这段代码有点奇怪。 我们打开两个文件的句柄,第一个在读模式下,第二个在写模式下。 然后我们从第一个复制到第二个。 我们通过再次关闭这两个文件来完成。 知道使用的内存是 393KB,您可能会感到惊讶。
这似乎很熟悉。 这不是生成器代码在读取每一行时用来存储的内容吗? 那是因为第二个参数 fgets
指定每行要读取的字节数(默认为 -1
或者直到它到达一个新行)。
第三个论点 stream_copy_to_stream
是完全相同类型的参数(具有完全相同的默认值)。 stream_copy_to_stream
正在从一个流中读取,一次一行,并将其写入另一个流。 它跳过了生成器产生值的部分,因为我们不需要使用该值。
用管道输送这段文本对我们没有用,所以让我们想想其他可能有用的例子。 假设我们想从我们的 CDN 输出一个图像,作为一种重定向的应用程序路由。 我们可以用类似于以下的代码来说明它:
// from piping-files-3.php
file_put_contents(
"piping-files-3.jpeg", file_get_contents(
"https://github.com/assertchris/uploads/raw/master/rick.jpg"
)
);
// ...or write this straight to stdout, if we don't need the memory info
require "memory.php";
想象一下应用程序路由将我们带到这段代码。 但是我们不想从本地文件系统提供文件,而是想从 CDN 获取它。 我们可以替代 file_get_contents
对于更优雅的东西(如 Guzzle),但在引擎盖下它是一样的。
内存使用量(对于此图像)约为 581KB。 现在,我们尝试流式传输这个怎么样?
// from piping-files-4.php
$handle1 = fopen(
"https://github.com/assertchris/uploads/raw/master/rick.jpg", "r"
);
$handle2 = fopen(
"piping-files-4.jpeg", "w"
);
// ...or write this straight to stdout, if we don't need the memory info
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
require "memory.php";
内存使用量略少(400KB),但结果是一样的。 如果我们不需要内存信息,我们也可以打印到标准输出。 事实上,PHP 提供了一种简单的方法来做到这一点:
$handle1 = fopen(
"https://github.com/assertchris/uploads/raw/master/rick.jpg", "r"
);
$handle2 = fopen(
"php://stdout", "w"
);
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
// require "memory.php";
其他流
我们可以通过管道和/或写入和/或读取其他一些流:
php://stdin
(只读)php://stderr
(只写,如 php://stdout)php://input
(只读)这让我们可以访问原始请求体php://output
(只写)让我们写入输出缓冲区php://memory
和 php://temp
(读写)是我们可以临时存储数据的地方。 不同之处在于 php://temp
一旦数据变得足够大,就会将数据存储在文件系统中,而 php://memory
将一直存储在内存中,直到用完为止。
过滤器
我们可以对流使用另一个技巧,称为过滤器。 它们是一种中间步骤,在不向我们公开的情况下提供对流数据的一点点控制。 想象一下,我们想压缩我们的 shakespeare.txt
. 我们可能会使用 Zip 扩展名:
// from filters-1.php
$zip = new ZipArchive();
$filename = "filters-1.zip";
$zip->open($filename, ZipArchive::CREATE);
$zip->addFromString("shakespeare.txt", file_get_contents("shakespeare.txt"));
$zip->close();
require "memory.php";
这是一段简洁的代码,但大小约为 10.75MB。 我们可以做得更好,使用过滤器:
// from filters-2.php
$handle1 = fopen(
"php://filter/zlib.deflate/resource=shakespeare.txt", "r"
);
$handle2 = fopen(
"filters-2.deflated", "w"
);
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
require "memory.php";
在这里,我们可以看到 php://filter/zlib.deflate
过滤器,它读取并压缩资源的内容。 然后我们可以将这个压缩数据通过管道传输到另一个文件中。 这仅使用 896KB。
我知道这不是相同的格式,或者制作 zip 存档有好处。 不过您一定想知道:如果您可以选择不同的格式并节省 12 倍的内存,不是吗?
要解压缩数据,我们可以通过另一个 zlib 过滤器运行压缩后的文件:
// from filters-2.php
file_get_contents(
"php://filter/zlib.inflate/resource=filters-2.deflated"
);
在 Understanding Streams in PHP 和“Using PHP Streams Effectively”中广泛介绍了流。 如果您想要不同的视角,请查看这些内容!
自定义流
fopen
和 file_get_contents
有自己的一组默认选项,但这些是完全可定制的。 要定义它们,我们需要创建一个新的流上下文:
// from creating-contexts-1.php
$data = join("&", [
"twitter=assertchris",
]);
$headers = join("rn", [
"Content-type: application/x-www-form-urlencoded",
"Content-length: " . strlen($data),
]);
$options = [
"http" => [
"method" => "POST",
"header"=> $headers,
"content" => $data,
],
];
$context = stream_content_create($options);
$handle = fopen("https://example.com/register", "r", false, $context);
$response = stream_get_contents($handle);
fclose($handle);
在这个例子中,我们试图做一个 POST
请求 API。 API端点是安全的,但我们仍然需要使用 http
上下文属性(用于 http
和 https
). 我们设置几个标题并打开一个文件句柄……