PHP 下载 60MB 文件直接 OOM?聊聊 readfile 的坑和我用过的三套方案

6 阅读6分钟

写在前面

前阵子在做一个软件分发系统,本地测试一切丝滑,部署上线第一天就翻车。

用户反馈点击下载按钮后什么都没发生,一看服务器日志:

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 65028096 bytes) in download.php on line 382

128MB 的 memory_limit,一个 60MB 的安装包就给炸了。

第一反应:readfile() 不是流式的吗?怎么会爆内存?

带着这个问题翻了一圈源码和文档,发现这里面坑比想象中深。今天把整个排查过程和三套递进方案分享出来,希望能帮到同样在做文件下载、软件分发、附件存储这类业务的同学。

全文约 3000 字,看完你能彻底搞懂:

  • 为什么 readfile() 在某些环境下会"假流式真缓存"
  • 三种方案分别适合什么场景
  • 一段可以直接抄走的生产级自适应降级代码

一、先抛结论

别用裸的 readfile() 下载大文件——至少不要在有输出缓冲(Output Buffering)的环境里直接调用。

三套方案,从能跑到优雅:

方案难度适用场景
清空 OB 再 readfile应急修复,小文件、低并发
fread 分块流式⭐⭐通用场景,零服务器配置
X-Sendfile / X-Accel-Redirect⭐⭐⭐生产环境,高并发软件分发

下面挨个拆。


二、readfile() 为什么会吃光内存?

很多人(包括踩坑前的我)以为 readfile() 是流式输出,不会占内存。

这个理解只对了一半。

readfile() 的内部实现确实是分块读取的(默认 8KB 一块),但它的输出会经过 PHP 的输出缓冲层。一旦你的环境满足下面任意一条,文件内容就会被完整缓存到内存里:

  • php.inioutput_buffering > 0(默认就是 4096)
  • 框架通过 ob_start() 开了一层或多层缓冲(WordPress、Laravel、ThinkPHP 全是常客)
  • zlib.output_compression = On(Gzip 也要先缓冲再压缩)

WordPress 这种环境,上面三条基本会同时成立

于是悲剧发生了:readfile() 一边按 8KB 读文件,一边把数据一块一块往输出缓冲区塞。文件 60MB,缓冲区也跟着膨胀到 60MB+,再叠加 PHP 自身的运行时开销,分分钟把 128MB 的 memory_limit 顶爆。

本质:不是 readfile() 有 bug,是输出缓冲把"流式传输"变成了"全量缓存"。

搞清楚这一点,下面三套方案就好理解了。


三、Level 1:清空缓冲区 + readfile(最小改动)

最快的应急修复,加几行代码就行:

while (ob_get_level()) {
    ob_end_clean();
}

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($file_path));

readfile($file_path);
exit;

ob_end_clean() 套个 while 是因为框架可能开了多层缓冲,得一层一层关。

优点:改动最小,立刻能下载。

缺点

  • PHP-FPM worker 会被这次下载完整占住,直到客户端接收完
  • 用户网速慢一点(比如 200KB/s 下 100MB),这个 worker 就被锁 8 分钟以上
  • 高并发场景下你的 pm.max_children 会被吃光,整站卡死

适合:内部工具、低频下载、文件不大的场景。


四、Level 2:fread 分块传输(推荐通用方案)

不依赖任何服务器配置,自己控制读取节奏:

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($file_path));

if (ob_get_level()) {
    ob_end_clean();
}

$fp = fopen($file_path, 'rb');
while (!feof($fp)) {
    echo fread($fp, 1048576); // 每次读 1MB
    flush();
}
fclose($fp);
exit;

关键就在 fread($fp, 1048576) + flush() 这两行:每次只把 1MB 推到客户端,立刻 flush 出去,PHP 内存峰值始终稳定在 1MB 左右

100MB 文件?1MB。1GB 文件?还是 1MB。

真实压测数据(同一台 2C4G 机器,下载 150MB 文件):

指标readfile 裸调fread 分块
内存峰值直接 OOM~1.2MB
CPU 占用-8%
下载完成时间失败12s(千兆内网)

但是它还有一个绕不过去的问题:PHP 进程被占用的时间没变。10 个用户同时下载,10 个 worker 就被锁住,跟方案 1 一样。

适合:90% 的常规业务(内网工具、附件下载、中小流量站点)。


五、Level 3:X-Sendfile(终极方案,毫秒级释放 worker)

前面两个方案的死穴都是 PHP worker 在传完文件之前不能干别的事

而 X-Sendfile 的思路非常硬核:

