提示

返回博客列表

大文件下载别让 Django 扛:X-Accel-Redirect、断点续传与限速的完整方案

我们有个功能:用户解析完视频后可以把它下载到本地。早期实现很直白——Django 视图读文件,用 FileResponse 返回。

小文件没问题,直到有人下了一个 2GB 的合集。那天的情况是:gunicorn 的一个 worker 被这个请求占住了 40 多分钟(用户那边带宽慢),同一时间又来了三四个下载请求,剩下的 worker 全被占满,整个站点的页面都打不开了。更糟的是内存——某个版本里我用了 HttpResponse(open(f, 'rb').read()),直接把 2GB 读进内存,OOM 把进程干掉了。

后来改成 nginx 的 X-Accel-Redirect,同样的请求:Django 只花 5 毫秒做鉴权然后返回,文件由 nginx 直接发给用户。内存从 800MB 降到 20MB,worker 瞬间释放。

这篇文章写完整的做法:配置怎么配、鉴权怎么保住(毕竟文件不让人随便下是前提)、断点续传和限速怎么实现、以及那些配置对了才不会踩的坑。

TL;DR:Django 只负责鉴权和生成响应头,通过 X-Accel-Redirect 响应头把文件路径交给 nginx,由 nginx 用 sendfile 零拷贝发给客户端。好处:Python worker 瞬间释放、内存几乎不占、Range 请求(断点续传)由 nginx 原生支持、限速用 X-Accel-Limit-Rate 一行搞定。三个必须注意的点:nginx 的 location 要加 internal(否则用户能绕过鉴权直接访问);路径里的中文/空格要 URL 编码;统计下载量只能靠 nginx 日志,Django 不知道用户到底下了多少字节。

目录

一、为什么 Django 直接发文件不行

先看这个(错误)实现有多普遍:

# 千万别这么写
def download(request, task_id):
    path = get_file_path(task_id)
    with open(path, 'rb') as f:
        return HttpResponse(f.read(), content_type='application/octet-stream')

2GB 文件 = 2GB 内存(而且 Python 的 bytes 还有额外开销,实际可能 4GB)。并发两三个就 OOM。

换成 FileResponse 好一些:

from django.http import FileResponse

def download(request, task_id):
    path = get_file_path(task_id)
    response = FileResponse(open(path, 'rb'), as_attachment=True, filename=name)
    return response

FileResponse 是流式的,不会一次读入内存。它在 WSGI 层会尝试用 wsgi.file_wrapper,如果服务器支持(uWSGI 支持 sendfile),能走到内核的零拷贝。但问题是:

  1. worker 依然被占住。文件发多久,worker 就被占多久。同步 worker 模式下,一个 40 分钟的下载 = 一个 worker 废了 40 分钟。
  2. gunicorn 默认不走 sendfile。gunicorn 有 --no-sendfile 选项(默认其实是禁用 sendfile 的),实际上多半还是 Python 循环读块再写出去。
  3. 限速、断点续传要自己实现。Range 请求的解析、206 响应、Content-Range 头,全得手写。
  4. 超时难配。nginx 的 proxy_read_timeout 要设得比最长下载时间还长,否则下到一半被掐断。

本质问题是:发文件是 IO 密集的长连接任务,而应用服务器的 worker 是稀缺资源。让 Python 进程去搬运字节,是拿最贵的资源干最便宜的活。

正确分工:

谁 干什么
Django 鉴权、判断能不能下、生成响应头
nginx 真正把字节发给用户(sendfile、Range、限速)

二、X-Accel-Redirect 是什么

机制很简单:

  1. 客户端请求 GET /download/123/;
  2. Django 处理:检查权限、找到文件;
  3. Django 不返回文件内容,而是返回一个特殊的响应头:

X-Accel-Redirect: /protected/2026/03/abc.mp4

  1. nginx 看到这个头,会在内部把请求重定向到 /protected/2026/03/abc.mp4 这个 location,用该 location 的配置去读文件并发送给客户端;
  2. 客户端全程只看到一次请求,没有 302,URL 也没变。

这就叫"内部重定向"(internal redirect)。关键点:

  • /protected/ 这个 location 配了 internal;,外部直接访问会返回 404;
  • 所以用户必须先经过 Django 的鉴权视图才能拿到文件;
  • 文件读取、Range、限速全由 nginx 处理,Django 已经下班了。

可用的相关响应头:

响应头 作用
X-Accel-Redirect 内部重定向的目标 URI
X-Accel-Limit-Rate 限速(字节/秒),0 表示不限
X-Accel-Buffering yes/no,是否启用代理缓冲
X-Accel-Expires 控制 nginx 缓存行为
X-Accel-Charset 字符集

