让 Nginx 发文件,让 PHP 管权限
个人站长都会遇到一个场景:用户下载的文件放在 /data/private/ 这种不在网站根目录下的路径,访问前必须先判断「这个用户有没有权限」。最常见的做法是用 PHP readfile() 把文件读出来再吐给浏览器,但几百 MB 的文件这样搞,PHP-FPM 进程会被长期占住,内存和带宽全耗在 PHP 上,并发一高整站就卡。
X-Accel-Redirect 解决的正是这个矛盾:让 PHP 只负责鉴权和「开票」,真正的文件传输交给 Nginx。PHP 返回一个特殊的响应头,Nginx 收到后内部跳转到指定位置,用零拷贝把文件发出去,全程不再经过 PHP。本文把这个机制讲透,包括 internal 定位、Range 断点续传、防直连和常见的响应头坑。
原理:一个响应头触发的内部跳转
工作流程是这样的:浏览器请求 /download.php?id=123,PHP 完成鉴权后,不输出文件内容,而是输出一个响应头:
// download.php
if (!user_can_download($id)) {
http_response_code(403);
exit('无权下载');
}
$file_path = '/data/private/' . $safe_name; // 磁盘真实路径
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($file_path) . '"');
header('X-Accel-Redirect: /protected/' . $safe_name); // 内部 URI
exit;Nginx 看到 X-Accel-Redirect 后,会丢掉 PHP 已经输出的内容(所以别在它之前 echo 任何东西),转而用这个 URI 去匹配自己的 location 并返回文件。这个跳转是 Nginx内部完成的,浏览器地址栏不变,外网也永远访问不到 /protected/ 这个路径。
关键:internal location 与别名映射
配套的 Nginx 配置里,最不能少的是 internal 指令——它保证这个 location 只能被内部跳转命中,外网直接请求 /protected/xxx 会得到 404:
server {
root /home/wwwroot/www.example.com;
# 外部永远访问不到,只能被 X-Accel-Redirect 跳进来
location /protected/ {
internal;
alias /data/private/; # 注意结尾斜杠
# 大文件传输可关掉缓冲,直接边读边发
sendfile on;
tcp_nopush on;
}
}alias 是把 URI 前缀映射到磁盘目录,和 root 的区别一定要分清:alias /data/private/ 表示 /protected/a.zip → /data/private/a.zip;如果错写成 root /data/private,实际访问的是 /data/private/protected/a.zip,必然 404。结尾斜杠也必须两边一致,少了斜杠会拼出畸形路径。
Range 断点续传与 Content-Length 的坑
下载大文件最怕不支持续传。X-Accel-Redirect 内部跳转后,Nginx 会接管完整范围的请求处理,所以 Range 是天然支持的——只要 PHP 不要自己设置 Content-Length 或 Accept-Ranges 去「帮忙」。这是最容易踩的坑:
// ✗ 错误:PHP 手动设了长度,会与 Nginx 的实际传输冲突
header('Content-Length: ' . filesize($file_path));
// ✓ 正确:交给 Nginx 自己处理,它知道真实文件大小
header('Content-Disposition: attachment; filename="a.zip"');
header('X-Accel-Redirect: /protected/a.zip');验证续传是否生效,用 curl 发带 Range 的请求看返回码:
# 期望 206 Partial Content,而不是 200
curl -sI -H 'Range: bytes=0-1023' https://www.example.com/download.php?id=1
# 用 grep 看 Content-Range 头
curl -sI -H 'Range: bytes=100-199' https://www.example.com/download.php?id=1 \
| grep -iE 'HTTP/|Content-Range|Accept-Ranges|Content-Length'用户下载限速与并发控制
免费文件下载最怕被人开一堆线程把带宽吃光。可以在 internal 的 location 里直接套 limit_rate,按用户连接限速:
location /protected/ {
internal;
alias /data/private/;
limit_rate 512k; # 单连接限速 512KB/s
limit_rate_after 1m; # 前 1MB 全速,之后限速,改善小文件体验
limit_conn perip 3; # 配合 limit_conn_zone 限制同 IP 并发
}别忘了在 http 块里先定义 zone:
limit_conn_zone $binary_remote_addr zone=perip:10m;这样下载既不会被单连接拖满,也不会被同 IP 的多线程占满带宽。
防盗链与统计:把日志补回来
因为下载不再经过 PHP,你会失去 PHP 层的下载日志。补救办法是在 Nginx 侧记录,或者让 PHP 把信息写进自己的日志文件(鉴权成功时 error_log() 或写一张下载记录表)。如果还想限制 Referer 防止被外站盗链,可以在 internal 之外再加一层校验:
location /protected/ {
internal;
alias /data/private/;
# X-Accel-Redirect 的内部请求 $http_referer 通常为空,
# 真正的来源校验应放在 PHP 鉴权里,不要在这里用 valid_referers 误伤
}这一点很重要:内部跳转的请求头不携带浏览器的 Referer,所以防盗链逻辑必须放在 PHP 端,不能在 internal location 里用 valid_referers,否则会把正常下载全部拦掉。
静默失效排查清单
- 403 / 404:
location少了internal,或alias路径/斜杠写错,或文件属主权限 Nginx 读不了(Nginx worker 用户要有r权限,目录要有x)。 - 下载到 0 字节:PHP 在
header()之前输出了任何内容(包括 BOM、空格、echo),导致X-Accel-Redirect头无效。 - 文件名变成 download.php:忘了设
Content-Disposition,或在 Nginx 侧又加了一次add_header覆盖掉。 - 中文文件名乱码:
Content-Disposition用filename*=UTF-8''编码形式,别直接塞中文。 - PHP-FPM 依然被打满:检查是不是还有别的脚本在用
readfile(),X-Accel-Redirect只对走了这个头的请求生效。
# 快速定位 alias 是否解析正确:看错误日志里的「文件未找到」完整路径
tail -f /var/log/nginx/error.log
# 期望看到 open() "/data/private/a.zip" failed (2: No such file) 这类明确路径进阶:细粒度权限与临时授权链接
把 X-Accel-Redirect 用熟之后,可以做更细的控制。比如「相同文件对不同用户要记不同下载次数」「链接 10 分钟后失效」这类需求,都能在 PHP 鉴权层实现,而 Nginx 依然只负责发文件。核心是让 PHP 生成一个带签名的临时 URI,Nginx 用正则把签名参数剥掉,只留真实文件名。
// PHP: 生成 10 分钟有效的签名下载链接
$expire = time() + 600;
$sign = hash_hmac('sha256', $file . '|' . $expire, $secret_key);
$url = '/download.php?f=' . urlencode($file)
. '&e=' . $expire . '&s=' . $sign;
// download.php 收到请求后校验
if (time() > $_GET['e']) { http_response_code(410); exit('链接已过期'); }
$expected = hash_hmac('sha256', $_GET['f'] . '|' . $_GET['e'], $secret_key);
if (!hash_equals($expected, $_GET['s'])) { http_response_code(403); exit; }用 hash_equals 而不是 == 做签名比对,是为了防时序攻击。这类「带签名的临时链接」是替代「把文件直接放公网目录」的更好方案——文件永远在 internal 路径后面,外网只能通过 PHP 拿到的临时票据访问,票据一过期就失效,也能在数据库里精准记账。
进阶:和 CDN 配合的分流策略
如果文件是热门资源、又想让 CDN 缓存,纯 internal 跳转会让 CDN 每次都回源到 PHP。可以做成双轨:小文件、公共资源放公网目录走 CDN 全量缓存;大文件、私有资源走 X-Accel-Redirect 鉴权下载。用 Nginx 的 map 按扩展名或路径前缀决定走哪条链:
map $uri $is_private {
default 0;
~^/download\.php 1;
~^/protected/ 1;
}
# 公共静态资源允许 CDN 长缓存
location ~* \.(css|js|woff2|png)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# 私有下载禁止任何中间缓存
location /protected/ {
internal;
alias /data/private/;
add_header Cache-Control "private, no-store";
}关键是私有下载一定要加 Cache-Control: private, no-store,否则 CDN 或中间代理可能把「已鉴权用户下载到的文件」缓存下来,让没权限的人也能命中缓存拿到——这是下载鉴权最常见的安全事故。
配合日志与监控排查下载异常
下载走 X-Accel-Redirect 后,出问题时排查路径和普通页面不同,需要盯住两个位置的信息。一是 PHP-FPM 的日志——鉴权是在 PHP 层完成的,403/410 这类拒绝都记在这里;二是 Nginx 的 access.log,它记录了实际的文件传输状态码和字节数。把两者按时间对齐,就能快速判断是「鉴权没通过」还是「文件传输失败」。
# 抓一次下载的完整日志:先在 PHP 写一条,再看 Nginx 的传输记录
tail -f /var/log/nginx/access.log | grep 'protected'
# 典型成功行:GET /protected/a.zip HTTP/1.1" 200 52428800
# 若字节数为 0 或返回 404,多半是 alias 路径或权限问题
# 看是否有大量 206(Range 续传)与 499(客户端主动断开)
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head如果发现大量 499,说明客户端在下载中途断开,通常不是服务器问题,而是用户网络或 limit_rate 限速太狠导致超时放弃。这时候适当放宽 limit_rate 或检查 send_timeout 是否过小。这些现象在纯 PHP readfile() 的方案里也会出现,但因为 PHP 方案本身容易超时,更容易被误判成「PHP 太慢」,改成 X-Accel-Redirect 后症状反而更清晰、更好定位。
和直接放公网目录相比,值不值得
有人会问:既然只是发文件,干嘛不直接把目录放开、省掉 PHP 鉴权?答案取决于文件的性质。公开的图片、CSS、JS,当然应该直接放公网目录吃 Nginx 和 CDN 的缓存;但只要是「需要判断权限」「需要记账」「需要临时链接」的文件——比如付费资源、会员资料、用户上传的私有文档——就绝不能让目录裸奔。裸奔目录的第一大风险是文件名可枚举:攻击者用字典扫描 /data/2024/01.zip、02.zip 这种规律命名,很快就能把整批文件拖走。而走 X-Accel-Redirect 后,文件真实路径完全隐藏在 internal 之后,外网无从枚举。
代价是每个下载请求要多一次 PHP 鉴权的开销。但对于下载这种「低频、大流量」的场景,这点开销和它省下的 PHP 内存占用、带宽消耗相比完全划算——鉴权是轻量的,传输是重量的,把两者分开正是这个方案的精髓。
结语:让每一层只做它擅长的事
X-Accel-Redirect 的价值不是「少写几行代码」,而是把职责拆对:PHP 擅长鉴权和业务判断,Nginx 擅长高效发送文件。让 PHP 读大文件,等于让会计去搬砖;让 Nginx 去查权限,又等于让搬运工去审账。用 X-Accel-Redirect 把两者接起来,是个人站长在文件下载场景里成本最低、收益最直接的一次架构优化。配好 internal、处理好 Range 和 Content-Length,你就能在低配服务器上稳稳支撑大文件下载。