限流误杀线上故障:Nginx 与网关限流陷阱根治

去年双十一前夜,我们的下单接口突然大面积 429。监控里流量并不高,真正被挡在门外的,是无数正常用户——这就是典型的限流误杀:本该保护系统的限流规则,反而成了故障源。本文复盘一次由 Nginx 与网关双层限流叠加导致的线上事故,并给出可落地的根治方案。

一、故障现场:保护机制变成故障源

限流误杀最可怕的地方在于,它让”系统没崩、但用户进不来”。当时订单成功率在 5 分钟内从 99.9% 跌到 61%,而 CPU、内存、数据库连接池全部正常。排障直觉完全被带偏——大家先去查后端,直到有人翻出 Nginx 的 error.log 才看清真相。

1.1 关键现象

  • 网关层与 Nginx 同时返回 429,但后端实例几乎零负载;
  • 被拦截的并非爬虫,而是真实下单用户,且集中在同一办公网络出口 IP;
  • 故障随时间扩散:越重试,被限流越多,形成”越错越堵”的死循环。

这类问题在Nginx 502/504 排查实录里也提到过:现象相似,根因完全不同。限流误杀的排查关键是先去限流层找日志,而不是先怀疑后端

二、根因:限流为什么误杀正常流量

这次事故不是单点配置错误,而是四个设计缺陷的叠加。理解它们,才能避免下一次误杀。

2.1 漏桶/令牌桶对”突发”的假设过于理想

限流算法默认流量是平滑的,但现实里用户会”同一秒点三下”。令牌桶的 burst 设得太小,正常突发也被当成攻击。更糟的是,很多团队把 burst 配上排队延迟,反而让正常请求卡死在队列里。

2.2 网关与 Nginx 双层限流叠加

我们在网关(Sentinel)和 Nginx 各做了一层限流,且都按 IP 维度。两层阈值相乘,实际允许的并发被压到单层的几分之一。任何一层触发,用户就被双重拦截。

2.3 客户端重试风暴放大了限流

收到 429 后,App 端立刻无退避地重试,请求量瞬间翻倍。被限流的请求越多,重试越多,进一步触发更严的限流——正反馈雪崩。这与服务器时钟漂移导致线上故障里的级联放大如出一辙。

2.4 按 IP 限流在 NAT/代理后的灾难

公司、学校、4G 出口往往是同一个公网 IP 承载成百上千用户。按 $binary_remote_addr 限流,等于把整个办公楼当成一个人在限。我们那晚被误杀的,正是某大客户办公网的批量下单。

2.5 误杀常发生在流量低谷,而非高峰

一个反直觉的事实:限流误杀最容易在流量低谷暴露。高峰期限流本就该触发,运维反而警觉;低谷时一切平静,一旦某个出口 IP 突发集中请求(比如整点打卡、批量任务下发),阈值瞬间被打满,而此时没人盯着监控,误杀悄然发生数小时。我们这次正是工作日晚间相对空闲时被触发,直到客诉涌来才察觉——真正危险的不是限流本身,而是它静默地拦住了你最该服务的用户。

三、排查手段:先锁定限流层

确认是不是限流误杀,最快的办法是去 Nginx 日志和网关指标里找证据。下面两条命令在事故当晚 10 分钟就定位了问题。

# 限流命中会写入 error.log,关键字 limiting requests
grep "limiting requests" /var/log/nginx/error.log | tail -20

# 统计被限流最多的客户端 IP,看是否集中在正常出口
grep "limiting requests" /var/log/nginx/error.log \
  | awk '{print $11}' | sort | uniq -c | sort -rn | head

如果命中的 IP 里出现大量企业出口、运维跳板机或合作伙伴网关,基本可以断定是按 IP 维度的误杀。结合网关 Sentinel 的 blockedQps 指标交叉验证,就能坐实。

四、根治方案:让限流只挡坏人

4.1 限流维度从 IP 改为用户/令牌

