LAMP:如何在不占用磁盘/CPU资源的情况下,即时为用户创建大文件的.Zip压缩包?

46

通常,Web服务需要将几个大文件压缩在一起供客户端下载。最明显的方法是创建一个临时的zip文件,然后将其 echo 到用户或保存到磁盘并重定向(在将来的某个时间删除它)。

然而,以这种方式做事情有缺点:

  • 密集的CPU和磁盘折腾的初始阶段会导致...
  • 在准备归档时,对用户造成相当大的初始延迟
  • 每个请求的内存占用很高
  • 使用大量的临时磁盘空间
  • 如果用户在下载中途取消,则所有用于初始阶段(CPU、内存、磁盘)的资源都将被浪费

ZipStream-PHP 这样的解决方案通过将数据逐个文件地倒入 Apache 中来改善这种情况。然而,结果仍然是高内存使用率(文件完全加载到内存中)和大量的、混乱的磁盘和CPU使用率。

相比之下,考虑以下Bash片段:

ls -1 | zip -@ - | cat > file.zip
  # Note -@ is not supported on MacOS

这里,zip 以流模式操作,从而使内存占用低。管道具有一个整体缓冲区 – 当缓冲区满时,操作系统会挂起写入程序(位于管道左侧的程序)。这里确保 zip 只在其输出能够被 cat 写入的速度下工作。

那么,最优的方法是使用类似的方式:用Web服务器进程替换 cat ,将生成的 zip 文件即时 流式传输 给用户。与仅流式传输文件相比,这会产生较少的开销,并且资源利用率良好、不易出现峰值。

如何在 LAMP 堆栈上实现这一点?


1
请注意:我部分地写这篇文章是因为各种类似的问题——这似乎是一个相对常见的问题,但尚未得到很好的解决。即已经尽力详细地描述了流媒体/PHP问题——请只提供认真的答案!(改进问题的建议也将不胜感激。) - Benji XVI
你可能可以使用Node.js。我知道它已被用于解析上传文件的头部(在上传时)。由于相比PHP,你对I/O缓冲区有更多的控制权,所以我猜实时编写zip文件不应该很难。 - Kendall Hopkins
7个回答

50
您可以使用popen()(文档)proc_open()(文档)来执行一个Unix命令(例如zip或gzip),并将stdout返回为PHP流。 flush()(文档)将尽最大努力将PHP的输出缓冲区内容推送到浏览器。
将所有这些组合在一起可以得到您想要的结果(前提是没有其他东西阻碍 - 请查看flush()文档页面上的警告)。
(注意:不要使用flush()。有关详细信息,请参见下面的更新。)
类似以下代码可以实现该功能:
<?php
// make sure to send all headers first
// Content-Type is the most important one (probably)
//
header('Content-Type: application/x-gzip');

// use popen to execute a unix command pipeline
// and grab the stdout as a php stream
// (you can use proc_open instead if you need to 
// control the input of the pipeline too)
//
$fp = popen('tar cf - file1 file2 file3 | gzip -c', 'r');

// pick a bufsize that makes you happy (64k may be a bit too big).
$bufsize = 65535;
$buff = '';
while( !feof($fp) ) {
   $buff = fread($fp, $bufsize);
   echo $buff;
}
pclose($fp);

你询问了关于“其他技术”的问题:我会说,“任何支持整个请求生命周期的非阻塞I/O”的东西都可以。如果你愿意深入研究非阻塞文件访问等内容,可以在Java或C/C++(或其他许多可用语言)中构建这样的组件作为独立服务器。
如果你想要一个非阻塞实现,但又不想涉及“下下策”,最简单的路径(依我之见)是使用nodeJS。现有版本的nodejs提供了你需要的所有功能支持:当然,使用http模块作为http服务器;并使用child_process模块来生成tar/zip/whatever管道。
最后,如果(仅当)你运行一个多处理器(或多核心)服务器,并且你想从nodejs中获得最大收益,你可以使用Spark2在同一端口上运行多个实例。不要在每个处理器核心上运行多个nodejs实例。

更新(根据Benji在此答案评论部分提供的出色反馈)

1. fread()的文档表明该函数仅会从非常规文件中读取最多8192个字节的数据。因此,8192可能是一个很好的缓冲区大小选择。

[编辑说明] 8192几乎肯定是一个平台相关的值--在大多数平台上,fread()将读取数据,直到操作系统的内部缓冲区为空,然后它将返回,允许操作系统异步地再次填充缓冲区。8192是许多流行操作系统的默认缓冲区大小。