PHP 只做鉴权 + 统计(毫秒级),文件传输甩给 Apache / Nginx。

PHP 代码极其简洁:

$this->check_permission($user, $package);
$this->increment_download_count($package_id);

header('X-Sendfile: ' . $file_path);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
exit; // PHP 瞬间退出,worker 立即被释放

Apache 收到 X-Sendfile 响应头后,会拦截这个响应、读取头里指定的文件路径,然后用内核的 sendfile() 系统调用做零拷贝传输——数据直接从磁盘 page cache 到网卡 DMA,完全不经过用户态内存,更不经过 PHP。

服务器端只要一次性配置:

sudo apt install libapache2-mod-xsendfile
sudo a2enmod xsendfile

然后在 VirtualHost 里加两行(注意:不能写在 .htaccess,必须主配置):

XSendFile On
XSendFilePath /var/www/your-site/secure-packages
sudo systemctl restart apache2

Nginx 用户对应的指令是 X-Accel-Redirect,原理和写法基本一致,配合 internal 标记的 location 使用。


六、三套方案性能对比

一张表说明所有问题:

指标readfile 裸调fread 分块X-Sendfile
PHP 内存峰值(150MB 文件)150MB+ → OOM~1MB~0MB
PHP worker 占用时长整个下载过程整个下载过程< 10ms
10 并发(max_children=20)直接崩剩 10 个 worker20 个全可用
是否需要服务器配置不需要不需要一次性 2 行
断点续传支持需手写 Range需手写 Range原生支持
适用规模玩具项目中小流量生产级分发

差距就是这么直观。如果你的业务是软件分发、视频下载、大附件,方案三基本是唯一答案。


七、生产级代码:自适应降级

真实世界里你没法保证每台服务器都装了 mod_xsendfile,最佳实践是先探测再选路

public function serve_file($file_path, $filename)
{
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    header('Content-Length: ' . filesize($file_path));
    header('Cache-Control: no-cache, must-revalidate');

    if (function_exists('apache_get_modules')
        && in_array('mod_xsendfile', apache_get_modules(), true)) {
        header('X-Sendfile: ' . $file_path);
        exit;
    }

    if (ob_get_level()) {
        ob_end_clean();
    }
    $fp = fopen($file_path, 'rb');
    while (!feof($fp)) {
        echo fread($fp, 1048576);
        flush();
    }
    fclose($fp);
    exit;
}

这段代码扔到任何 PHP 环境都能跑:有 mod_xsendfile 走最优路径,没有就降级到 fread 分块,不崩、不报错、不需要改业务代码

如果你跑在 Nginx + PHP-FPM 上,把第一段判断换成检测 X-Accel-Redirect 路径配置即可,思路完全一样。


八、安全提醒(很多人栽在这里)

不管用哪种方案,鉴权必须在传文件之前。完整流程:

  1. 验身份:登录状态 / Token / Session 校验
  2. 验权限:是否购买、许可证是否有效、是否在有效期内
  3. 验路径:用 realpath() + str_starts_with() 防止路径遍历(../../etc/passwd
  4. 记统计:下载计数、下载日志
  5. 传文件:返回响应

X-Sendfile 不会绕过你的权限检查——它只是把第 5 步从"PHP 读文件"变成"通知 Apache 读文件",前 4 步一步都不能少。

另外,存放敏感文件的目录必须放在 Web 根目录之外,或者用 Apache .htaccess / Nginx internal 指令禁止直接 HTTP 访问。我见过太多人把付费安装包丢在 /wp-content/uploads/ 下,整个文件被 Google 索引爬走,眼泪都来不及流。


九、写在最后

PHP 做文件下载这个坑,几乎每个做过数字商品分发、SaaS 付费下载、企业内网附件系统的同学都会踩一遍。核心原则就一句话:

让 PHP 做业务逻辑,让 Web 服务器做文件传输。

我自己也是因为做软件销售业务才把这些坑踩得比较彻底——除了文件下载,还有授权激活、订单回调、版本升级推送、多语言定价等一堆乱七八糟的工程问题。后来干脆把这些通用能力抽出来做成了一个 WordPress 插件方案(基于这套 X-Sendfile 架构做的安全分发),如果你也在搞独立软件销售或数字商品分发,可以省不少重复造轮子的时间。具体就不在这里展开了,感兴趣的同学评论区戳我。


最后留个话题

  • 你在 PHP 下载这块踩过什么印象深刻的坑?
  • 用 Nginx 的同学可以分享下你们 X-Accel-Redirect 的配置写法
  • 大文件下载有没有遇到过 CDN 缓存导致鉴权失效的问题?