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 和前端一次性退避,否则用户的重试会把拥塞放大。
查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。