最彻底的修复是按”登录用户 ID”或”API Token”限流,而不是 IP。这样同一办公网的一百个用户,每人有独立额度,互不连坐。Nginx 侧可用 limit_req_zone 配合业务传来的请求头维度。

4.2 Nginx 限流配置:给突发留余地

# nginx.conf 的 http 段:定义限流区,按 token 维度而非 IP
limit_req_zone $http_x_api_token zone=api_per_token:10m rate=20r/s;

server {
    location /api/ {
        # burst 允许突发请求,nodelay 立即处理不排队
        limit_req zone=api_per_token burst=40 nodelay;
        limit_req_status 429;
        proxy_pass http://backend;
    }
}

4.3 客户端必须退避,且与限流解耦

限流只负责”拒绝”,重试策略必须放在客户端,且要指数退避。下面的 Go 示例收到 429 后等待 200ms、400ms、800ms 再试,避免重试风暴。

func getWithBackoff(ctx context.Context, url string) (*http.Response, error) {
    var resp *http.Response
    var err error
    for i := 0; i < 4; i++ {
        req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        resp, err = http.DefaultClient.Do(req)
        if err == nil && resp.StatusCode != 429 {
            return resp, nil
        }
        if resp != nil {
            resp.Body.Close()
        }
        // 指数退避:200ms, 400ms, 800ms
        backoff := time.Duration(1<<uint(i)) * 200 * time.Millisecond
        select {
        case <-time.After(backoff):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, err
}

4.4 网关层用熔断兜底,而不是更狠地限流

当后端真的慢了,应该用熔断(degrade)切流,而不是在前端把用户全挡掉。Sentinel 的热点参数限流按用户维度,配合慢调用比例熔断,能精准保护又不误伤。

# sentinel 热点参数限流:按用户 token 而非 IP
resources:
  - resource: "POST:/api/order"
    flowRules:
      - count: 100
        grade: 1          # 1=QPS 限流
        limitApp: "user"  # 按调用方/用户维度
    degradeRules:
      - grade: 0          # 0=慢调用比例
        count: 0.5        # 慢调用超过 50% 触发
        timeWindow: 10    # 熔断 10 秒

4.5 灰度验证:限流规则不要一次性全量

修完规则别急着全量。我们用网关的灰度路由把 10% 流量先切到新限流策略,紧盯 429 占比与订单成功率 30 分钟,确认正常用户零误杀、真实异常流量仍被拦,才逐步放量到全站。限流是牵一发动全身的全局开关,灰度是它唯一安全的上线方式;直接全量切换,等于拿全体用户的可用性做一次未被授权的实验。

五、防复发清单

事故复盘后,我们把它沉淀成一张检查表,纳入每次上线前的评审。你也可以对照自查。

检查项常见误杀点正确做法
限流维度按 IP 限办公网/代理后用户按用户 ID / API Token 限流
层级数量Nginx + 网关双重叠加统一在一层决策,另一层仅观测
突发余量burst 过小、排队延迟burst 留 2–3 倍,配合 nodelay
客户端重试无退避猛重试指数退避 + 上限次数
可观测性无 429 监控429 突增即告警

可观测性这条最容易被忽略。把 429 当作一等指标来监控,误杀发生时你能第一时间发现,而不是等客诉。

# 被限流请求数突增即告警
sum(rate(nginx_http_requests_total{status="429"}[5m])) by (server) > 5

六、小结

限流误杀的本质,是"保护意图"与"用户现实"之间的错位:算法假设流量平滑、假设一个 IP 一个人,而现实是突发、是 NAT、是重试。根治之道不在把阈值调大,而在三件事——按用户维度限流、给突发留余地、让客户端退避并与限流解耦。限流是盾,不是墙;它该挡住洪水,而不是把正常用户关在门外。更多生产排错思路,可参考Nginx 反向代理完整配置服务器安全加固实战

上一篇 cert-manager 实战:K8s 证书自动签发续期
下一篇 绞杀者模式实战:遗留系统渐进式重构不宕机