Nginx X-Accel-Redirect 实战:让 PHP 只管鉴权、Nginx 零拷贝发文件,含 Range 续传与防盗链

让 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,你就能在低配服务器上稳稳支撑大文件下载。

Last modification:October 3rd, 2026 at 10:28 pm

Leave a Comment