去年双十一前夜,我们的下单接口突然大面积 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 反向代理完整配置与服务器安全加固实战。