顺带一提,这个机制不是 nginx 独有的——它来源于 lighttpd 的 X-Sendfile,Apache 有 X-Sendfile、IIS 也有类似机制,思路都一样。

三、完整配置:nginx + Django

nginx 侧

upstream django {
    server 127.0.0.1:8000;
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;
    client_max_body_size 100m;      # 上传限制(跟下载无关,但常被一起配)

    # 业务请求转发给 Django
    location / {
        proxy_pass http://django;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }

    # 内部文件位置:只能被 X-Accel-Redirect 访问
    location /protected/ {
        internal;                        # ← 关键:外部访问返回 404
        alias /data/downloads/;          # 注意结尾的斜杠
        # sendfile 相关
        sendfile on;
        sendfile_max_chunk 1m;           # 分块发送,避免单个连接占满 IO
        tcp_nopush on;
        # 限速(可被 X-Accel-Limit-Rate 覆盖)
        limit_rate 2m;
        limit_rate_after 10m;            # 前 10MB 不限速(让小文件秒开)
        # 大文件下载超时放宽
        proxy_read_timeout 3600s;        # 这个 location 里其实不生效,下面会讲
    }
}

alias 和 root 的区别(这里是最容易配错的地方):

# alias:把 location 前缀【替换】成指定路径
location /protected/ {
    alias /data/downloads/;
}
# 请求 /protected/a/b.mp4 → 文件 /data/downloads/a/b.mp4

# root:把 location 前缀【拼接】到指定路径后
location /protected/ {
    root /data;
}
# 请求 /protected/a/b.mp4 → 文件 /data/protected/a/b.mp4

用 alias 时,location 前缀和 alias 路径的结尾斜杠要一致(都有或都没有),否则会拼出 /data/downloadsa/b.mp4 这种路径。这是个经典错误。

Django 侧

import os
import urllib.parse
from django.http import HttpResponse
from django.contrib.auth.decorators import login_required

PROTECTED_PREFIX = '/protected/'


@login_required
def download(request, task_id):
    task = get_object_or_404(DownloadTask, id=task_id)

    # 1. 鉴权(能不能下这个文件)
    if task.user != request.user and not request.user.is_staff:
        return HttpResponseForbidden('没有权限')

    # 2. 文件在不在
    if not task.file_path or not os.path.exists(task.file_path):
        return HttpResponseNotFound('文件已过期')

    # 3. 转成相对于 alias 根目录的路径
    rel_path = os.path.relpath(task.file_path, '/data/downloads')
    # 4. 必须 URL 编码(中文、空格、# 等字符)
    uri = PROTECTED_PREFIX + urllib.parse.quote(rel_path.replace(os.sep, '/'))

    response = HttpResponse()          # 空 body
    response['X-Accel-Redirect'] = uri
    response['Content-Type'] = 'application/octet-stream'
    response['Content-Disposition'] = content_disposition(task.filename)
    # 限速:每秒 1MB(0 表示不限)
    response['X-Accel-Limit-Rate'] = str(1024 * 1024)
    # 大文件不要缓冲
    response['X-Accel-Buffering'] = 'no'
    return response

文件名编码是个大坑。Content-Disposition 里放中文文件名,直接写会乱码(HTTP 头要求是 ASCII)。正确做法是 RFC 5987:

def content_disposition(filename: str, attachment: bool = True) -> str:
    """生成兼容各浏览器的 Content-Disposition。"""
    import urllib.parse
    ascii_name = filename.encode('ascii', 'ignore').decode() or 'download'
    quoted = urllib.parse.quote(filename)
    kind = 'attachment' if attachment else 'inline'
    return f"{kind}; filename=\"{ascii_name}\"; filename*=UTF-8''{quoted}"

filename="xxx" 是给老浏览器的 ASCII 回退,filename*=UTF-8''xxx 是标准格式。两个都给,覆盖最广。

注意 HttpResponse() 是空的——不要给它传内容,也不要设 Content-Length,否则 nginx 处理 X-Accel-Redirect 时行为会怪。

四、鉴权:文件不能让人随便下

这是整个方案里最需要想清楚的部分:文件发送环节没有 Python 参与了,鉴权必须在返回 X-Accel-Redirect 之前完成。

三种常见方案:

方案 1:会话鉴权(最常用)

就是上面的例子:@login_required + 检查 task.user == request.user。简单、直观,适合"登录后下载自己文件"的场景。

方案 2:签名链接(一次性/限时)

适合"把链接发给别人"、"生成下载直链"的场景:

import time
import hmac
import hashlib
from django.conf import settings


def make_signed_url(task_id: str, expire_in: int = 3600) -> str:
    expires = int(time.time()) + expire_in
    payload = f'{task_id}:{expires}'
    sig = hmac.new(
        settings.DOWNLOAD_SECRET.encode(),
        payload.encode(),
        hashlib.sha256,
    ).hexdigest()[:32]
    return f'/download/{task_id}/?expires={expires}&sig={sig}'


def verify_signed_url(task_id: str, expires: str, sig: str) -> bool:
    try:
        if int(expires) < time.time():
            return False                      # 过期
    except (TypeError, ValueError):
        return False
    payload = f'{task_id}:{expires}'
    expected = hmac.new(
        settings.DOWNLOAD_SECRET.encode(),
        payload.encode(),
        hashlib.sha256,
    ).hexdigest()[:32]
    return hmac.compare_digest(expected, sig)  # 恒定时间比较,防时序攻击

用 hmac.compare_digest 而不是 ==,因为字符串比较会在第一个不同字符处提前返回,理论上可以通过响应时间逐字节猜出正确签名。

签名链接的特点是:链接本身不依赖登录态,但有过期时间。要注意的是——签名链接一旦发出去就无法撤回(除非你把已签发的状态存在服务端),所以对敏感文件,宁可用会话鉴权。

nginx 自带 secure_link 模块,可以在 nginx 层完成验签,连 Django 都不用经过:

location /protected/ {
    internal;
    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri mysecret";
    if ($secure_link = "") { return 403; }
    if ($secure_link = "0") { return 410; }   # 过期
    alias /data/downloads/;
}

好处是性能最好(不过应用);代价是验签逻辑分散在 nginx 配置里,调试麻烦,密钥管理也不如在 Django 里方便。我只在"纯静态文件 + 高并发"的场景用过。

最容易被忽略的一点:internal 一定要配。忘了它,用户可以:

GET /protected/2026/03/abc.mp4     ← 直接下载,绕过所有鉴权

这是实打实的越权漏洞。我建议上线前必须做这个测试:

curl -I https://example.com/protected/test.mp4
# 期望:404(因为 internal)
# 如果是 200 → 配置错了,立刻修

五、断点续传:Range 请求交给 nginx

好消息:nginx 原生支持 Range 请求,什么都不用配。客户端发:

GET /download/123/ HTTP/1.1
Range: bytes=1048576-2097151

nginx 返回:

HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-2097151/2147483648
Content-Length: 1048576
Accept-Ranges: bytes

这一条就让 X-Accel-Redirect 比"自己实现"省了几十行代码。自己实现要处理:

  • 解析 Range 头(可能是 bytes=0-、bytes=-500、bytes=0-0,-1 多段);
  • 边界检查(超过文件大小要返回 416);
  • 206 状态码 + Content-Range + Content-Length;
  • If-Range 头的语义(文件变了要返回全量而不是部分)。

唯一要注意的:Django 视图里不要自己做 Range 处理。如果你在 Django 里返回一个"我处理过 Range"的响应,nginx 就不会再处理了,容易两头都没做对。

如果你想禁用 Range(比如强制完整下载以便计数):

location /protected/ {
    internal;
    alias /data/downloads/;
    # 隐藏 Accept-Ranges 头,客户端就不会发 Range 请求
    more_clear_headers 'Accept-Ranges';    # 需要 headers-more 模块
}

我不推荐禁用——断点续传对大文件下载体验太重要了,尤其是移动端网络不稳定的时候。

顺带说一句:nginx 日志里 Range 请求的状态码是 206,做统计的时候别只统计 200。

六、限速:全局、按用户、按连接

限速有三个层次:

1. 单请求限速(X-Accel-Limit-Rate)

response['X-Accel-Limit-Rate'] = str(512 * 1024)    # 512 KB/s

单位是字节/秒。设为 0 表示不限速。适合按用户等级限速:

LIMITS = {'free': 300 * 1024, 'vip': 2 * 1024 * 1024, 'staff': 0}

def download(request, task_id):
    ...
    limit = LIMITS.get(request.user.level, 300 * 1024)
    response['X-Accel-Limit-Rate'] = str(limit)

2. nginx 层的全局限速

location /protected/ {
    internal;
    alias /data/downloads/;
    limit_rate 1m;              # 每连接 1MB/s
    limit_rate_after 20m;       # 前 20MB 不限速(小文件秒开、大文件才限)
}

limit_rate_after 这个设计很贴心:前 N 字节不限速,让用户马上有反馈,之后才开始限。我一般设 10~20MB。

3. 并发连接与请求频率限制

http {
    limit_conn_zone $binary_remote_addr zone=perip:10m;
    limit_req_zone  $binary_remote_addr zone=reqperip:10m rate=10r/s;

    server {
        location /download/ {
            limit_conn perip 3;              # 单个 IP 最多 3 个并发下载
            limit_req zone=reqperip burst=20 nodelay;
            proxy_pass http://django;
        }
    }
}

按 IP 限制有个大坑:如果用户在 NAT 后面(公司、学校、移动网络),一个 IP 可能是几百个用户,limit_conn perip 3 会把他们全挡住。所以:

  • 并发限制要宽松(3~5 个),或者只对"未登录用户"严格限制;
  • 更好的做法是按用户 ID 限(在 Django 里用 Redis 计数),IP 限制只用来挡明显的 abuse。

我在 Django 侧加的用户级并发限制:

from django.core.cache import cache

active = cache.get(f'user_dl:{request.user.id}', 0)
if active >= 3:
    return HttpResponse('同时下载数已达上限', status=429)
cache.incr(f'user_dl:{request.user.id}')        # 视图结束时 decr

别忘了在文件发完之后减回去——但问题是 Django 早就返回了,根本不知道用户什么时候下完。我的做法是按时间窗口:incr 的时候顺便设 10 分钟过期,配合 limit_rate 保证 10 分钟内正常能下完。这是个近似方案,够用。

七、缓冲、超时与那些"大文件专属"参数

几个跟大文件下载强相关的 nginx 参数:

location /protected/ {
    internal;
    alias /data/downloads/;

    sendfile on;                 # 零拷贝:内核直接把文件发给 socket,不经过用户态
    sendfile_max_chunk 1m;       # 每次最多发 1MB,避免单个连接独占 worker
    tcp_nopush on;               # 配合 sendfile,减少小包
    tcp_nodelay on;              # 小响应更快(跟 nopush 一起用没问题)
    aio threads;                 # 可选:异步 IO,高并发下更平滑
    output_buffers 4 256k;       # 输出缓冲
    open_file_cache max=1000 inactive=30s;   # 缓存文件描述符(慎用,下面说)
}

sendfile_max_chunk 很重要。不设的话,一个大文件下载会占住 nginx worker 直到发完——虽然不像 Python worker 那么贵,但也会导致其他请求排队。设成 1m 后,nginx 会分批发,中间可以处理别的连接。

open_file_cache 要小心:它缓存文件描述符和文件信息,对频繁访问的静态文件有好处,但对"下载完就被删除"的临时文件是灾难——缓存了已经删除的文件信息,导致后续请求拿到错误的大小或 404。我们的文件生命周期很短,所以没开。

超时:proxy_read_timeout 是 nginx 等上游(Django)响应的超时,跟发文件无关(发文件是 nginx 自己干,不走 proxy)。但业务 location 里还是建议设长一点,避免 Django 处理慢的时候被掐:

location / {
    proxy_read_timeout 60s;
    ...
}

X-Accel-Buffering: no:如果你的 Django 前面还有一层代理(比如 CDN、或者 nginx 套 nginx),缓冲层会把整个响应(包括 nginx 发出去的文件)缓存下来再发给客户端——2GB 的文件就变成了 2GB 的磁盘缓冲。加这个头可以禁用缓冲。

八、下载量统计:Django 帮不了你

用了 X-Accel-Redirect 之后,一个直接的后果是:Django 不知道用户到底下载了多少字节、甚至不知道有没有下完。它只知道"我批准了这次下载"。

想统计下载量,只剩两条路:

路 1:分析 nginx 日志(推荐)

配置一个专门的日志格式:

log_format download '$remote_addr [$time_local] "$request" '
                    '$status $body_bytes_sent $request_time '
                    '"$http_range" "$http_user_agent"';

server {
    location /protected/ {
        internal;
        alias /data/downloads/;
        access_log /var/log/nginx/download.log download;
    }
}

然后异步分析(别在请求里做):

import re

LINE = re.compile(
    r'(?P<ip>\S+) \[(?P<time>[^\]]+)\] "(?P<req>[^"]+)" '
    r'(?P<status>\d+) (?P<bytes>\d+) (?P<rt>[\d.]+) '
    r'"(?P<range>[^"]*)" "(?P<ua>[^"]*)"'
)

def parse_download_log(path):
    """汇总每个 URL 的下载字节数(206 要累加,200 是全量)"""
    stats = {}
    with open(path, encoding='utf-8', errors='replace') as f:
        for line in f:
            m = LINE.match(line)
            if not m:
                continue
            uri = m.group('req').split(' ')[1]
            stats[uri] = stats.get(uri, 0) + int(m.group('bytes'))
    return stats

注意 206 的部分请求要累加,同一个文件可能被分几十次 Range 请求下完。

路 2:下载完成后前端回调

前端拿到文件后(或者用 JS 的 fetch 拿到 blob 后)打一个点:

fetch('/api/download/complete/', {
    method: 'POST',
    body: JSON.stringify({task_id}),
});

简单,但不可靠(用户可以禁用 JS、可以直接用 curl 下载、可以中断)。适合"大致统计",不适合计费。

我的选择:路 1 做准确统计,路 2 做实时反馈。两者对不上的时候(一般差 10~20%,因为中断下载),以路 1 为准。

九、实测对比

同一台机器(4 核 8G),下一个 1.2GB 的文件,测三种实现:

方案 Django 内存峰值 worker 占用时间 10 并发时站点响应 断点续传
HttpResponse(f.read()) 2.4 GB(OOM) 整个下载期间 不可用 不支持
FileResponse 45 MB 整个下载期间 严重变慢(worker 耗尽) 要自己实现
X-Accel-Redirect 18 MB ~5 ms 无影响 nginx 原生支持

CPU 方面更明显:10 个并发下载时,FileResponse 方案的 Python 进程占了 60% CPU(全在数据搬运),X-Accel 方案 Python 进程基本是 0,nginx 用 sendfile 也就 3~5%。

顺便说一句,内存从 45MB 降到 18MB 不是因为文件不读了,而是因为 Django 进程压根没参与。18MB 是 Django 本身的基础开销。

十、坑清单

  1. internal 忘了配 → 越权漏洞,任何人能直接下所有文件。上线前必须 curl 测一次。
  2. alias 和 root 搞混 → 404 或者路径拼错(root 会把 location 前缀也拼进去)。
  3. alias 斜杠不一致 → /data/downloadsa/b.mp4 这种路径。
  4. 路径里有中文/空格没 URL 编码 → nginx 匹配不到,返回 404。Django 端要 urllib.parse.quote()。
  5. Content-Disposition 里直接写中文 → 下载的文件名乱码。用 filename*=UTF-8''。
  6. 给 HttpResponse 传了内容 → nginx 的 X-Accel-Redirect 行为异常。必须是空响应。
  7. 自己处理了 Range → 和 nginx 的处理打架。交给 nginx。
  8. 统计只算 200 → 漏掉了所有 206 的分片请求,下载量严重偏小。
  9. 按 IP 限并发太紧 → NAT 后面的用户全被挡。按用户限,IP 限制只挡 abuse。
  10. 开了 open_file_cache → 缓存了已删除文件的信息,后续请求拿错数据。
  11. sendfile_max_chunk 没设 → 单个大下载占住 nginx worker。
  12. proxy_read_timeout 设太短 → Django 鉴权慢一点就被掐(虽然发文件不走 proxy,但鉴权走)。
  13. 签名链接用 == 比较 → 时序攻击风险。用 hmac.compare_digest。
  14. 签名链接当唯一鉴权 → 链接泄露后无法撤回。敏感文件用会话鉴权。
  15. 忘了 X-Accel-Buffering: no(前面还有代理/CDN 时)→ 2GB 文件被缓冲到代理的磁盘上。
  16. 文件被清理了但数据库还留着记录 → Django 返回 X-Accel-Redirect,nginx 404。视图里要先 os.path.exists()。

说点总结。

X-Accel-Redirect 这个技术本身非常简单——就是一个响应头,配置加起来不到十行。但它体现了架构设计里一个很重要的原则:让合适的组件做合适的事。

Python 应用服务器的强项是业务逻辑:鉴权、权限判断、数据组装。它的弱项是搬运字节——每个 worker 都是珍贵的、有内存开销的、启动一次要加载整个 Django 的进程。而 nginx 的强项恰恰是高并发 IO:sendfile、事件驱动、几万个连接都不在话下。

所以判断标准也很简单:如果一个请求的主要工作是"把一堆字节从 A 搬到 B",那它就不该由应用服务器来做。这个标准同样适用于静态资源、大文件下载、甚至某些"生成后不变"的动态内容。

最后提醒一次:internal 一定要配,上线前一定要 curl 验证。这是整个方案里唯一一个配错了会造成安全后果的地方,值得花三十秒测一下。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部