大促开场 3 分钟,87% 的正常用户被自己的限流拦在门外

引言

618 开场 3 分钟,客服群先是安静,然后炸了:大量用户反馈"点进去就是系统繁忙",但后台监控显示后端 QPS 只有预期的四分之一,服务器闲得发慌。

翻网关日志,429 占比 87%。也就是说,我们自己的限流把 87% 的真实用户拒了,同时那批刷子请求畅通无阻。

配置是四个 Nginx 实例,每个上面一行 limit_req_zone $binary_remote_addr zone=perip:10m rate=5000r/s;。

我当时的理解是"每个用户每秒 5000 次",显然过于宽松,肯定是刷子打太多了。第一反应是把 rate 调到 50000,多放点人进来。

调完 5 分钟,后端 QPS 从 2 万冲到 19 万,数据库连接池瞬间打满,订单服务开始超时,事故从"网关误杀"升级成"后端雪崩"。

先给结论:限流限的是"谁",不是"多少"。 你把 key 选错,rate 配得再准也是在限一个不存在的人;你把单机限流当全局限流,四个实例就是四份配额,总量永远是配置值的 N 倍。这篇按事故顺序讲完三层限流的完整配置:CDN 边缘 → Nginx 原生 + Lua 分布式 → 应用层兜底,以及每一层在什么情况下会失效。

一、第一反应:调大阈值,把事故放大了

当时的判断链是这样的:429 多 → 说明 rate 设小了 → 调大 rate → 用户能进来了。

这个推理每一步都错。

第一,rate 的单位是"每个 zone key 每秒多少请求",不是"全站每秒多少请求"。四个 Nginx 实例各有一份独立的计数器,全站的真实上限是 rate × 实例数,不是 rate。我们把 5000 当成全站配额,实际放行的是 2 万——这解释了为什么"看起来限得很严,后端还是被打了 2 万"。

第二,调大 rate 等于放大误杀。因为被拒的 87% 里绝大多数是正常用户,放宽之后他们一拥而入;而刷子的请求一直排在最前面,它们拿走的配额同比放大。误杀和放水同时发生,这正是 key 选错的典型症状。

第三,后端容量从来没变。网关放多少,后端就得接多少;限流阈值不是"业务参数",是"后端容量的换算结果",拍脑袋改它等于把压力直接灌进数据库。

二、根因:限的是 IP,但你看到的 IP 不是用户的 IP

$binary_remote_addr 取的是 TCP 连接的对端地址。我们的流量链路是:用户 → CDN → 回源 → Nginx。

到了 Nginx 这一跳,TCP 对端是 CDN 的回源节点,不是用户。全站几百万用户,在 Nginx 眼里只有 3 个 IP——三个 CDN 回源节点。

所以那行配置的真实语义是:"这三个 CDN 节点,每个每秒 5000 次"。十万用户抢这三个计数器的名额,先到先得。刷子的请求并发高、响应快,永远抢在最前面;正常用户点一下,名额已经没了,于是 87% 被拒。

这里有个很多人会踩的推论:你限流时的 key,必须和你要保护的对象的身份一致。 要保护"每个用户",key 就得是真实客户端 IP 或用户 ID;要保护"整个活动",key 就得是活动 ID。用代理 IP 做 key,等于把全站用户当成同一个人,把刷子当成 VIP。

还有一层隐性风险:如果 CDN 回源时是把用户传进来的 X-Forwarded-For 原样"追加",而不是"覆盖",那么客户端自己就能伪造 XFF,real_ip_recursive 会一路往右找到那个伪造地址,限流 key 也就跟着被伪造。所以 set_real_ip_from 必须只填你真正信任的 CDN 网段,同时要求 CDN 侧覆盖这个头——这一条我们后来写进了接入规范。

三、修复第一步:把 IP 还原,让限流对准真人

3.1 配置 real_ip,先修身份

Nginx 的 realip 模块可以从 X-Forwarded-For 里还原真实 IP,并就地改写 $remote_addr——之后所有基于地址的模块(limit_req、limit_conn、日志)看到的都是客户端 IP。

# http 块
set_real_ip_from 100.64.0.0/10;   # CDN 回源网段,按实际填
real_ip_header X-Forwarded-For;
real_ip_recursive on;             # 从右往左找第一个非可信地址

limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=connip:10m;

# server 块
limit_req zone=perip burst=20 nodelay;
limit_req_status 429;             # 默认是 503,必须显式改
limit_conn connip 50;
limit_conn_status 429;

三个参数必须说清,因为它们最容易被抄错:

burst=20 是允许的突发排队数——用户在 1 秒内点 21 次,前 1 次按 rate 放,后面 20 次排队等,第 22 次才拒。nodelay 表示这 20 次立即处理、不延迟,只占据配额;不加 nodelay,Nginx 会真的把请求挂住等你,现场表现是"接口慢",不是"被拒",更难排查。

limit_req_status 429 必须显式配。默认返回 503,客户端和网关的熔断器会把 503 当成"服务不可用",触发重试和熔断,把一次限流放大成一次雪崩。

zone=perip:10m 的内存要算够:1MB 大约能存 1.6 万个地址的计数状态,10MB 约 16 万。活动的真实 IP 超过这个量,老状态被淘汰,等于限流精度下降——这个容量是按"同时活跃的 key 数"估的,不是按总用户数。

3.2 但单机限流,永远不是集群限流

修完身份,第二个问题立刻冒出来:我们要的是"全局限 2 万 QPS",四台 Nginx 各自的 limit_req 只保证"每台每 IP 10r/s"。

按 IP 限流在防刷上够用,但它管不住总量:刷子换 10 万个 IP,每个 IP 都合规,总量照样打穿。后端容量是全局的,配额也必须是全局的——本地内存里的计数器做不到这件事。

还有一个更隐蔽的偏差:负载均衡会把同一个用户的请求打到不同实例上,每个实例的计数器都只拿到一部分。配 10r/s、4 台实例,那个用户实际能放行的是 40r/s。只要 key 是"每实例维护"的,真实阈值永远是配置值的实例数倍,这个倍数还会随扩缩容变化,压测时和线上完全对不上。

这就是必须引入第二层的原因。

四、第二层:Lua + Redis,把配额变成全局的

4.1 两层判定:本地先挡,Redis 后判

如果每个请求都去查一次 Redis,10 万 QPS 全压在 Redis 上,限流本身就成了新的瓶颈。我们的做法是两级:

本地 lua_shared_dict 做粗筛:单个 IP 每秒超过 50 次,直接在 Nginx 本地 429,不碰 Redis。这一层挡掉的是最明显的刷子,实测拦下了 83% 的恶意流量。
通过粗筛的请求才去 Redis 做全局令牌桶判定,精确控制总放行量。

-- access_by_lua_block
local dict = ngx.shared.limit_local
local ip   = ngx.var.binary_remote_addr
local key  = "l:" .. ip
local n, err = dict:incr(key, 1, 0, 1)     -- 1 秒固定窗口
if not n then return end
if n > 50 then
    ngx.log(ngx.WARN, "local limit hit, ip=", ip)
    return ngx.exit(429)                    -- 本地粗筛,不查 Redis
end

local red = require("resty.redis").new()
red:set_timeouts(100, 100, 100)
local ok, cerr = red:connect("redis-limit.internal", 6379)
if not ok then
    -- Redis 不可用:放行,交给下游兜底(见 4.3)
    return
end

local res, rerr = red:eval(TOKEN_BUCKET_SCRIPT, 1, "tb:seckill:618", 20000, 20000)
red:set_keepalive(10000, 100)
if res == 0 then
    return ngx.exit(429)
end

令牌桶脚本按毫秒补充令牌,全程在 Redis 内原子执行:

-- KEYS[1] 桶 key;ARGV[1] 每秒补充速率;ARGV[2] 桶容量
local rate     = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local t        = redis.call('TIME')
local now      = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local d      = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(d[1]) or capacity
local ts     = tonumber(d[2]) or now

local delta  = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * rate / 1000)

if tokens < 1 then
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
    return 0
end
redis.call('HMSET', KEYS[1], 'tokens', tokens - 1, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate * 2))
return 1

