如何用 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://memoryphp://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”中广泛介绍了流。 如果您想要不同的视角,请查看这些内容!

自定义流

fopenfile_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 上下文属性(用于 httphttps). 我们设置几个标题并打开一个文件句柄……

阅读更多

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注