LAMP:如何为用户快速创build.zip大文件,无需磁盘/ CPU抖动

Web服务通常需要压缩几个大文件供客户端下载。 最明显的方法是创build一个临时的zip文件,然后或者将其echo给用户,或者将其保存到磁盘并redirect(将来有一段时间将其删除)。

但是,这样做有缺点:

  • 密集的CPU和磁盘颠簸的初始阶段,导致…
  • 在档案准备的同时,对用户的相当大的初始延迟
  • 每个请求的内存占用量非常高
  • 使用大量的临时磁盘空间
  • 如果用户取消下载的一半,初始阶段(CPU,内存,磁盘)使用的所有资源将被浪费

像ZipStream-PHP这样的解决scheme通过将数据按文件铲入Apache文件来改善这一点。 但是,结果仍然是高内存使用率(文件全部加载到内存中),而且磁盘和CPU使用率也很高。

相比之下,请考虑下面的bash代码片段:

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

在这里, zip以stream模式运行,导致内存占用less。 一个pipe道有一个整体的缓冲区 – 当缓冲区满时,操作系统暂停写入程序(pipe道左侧的程序)。 这里确保zip运行速度与cat输出速度一样快。

那么最好的方法就是做同样的事情:用web服务器进程replacecat ,将zip文件stream式传输给用户。 与仅传输文件相比,这会产生很小的开销,并且会有一个没有问题的,非尖锐的资源configuration文件。

你怎么能在LAMP栈上实现这个function?

您可以使用popen() (docs)或proc_open() (docs)来执行一个unix命令(例如.zip或gzip),然后以stdout的forms返回。 flush() (docs)将尽其最大的努力推动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 ++(或任何其他可用的语言)构build一个独立的服务器组件。

如果你想要一个非阻塞的实现,但你宁愿避免“沮丧和肮脏”,最简单的path(恕我直言)将使用nodeJS 。 在现有的nodejs发行版中,对于你需要的所有function都有很多的支持:使用http模块(当然是)http服务器; 并使用child_process模块来产生tar / zip /任何pipe道。

最后,如果(且仅当)您运行的是多处理器(或多核)服务器,并且您希望从nodejs得到最多,则可以使用Spark2在同一端口上运行多个实例。 每个处理器内核不要运行多个nodejs实例。


更新 (来自Benji在这个答案的意见部分的优秀反馈)

1. fread()的文档表明,函数一次只能读取最多8192个字节的数据,而不是普通文件。 因此,8192可能是一个很好的缓冲区大小的select。

8192几乎肯定是一个平台相关的值 – 在大多数平台上, fread()将读取数据,直到操作系统的内部缓冲区为空,此时它将返回,允许os以asynchronous方式再次填充缓冲区。 8192是许多stream行操作系统上默认缓冲区的大小。

还有其他一些情况可能导致fread返回甚至less于8192字节 – 例如,“远程”客户端(或进程)缓慢地填充缓冲区 – 在大多数情况下, fread()将返回input的内容现在缓冲区没有等待它充满。 这可能意味着任何从0..os_buffer_size字节得到返回。

道德是:你传递给fread()的价值作为buffsize应该被认为是一个“最大”的大小 – 永远不要假设你已经收到你要求的字节数(或者其他任何数字)。

2.根据fread文档的评论,一些注意事项: 魔术引号可能会干扰,必须closures 。

3.设置mb_http_output('pass') (docs)可能是一个好主意。 虽然'pass'已经是默认设置,但是如果您的代码或configuration先前已将其更改为其他设置,则可能需要明确指定它。

4.如果你正在创build一个zip(而不是gzip),你会想要使用内容types头:

 Content-type: application/zip 

或者…'application / octet-stream'可以用来代替。 (这是用于所有不同种类的二进制下载的通用内容types):

 Content-type: application/octet-stream 

如果您希望提示用户下载文件并将其保存到磁盘(而不是让浏览器尝试将文件显示为文本),那么您将需要内容处置标题。 (其中文件名指示应在保存对话框中build议的名称):

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

还应该发送内容长度标题,但这是很难用这种技术,因为你事先不知道拉链的确切大小。 是否有一个标题可以设置为表示内容是“stream”还是未知的长度? 有人知道吗?


最后,这是一个修改后的例子,它使用了@ Benji的所有build议(并创build了一个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)我发现在read / echo循环中调用flush()会在处理非常大的文件和/或非常慢的networking时导致问题。 至less,在Apache中运行PHP作为cgi / fastcgi时,情况就是这样,在其他configuration中运行时也会出现同样的问题。 当PHP刷新输出到Apache的速度比Apache实际上通过套接字发送的时候要快。 对于非常大的文件(或慢速连接),这最终会导致Apache的内部输出缓冲区溢出。 这会导致Apache杀死PHP进程,这当然会导致下载挂起,或者只是发生部分传输而提早完成。

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

另一个解决scheme是我的mod_zip模块Nginx,专门为此编写的:

https://github.com/evanmiller/mod_zip

它非常轻便,不会调用单独的“zip”进程或通过pipe道进行通信。 您只需指向一个脚本,列出要包含的文件的位置,其余部分则由mod_zip完成。

我上周末写了这个s3蒸汽文件拉链微服务 – 可能是有用的: http : //engineroom.teamwork.com/how-to-securely-provide-a-zip-download-of-a-s3-file-bundle/

根据PHP手册 , ZIP扩展提供了zip:wrapper。

我从来没有使用它,我不知道它的内部,但从逻辑上来说,它应该能够做你想要的,假设ZIP档案可以stream式传输,我不完全确定。

至于你关于“LAMP堆栈”的问题,只要PHP 没有 configuration为缓冲输出 就不应该是一个问题。


编辑:我试图把一个概念validation,但似乎不是微不足道的。 如果你对PHP的stream没有经验,如果甚至可能的话,它可能太复杂了。


编辑(2):看看ZipStream后重读你的问题,我发现当你说(重点增加)时,这里将会是你的主要问题,

操作中的压缩应该以stream模式操作,即处理文件和以下载速率提供数据。

这部分将非常难以实现,因为我不认为PHP提供了一种方法来确定Apache的缓冲区是多么的完整。 所以,你的问题的答案是否定的,你可能无法在PHP中做到这一点。

试图实现一个dynamic生成的下载与大量不同大小的文件我遇到了这个解决scheme,但我遇到了各种内存错误,如“允许内存大小134217728字节用尽……”。

添加ob_flush(); flush(); 内存错误消失。

与发送标题一起,我的最终解决scheme如下所示(只需将文件存储在不带目录结构的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); 

看来,你可以通过使用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);