Nginx 限流实战:用 limit_req 与 limit_conn 挡住 CC 攻击和恶意爬虫

为什么每个站长迟早都要面对限流

站点上线之后,你大概率会遇到这样一天:服务器 CPU 突然飙到 100%,带宽跑满,MySQL 连接数告急,网站打开变成 502 或者一直转圈。打开日志一看,某个 IP 在一分钟内请求了八千次,路径全是 /search?q=xxx 或者干脆是随机字符串。这就是最典型的 CC 攻击或者恶意爬虫采集。

很多站长的第一反应是装一堆安全插件、防火墙软件,甚至上 Cloudflare 的付费套餐。但说实话,对于绝大多数个人站点,Nginx 自带的限流模块就足够挡住九成以上的无效流量,而且零成本、无依赖、不增加任何请求链路。这篇文章把 limit_req(请求速率限制)和 limit_conn(并发连接限制)讲透,包括参数含义、真实配置模板,以及新手最容易踩的坑。

限流的两块基石:漏桶算法与共享内存区

Nginx 的 limit_req 用的是漏桶算法(Leaky Bucket)。你可以把它想象成一个底部有个小孔的水桶:请求就是倒进去的水,小孔的流速就是 rate,桶的容量就是 burst。水倒得太快,桶满了,多余的水就溢出去——溢出去的那部分就是被拒绝的请求,返回 503。

关键在于,漏桶允许多少「突发」被缓存在桶里等着排队处理,而不是直接拒绝。这就是 burst 参数存在的意义,也是很多人配错的地方。

而 limit_conn 是另一维度:它不看你请求多快,只看同一个 key 同时有多少个连接还没结束。慢速攻击(Slowloris,一个连接慢慢发 header 拖住 worker)就得靠它来防。

两者都必须先在 http 块里声明一个共享内存区(shared memory zone),否则 nginx -t 会直接报错。共享内存区的作用是让所有 worker 进程共享计数器——因为 Nginx 是多进程模型,没有共享区的话每个 worker 各算各的,限流就形同虚设。

http {
    # 第一个参数是 zone 名字,第二个是内存大小
    # 10m 大约可以存 16 万个 IP 的状态,个人站 10m 完全够用
    limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=10r/s;

    # 限制单 IP 并发连接数,同样是 10m
    limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

    # 全局每秒请求数上限,防止整体被压垮(备用兜底)
    limit_req_zone $server_name zone=req_global:10m rate=500r/s;
}

注意这里用的是 $binary_remote_addr 而不是 $remote_addr。binary 版本用 4 字节存 IPv4(而不是 15 字节的字符串),内存利用率高得多。所以网上有人说「1m 只能存 8000 个 IP」——那是用 $remote_addr 的算法,用 binary 版本 1m 能存 16000 个左右。

limit_req 参数逐条拆解

先看一个可以直接抄进 server 块的配置:

location / {
    limit_req zone=req_per_ip burst=20 nodelay;
    limit_req_status 429;
    limit_conn conn_per_ip 20;
    limit_conn_status 429;

    # 命中限流时记录日志,便于排查(可选)
    limit_req_log_level warn;

    try_files $uri $uri/ /index.php?$query_string;
}

逐个说:

  • burst=20:允许突发 20 个请求排队。也就是说,如果某个 IP 在一瞬间发来 30 个请求,前 10 秒平均速率没超的前提下,Nginx 会立刻处理 1 个,把剩下 19 个放进队列等,第 30 个才会被拒绝并返回 limit_req_status。
  • nodelay:关键参数。不加它,排队里的请求会被按 rate 的速度慢慢放行——你写 10r/s,那第 20 个排队请求要等 2 秒才处理。对于正常用户,这意味着打开一个引用了 20 个静态资源的页面,图片会一张张挤出来,体验极差。nodelay 的含义是「桶里的排队请求立刻放行,只是占了桶的位置」,既保住了平均值限制,又不影响突发体验。
  • limit_req_status 429:默认限流返回 503 Service Unavailable。这有两个坏处:一来搜索引擎会认为你的站点在宕机,二来监控告警会误报。改成 429(Too Many Requests)语义准确得多。同时记得在站点错误页里给 429 也配一个友好页面。
  • limit_conn conn_per_ip 20:单 IP 最多 20 个并发连接。正常浏览器打开一个页面大概并发 6 个连接,20 是相当宽松的值。如果有人用 ab / wrk 压你,或者用多线程采集器,这个数字会立刻生效。