还有其他情况可能导致fread返回少于8192个字节--例如,“远程”客户端(或进程)填充缓冲区较慢--在大多数情况下,fread()将返回输入缓冲区的内容,而不必等待其变满。这可能意味着返回0..os_buffer_size个字节。

道德是:将buffsize作为传递给fread()的值应被视为“最大”大小--永远不要假设您已经收到了所请求的字节数(或任何其他数字)。

2. 根据fread文档的评论,有一些注意事项:magic quotes可能会干扰并且必须关闭

3. 设置mb_http_output('pass')(文档)可能是个好主意。尽管'pass'已经是默认设置,但如果您的代码或配置先前将其更改为其他内容,则可能需要明确指定它。

4. 如果您正在创建zip文件(而不是gzip),则需要使用内容类型标题:

Content-type: application/zip

或者...可以使用"application/octet-stream"代替。(它是用于各种不同类型的二进制下载的通用内容类型):

Content-type: application/octet-stream

如果您希望用户提示下载并将文件保存到磁盘(而不是可能让浏览器尝试将文件显示为文本),则需要使用content-disposition头。 (其中文件名指示在保存对话框中建议的名称):

Content-disposition: attachment; filename="file.zip"

在使用这种技术时,应该发送Content-length头部,但由于您事先不知道zip文件的确切大小,因此这很困难。 是否有一种可以设置的头部来指示内容是"流式传输"或长度未知?有人知道吗?


最后,这是一个经过修订的示例,使用了@Benji的所有建议(并创建ZIP文件而不是TAR.GZIP文件):

<?php
// make sure to send all headers first
// Content-Type is the most important one (probably)
//
header('Content-Type: application/octet-stream');
header('Content-disposition: attachment; filename="file.zip"');

// use popen to execute a unix command pipeline
// and grab the stdout as a php stream
// (you can use proc_open instead if you need to 
// control the input of the pipeline too)
//
$fp = popen('zip -r - file1 file2 file3', 'r');

// pick a bufsize that makes you happy (8192 has been suggested).
$bufsize = 8192;
$buff = '';
while( !feof($fp) ) {
   $buff = fread($fp, $bufsize);
   echo $buff;
}
pclose($fp);

更新:(2012年11月23日)我发现在读取/回显循环内调用flush()可能会在处理非常大的文件和/或非常慢的网络时导致问题。至少在Apache后面运行PHP作为cgi/fastcgi时是这样的,似乎在其他配置中运行时也可能发生同样的问题。当PHP将输出刷新到Apache比Apache实际通过套接字发送更快时,该问题似乎会出现。对于非常大的文件(或慢速连接),这最终会导致Apache内部输出缓冲区的溢出。这会导致Apache杀死PHP进程,这当然会导致下载挂起,或者过早地完成,只完成了部分传输。

解决方案不是根本不调用flush()。我已经更新了上面的代码示例以反映这一点,并在答案顶部的文本中放置了一个注释。


2
几个小问题:(1)根据文档(以及描述文档错误的bug报告!),fread只会一次从非普通文件读取最多8192字节的数据。因此,8192可能是一个不错的缓冲区大小选择。(2)根据fread文档中的评论,有一些注意点:魔术引号可能会干扰,必须关闭;设置mb_http_encoding('pass')可能是个好主意。(3)也许正如这个问题特别涉及zip,(这是为了跨平台服务用户唯一的选项),更改代码中的这些部分? - Benji XVI
1
有用的头部信息: "Content-type: application/zip"(或 application/octet-stream),以及 Content-disposition: attachment; filename="file.zip"。还应该设置 Content-length,但由于在使用这种技术时无法提前知道 zip 文件的确切大小,所以这很困难。 - Benji XVI
1
还有一件事:有趣的是,flush()似乎是不必要的。(在运行mod_fastcgi的Apache下测试。)我怀疑正常的PHP和Apache缓冲行为对于大文件下载变得无关紧要。它似乎是这样工作的:PHP填充缓冲区,并暂停直到Apache发送它。此脚本的操作部分是1. PHP从不在内存中保存超过8192字节,2. zip以流模式工作并使用很少的内存,3.执行被暂停,而Apache清除(发送)其缓冲区。 - Benji XVI
2
对于那些使用过这种方法的人:我刚刚发布了一个更新。简要总结是“不要使用flush()”。如果您的实现中使用了flush(),请查看我上面添加的信息。 - Lee
如果您需要使用“find”命令,可以这样做:find /path/ -iname "*.txt" -print | zip -@ - - Meetai.com
显示剩余9条评论

