有个站长在论坛上发帖,说自己的搜索页每天被同一个 IP 段扫几千次,参数是 ?s= 加一串毫无意义的字符。他第一反应是加 limit_req 限速,配了 rate=5r/s burst=10,结果第二天发现问题还在——对方把速率降到 4 次每秒,正好卡在限速之下,但一天下来照样是三十多万次请求,把 PHP 进程池占得满满当当,正常用户点搜索要等五六秒。
这就是「按速率限速」的盲区:它能挡住短时间的爆发,挡不住长期低速的持续消耗。一个攻击者用十个 IP,每个 IP 每秒 3 次请求,你的每条 limit_req 规则都不触发,但总量已经是三十次每秒,全都打在最贵的搜索页上。
这篇讲怎么在 Nginx 这一层做按路径的配额控制——不是限速(每秒多少次),而是配额(每天/每小时总共允许多少次)。这个视角在个人站长的手册里很少被提到,但对内容站、搜索页、API 这类「单次请求成本高、正常用户用量低」的场景非常对症。
一、限速与配额:两种完全不同的模型
先把概念分清,否则配出来的东西一定不对。
限速(rate limiting)建模的是「单位时间内的瞬时速率」。Nginx 的 limit_req 用一个令牌桶实现:桶里有 burst 个令牌,每秒补充 rate 个,请求来了消耗一个,桶空了就排队或者拒绝。它擅长挡「一秒钟打五百次」这种,但对「每秒三次打一整天」几乎无效——因为从每一秒看过去,这个用户都是「正常」的。
限制并发连接数(limit_conn)建模的是「同一时刻占用多少资源」。它对慢连接攻击有效,但对「快速请求、每次两百毫秒就返回」的扫描也没有效果。
配额建模的是「一段时间内的总消耗量」。比如:一个 IP 每天最多请求搜索页 200 次。这个模型直接对应「成本」而不是「速率」,所以更适合防资源消耗型的滥用。
三种手段不是替代关系。真实场景里通常是三层叠加:limit_req 挡瞬时爆发,limit_conn 挡并发占用,配额挡长期慢性消耗。
二、用 limit_req 模拟配额:把速率调低到「日均」的量级
Nginx 没有原生的「每日配额」指令,但用 limit_req 可以近似实现,思路是:把速率设成「你希望这个 IP 每天能用的次数 / 86400」,把 burst 设成「允许的短时集中量」。
比如搜索页你希望单个 IP 每天最多两百次,正常用户可能一口气搜十几次然后就不搜了。那么:
limit_req_zone $binary_remote_addr zone=search_quota:10m rate=2r/m;
server {
location = /search {
limit_req zone=search_quota burst=20 nodelay;
limit_req_status 429;
...
}
}rate=2r/m 是每分钟两次,一天就是 2880 次——这个数字比 200 大得多,说明单靠 rate 做不到严格日配额。真正起作用的是 burst=20:桶里最多存 20 个令牌,加上持续补充的速率,一个 IP 在短时间内最多打 20 次,之后就得等。
这个模型的实际效果是:正常用户搜十几次没问题(被 burst 覆盖),扫描器要打一千次就得等上几个小时,成本被大幅抬高。它不是严格的「日配额」,但已经足够挡住绝大多数自动化扫描,因为对方的收益太低。
如果确实需要严格的日配额,就得用 limit_req 加上更小的 rate 和更小的 burst,代价是可能误伤正常用户。或者离开 Nginx,用 njs、OpenResty 的 Lua、或者应用层配合 Redis 做计数。对个人站来说,前者通常够了,不必上那么重的东西。
nodelay 这个参数值得单独说。不加它的时候,超出速率的请求会排队等待,直到有令牌可用;加了之后,只要桶里还有令牌就直接处理,用完了就立刻返回 429。对搜索页这种场景,应该加 nodelay:排队只会让攻击者的请求占着连接不放,反而更糟。而对「每秒只允许 1 个请求」的严格全局限速,不加 nodelay 反而能让流量变得平滑。
三、zone 的大小怎么算:一个被严重低估的参数
limit_req_zone ... zone=name:10m 里的 10m 是共享内存大小。很多人随手写 10m 就完事,不清楚它意味着什么。
Nginx 的文档给出的经验值是:1MB 大约能存 16000 个 IP 的状态。但这个数字是 32 位系统、IPv4 的估算。实际情况是:每个状态条目在 64 位系统上占 64 字节左右,加上 Nginx 内部的红黑树节点开销,1MB 大概能存 8000~16000 个 key。IPv6 地址用 16 字节,比 IPv4 的 4 字节大,所以同样的内存能存的 IPv6 条目更少。
为什么要关心这个?因为内存满了之后,Nginx 会用 LRU 淘汰老的条目。这意味着攻击者可以用大量不同的 IP(比如来自僵尸网络或者代理池)把 zone 打满,把你正在跟踪的正常用户的状态挤出去。挤出之后,这个 IP 重新开始计数——配额就绕过去了。
所以 zone 大小要按「预期同时在跟踪的 IP 数量」来定。日均一万 UV 的站,同时跟踪的 IP 可能也就几千,10m 够用。但如果你的站被大范围扫描过,zone 很容易被打满,这时候要么扩大 zone(20m、50m),要么用更省内存的 key。
$binary_remote_addr 是二进制形式,比 $remote_addr(字符串)省一半以上内存,这是必须用的写法。如果站点在 CDN 后面,请先确认 real_ip 配置正确,否则 $binary_remote_addr 拿到的是 CDN 节点的 IP——那你就等于在给 CDN 节点做限速,所有用户共享一份配额。
还有个小技巧:如果只想限制「每个 IP 对每个站点的搜索」,可以把 key 写成 $binary_remote_addr$host。这样同一台服务器上的多个站点各自独立计数,不会互相连坐。
四、429 还是 403 还是 444:状态码的取舍
被限流之后返回什么,这件事影响的不只是攻击者,还有搜索引擎。
444 是 Nginx 特有的,意思是「直接关连接,不回任何内容」。它对扫描器最"省钱"(省带宽、省连接),但对正常用户很不友好——浏览器会显示「连接被重置」,用户不知道发生了什么,也不会重试。
403 是「禁止访问」,语义上说的是「你没有权限」,但用户其实是有权限的,只是超量了。用它容易误导,也让搜索引擎把它理解成永久性拒绝。
429 Too Many Requests 才是语义正确的选择。它明确表示「你请求太多了」,而且可以配合 Retry-After 头告诉对方多久后再来:
limit_req_status 429;
location = /search {
limit_req zone=search_quota burst=20 nodelay;
limit_req_status 429;
add_header Retry-After 60 always;
}
always 这个参数很重要——不加它,add_header 只在 2xx/3xx 响应里生效,429 响应反而不会带上这个头。这是一个很隐蔽的坑,我当初配的时候漏了,看响应头里一直没有 Retry-After,排查了半天才发现。
对真正的搜索引擎爬虫,不要返回 429。Google、Bing 的爬虫收到 429 会认为你的服务器过载,于是降低对这个站的抓取频率,这是你不想要的。如果搜索引擎爬虫确实抓得太猛(比如参数组合爆炸),正确做法是通过 robots.txt 屏蔽那些无意义的参数,或者对爬虫返回 503 加 Retry-After,而不是限流。
区分爬虫和攻击者,最省事的做法是用 map 建一个「可信爬虫」的判定,命中就换一个不计入配额的 key:
map $http_user_agent $quota_key {
default $binary_remote_addr;
"~(Googlebot|bingbot|Baiduspider)" "";
}
limit_req_zone $quota_key zone=search_quota:10m rate=2r/m;
这里的关键是:空字符串的 key 不会被计入限制。Nginx 的 limit_req 在 key 求值为空时直接跳过限制。这是个干净的写法,比用 geo 白名单再 return 200 简洁得多。
但注意:这个 map 是基于 UA 判断的,而 UA 可以伪造。攻击者只要把 UA 改成 Googlebot 就能绕过。所以如果这个绕过是「有代价的」(比如伪装爬虫会被 fail2ban 记录、或者后续有基于 IP 反查的校验),就没问题;如果只是纯粹靠 UA 放行,那就是个摆设。对个人站来说,「UA 看起来像爬虫 + 我们记录了这个 IP」通常够了,因为攻击者伪装爬虫的动机不高——他要的是快速扫完,伪装爬虫反而限制了他的速度上限。
五、路径级配额的三个具体场景
场景一:搜索页防枚举。这是最常见的。攻击者用 ?s=a、?s=aa、?s=aaa 去枚举,试图把数据库里所有内容都拉一遍,或者单纯消耗你的数据库资源。对策如上,单独给 location = /search 配一个低配额的 zone。
同时,搜索页应该在 robots.txt 里 Disallow(如果你的站是用 ?s= 做搜索参数的话),并且页面本身加 noindex。这两者是配套的:Disallow 阻止爬虫抓取,noindex 阻止它被索引。注意两者不能同时用在同一方向——如果页面被 Disallow 屏蔽了,爬虫就看不到 noindex 标签,结果是被「已发现未编入索引」;应该让爬虫能抓取但看到 noindex。所以对搜索页更好的做法是:允许抓取、返回 noindex、靠 robots.txt 只屏蔽无意义的参数组合。
场景二:API 接口防拉取。如果你有 /api/v1/posts 这样的列表接口,一次能返回一百条数据,那它对爬虫的价值极高,一天拉几十万次就能把你全站内容搬走。这时候配额应该按「每次请求的实际成本」来定——返回一百条的接口,一天给二十次就够正常用户翻页了。
场景三:文件下载与图片接口防盗刷。个人站常有的场景:一个图片床或者附件下载,被人批量拉走做备份。这里配额要按带宽算,不按次数。可以配合 limit_rate(每秒字节数限制)实现「能下但很慢」,比直接拒绝更友好。
这三个场景有一个共同点:先算出「正常用户的上限」,再按上限的两到三倍设配额。凭感觉拍脑袋设配额,要么误伤正常用户(客户投诉),要么形同虚设。
六、上线顺序:先 log-only 观察一周
限流和配额最怕的不是「没生效」,是「误伤了不该误伤的人」。客户的移动网络出口是一堆人共享的 NAT,你的配额把他们一起限了;或者某个第三方服务(比如你用的统计工具、监控探针)频率高了点,被挡了。
所以上线分三段:
第一段,只记录不拦截。用一个很大的 burst(比如 10000),让所有请求都通过,但打开 limit_req_log_level notice,在 error.log 里看哪些 IP 会被触发。跑一周,看看命中的 IP 是什么性质。
第二段,把 burst 收紧到目标值,但仍然返回一个「友好的」状态码,记录并观察正常用户有没有受影响。可以看 access.log 里 429 的比例,正常应该低于百分之零点几。
第三段,切到正式的 state code(429),并配上监控告警:如果 429 的比例突然超过百分之一,说明可能误伤或者被打了,需要人工看一眼。
这个过程中,一定要把规则变更写进 nginx 的版本控制。配额这种参数以后一定会调,调了之后如果没有记录,三个月后你看到一行 rate=2r/m burst=20,完全想不起来为什么是这个数。
七、两个容易忽略的边界
第一个:limit_req 的计数是在 Nginx worker 之间共享的(通过 zone 的共享内存),但只在同一台机器上共享。如果你有多台前端服务器,每台各自计数,那么攻击者打十台就能拿到十倍配额。这种情况下要靠 CDN 边缘层或者应用层的集中计数来解决。单个个人站一般没这个烦恼,但如果你做了双机负载均衡,要意识到这一点。
第二个:被限流之后,用户的浏览器缓存可能让你以为没问题。测试限流的时候一定要用 curl -H 'Cache-Control: no-cache' 或者加一个随机查询参数,避免拿到浏览器缓存或者 CDN 缓存里的旧 200 响应。同理,CDN 会把你的 429 也缓存下来——所以 429 响应必须带 Cache-Control: no-store,否则一个 IP 触发限流之后,CDN 可能把这个 429 发给其他用户,这就是所谓的「缓存中毒」,后果比原本的滥用严重得多。
顺带说一句 limit_req_log_level。默认是 error,也就是每次触发都会往 error.log 写一行。如果被大规模扫描,这个日志会飞速增长,可能把磁盘写满。建议设成 notice,或者用 limit_req_log_level notice 加 limit_conn_log_level notice,把限流日志和应用错误分开,方便后续统计。
配额控制这件事,本质上是把「防滥用」从「响应速度维度」换到「资源消耗维度」来思考。想清楚你的哪个路径最贵、正常用户会用多少次、攻击者想用它达到什么目的,配额才好定。技术手段是简单的,难的是把这三个问题答清楚。