Nextcloud 的webdav拷贝性能优化

By | 2020 年 2 月 1 日

原文:优化 PHP 大文件下载速度至万兆,让 Nextcloud 支持万兆网络

https://www.v2ex.com/amp/t/555144/5

背景

最近在 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 并且遇到了下载速度瓶颈,可以试着打一下我这个补丁。

发表评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据