带宽被大文件吃光?三层限速方案

从一个 PDF 拖慢整条链路说起

去年有个客户找我,说他们的个人博客一到晚上就慢,前端资源几百 KB、数据库也不大,可首页首字节时间稳定在 4 秒以上。我上去看了一轮 Nginx 访问日志,发现一个很怪的现象:白天带宽曲线几乎贴着 20Mbps 的天花板,但日志里请求数并不高;故障时段并发连接数不到 40,CPU、内存、磁盘 IO 全部正常。

最后定位到一篇半年前的教程,里面贴了个 60MB 的 PDF 做附件。作者自己在站内做了链接,搜索引擎也收录了那个直链,于是每天有几十个爬虫和下载工具在并发拉这个文件。一个 60MB 的文件,10 个人同时下就是把整个上行带宽吃光——同机上的动态页面自然卡到不能看。

这个案例说明一件事:小站的带宽问题,往往不是"带宽不够",而是"带宽被吃掉的同时没有边界"。本文把我这些年处理过的带宽问题整理成一套可落地的限速方案:先量出真实占用,再在 Nginx 层做分文件类型、分来源、分时段的三层限速,最后配上日志与告警,让问题在用户投诉之前就暴露。全文以 Debian 12 + Nginx 1.22 + PHP-FPM 为例,所有命令都经过实测。

第一步:先量出带宽到底被谁吃掉了

动手改配置之前,必须先有一份"带宽归因表"。没有它,限速就是在猜——限得太松没效果,限得太紧把正常用户挡在门外。

看总出方向的实时流量

sar -n DEV 1 5
# 只关心对外的网卡,例如 eth0 或 ens3
# 关键列:rxkB/s 收、txkB/s 发(单位 KB/s,乘 8 得 kbps)

如果是突发性故障,`sar` 只能看到当下的平均值,建议用长期数据回看:

sar -n DEV -f /var/log/sysstat/sa$(date -d yesterday +%d) | head -40

我把带宽告警阈值定在"日常峰值的 70%":一旦持续 3 分钟超过这个值,说明有异常流量进来,这时候限速策略会立刻生效,而不是等带宽彻底打满。

按文件大小和状态码做归因

Nginx 默认日志不带响应字节数,先确认 `log_format` 里有 `$body_bytes_sent`:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" $request_time';

然后用一条 awk 聚合出"谁在占带宽":

awk '{s[$7]+=$10; n[$7]++} END{for(k in s) print s[k]/1048576" MB\t"n[k]"\t"k}' \
  /var/log/nginx/access.log | sort -rn | head -20

输出的第一列是 MB,第二列是请求次数,第三列是 HTTP 状态码。通常会出现两个典型模式:状态码 200 且请求数极少但 MB 极大——大文件下载;状态码 206 且请求数极多——断点续传或视频拖动。这两种要分开处理。

再按 URL 排序一次,找到具体的"带宽大户":

awk '{s[$7" "$9]+=$10} END{for(k in s) print s[k]/1048576" MB\t"k}' \
  /var/log/nginx/access.log | sort -rn | head -20

这一步的产出就是后续限速规则的依据。我一般的分类是:图片(建议走 CDN,源站只做兜底)、音视频与 PDF(必须限速+防盗链)、前端静态资源(该长缓存+压缩)、动态请求(限并发而不是限速)。

第二步:Nginx limit_rate 分文件类型限速

Nginx 的 `limit_rate` 作用在"响应发送"阶段,对已经连上的用户做字节级限速,几乎不影响内存和 CPU。它是治"大文件把带宽吃光"最直接的工具。

用 map 给扩展名打上速率标签

map $sent_http_content_type $rate_for_type {
    default          0;          # 0 表示不限速
    ~*^video/        512k;
    ~*^audio/        512k;
    ~*^application/pdf  384k;
    ~*^image/        1m;
}

server {
    listen 443 ssl http2;
    server_name www.example.com;

    # 关键:limit_rate 只对单连接生效,多人同时下仍会叠加占满带宽
    limit_rate $rate_for_type;
    # 前 1MB 不限速,让页面首屏资源能快速到位
    limit_rate_after 1m;

    location ~* \.(mp4|webm|mp3|pdf|zip)$ {
        limit_rate 384k;
        limit_rate_after 512k;
        add_header Accept-Ranges bytes;
    }
}

两个细节特别容易踩:第一,`limit_rate` 是单连接限速,如果用户用 8 线程下载工具,实际占用仍是 8 倍速率;第二,`limit_rate_after` 应当是"让首屏先到"的字节数,设太大等于没限速,设成 0 又会让图片站点首屏变慢。我的经验值是静态页面 512KB、图片 256KB。