3

另一个解决方案是我为Nginx编写的mod_zip模块,专门用于此目的:

https://github.com/evanmiller/mod_zip

它非常轻巧,不需要单独调用“zip”进程或通过管道进行通信。您只需指向列出要包含文件位置的脚本,mod_zip就会完成剩下的工作。


3

我试图实现一个动态生成的下载,其中包含大量不同大小的文件。然而,我遇到了各种内存错误,例如“内存限制已达到134217728字节...”。

flush();之前添加ob_flush();后,内存错误消失了。

发送标题时,我的最终解决方案如下(只将文件存储在zip中,不包括目录结构):

<?php

// Sending headers
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="download.zip"');
header('Content-Transfer-Encoding: binary');
ob_clean();
flush();

// On the fly zip creation
$fp = popen('zip -0 -j -q -r - file1 file2 file3', 'r');

while (!feof($fp)) {
    echo fread($fp, 8192);
    ob_flush();
    flush();
}

pclose($fp);

2

1
根据PHP手册ZIP扩展提供了一个zip:包装器。
我从未使用过它,也不知道它的内部工作原理,但从逻辑上讲,它应该能够做到你所需要的,只要ZIP归档可以被流式传输,这一点我并不完全确定。
至于你关于“LAMP堆栈”的问题,只要PHP没有配置输出缓冲区,就不应该有问题。

编辑:我正在尝试组合一个概念验证,但似乎并不容易。如果您没有经验处理PHP的流,这可能会变得过于复杂,甚至可能无法实现。


编辑(2): 在查看ZipStream后,重新阅读您的问题后,我发现当您说(强调添加)

操作员Zipping应在流模式下运行,即处理文件并以下载速率提供数据

由于我认为PHP没有提供确定Apache缓冲区已满的方式,因此实现这一部分将非常困难。 因此,您的问题的答案是否定的,您可能无法在PHP中实现它。


针对你的第一个问题,是的,压缩可以以流式方式完成,事实上与上面的 bash 伪代码片段一样,标准的 Unix 工具可以做到。 - Benji XVI
更多背景信息:bash代码片段如此精妙的原因在于管道具有一个积分缓冲区(在Linux上为64k)- 当这个缓冲区满时,操作系统会暂停提供进程(在本例中为zip)。 - Benji XVI

1

我刚刚在这里发布了一个纯PHP用户空间编写的ZipStreamWriter类:

https://github.com/cubiclesoft/php-zipstreamwriter

这个类支持将流数据写入和读出,而不是使用外部应用程序(例如zip)或ZipArchive等扩展。它通过实现一个完整的ZIP编写器来支持这个功能。

流式处理的工作原理是使用ZIP文件格式的“数据描述符”,如PKWARE ZIP文件规范的第4.3.5节所述:

4.3.5 文件数据可以在文件后面跟着一个“数据描述符”。 数据描述符用于促进ZIP文件流。

但需要注意一些可能存在的限制。并非所有工具都能读取流式ZIP文件。此类文件超过2GB时,对Zip64流式ZIP文件的支持可能会更少,但这仅适用于该类文件。然而,7-Zip和Windows 10内置的ZIP文件读取器似乎可以很好地处理ZipStreamWriter类抛出的所有疯狂文件。我使用的十六进制编辑器也得到了很好的锻炼。

使用ZipStreamWriter类时,建议在发送到Web服务器之前允许缓冲区至少累积到4KB,但不超过65KB。否则,对于大量非常小的文件,您将会刷新零散的小数据块,并浪费大量额外的CPU周期在Apache回调端上。 当某些东西不存在或我不喜欢现有的选项时,我会找到官方和非官方规范、一些示例进行工作,然后从头开始构建。这是解决问题的相当可靠的方法,只是有点过度解决问题了。

1

通过使用fpassthru(),您似乎可以消除任何与输出缓冲相关的问题。我还使用-0来节省CPU时间,因为我的数据已经很紧凑了。我使用这段代码实时压缩整个文件夹:

chdir($folder);
$fp = popen('zip -0 -r - .', 'r');
header('Content-Type: application/octet-stream');
header('Content-disposition: attachment; filename="'.basename($folder).'.zip"');
fpassthru($fp);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接