<h2>为什么每个站长迟早都要面对限流</h2>
<p>站点上线之后,你大概率会遇到这样一天:服务器 CPU 突然飙到 100%,带宽跑满,MySQL 连接数告急,网站打开变成 502 或者一直转圈。打开日志一看,某个 IP 在一分钟内请求了八千次,路径全是 <code>/search?q=xxx</code> 或者干脆是随机字符串。这就是最典型的 CC 攻击或者恶意爬虫采集。</p>
<p>很多站长的第一反应是装一堆安全插件、防火墙软件,甚至上 Cloudflare 的付费套餐。但说实话,对于绝大多数个人站点,<strong>Nginx 自带的限流模块就足够挡住九成以上的无效流量</strong>,而且零成本、无依赖、不增加任何请求链路。这篇文章把 <code>limit_req</code>(请求速率限制)和 <code>limit_conn</code>(并发连接限制)讲透,包括参数含义、真实配置模板,以及新手最容易踩的坑。</p>
<h2>限流的两块基石:漏桶算法与共享内存区</h2>
<p>Nginx 的 <code>limit_req</code> 用的是<strong>漏桶算法(Leaky Bucket)</strong>。你可以把它想象成一个底部有个小孔的水桶:请求就是倒进去的水,小孔的流速就是 <code>rate</code>,桶的容量就是 <code>burst</code>。水倒得太快,桶满了,多余的水就溢出去——溢出去的那部分就是被拒绝的请求,返回 503。</p>
<p>关键在于,漏桶允许多少「突发」被缓存在桶里等着排队处理,而不是直接拒绝。这就是 <code>burst</code> 参数存在的意义,也是很多人配错的地方。</p>
<p>而 <code>limit_conn</code> 是另一维度:它不看你请求多快,只看<strong>同一个 key 同时有多少个连接还没结束</strong>。慢速攻击(Slowloris,一个连接慢慢发 header 拖住 worker)就得靠它来防。</p>
<p>两者都必须先在 <code>http</code> 块里声明一个共享内存区(shared memory zone),否则 <code>nginx -t</code> 会直接报错。共享内存区的作用是让所有 worker 进程共享计数器——因为 Nginx 是多进程模型,没有共享区的话每个 worker 各算各的,限流就形同虚设。</p>
<pre><code>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;}</code></pre>
<p>注意这里用的是 <code>$binary_remote_addr</code> 而不是 <code>$remote_addr</code>。<code>binary</code> 版本用 4 字节存 IPv4(而不是 15 字节的字符串),内存利用率高得多。所以网上有人说「1m 只能存 8000 个 IP」——那是用 <code>$remote_addr</code> 的算法,用 binary 版本 1m 能存 16000 个左右。</p>
<h2>limit_req 参数逐条拆解</h2>
<p>先看一个可以直接抄进 <code>server</code> 块的配置:</p>
<pre><code>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;}</code></pre>
<p>逐个说:</p>
<ul>
<li><code>burst=20</code>:允许突发 20 个请求排队。也就是说,如果某个 IP 在一瞬间发来 30 个请求,前 10 秒平均速率没超的前提下,Nginx 会立刻处理 1 个,把剩下 19 个放进队列等,第 30 个才会被拒绝并返回 <code>limit_req_status</code>。</li>
<li><code>nodelay</code>:<strong>关键参数</strong>。不加它,排队里的请求会被按 rate 的速度慢慢放行——你写 10r/s,那第 20 个排队请求要等 2 秒才处理。对于正常用户,这意味着打开一个引用了 20 个静态资源的页面,图片会一张张挤出来,体验极差。<code>nodelay</code> 的含义是「桶里的排队请求立刻放行,只是占了桶的位置」,既保住了平均值限制,又不影响突发体验。</li>
<li><code>limit_req_status 429</code>:默认限流返回 <strong>503 Service Unavailable</strong>。这有两个坏处:一来搜索引擎会认为你的站点在宕机,二来监控告警会误报。改成 429(Too Many Requests)语义准确得多。同时记得在站点错误页里给 429 也配一个友好页面。</li>
<li><code>limit_conn conn_per_ip 20</code>:单 IP 最多 20 个并发连接。正常浏览器打开一个页面大概并发 6 个连接,20 是相当宽松的值。如果有人用 ab / wrk 压你,或者用多线程采集器,这个数字会立刻生效。</li>
</ul>
<h2>按路径做差异化限流</h2>
<p>整站一刀切是不合理的。静态资源应该给得很宽松,而搜索、登录、评论、API 这些「重」接口必须收紧。做法是给不同 location 配不同的 zone:</p>
<pre><code>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;
}}</code></pre>
<p>这里的 <code>rate=1r/m</code> 是「每分钟 1 次」,Nginx 支持 <code>r/s</code> 和 <code>r/m</code> 两种单位。登录接口用 1r/m 看起来夸张,但实际上正常用户登录一次成功后就不会反复提交,而暴力破解脚本会被死死卡住。配合 <code>burst=3</code> 给用户留一点重试余地(输错密码重试 2 次完全够)。</p>
<h2>白名单:别把搜索引擎和监控打出去</h2>
<p>限流最怕的就是误伤。搜索引擎爬虫如果被 429 拦住,收录会立刻掉;你自己的监控探针被拦住,告警就不准了。Nginx 提供了 <code>geo</code> 模块配合 <code>map</code> 来做白名单:</p>
<pre><code>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;}</code></pre>
<p>不过更稳妥、更常用的做法是基于 IP 白名单。用 <code>geo</code> 把可信网段标记为 0,然后 <code>map</code> 它们为空字符串——因为 Nginx 的限流 key 为空时<strong>不参与计数</strong>,等于直接放行:</p>
<pre><code>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;</code></pre>
<p><strong>最后这一条是使用 CDN 的站点最容易翻车的地方。</strong>如果你的站前面挂了 CDN,Nginx 看到的 <code>$remote_addr</code> 全是 CDN 边缘节点的 IP。此时限流等于在限制 CDN 节点本身,几分钟内整站就可能对部分用户不可用。正确做法是用 <code>real_ip</code> 模块还原真实客户端 IP:</p>
<pre><code># 必须放在 http 或 server 块
set_real_ip_from 23.225.146.0/24; # 你的 CDN 回源网段
real_ip_header X-Forwarded-For;
real_ip_recursive on;</code></pre>
<p>配置完之后 <code>$remote_addr</code> 就变成了真实访客 IP,限流才是有意义的。验证方法很简单:把 <code>$remote_addr</code> 加到 access log 格式里,看日志里的 IP 是访客的还是 CDN 的。</p>
<h2>排查:为什么限流没生效?</h2>
<p>配完限流最常见的三个问题:</p>
<p><strong>第一,</strong>限流完全没反应,被压测时照样全放行。九成的原因是 zone 定义在了 <code>server</code> 块里或者被 <code>include</code> 到了错误的位置。<code>limit_req_zone</code> 只能出现在 <code>http</code> 上下文,<code>limit_req</code> 才可以在 http/server/location 里用。执行 <code>nginx -t</code> 确认没报错。</p>
<p><strong>第二,</strong>误伤了正常用户。症状是用户抱怨「网站偶尔打不开」「刷新一下又好了」。先看 <code>burst</code> 是不是太小(建议至少 10),再看有没有加 <code>nodelay</code>。如果站点目录里图片特别多、页面引用了上百个静态文件,那静态资源的 rate 一定要给大(100r/s 起步)。</p>
<p><strong>第三,</strong>想确认限流真的在拦截,看日志数 429:</p>
<pre><code># 统计最近 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"</code></pre>
<p>error.log 里会输出类似 <code>limiting requests, excess: 12.345 by zone "req_per_ip", client: 1.2.3.4</code> 的行,<code>excess</code> 就是超出速率限制的倍率,直接告诉你某个 IP 超了多少。</p>
<h2>被封的 IP 要不要永久拉黑?</h2>
<p>限流是「软防御」——它只是减速,不封禁。但如果你发现某个 IP 反复触发限流(比如一个小时内被限流上千次),那就该上黑名单了。最简单的方式是用 <code>deny</code>,或者用 fail2ban 自动从日志里提取 IP 并写入 iptables:</p>
<pre><code># 手动封禁
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</code></pre>
<p>fail2ban 内置了 <code>nginx-limit-req</code> 这个 filter,直接匹配 error.log 里的 limiting requests 日志,10 分钟内触发 20 次就封 1 小时。这是我最推荐的组合:<strong>Nginx 限流负责减速,fail2ban 负责对屡教不改者上手段</strong>。</p>
<h2>小结</h2>
<p>把整篇文章压缩成一份可以直接抄的清单:</p>
<ul>
<li>在 <code>http</code> 块声明 <code>limit_req_zone</code> / <code>limit_conn_zone</code>,key 用 <code>$binary_remote_addr</code>。</li>
<li>正常页面 <code>rate=10r/s burst=20 nodelay</code>,静态资源放宽到 100r/s,搜索/登录收紧到 2r/s 甚至 1r/m。</li>
<li>限流状态码一律改成 <code>429</code>,避免 503 被搜索引擎误判为宕机。</li>
<li>挂 CDN 的站<strong>必须先配 <code>set_real_ip_from</code> + <code>real_ip_header</code></strong>,否则限流会打到 CDN 自己身上。</li>
<li>用 <code>geo</code>/<code>map</code> 把搜索引擎爬虫、监控探针、内网网段排除在计数之外。</li>
<li>配合 fail2ban 的 <code>nginx-limit-req</code> jail,自动封禁反复触发限流的 IP。</li>
<li>上线后看 <code>access.log</code> 里 429 的数量和 <code>error.log</code> 里的 excess 值来验证效果。</li>
</ul>
<p>限流从来不是一道「配了就完事」的开关,而是一组需要根据自己站点真实流量特征反复微调的旋钮。建议上线初期把 rate 设得宽松一些(观察一周 429 的数量),确认没有误伤之后再逐步收紧。宁可慢一点收紧,也不要一夜之间把真实用户和爬虫一起拒之门外。</p>
<div style="margin:20px 0;padding:10px 0;border-top:1px solid #eee;border-bottom:1px solid #eee">
<ins class="adsbygoogle" style="display:block;text-align:center" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-1561091167355374" data-ad-slot="8963568440"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>