把带宽上限放到"每个 IP 的总量"上

要挡住多线程下载工具,得用 `limit_conn` 限制同一 IP 的并发连接数,两者配合才完整:

limit_conn_zone $binary_remote_addr zone=addr_per_ip:10m;
limit_req_zone  $binary_remote_addr zone=req_per_ip:10m rate=5r/s;

server {
    # 同一 IP 对下载目录最多 2 个并发连接
    location ~* \.(mp4|mp3|pdf|zip|iso)$ {
        limit_conn addr_per_ip 2;
        limit_conn_status 429;
        limit_rate 384k;
        limit_rate_after 512k;

        # 防盗链:只允许本站与搜索引擎引用
        valid_referers none blocked server_names *.example.com;
        if ($invalid_referer) { return 403; }
    }

    # 普通页面限制突发请求,防爬虫扫站
    location / {
        limit_req zone=req_per_ip burst=20 nodelay;
        limit_req_status 429;
    }
}

注意 `limit_conn_status` 默认是 503,前端会显示"服务不可用",用户以为站挂了;改成 429 语义准确得多,也便于日志统计。同样的道理,`limit_req_status` 也要显式改掉。

第三步:按来源和时段做差异化,别一刀切

限速最怕"把搜索引擎也限死"。百度、Google 的爬虫抓取速度被压下来以后,收录会明显变慢,这对个人站长是长期的隐性损失。所以规则必须能识别来源。

爬虫单独一档,宽松但限并发

map $http_user_agent $is_crawler {
    default                                   0;
    ~*(baiduspider|googlebot|bingbot|360spider|sogou)   1;
}

map $is_crawler $crawler_rate {
    1    1m;
    0    384k;
}

location ~* \.(pdf|mp4|mp3)$ {
    limit_rate $crawler_rate;
    limit_conn addr_per_ip 2;
    limit_req  zone=req_per_ip burst=50 nodelay;
}

爬虫给 1M/s 的单连接速率、50 的突发额度,既不会拖垮带宽,也不至于让收录停滞。这里有个容易忽略的点:不要给爬虫做全站限速,只对大文件限速。HTML 页面本身只有几十 KB,限速只会让抓取变慢且毫无收益。

夜间批量任务用时段矩阵分流

小站通常还要跑备份上传、图片同步、抓取数据这些批处理任务。它们和白天的访问抢同一条上行链路,必须分时段:

# /etc/nginx/conf.d/traffic.conf
map $time_iso8601 $hour_bucket {
    ~T(0[0-6]):    off_peak;
    default        peak;
}

map $hour_bucket $batch_rate {
    off_peak   0;
    peak       256k;
}

server {
    location /batch/ {
        # 白天只有 256k/s,凌晨不限速
        limit_rate $batch_rate;
        # 内网白名单不受限
        allow 10.0.0.0/8;
        deny  all;
    }
}

用 `$time_iso8601` 判断小时比用 `$time_hour` 更可靠,后者在部分旧版本上取的是本地时间字符串,跨时区会错位。如果服务器时区不是 UTC+8,先在 `/etc/systemd/system/nginx.service.d/` 或 `nginx.conf` 里确认时区一致性,否则"凌晨"会算在错误的小时上。

第四步:让超过限额的人"慢但能看",而不是直接 404

个人站点的流量成本要算,但用户体感更不能丢。我的原则是:限速优先于限连,限连优先于拒绝,拒绝优先于 502。

Nginx 本身不支持"限速降到 XX 并返回提示页"的组合,但可以用 `limit_rate` 加 `error_page` 变通:

limit_conn_zone $binary_remote_addr zone=dl:10m;

server {
    location /download/ {
        limit_conn dl 1;
        limit_conn_status 429;
        limit_rate 256k;
        limit_rate_after 256k;
    }

    # 429 返回一个说明页,而不是默认错误页
    error_page 429 /429.html;
    location = /429.html {
        internal;
        return 200 "当前下载人数较多,已为你降速,请稍候重试。\n";
        add_header Content-Type text/plain;
    }
}

用 `return 200` 加 `internal` 是最省资源的写法——不读磁盘、不打日志(`internal` 的内部跳转默认不进 access_log,具体取决于版本,实测 1.22 会记录一条)。如果站点有静态的"稍候"页面,也可以 `root` 指过去并 `try_files`。

另一个必须做的兜底:给 `proxy_pass` 的上游加超时,避免 PHP-FPM 长时间占着连接导致连接数耗尽:

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_read_timeout 15s;
    fastcgi_send_timeout 15s;
    fastcgi_buffers 16 16k;
    fastcgi_busy_buffers_size 32k;
}

`fastcgi_read_timeout` 从默认 60s 降到 15s,对博客类站点足够,同时对"慢查询把 FPM 子进程全占满"的场景是一道有效的闸门。