两个细节:时间必须取 redis.call('TIME'),不能取 ngx.now()。四个 Nginx 的时钟有几十毫秒漂移,用本地时间会让令牌凭空多出来或者少掉。桶容量等于速率(20000),意味着允许 1 秒的突发,超过就拒——这个值要按后端能承受的瞬时峰值定,不是按平均值定。

4.2 这层能扛多少

10 万 QPS 进来,本地粗筛拒掉 8.3 万,只有 1.7 万走到 Redis;Redis 侧按 3 主 3 从、单分片 5 万的量级,压力很轻。真正的好处不是"更快",而是后端拿到的永远是 2 万,与入口打多少无关。

这一层必须打点,否则你不知道它在不在工作。我们在 Lua 里按"本地拒 / Redis 拒 / 放行"三个维度打计数器,每分钟上报一次;Redis 拒绝率突然从 20% 掉到 0,基本就是脚本没执行或者桶 key 被清空了。

4.3 Redis 挂了,限流不能变成新的单点

这是设计时就必须定的事,不能等出事再想。两种选择:

fail-open(Redis 故障时放行)保护可用性,但后端可能被打穿;
fail-close(Redis 故障时全拒)保护后端,但等于全站不可用。

我们的选择是按接口分级:秒杀下单这类"后端绝不能被穿透"的接口走 fail-close,并在本地粗筛保留一道 50r/s 的兜底;商品详情、查询类接口走 fail-open,靠应用层限流兜住。限流组件的目标不是自己不挂,而是它挂了之后,后端还有第二道闸。

五、第三层兜底与验证

5.1 应用层:最后一道闸

Nginx 再准,也挡不住"配置漏了一个 location"或者"新加了一个直连端口"。应用层用 Sentinel 的 QPS 流控(或 Resilience4j 的 RateLimiter)按接口名做最后一道限制,超阈值直接 BlockException 转 429。它同时是熔断的入口——当 P99 超过阈值,先限流、再熔断,比直接拒绝整批请求更平滑。

这一层的价值不在精度,在于它是唯一知道"这个接口现在有多慢"的一层。网关只看到请求数,它看不到下游数据库的连接等待、也看不到某个下游服务的 RT 抖动;应用层的限流阈值可以跟着 RT 动态调整,这是 Nginx 静态配置做不到的。

5.2 怎么确认限流真的生效

限流最怕"配了但没生效",验证要盯三个点:

# 1. 语法与配置加载
openresty -t

# 2. 压测:入口打 10 万,看放行是否稳定在 2 万
wrk -t8 -c1000 -d30s -H "X-Forwarded-For: 203.0.113.7" http://gateway/seckill

# 3. 看日志里每一层的判定结果
grep "limit" access.log | awk '{print $NF}' | sort | uniq -c

Nginx 1.17.1 之后有 $limit_req_status 变量(PASSED / DELAYED / REJECTED),把它加进日志格式,就能分清哪些是被原生层拒的、哪些是被 Lua 层拒的。上线前先开 limit_req_dry_run on,它只记录不拒绝,观察一天 REJECTED 的量级,再改成真实拦截——这一步能避免 90% 的"阈值设错"事故。

我们压完的结果:入口 10 万,429 稳定在 80%,后端 QPS 恒定 2 万,P99 从事故时的 3.1 秒回到 68ms。

5.3 避坑清单

  • 不配 real_ip 就限流,等于给代理 IP 限流,误杀和放水同时发生。
  • 单机 limit_req 不是集群限流,实例数 × rate 才是真实放行上限。
  • nodelay 不是"不限流",它只是让突发请求不排队;不加它,表现是变慢而不是被拒。
  • limit_req_status 默认 503,不改成 429,会触发上游重试和熔断。
  • 限流 key 用 $binary_remote_addr,比 $remote_addr 省一半内存,同样的 zone 能多存一倍 IP。

收尾

限流配置里最贵的从来不是 rate 那个数字,而是 key 选错、单机当全局、以及没有为"限流组件自己挂掉"准备第二道闸——这三件事决定了限流是保护还是事故。

你们线上的限流 key 用的是什么?遇到 CDN 或负载均衡之后,是怎么把真实 IP 还原出来的?

关注我,每周拆一个真实生产故障。


标题:大促开场 3 分钟,87% 的正常用户被自己的限流拦在门外
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/11/03/1793505878277.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消