limit_req 把正常用户挡在门外:漏桶、突发与真实 IP
限流上线第二天,投诉集中在两家客户。日志里被限的 IP 只有四个,全是我们自己的代理。从 set_real_ip_from、burst 的真实容量到按接口分档的完整配置。
限流是上周四上线的。配置很简单,在 location /api/ 里加了 limit_req zone=api burst=20 nodelay,目的是挡住那个每天跑十几万次请求的爬虫。上线当天没动静,第二天上午客服转来三张工单,都来自同一家客户的运营团队:打开列表页转圈,刷新两次才出来。
我先看了监控,503 的比例从 0 涨到 6.8%,nginx 的 limiting requests 日志一行接一行。奇怪的是被限的请求不集中在深夜爬虫活跃的时段,反而集中在上午九点半到十一点——那是客户上班的时间。
更奇怪的是,被限的 IP 只有四个,而且这四个 IP 我认得,是我们自己机房的反向代理。也就是说,Nginx 眼里的「一个客户端」,其实是整个办公网几百号人。
一、先看日志:为什么所有请求的 IP 都一样
排查第一步不是改配置,是把「谁被限了」打出来。默认的 error.log 只有一行 limiting requests, excess: 20.317 by zone "api",不带 IP,什么都说明不了。
我临时加了一个日志格式,把限流状态和真实 IP 都打出来:
log_format limit '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time '
'xff="$http_x_forwarded_for" '
'limit="$limit_req_status"';
access_log /var/log/nginx/api-access.log limit;
跑十分钟,awk 统计一下:
awk -F'limit=' '{print $2}' /var/log/nginx/api-access.log \
| sort | uniq -c | sort -rn | head
# 4821 "REJECTED"
# 61234 "PASSED"
再看被拒的那些行的 $remote_addr,果然全是我们自己两台入口机的内网地址 10.20.3.11 和 10.20.3.12。而真正的客户端 IP 在 $http_x_forwarded_for 里,形如 203.0.113.47, 10.20.3.11。
这里就是第一个错:limit_req_zone 的 key 我写的是 $binary_remote_addr,它取的是直连对端地址。请求经过一层代理之后,直连对端永远是代理自己。于是「按 IP 限流」退化成了「按代理限流」,几百个用户共享一个 20 的桶。
顺带一个细节:$binary_remote_addr 是二进制压缩形式(IPv4 占 4 字节),比 $remote_addr 省内存,但前提是你取到的是对的地址。取错了,省下来的内存毫无意义。
二、真实 IP 从哪来:set_real_ip_from 的边界
要让 $binary_remote_addr 变成真实客户端地址,得用 real_ip 模块改写它。关键是 set_real_ip_from 的名单必须精确到「我们自己信任的代理」,不能写成 0.0.0.0/0:
# 只信任自己的入口代理,名单之外的一律不改写
set_real_ip_from 10.20.3.0/24;
set_real_ip_from 10.20.4.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
real_ip_recursive on 的作用是从右往左剥,跳过名单内的地址,取第一个不在名单里的。如果写 off,它直接取 X-Forwarded-For 最右边那个,那还是代理的地址,等于没改。
这里有个必须说清楚的风险:X-Forwarded-For 是客户端可以伪造的头。如果 set_real_ip_from 配成 0.0.0.0/0,任何人只要自己带一个 X-Forwarded-For: 1.2.3.4,就能绕过所有基于 IP 的限流,还能把日志污染成任意 IP。我们这次的名单是内网两段,外部请求带的头不会被采信,是安全的。
改完验证很简单,看日志里 $remote_addr 是不是变成了公网地址,以及同一个办公网的用户是不是开始出现多个不同 IP(出口 NAT 池通常有多个)。
三、漏桶到底怎么算:rate、burst、nodelay
IP 修对之后,第二个问题才浮出来:即便按真实 IP 分桶,还是有人在正常操作时被限。这说明参数本身不对。
limit_req 用的是漏桶:rate 是漏的速度,burst 是桶的容量。我最初的配置是 rate=10r/s burst=20 nodelay,当时的理解是「一秒十个请求,允许突发二十个」——这个理解只对了一半。
实际行为是这样的:请求进来先看桶里有没有位置。桶的容量是 burst,但桶里始终有一个「正在被处理」的槽位,所以真正能瞬间通过的请求数是 burst + 1,不是 burst。这一点在文档里写得含糊,我是拿 ab 打出来的:
# 200 个并发,看前多少个是 200,后面是不是 503
ab -n 200 -c 50 -H "X-Forwarded-For: 203.0.113.47" \
http://127.0.0.1/api/orders/list
结果前 21 个返回 200,第 22 个开始 503。burst=20 加 1,对上了。
nodelay 的含义是「桶里排队的请求不延迟,直接放行」。不加 nodelay 的话,超出的请求会被排队,按 rate 的速度一个一个放,客户端感受到的是「变慢」而不是「报错」。加了 nodelay,超出桶容量的立刻 503。
这两种行为适合不同场景。列表页这种交互接口,被排队会让人以为页面卡死,直接 503 反而能被前端捕获后重试;而导出、批量提交这种长任务,排队更友好,因为用户本来就在等。
| 参数 | 含义 | 我们踩的坑 |
|---|---|---|
rate=10r/s |
每秒补 10 个令牌 | 按 IP 分桶后 10 太小,一个页面要打 18 个接口 |
burst=20 |
桶容量 20 | 实际瞬时通过是 21,压测对不上时先想这个 |
nodelay |
排队请求立即放行 | 交互接口合适,长任务接口不合适 |
delay=5 |
前 5 个立即放行,其余排队 | 我们后来用它给导出接口做折中 |
limit_req_status |
拒绝时返回的状态码 | 默认 503,容易被监控误判为服务不可用 |
四、第一版配置错在哪
把三个错摊开看,其实是一条链:取错了 IP,导致桶被几百人共享;桶不够用,于是调大 burst;burst 调大之后,慢接口的排队把 worker 连接占住,又引发另一批超时。
第一版的实际配置是这样,注释是我当时写的「理由」:
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 443 ssl;
server_name ispt.top;
location /api/ {
limit_req zone=api burst=20 nodelay; # 想着「一个用户一秒十个够了吧」
limit_req_status 503; # 默认就是这个,写不写一样
proxy_pass http://app_backend;
}
}
三个问题:key 错、rate 太小、状态码用了 503。第三条容易被忽略——503 在我们的监控里等于「服务不可用」,限流触发会直接拉起值班;改成 429 之后,告警归到「容量」这一类,由值班看趋势而不是立刻打电话。
改完的版本按接口分档,因为一个页面要打十几个接口,用同一个阈值必然误伤:
# 三个桶:读接口宽松、写接口收紧、导出单独排队
limit_req_zone $binary_remote_addr zone=api_read:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=api_write:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=api_heavy:10m rate=1r/s;
server {
listen 443 ssl;
server_name ispt.top;
location ~ ^/api/(orders|users|customers)/ {
limit_req zone=api_read burst=60 nodelay;
limit_req_status 429;
proxy_pass http://app_backend;
}
location ~ ^/api/(orders|users)/(create|update|delete) {
limit_req zone=api_write burst=10 nodelay;
limit_req_status 429;
proxy_pass http://app_backend;
}
location = /api/export {
limit_req zone=api_heavy burst=3 delay=2; # 长任务,排队比拒绝友好
limit_req_status 429;
proxy_read_timeout 300s;
proxy_pass http://app_backend;
}
}
注意 location 的顺序:正则 location 按出现顺序匹配,第一个匹配上的生效。所以读接口那条写在前面会把写接口也吃进去——/api/orders/create 同时匹配两个正则。这是我的第二个坑,上线后写接口实际用的是读接口的 30r/s。
正确写法是把更具体的正则放前面,或者用 ^~ 前缀匹配锁死。我最后改成了把写接口的规则提到前面,并且加了注释说明顺序依赖,免得下一个人调换顺序。
五、被限流之后,前端该做什么
后端返回 429 只完成了一半。如果前端把这个异常当成普通失败弹「系统繁忙」,用户会一直点重试,把桶打得更空。
我们在请求层加了一层:429 时读 Retry-After,等一会儿再自动重试一次;重试还失败就提示「操作太频繁,请稍后再试」,并且把按钮置灰三秒。
# 拒绝时告诉客户端多久之后可以再试
limit_req_status 429;
add_header Retry-After 2 always;
// 请求层:429 只重试一次,且带退避
async function request(url, options = {}, retried = false) {
const res = await fetch(url, options);
if (res.status === 429 && !retried) {
const wait = Number(res.headers.get('Retry-After') || 2) * 1000;
await new Promise((r) => setTimeout(r, wait));
return request(url, options, true);
}
if (res.status === 429) {
throw new Error('操作太频繁,请稍后再试');
}
return res;
}
只重试一次是刻意的。无限重试在限流场景里等于自己打自己,会把一个瞬时拥塞放大成持续拥塞。
六、防复发:把限流做成可观测的
配置改对之后,我做的最后一件事是让它可观测,否则下次再误伤还是只能靠客服转工单。
三件事:把 $limit_req_status 打进访问日志(前面已经做了);把 429 计数按 location 维度打到监控里;再加一条「同一 IP 一分钟内被拒超过 50 次」的日志,用来捞真正的爬虫。
# 每天捞一次高频被拒的 IP,作为爬虫线索
awk -F'limit=' '/REJECTED/ {print $1}' /var/log/nginx/api-access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
这条命令的输出后来真的抓到一个:某个 IP 一分钟被拒三百多次,UA 是空字符串,路径规律是 /api/orders/list?page=N 递增。这种人你限他没问题,问题在于限之前要先确认他不是我们自己的用户。
小结
$binary_remote_addr在代理后面取到的是代理地址,必须先配set_real_ip_from+real_ip_recursive on,名单要精确到可信代理,不能写0.0.0.0/0。limit_req的瞬时通过量是burst + 1,压测对不上数时先想这一条。- 一个页面打十几个接口时,rate 要给到「页面请求数 × 2」以上,否则正常用户会被自己的页面限住。
- 正则
location按顺序匹配,写接口规则要放在读接口前面,或者用^~锁死。 - 拒绝码用 429 而不是 503,前者是容量语义,后者会被监控当成服务不可用。
- 限流要配
Retry-After和前端一次性退避,否则用户的重试会把拥塞放大。
正在加载评论…