第五步:把限速规则变成可观测的数字

规则写完不算完,得能证明它有效。我一般在日志里加两个变量:

log_format throttle '$remote_addr [$time_local] "$request" $status '
                    'bytes=$body_bytes_sent rt=$request_time '
                    'lim=$limit_rate conns=$connection ';

然后用一条命令统计"被限速的请求占比"和"限速之后带宽是否下降":

# 每小时被 429 拒绝的比例
grep -c '" 429 ' /var/log/nginx/access.log
# 单 IP 的平均出站字节(发现异常大户)
awk '{s[$1]+=$10; n[$1]++} END{for(k in s) if(s[k]>104857600) print k, s[k]/1048576" MB", n[k]}' \
  /var/log/nginx/access.log | sort -k2 -rn | head

我还会做一个每分钟采集一次的轻量监控脚本,写入本地 SQLite 或直接落到 CSV,用来画带宽曲线:

#!/bin/bash
# /usr/local/bin/bw_sample.sh  每 5 分钟由 cron 调一次
IFACE=$(ip route | awk '/default/ {print $5; exit}')
read -r _ _ _ rx _ _ _ _ tx _ <<< "$(cat /proc/net/dev | grep " $IFACE:")"
echo "$(date +%s),$(( (rx+tx) / 1024 ))" >> /var/log/bw.log
tail -n 2000 /var/log/bw.log > /tmp/bw.tmp && mv /tmp/bw.tmp /var/log/bw.log

这个脚本只读 `/proc/net/dev`,开销可以忽略;保留最近 2000 条采样(约 7 天)足够看出规律。真正的价值在于:下次带宽告警时,你能一眼看出是"持续高位"还是"每天固定时段尖峰",前者是攻击或爬虫,后者是备份或定时任务没排好。

六个踩过的坑

第一,把 limit_rate 当成总带宽限制。它是单连接限速,100 个人各 1M/s 就是 100M/s。要控总量只能靠 `limit_conn` 配合 `limit_conn_zone`,或者在上游交换机/云厂商侧做 QoS。

第二,限速影响了 TLS 握手与首字节。`limit_rate` 生效在响应体阶段,正常不影响 TLS,但如果同时开了 `limit_rate_after 0`,小文件(如 favicon)也会被限成 384k,页面整体感知变慢。永远给一个大于首屏资源的 `limit_rate_after`。

第三,防盗链的 valid_referers 漏了 https 场景。`valid_referers` 只匹配 host,不匹配协议,但如果你写成了 `http://www.example.com`,https 来源会被判为非法并 403。只写域名或 `server_names` 最稳。

第四,没给 CDN 回源单独放行。站点接入 CDN 后,源站看到的是 CDN 节点 IP,限速会把 CDN 回源也限掉,导致全站变慢。必须用 `set_real_ip_from` + `real_ip_header` 把真实 IP 还原,或者干脆给 CDN 节点段做 `allow` 白名单。

第五,只限速不清理大文件。限速只是控制速率,一个 60MB 的 PDF 放在源站仍然是隐患。我的做法是把超过 5MB 的文件直接迁到对象存储或 CDN,源站只留一个 302 跳转,带宽压力立即归零。

第六,忘记 nginx -t 与平滑重载。改完 `map` 块要 `nginx -t` 再 `nginx -s reload`。`map` 只能放在 `http` 块里,错误地放进 `server` 块会直接启动失败;重载后旧 worker 会按旧配置跑完现有连接,验证时要等 10~20 秒再看新连接的表现。

上线顺序与验收

限速这类改动一旦上去影响全站,我固定的顺序是:

先改 `log_format` 并收集一天数据,确认归因表准确;再只在测试 server 块里加 `limit_rate` 观察;然后灰度到 `location /download/` 这类边界清晰的路径;最后才放开到全站静态资源。每一步都 `nginx -t` + `reload`,并用 `curl -o /dev/null -w '%{speed_download}'` 实测单连接速率是否如预期。

# 实测单连接下载速率(单位 B/s)
curl -s -o /dev/null -w '%{speed_download}\n' https://www.example.com/static/big.zip
# 期望值 ≈ 384k/s = 393216 B/s,实测在 350k~400k 之间都正常

最后提醒一句:带宽治理是"三段论"——先归因,再限速,最后收口到存储。只做中间一段,往往会陷入"限了还是慢、松了又打满"的反复。整套规则大约 40 行 Nginx 配置,却能让一台 2 核 4G、5M 带宽的小站在流量翻倍时依然稳定,这是个人站长最划算的一笔投入。

Last modification:October 29th, 2026 at 01:26 pm

Leave a Comment