API 限流到底怎么选:令牌桶、漏桶、滑动窗口实测对比,附 Redis+Lua 脚本

🔑 关键词:API限流,令牌桶,漏桶算法,滑动窗口限流,Redis限流

📖 摘要:从一次凌晨故障讲起,对比固定窗口、滑动窗口、令牌桶、漏桶四种限流算法的精度与内存开销,给出 Redis+Lua 原子实现和集群限流的避坑参数。

凌晨两点半被电话叫醒,告警群里一片红。下游订单库连接数打满,慢查询堆到 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。

就先写这么多吧。有不同看法的欢迎来聊,我承认我对漏桶有点偏见。

🏷️ 标签: