接手那个下载服务的时候我完全没想到问题出在“搬家”上。1GB 的安装包每天被拉走几十万次16 核机器 CPU 一路飙到 80%top 里清一色 php-fpm 进程。第一反应是带宽不够可网卡明明只跑到 300Mbps直到我 strace 挂上去才看到真相文件被一层层 memcpy 从内核缓冲区拷进 PHP 进程再拷回 socket 缓冲区。CPU 全浪费在用户态搬运上了。这篇文章想聊的就是如何用 PHP 方案 sendfile 零拷贝把文件从磁盘“直传”给客户端不再让 PHP 当那个可怜的用户态搬运工。适合谁看写 PHP 下载接口的人、用 Swoole/Workerman 做常驻服务的人、被大文件下载拖垮过 CPU 的人。只要你搞清楚一条数据从磁盘到网卡到底经过了哪些手后面所有方案都是一张窗户纸。1. 传统PHP下载方案的数据搬运真相1.1 平常写下载接口的三种姿势我见过最多的 PHP 文件下载写法基本逃不开这三种readfile($file)一行搞定PHP 官方文档直接推荐用于“输出文件”。file_get_contents($file)再echo $content新手最爱也是炸内存最快的姿势。fopen()fpassthru()或fread()循环输出稍微讲究点能控制内存但依然没跑出用户态。先说结论这三种本质都是“把文件内容从内核态拷到用户态再从用户态拷回内核态”。区别只在能不能控制内存峰值、会不会触发超时。readfile()和fpassthru()内部确实会分块读不会像file_get_contents()那样把 1GB 直接塞进内存但它们没有改变一个事实——PHP 进程必须亲手把每一块数据捧着递出去。我接手时线上用的就是readfile()当时觉得这已经是“标准答案”了直到看到 CPU 占用才意识到标准答案离正确答案隔着一次系统调用。1.2 readfile为什么慢四次拷贝与核心态切换一次普通文件下载在传统 readfile 路径里数据是这样走的磁盘 DMA 到内核页缓存page cache这次是 DMA不占 CPU。内核页缓存 copy_to_user 到 PHP 用户态内存CPU 全程参与 memcpy。PHP 把数据交给 socket 时再 copy_from_user 到 socket 发送缓冲区又是 CPU memcpy。socket 缓冲区 DMA 到网卡发出去了这次不占 CPU。也就是说用户态和内核态之间发生了两次我们肉眼看不见的“倒腾”算上 DMA 一共四次数据复制。每次进入内核态还伴随着上下文切换read/write 反复横跳。如果前面还套了一层 PHP-FPM 和 Nginx 的 FastCGI 通信那 PHP 的输出还得先经过 Nginx 的 buffer等于又多了一次搬动。为什么没人一开始就觉得这有问题因为小文件下CPU 的 memcpy 速度快到可以忽略。但到了 GB 级别1GB 数据被拷贝两三次那可就是几十 GB 的内存带宽消耗全部压在 CPU 上。1.3 大文件高并发下最先顶不住的是什么大文件下载摊上高并发最先挂掉的往往不是磁盘 IO而是 CPU 和内存带宽。磁盘顺序读靠 page cache 撑着一般还好真正消耗 CPU 的是反复 memcpy。另一个隐藏炸弹是 PHP-FPM 进程池一个下载用户可能占着一个 php-fpm worker 好几十秒甚至几分钟慢用户多的时候worker 被下载任务占满了正常动态请求全被堵在门外。内存方面readfile()虽然不会一次性把整个文件读进 PHP 内存但 PHP 输出过程里的临时缓冲区、Nginx 的 fastcgi 缓冲区都会吃内存。并发一高内存跟着涨。最后你会看到一台机器 CPU 100%、内存报警网卡带宽却远没跑满典型的用户态搬运工拖垮整个服务的状态。这个阶段我用一个比喻来理解文件就是一批货内核是仓库网卡是快递员。传统方式相当于 PHP 当搬运工从仓库搬出来放到路边再搬回仓库交给快递员——来回折腾搬运工累死仓库门口还堵车。sendfile 就是让仓库管理员直接从仓库把货递给快递员PHP 只需要在旁边说一句“送这车货”。2. sendfile零拷贝的内核级直传原理2.1 零拷贝并不是真的没有拷贝很多人一听“零拷贝”以为从磁盘到网卡一个字节都不拷这是误解。零拷贝真正省掉的是“用户态和内核态之间的拷贝”也就是省掉 CPU 参与的 memcpy 那两趟。磁盘到内核页缓存、页缓存到网卡依然有复制但那是 DMA 完成的硬件干活CPU 几乎不花钱。类比一下老办法是“仓库到临时堆场再到仓库”需要人工搬两次零拷贝是“仓库内部传送带直接送到快递车”传送带还是要转但不需要人扛了。这个“人扛”的成本才是我们在 PHP 里想优化的东西。所以零拷贝不是玄学就是把 CPU 从数据搬运工的位置上解放出来让它回去干该干的事——处理请求、跑业务逻辑。2.2 sendfile系统调用的参数与数据路径系统调用原型是ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);out_fd目标描述符在文件下载场景里就是客户端 socket。in_fd源描述符指向磁盘文件。offset从文件哪个位置开始发支持断点续传的关键参数。count最多发送多少字节。调用它之后内核做的事简单粗暴文件内容从 page cache 直接映射/拷贝到 socket默认不进用户态。Linux 内核现在的实现多用 splice 机制完成DMA 能直接搞定的路径就走 DMA不需要网卡支持什么特殊功能传统千兆网卡也能享受这个流程。数据路径从传统四步变成两步磁盘 DMA 到内核页缓存。内核页缓存 DMA 到网卡或经由 socket 缓冲区的 DMA 映射。上下文切换也从至少四次变成一次系统调用搞定。CPU 不用再去碰文件数据自然就凉下来了。2.3 sendfile的适用边界和常见的错误期望边界要先说清楚不然你会到处碰壁out_fd必须是 socketin_fd必须是普通文件。想拿它做“两个文件之间零拷贝复制”不行那得换copy_file_range。文件必须已经在内核 page cache 里或者至少能走 page cache。冷文件首次访问磁盘到 page cache 的 DMA 还是躲不掉的。大文件、静态文件、原样输出这是它的主场。小文件、几十 KB 的 JSON/HTML没必要用系统调用本身的固定开销反而可能比 memcpy 还贵。需要对内容做加密、压缩、拼接、或者其他任何改造的场景也别用。sendfile 只能“原封不动”地发整个文件。一个常见的错误期望是以为“我上了 sendfilePHP 就完全不碰文件了”。实际上 PHP 仍然要负责鉴权、解析路径、拼响应头、算 Range 范围只是不再承担搬运文件体这个重体力活。这不亏业务逻辑本来就应该在 PHP 层。3. PHP代码里落地sendfile的三种路线3.1 路线ANginx X-Accel-RedirectPHP-FPM下最值得采用的方案大多数人线上都是 PHP-FPM Nginx 的标配。这时候有个反直觉的结论即便你在 PHP 里用 FFI 直接调 sendfile也很难发挥作用因为 PHP-FPM 的输出要先进 FastCGI由 Nginx 再转给客户端。文件内容已经被 Nginx buffer 兜了一道。正确的姿势是 Nginx 的X-Accel-Redirect也有叫“内部重定向”的。思路是PHP 只负责鉴权、定位文件、设置响应头然后返回一个特殊的 HTTP 头告诉 Nginx“这个文件你直接替我发了吧”。Nginx 看到这个头之后不再从 PHP 拿 body而是自己接管文件发送走的是 Nginx 自己的 sendfile 流程。这个方案的好处是零改造、零额外进程PHP 代码几乎不用学新 API纯写 header 就能实现。Nginx 默认sendfile on很多发行版还会开tcp_nopush效果直接拉满。3.2 路线BSwoole/Workerman原生sendfile如果你的服务已经是用 Swoole 或 Workerman 写成的常驻 PHP 进程那恭喜你PHP 本身就是服务器不需要经过 Nginx 中转。这时直接调用框架自带的 sendfile 相关方法就行。Swoole 的 HTTP Response 对象提供了sendfile()方法$response-sendfile($filePath, $offset, $length);它底层调用的就是 Linux 的 sendfile 系统调用把文件从in_fd直传给客户端 socketPHP 用户态完全不碰文件内容。Workerman 的TcpConnection也有对应的 sendfile 能力。这套路的好处是不依赖外部 Web 服务器下载服务自己就能扛最适合做成独立的文件网关或微服务。3.3 路线CPHP FFI直连系统调用想亲眼看看 sendfile 长什么样PHP 7.4 的 FFI 可以让你在 PHP 里直接调 C 函数。这个方法的生产价值不如 A/B但理解原理的价值极高。我写过一个最小验证 demo核心就三件事定义函数签名、拿到文件 fd、循环调用 sendfile。?php // 最简的 FFI sendfile 演示理解原理用生产环境别这么干 $ffi FFI::cdef( ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);, libc.so.6 ); $filePath /data/files/demo.zip; $inFd $ffi-open($filePath, 0); // 只读方式打开拿到源文件 fd $off $ffi-new(off_t); $off-cdata 0; $chunkSize 1 20; // 每次最多发 1MB while ($chunkSize 0) { $sent $ffi-sendfile($clientFd, $inFd, FFI::addr($off), $chunkSize); if ($sent 0) { // 处理 EAGAIN / EINTR 等错误 break; } if ($sent 0) { break; // 发完了 } $off-cdata $sent; $chunkSize - $sent; }注意几个点offset是off_t *类型的指针所以要用FFI::new(off_t)创建变量再通过FFI::addr()取地址客户端 socket 的 fd 在纯 PHP 里不好直接拿到所以我这个 demo 通常是配合 Swoole 的Socket对象属性来用。真正生产环境里你直接用 Swoole 的sendfile()就好FFI 的价值是让你看清参数、看清循环、看清 offset 累加到底怎么回事。3.4 三种路线的选型对照部署模式推荐方案核心理由需要留意的地方PHP-FPM NginxX-Accel-RedirectPHP 输出本身要经过 FastCGI自己调 sendfile 收益有限Nginx 的 internal 路径不能暴露Swoole/Workerman 常驻服务框架原生 sendfilePHP 自己就是服务器fd 直传链路最短需要自己处理 Range/响应头/并发模型学习原理/极端定制FFI 直调系统调用能看清完整参数和系统调用行为可维护性低错误处理繁琐不建议生产直接用Apache mod_phpmod_xsendfile等价于 Nginx 的 X-Accel-Redirect需要额外安装扩展模块选型没有绝对答案核心逻辑很明确哪一层离“最终发给客户端的 socket”最近就让哪一层发文件。Nginx 离得近就用 NginxSwoole 离得近就用 Swoole千万别隔着 FastCGI 硬在 PHP 里发。4. 支持断点续传的零拷贝下载控制器实现4.1 基于Nginx X-Accel-Redirect的鉴权下载控制器先看 Nginx 配置location /protected/ { internal; alias /data/files/; default_type application/octet-stream; sendfile on; tcp_nopush on; }internal是关键它让这个路径只能被内部重定向访问外部请求直接 404防止有人绕过鉴权拿文件。PHP 控制器这边核心干三件事校验凭证、解析文件路径、设响应头。public function download(string $token): void { // 1. 鉴权比如校验签名、登录态、过期时间 if (!$this-auth-check($token)) { http_response_code(403); exit(forbidden); } // 2. 从 token 解析出允许访问的相对路径注意防目录穿越 $relativePath $this-resolvePath($token); $fullPath $this-baseDir . DIRECTORY_SEPARATOR . $relativePath; if (!is_file($fullPath)) { http_response_code(404); exit(not found); } // 3. 交给 Nginx 直传 header(X-Accel-Redirect: /protected/ . $relativePath); header(Content-Type: application/octet-stream); header(Content-Length: . (string) filesize($fullPath)); header(Content-Disposition: attachment; filename . rawurlencode($fileName) . ); http_response_code(200); exit; }这里最容易翻车的是直接把用户传的文件名拼进 X-Accel-Redirect造成路径穿越。我的习惯是把“实际文件名”和“外部展示名”分开外部用不可预测的 ID 或签名串内部再映射到真实路径这样就算 header 被截获也不至于把整个磁盘目录暴露出去。4.2 基于Swoole的Range断点续传控制器Swoole 的Response-sendfile()是给常驻服务用的它的爽点是可以直接传offset和length。但 HTTP 的断点续传不会自动好需要自己解析 Range 头。一个能跑起来的简单实现public function download(Swoole\Http\Request $request, Swoole\Http\Response $response): void { $filePath /data/files/large-game-installer.zip; $fileSize filesize($filePath); $offset 0; $length $fileSize; $response-header(Content-Type, application/octet-stream); $response-header(Accept-Ranges, bytes); $range $request-header[range] ?? ; if ($range ! preg_match(/bytes(\d*)-(\d*)/, $range, $m)) { $start $m[1] ? null : (int) $m[1]; $end $m[2] ? null : (int) $m[2]; if ($start null) { // 形如 bytes-500表示最后 500 字节 $length min((int) $end, $fileSize); $offset $fileSize - $length; } else { $offset $start; $end $end null ? $fileSize - 1 : min($end, $fileSize - 1); $length $end - $offset 1; } $response-status(206); $response-header(Content-Range, sprintf(bytes %d-%d/%d, $offset, $offset $length - 1, $fileSize)); } $response-header(Content-Length, (string) $length); $response-sendfile($filePath, $offset, $length); }几个坑我一开始全踩过Content-Length必须是本次实际发送的$length不是整个文件大小。Range 请求下用完整文件大小会让客户端一直等。单 Range 请求一般就够满足浏览器和下载工具了。多 Rangemultipart/byteranges很少见我建议先不支持返回 200 完整文件即可绝大多数场景不会触发。Content-Range里的总大小要写文件真实大小不是分段后的值每次都得带上/totalSize。中文文件名要处理Content-Disposition的编码不要直接塞 header容易乱码甚至被某些客户端拒绝。4.3 FFI版sendfile循环发送的本质演示前面第 3 章给了最短的 FFI demo这里把循环里那张“容易被忽略的窗户纸”捅破sendfile一次调用不一定能把count字节全部发完。为什么因为 socket 发送缓冲区有水位写满了就会返回实际已发送的字节数或者干脆返回 -1 置EAGAIN非阻塞模式下。所以必须循环发送每次把offset加上已发送的字节数然后再战。这个过程很像你往快递柜里塞包裹柜子满了就等下一趟但货物不会丢位置要自己记住。$remaining $length; while ($remaining 0) { $toSend min($remaining, 1 20); $sent $ffi-sendfile($clientFd, $inFd, FFI::addr($off), $toSend); if ($sent 0) { $err FFI::lastError(); if ($err 11 /* EAGAIN */) { // 非阻塞模式下应该等 socket 可写后重试 continue; } if ($err 4 /* EINTR */) { continue; // 被信号打断重试即可 } // 真正出错记录日志并中断 break; } if ($sent 0) { break; // 文件读完了 } $off-cdata $sent; $remaining - $sent; }EAGAIN 的处理在纯 FFI 同步代码里很难做漂亮因为你会想用usleep自旋这在高并发下是灾难。正确的做法是交给事件循环Swoole 的异步非阻塞模型这也是为什么我一直强调生产环境不要自己用 FFI 写下载服务——原理可以玩工程还是要靠框架。4.4 响应头与Content-Length的经典翻车现场翻车现场一用了sendfile()又调用了$response-end(extra)客户端下载完文件后文件末尾多出一段内容zip 直接损坏。解决方式sendfile 之后不要再输出任何 body所有业务日志都走 error_log别跟响应体混在一起。翻车现场二设了Content-Type: application/json去下载二进制文件浏览器直接以文本打开乱码。下载场景要视需求定类型拿不准就统一application/octet-stream并配上Content-Disposition: attachment。翻车现场三漏了Accept-Ranges: bytes。这会导致浏览器播放器尤其是 iOS Safari 的 video 标签无法拖动进度条因为它以为服务端不支持 Range 请求。还有一个细节响应头里的filename如果带空格记得 URL 编码Swoole 和 Nginx 对 header 的校验严格非法字符直接 500。5. 压测数据与实战避坑备忘5.1 一次1GB文件的压测三种方案的真实差距我自己在某台 4 核云主机上做过一轮压测1GB 固定文件、100 并发连续拉取结果偏向示意重点看相对关系方案CPU 峰值% of 4 coresPHP 进程内存峰值单文件 TTFBreadfile()直接输出85-9580MB缓冲区叠 buff略慢Nginx X-Accel-Redirect20-30基本不涨快SwooleResponse-sendfile()18-25稳定在协程基线快readfile 那条路径 CPU 几乎全被 memcpy 和上下文切换吃掉了连磁盘 page cache 命中完美也救不了。换成 X-Accel-Redirect 或 Swoole sendfile 后CPU 立竿见影地凉下来。注意纯磁盘冷读时数据还是要从磁盘 DMA 到 page cache第一波并发可能会有短暂延迟但这是所有方案都躲不掉的物理过程。5.2 EINTR与EAGAIN被信号和慢客户端打断时怎么办慢客户端是这个领域最隐蔽的杀手。用户拿个烂 WiFi 下 1GB 文件socket 缓冲区很容易写满。阻塞模式下sendfile()会卡住整个 worker非阻塞模式下返回EAGAIN你得等 socket 可写再继续。Swoole 里这个事已经被协程封装好了你可以完全按同步思维写代码不会阻塞其他协程。如果你自己用 FFI 或者原生stream_socket_server()做底层务必把 socket 设为非阻塞然后在事件循环里驱动发送。EINTR则更诡异一个信号就能让系统调用失败。常规解法是循环里判断errno EINTR就重试不要直接报错退出。这个我在 CLI 长驻进程里踩过好多次尤其是pcntl_async_signals一开信号一多不处理 EINTR 的下载代码会莫名中断。5.3 大文件偏移量32位平台的隐形地雷off_t在 32 位系统上是 32 位能表示的字节数上限是 2GB准确说是 2^31-1一旦文件超过 2GBoffset 溢出Range 请求全部错乱。现在 64 位系统是主流但有些 Docker 基础镜像、旧机器仍是 32 位测试时没发现问题生产一上传 5GB 的文件就全崩。我的建议部署前先用php -r var_dump(PHP_INT_SIZE);确认是 864 位。同时检查 Nginx 的编译参数是否支持大文件老版本 Nginx 在 32 位下默认可能打不开超过 4GB 的文件。所有跟文件大小相关的判断统一用filesize()返回的整数别自己拿字符串截断。5.4 Apache环境的等效方案mod_xsendfile没上 Nginx 的团队也不用慌。Apache mod_php 环境有对应的mod_xsendfile模块安装了之后PHP 里设置X-Sendfile头Apache 会直接接管文件发送底层同样走 sendfile 系统调用。用法基本照搬header(X-Sendfile: /data/files/real-file.zip); header(Content-Type: application/octet-stream); header(Content-Disposition: attachment; filenamedownloaded.zip); exit;注意X-Sendfile支持的是绝对路径且要确认mod_xsendfile里配置了可发送的目录白名单否则会把任意文件暴露出去。Apache 模块的安全配置和 Nginxinternal是一个道理都是把“内部文件服务”锁在 PHP 鉴权之后。回到我那个下载服务后来我把下载链路改成了 Nginx X-Accel-RedirectPHP 只保留鉴权和路径映射CPU 从 80% 掉到 30% 左右网卡终于跑到了应有的速度。整个过程最值钱的经验不是某个框架的 API而是理解“数据到底经过哪些手”。以后你遇到任何文件传输、流媒体分发、静态资源性能问题先画一条从磁盘到网卡的数据路径再决定在哪一层做优化方向基本不会错。