凌晨两点半被电话叫醒,告警群里一片红。下游订单库连接数打满,慢查询堆到 8000 多条,运维在群里 @ 我。爬起来查了四十分钟,问题不在业务代码,在我们那层网关限流上——控制台里明明白白配着 500 QPS,实际打进来的峰值是 1200 多。
那天我才真正搞明白,限流这事算法选错了比不选还危险。因为你以为自己有防护网,实际上那网破了个洞,你还睡得特别踏实。
这篇文章不讲概念,就讲我这两年在支付网关和订单服务上踩过的坑,以及最后落下来的方案。
固定窗口:最便宜,也最会骗你
我见过的大部分第一版限流都长这样:key 拼上用户 ID 和当前秒数,INCR 一下,超过 100 就拒,然后 EXPIRE 60。
看着没毛病,但它有个特别经典的临界问题。假设阈值 100 QPS,窗口按自然秒切。某个写错的重试逻辑在第 0.95 秒打进来 100 个请求,全过;第 1.05 秒又打进来 100 个,也全过。两个窗口各自都没超限,但系统在 100 毫秒里实打实扛了 200 个请求,瞬时 QPS 是标称值的两倍。
我后来用 wrk 压过好几轮,固定窗口在边界处的峰值一般能到标称值的 1.6 到 2.0 倍,跟请求分布的均匀程度强相关,越不均匀越夸张。如果你下游是个连接池只有 50 的 MySQL,这个洞足够让它当场躺下。
所以固定窗口只适合两种场景:一是你对精度真的无所谓,二是你有下游的二次保护。除此之外我不推荐。
滑动窗口、令牌桶、漏桶,三个算法三种性格
先上一张我自己整理的对比,参数是按实际压测估的:
| 维度 | 滑动窗口日志 | 滑动窗口计数 | 令牌桶 | 漏桶 |
|---|---|---|---|---|
| 精度 | 精确 | 近似 | 精确 | 精确 |
| 内存占用 | O(请求数) | O(窗口数) | O(1) | O(1) |
| 允许突发 | 否 | 否 | 是 | 否 |
| 典型实现 | Redis ZSET | Sentinel | Guava、Redis | Nginx limit_req |
滑动窗口日志用 Redis ZSET 实现,每个请求 ZADD 一个成员,再 ZREMRANGEBYSCORE 清掉窗口外的,最后 ZCARD 取数量。精确是真精确,但你算算内存:一个用户 1 秒打 100 个请求,成员要唯一,通常用 UUID 或者时间戳加随机串,算 40 字节一个,100 个就是 4KB。十万用户同时活跃就是 400MB 纯内存。这笔账不划算,我们后来放弃了这条路。
生产上用得最多的是滑动窗口计数,Sentinel 就是这个思路——默认把 1 秒切成 2 个 500 毫秒的桶,按权重算当前值。精度够用,内存可控,还顺便解决了边界问题。
令牌桶和漏桶的区别,网上讲得云山雾罩。我一句话概括:令牌桶是「攒下来的额度可以一次用掉」,漏桶是「不管你来多少,我出去的速率恒定」。前者适合容忍突发的入口,比如登录接口被用户连点;后者适合保护脆弱下游,比如你调用的第三方只给你 10 QPS 的配额。
Guava 的 RateLimiter 有个坑我必须提:默认的 SmoothBursty 允许攒 1 秒的令牌。你设 100 QPS,瞬时是真的能过 100 个。很多人以为它是严格匀速,不是。要接近匀速得用 SmoothWarmingUp,或者干脆自己算。我们线上就因为这个坑,在一次秒杀预热时把库存服务打了一次。
Redis 加 Lua:多实例下唯一靠谱的原子写法
单机限流用 Guava 或者 atomic 计数器都行,但只要线上超过一个实例,就绕不开 Redis 或者 Sentinel 集群。
新手最容易犯的错,是把 INCR 和 EXPIRE 拆成两次发。如果 INCR 之后进程被 kill 或者网络抖了一下,这个 key 就永远没有过期时间,内存慢慢涨到 Redis 告警。我见过一个团队因为这个原因,Redis 里堆了 2700 万个僵尸 key。
正确姿势是 Lua 脚本,一条命令做完计数和过期:
-- rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, window)
end
if current > limit then
return 0
end
return 1
调用的时候 EVAL 传参,注意 KEYS 和 ARGV 不要混。返回 0 就是超限,业务侧返回 429,并且最好带上 Retry-After 头,告诉客户端 1 秒后再来。这个头部很多团队省了,结果就是客户端疯狂重试,把限流本身又打穿一次。
另外 PEXPIRE 用的是毫秒,别写成秒,不然窗口会大 1000 倍。我第一次写错这个,测了半天才发现。
集群限流不是把单机阈值乘一下
这是我认为最容易被忽略的一点,也是我想说的核心观点。
很多团队的限流配置是写死在代码或者配置中心的,比如单机 100 QPS,10 台机器就是 1000 QPS。听起来对,但你一旦扩容到 15 台,总阈值就变成 1500,可下游数据库的承受能力没变。扩容本来是为了抗压,结果是把压力原封不动传给了下游。
正确的做法是「总量控制 + 单机兜底」两层。总量那层用一个全局 Redis key,阈值是你真正能承受的总 QPS,比如 800;单机那层设 150 防止单实例被打爆。两层都过才放行。
但全局 key 又带来热点问题。如果按 user_id 维度做全局限流,某个大客户的流量会集中打到 Redis 的某一个分片上。我们的做法是加 hash tag 打散,或者干脆把这个维度降级成单机限流,只用全局 key 做接口级别的保护。
还有个参数我踩过:Redis 做集群限流时,从节点有复制延迟。如果你读从节点判断,会有一小段时间的误差,高并发下能放过 5% 左右的超额请求。要精确就得走主节点,代价是延迟和主节点压力,自己权衡。
我最后落下来的方案
支付和订单这类核心链路,我用的是 Sentinel 集群流控,QPS 阈值按压测的 70% 设置,留 30% 缓冲。理由很简单:压测环境和真实流量分布不一样,留点余量比追求极限安全。
普通查询接口用 Nginx 的 limit_req,配置大概是 limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s; 加上 limit_req zone=api burst=100 nodelay;。10m 的共享内存大概能存 16 万个 IP 的状态,够用了。burst 设成 rate 的两倍,nodelay 让突发请求立即处理而不是排队,避免客户端超时。
第三方回调那种必须精确的,用 Redis 加 Lua 的自定义滑动窗口,窗口 60 秒,阈值按对方文档给的 80% 设。
说实话,限流最难的不是算法,是想清楚「超限了之后怎么办」。是直接拒绝,还是排队,还是降级返回缓存数据?这个问题没有标准答案,得看你的业务能不能接受 429。我们支付回调用的是排队加异步补偿,查询接口就是干脆利落的 429。
就先写这么多吧。有不同看法的欢迎来聊,我承认我对漏桶有点偏见。