按路径做差异化限流

整站一刀切是不合理的。静态资源应该给得很宽松,而搜索、登录、评论、API 这些「重」接口必须收紧。做法是给不同 location 配不同的 zone:

http {
    limit_req_zone $binary_remote_addr zone=static_zone:10m rate=100r/s;
    limit_req_zone $binary_remote_addr zone=search_zone:10m rate=2r/s;
    limit_req_zone $binary_remote_addr zone=login_zone:10m  rate=1r/m;
}

server {
    # 静态资源:宽松,几乎不会误伤
    location ~* \.(jpg|jpeg|png|gif|webp|css|js|woff2)$ {
        limit_req zone=static_zone burst=200 nodelay;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # 搜索接口:最容易被打,严格
    location = /search {
        limit_req zone=search_zone burst=5 nodelay;
        limit_req_status 429;
        limit_conn conn_per_ip 5;
    }

    # 登录:防暴力破解,一分钟只允许一次
    location = /admin/login.php {
        limit_req zone=login_zone burst=3 nodelay;
        limit_req_status 429;
    }
}

这里的 rate=1r/m 是「每分钟 1 次」,Nginx 支持 r/s 和 r/m 两种单位。登录接口用 1r/m 看起来夸张,但实际上正常用户登录一次成功后就不会反复提交,而暴力破解脚本会被死死卡住。配合 burst=3 给用户留一点重试余地(输错密码重试 2 次完全够)。

白名单:别把搜索引擎和监控打出去

限流最怕的就是误伤。搜索引擎爬虫如果被 429 拦住,收录会立刻掉;你自己的监控探针被拦住,告警就不准了。Nginx 提供了 geo 模块配合 map 来做白名单:

http {
    # 先把 UA 归类
    map $http_user_agent $limit_bot {
        default          "";
        # 已知搜索引擎爬虫不限制
        ~*Googlebot      "";
        ~*Baiduspider    "";
        ~*bingbot        "";
        ~*YandexBot      "";
        ~*Bytespider     "";
        # 可疑 UA 单独给一个严格 zone
        ~*(python-requests|curl|wget|Scrapy|Go-http-client)  "abuser";
    }

    limit_req_zone $limit_bot zone=ua_zone:10m rate=1r/s;
}

不过更稳妥、更常用的做法是基于 IP 白名单。用 geo 把可信网段标记为 0,然后 map 它们为空字符串——因为 Nginx 的限流 key 为空时不参与计数,等于直接放行:

geo $limit_key {
    default        1;
    127.0.0.1      0;
    10.0.0.0/8     0;
    172.16.0.0/12  0;
    192.168.0.0/16 0;
    # 你的 CDN 回源 IP 段一定要加进来!
    # 否则所有流量都来自 CDN 节点,限流会把整个 CDN 打死
}

map $limit_key $limit_key_final {
    0  "";
    1  $binary_remote_addr;
}

limit_req_zone $limit_key_final zone=req_per_ip:10m rate=10r/s;

最后这一条是使用 CDN 的站点最容易翻车的地方。如果你的站前面挂了 CDN,Nginx 看到的 $remote_addr 全是 CDN 边缘节点的 IP。此时限流等于在限制 CDN 节点本身,几分钟内整站就可能对部分用户不可用。正确做法是用 real_ip 模块还原真实客户端 IP:

# 必须放在 http 或 server 块
set_real_ip_from 23.225.146.0/24;   # 你的 CDN 回源网段
real_ip_header X-Forwarded-For;
real_ip_recursive on;

配置完之后 $remote_addr 就变成了真实访客 IP,限流才是有意义的。验证方法很简单:把 $remote_addr 加到 access log 格式里,看日志里的 IP 是访客的还是 CDN 的。

排查:为什么限流没生效?

配完限流最常见的三个问题:

第一,限流完全没反应,被压测时照样全放行。九成的原因是 zone 定义在了 server 块里或者被 include 到了错误的位置。limit_req_zone 只能出现在 http 上下文,limit_req 才可以在 http/server/location 里用。执行 nginx -t 确认没报错。

第二,误伤了正常用户。症状是用户抱怨「网站偶尔打不开」「刷新一下又好了」。先看 burst 是不是太小(建议至少 10),再看有没有加 nodelay。如果站点目录里图片特别多、页面引用了上百个静态文件,那静态资源的 rate 一定要给大(100r/s 起步)。

第三,想确认限流真的在拦截,看日志数 429:

# 统计最近 1000 条日志里被限流的请求
tail -n 1000 /var/log/nginx/access.log | awk '$9 == 429' | wc -l

# 看是哪些 IP 在被限流
tail -n 5000 /var/log/nginx/access.log | awk '$9 == 429 {print $1}' \
  | sort | uniq -c | sort -rn | head -20

# 实时观察限流日志(需要 limit_req_log_level warn)
tail -f /var/log/nginx/error.log | grep "limiting requests"

error.log 里会输出类似 limiting requests, excess: 12.345 by zone "req_per_ip", client: 1.2.3.4 的行,excess 就是超出速率限制的倍率,直接告诉你某个 IP 超了多少。

被封的 IP 要不要永久拉黑?

限流是「软防御」——它只是减速,不封禁。但如果你发现某个 IP 反复触发限流(比如一个小时内被限流上千次),那就该上黑名单了。最简单的方式是用 deny,或者用 fail2ban 自动从日志里提取 IP 并写入 iptables:

# 手动封禁
location / {
    deny 1.2.3.4;
    limit_req zone=req_per_ip burst=20 nodelay;
}

# fail2ban 自动封禁(/etc/fail2ban/jail.local)
[nginx-limit-req]
enabled  = true
filter   = nginx-limit-req
logpath  = /var/log/nginx/error.log
maxretry = 20
findtime = 600
bantime  = 3600

fail2ban 内置了 nginx-limit-req 这个 filter,直接匹配 error.log 里的 limiting requests 日志,10 分钟内触发 20 次就封 1 小时。这是我最推荐的组合:Nginx 限流负责减速,fail2ban 负责对屡教不改者上手段。

小结

把整篇文章压缩成一份可以直接抄的清单:

  • 在 http 块声明 limit_req_zone / limit_conn_zone,key 用 $binary_remote_addr。
  • 正常页面 rate=10r/s burst=20 nodelay,静态资源放宽到 100r/s,搜索/登录收紧到 2r/s 甚至 1r/m。
  • 限流状态码一律改成 429,避免 503 被搜索引擎误判为宕机。
  • 挂 CDN 的站必须先配 set_real_ip_from + real_ip_header,否则限流会打到 CDN 自己身上。
  • 用 geo/map 把搜索引擎爬虫、监控探针、内网网段排除在计数之外。
  • 配合 fail2ban 的 nginx-limit-req jail,自动封禁反复触发限流的 IP。
  • 上线后看 access.log 里 429 的数量和 error.log 里的 excess 值来验证效果。

限流从来不是一道「配了就完事」的开关,而是一组需要根据自己站点真实流量特征反复微调的旋钮。建议上线初期把 rate 设得宽松一些(观察一周 429 的数量),确认没有误伤之后再逐步收紧。宁可慢一点收紧,也不要一夜之间把真实用户和爬虫一起拒之门外。

Last modification:September 20th, 2026 at 12:24 pm

Leave a Comment