最近在 HP Microserver Gen8 上重新搭建了 Nextcloud (在虚拟机里面容器里,基于 PHP 7.2 ),可惜通过 virtio 虚拟万兆网络进行下载,SSD 上文件的下载速度不超过 260MiB/s,机械硬盘上文件的下载速度不超过 80MB/s。要知道直接本地访问时,SSD 能达到 550MB/s 左右,机械硬盘平均 130MB/s,不甘心(这时 PHP 进程的 CPU 占用率很低,说明根本没有达到 CPU 执行瓶颈)。
排除了网络问题后,我在存储目录上搭建了一个 Nginx 进行测试,发现通过 Ngninx 直接下载文件几乎能达到本地直接访问的性能。于是,下载速度慢的锅就落在了 PHP 的性能上。
经过一番调研,Nextcloud 的 WebDav 服务是基于 sabre/dav 的框架开发的。于是找到了 sabre/dav 的源码,最后定位到下载文件代码的位置:3rdparty/sabre/http/lib/Sapi.php 。原来 sabre/dav 是通过调用 stream_copy_to_stream
将要下载的文件拷贝到 HTTP 输出的:直接把文件流和 PHP 的输出流进行对拷,之前并没有其他的读写操作,说明瓶颈就在这一行代码。
// 3rdparty/sabre/http/lib/Sapi.php
// ...
if (is_resource($body) && 'stream' == get_resource_type($body)) {
if (PHP_INT_SIZE !== 4) {
// use the dedicated function on 64 Bit systems
stream_copy_to_stream($body, $output, (int) $contentLength);
} else {
// ...
我本人并不是 PHP 程序员,于是开始了漫长的搜索。Google 娘告诉我 PHP 专门提供了fpassthru
函数提供高性能文件下载,于是我修改代码把 stream_copy_to_stream 换成了fpassthru
。
// 3rdparty/sabre/http/lib/Sapi.php
// ...
if (is_resource($body) && 'stream' == get_resource_type($body)) {
if (PHP_INT_SIZE !== 4) {
// use the dedicated function on 64 Bit systems
// stream_copy_to_stream($body, $output, (int) $contentLength);
fpassthru($body); // 改动这一行
} else {
// ...
测试了一下,发现下载速度直接打了鸡血,440-470 MiB/s。可惜 fpassthru
只能把文件输出到结尾,不能只输出文件的一部分(为了支持断点续传和分片下载)。另外翻了一下 sabre/dav 的 issues,发现 sabre/dav 不用fpassthru
的另外一个原因是有些版本的 PHP 中fpassthru
函数存在 BUG。
那为什么stream_copy_to_stream
速度和fpassthru
差距大得不科学呢?只能去读 PHP 的源码了,幸好 C 语言是我的强项。
我发现,fpassthru
函数和stream_copy_to_stream
函数实现是及其类似的:先尝试把源文件创建为内存映射文件(通过调用 mmap ),如果成功则直接从内存映射文件拷贝到目的流,否则就读到内存中进行传统的手动拷贝。差别来了,stream_copy_to_stream
的第三个参数是要拷贝的字节数,可惜如果这个值大于 4MiB,PHP 就拒绝创建内存映射文件,直接回退到传统拷贝。
在循环中调用stream_copy_to_stream
,每次最多拷 4MiB:
// 3rdparty/sabre/http/lib/Sapi.php
// ...
if (is_resource($body) && 'stream' == get_resource_type($body)) {
if (PHP_INT_SIZE !== 4) {
// use the dedicated function on 64 Bit systems
// 下面是改动的部分:
// allow PHP to use mmap by copying in 4MiB chunks
$chunk_size = 4 * 1024 * 1024;
stream_set_chunk_size($output, $chunk_size);
$left = $contentLength;
while ($left > 0) {
$left -= stream_copy_to_stream($body, $output, min($left, $chunk_size));
}
} else {
// ...
测试了一下,结果令人震惊:下载速度几乎和本地读取无异了:SSD 文件的下载速度超过了 500 MB/s,甚至超过了 fpassthru
的速度(大概是因为缓冲区开的比fpassthru
大)。
我又试着创建了一个 10G 大小的 sparse 文件 ( truncate -s 10G 10G.bin ),Linux 在读取 sparse 文件时可以立即完成,可以用来模拟如果硬盘速度足够快的情况。继续测试,发现下载速度超过了 700MiB/s,已经接近万兆网络的传输极限。这时 PHP 进程的 CPU 占用率已经达到 100%,说明瓶颈在 CPU 性能上了。
用stream_copy_to_stream
拷贝流时,如果 source 是文件并且每次拷贝小于 4MiB,PHP 会用内存映射文件对拷贝进行加速。超过 4MiB 后就会回退到传统读取机制。
向 Sabre 项目提了 PR:https://github.com/sabre-io/http/pull/119。如果各位也在玩 Nextcloud 并且遇到了下载速度瓶颈,可以试着打一下我这个补丁。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.