跳至正文

给 AI 接口加限流,我先把自家的流式输出干废了:Nginx limit_req 五个坑

上个月自建的那个 AI 接口被刷了。

不是攻击,是我自己写的脚本没写好——重试逻辑写成了死循环,一个出错就狂刷,一晚上烧掉两位数美金的 token。第二天早上看账单,我坐在工位上沉默了三十秒。

修脚本是一方面,另一方面必须得在 Nginx 上兜一道。于是就有了这一天的折腾:加限流加出一个流式输出全废、前端报 503 找不到原因、套了 CDN 之后限流形同虚设的连环事故。

记下来,省得下次再踩。

夜间的黑客工作站,全息终端日志和请求计数器浮动在四周,一道光盾挡住了蜂拥而来的红色请求

先搞清楚是谁在刷

别急着写配置。先把日志翻出来,看清楚请求特征。

# 按 IP 统计今天请求数 top 10
awk '{print $1}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -10
# 按接口路径统计
awk '{print $7}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -20

我这一看就明白了:/v1/chat/completions 一个路径占了 90% 的请求,而且全来自同一个内网 IP。

知道自己被谁刷、刷的哪个接口,限流策略才有得配。上来就全局限 10r/s 的,纯属给自己添堵。

坑一:limit_req_zone 写进 server 块,Nginx 直接起不来

第一版我图省事,把 limit_req_zone 写在 server 里面了。nginx -t 一跑:

nginx: [emerg] "limit_req_zone" directive is not allowed here

这个指令属于 http 上下文,只能写在 http 块里,不能进 server、更不能进 location。

正确姿势:

http {
    # 定义限流区:每秒 5 个请求,10MB 内存存状态
    limit_req_zone $binary_remote_addr zone=ai_api:10m rate=5r/s;

    server {
        location /v1/ {
            limit_req zone=ai_api burst=10 nodelay;
        }
    }
}

记住:limit_req_zone 在 http,limit_req 在 location。这俩容易搞混,我见过不止一个人写反。

坑二:burst 和 nodelay 理解错,正常用户被误伤

burst 是允许突发排队的请求数,nodelay 是”突发额度不延迟、立刻处理”。

我先配了 burst=10 没加 nodelay,结果正常用户打开页面会卡——

没 nodelay 的时候,超出 rate 的请求会被延后处理,一个请求排队最多等好几秒。前端看起来就是”接口好慢”。

对 API 来说正确写法几乎都是:

limit_req zone=ai_api burst=10 nodelay;

要么立刻给结果,要么立刻给 429,别让用户干等着。排队适合的是下载、静态资源这类不在乎延迟的场景。

坑三:流式输出(SSE)直接被限流掐死

这是最狠的一个。

我的接口支持 stream 模式,一次对话前端只发一个请求,但连接要保持好几十秒。加了 limit_req 之后,多开两个标签页就报错。

一开始我以为是 rate 太小,调到 50r/s 也没用。后来才想明白:

limit_req 管的是请求频率,不是连接数。但问题在于——如果前端有自动重连(SSE 断线重连是很常见的实现),一次网络抖动就会在瞬间生成 3~5 个新请求,直接把 burst 打满,后续真正的请求全被拒。

一条发光的长连接数据流管道穿过限流瓶颈环,旁边是几条细小的伴生连接线

解决办法两条,我两条都上了:

# 1. 给流式接口单独放更大的 burst
limit_req_zone $binary_remote_addr zone=ai_stream:10m rate=3r/s;

location /v1/chat/stream {
    limit_req zone=ai_stream burst=30 nodelay;
    limit_conn perip 5;              # 同时并发连接数限制,这才是管长连接的
    proxy_buffering off;
    proxy_read_timeout 3600s;
}

# 2. 前端重连加退避,别用固定的 1 秒
// 指数退避重连,别一断就狂连
let delay = 1000;
function connect() {
  const es = new EventSource('/v1/chat/stream');
  es.onerror = () => {
    es.close();
    setTimeout(connect, delay);
    delay = Math.min(delay * 2, 30000);
  };
  es.onopen = () => { delay = 1000; };
}

关键认知:短请求用 limit_req,长连接用 limit_conn。这俩是两套东西,混着用一定出事。

坑四:默认返回 503,前端完全不知道该干嘛

limit_req 触发后,Nginx 默认回 503 Service Unavailable。

问题来了——503 在前端和监控系统里是”服务挂了”的信号。我的健康检查脚本看到 503 直接把服务标记为不可用,然后开始告警,半夜给我打了三个电话。

限流是正常的业务响应,不是服务故障,状态码应该区分开:

location /v1/ {
    limit_req zone=ai_api burst=10 nodelay;
    limit_req_status 429;    # 改成 Too Many Requests
    limit_conn_status 429;
}
机房墙面上的全息监控大屏,一边是刺眼的 429 限流告警曲线,一边是平静的绿色基线

顺便还能给 429 加个 Retry-After,让客户端知道多久后重试:

error_page 429 = @too_many;
location @too_many {
    add_header Retry-After 1 always;
    add_header Content-Type application/json always;
    return 429 '{"error":"rate_limited","message":"请求太频繁,1 秒后重试"}';
}

改完之后,监控安静了,前端也能拿着 429 做友好提示。

坑五:套了 CDN,限流把所有人当成一个人

最后一个坑最阴。

我在前面挂了一层 CDN。加上限流后,正常用户开始批量报 429——但明明谁都没刷。

原因:CDN 回源的时候,Nginx 看到的 $binary_remote_addr 全是 CDN 节点的 IP。所有用户共用一个”IP”,限流区自然瞬间打满。

必须先把真实 IP 还原出来,再做限流:

# 只信任自家 CDN 的段,别写 0.0.0.0/0
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

# 限流必须在 real_ip 之后生效 —— 顺序很重要
limit_req_zone $binary_remote_addr zone=ai_api:10m rate=5r/s;

这里有个细节:set_real_ip_from 千万别图省事写 0.0.0.0/0。那等于告诉 Nginx”谁的 X-Forwarded-For 我都信”,客户端自己伪造一个头就能绕过你的全部限流。

最后的配置长这样

http {
    # 还原真实 IP 在前
    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    # 分级限流:普通接口宽一点,贵的 AI 接口紧一点
    limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
    limit_req_zone $binary_remote_addr zone=ai_api:10m  rate=5r/s;
    limit_conn_zone $binary_remote_addr zone=perip:10m;

    server {
        location /v1/chat/ {
            limit_req  zone=ai_api burst=10 nodelay;
            limit_conn perip 5;
            limit_req_status 429;
            limit_conn_status 429;
            proxy_pass http://127.0.0.1:8000;
        }

        location / {
            limit_req zone=general burst=50 nodelay;
            proxy_pass http://127.0.0.1:8000;
        }
    }
}

写在最后

这一轮下来我的体会是:限流不是加一行配置的事,得先想清楚”我要保护什么”。

  • 保护 CPU/带宽 → 管请求频率
  • 保护 token 成本 → 按用户做配额,Nginx 只能兜第一层
  • 保护长连接资源 → 管并发连接数

Nginx 的 limit_req 只能做”防打穿”的最后一道闸,真正该管住的是应用层的用户配额和重试逻辑。我那个问题说到底,是脚本里的重试没写退避——限流只是让后果不至于那么难看。

配置改完跑了一周,token 账单回到了正常水平,也没再被 503 骗过。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注