写在前面
前阵子在做一个软件分发系统,本地测试一切丝滑,部署上线第一天就翻车。
用户反馈点击下载按钮后什么都没发生,一看服务器日志:
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.ini里output_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 个 worker | 20 个全可用 |
| 是否需要服务器配置 | 不需要 | 不需要 | 一次性 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 路径配置即可,思路完全一样。
八、安全提醒(很多人栽在这里)
不管用哪种方案,鉴权必须在传文件之前。完整流程:
- 验身份:登录状态 / Token / Session 校验
- 验权限:是否购买、许可证是否有效、是否在有效期内
- 验路径:用
realpath()+str_starts_with()防止路径遍历(../../etc/passwd) - 记统计:下载计数、下载日志
- 传文件:返回响应
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 缓存导致鉴权失效的问题?