九零HTTP头像

令牌桶、漏桶、滑动窗口:限流到底该选哪个

令牌桶、漏桶、滑动窗口:限流到底该选哪个

去年做过一次活动页,预估峰值三千 QPS,提前一周压测过,机器一点问题没有。上线当天接口还是挂了。事后看监控,打进来的流量到了八千多,翻了一倍还不止。多出来的部分不是外部流量,是我们自己的客户端在超时之后疯狂重试,每一层都在重试,乘起来就炸了。

那次之后我给所有对外接口都加了限流。加的过程里踩的坑比想象中多,最典型的一个是:我一开始用固定窗口,简单到只有三行代码,结果跨窗口那一秒照样被打穿。后来换成滑动窗口、漏桶、令牌桶,每换一次都要重新想一遍——到底在限什么。

这篇把我对这四种做法的理解摊开讲,包括实测数据和我最后的选择。

限流到底在限什么

大部分人第一反应是限 QPS。但 QPS 和并发量不是一个东西。

QPS 是每秒进来多少个请求,并发是同一时刻有多少个正在处理。一个接口 QPS 五百,每个请求耗时两百毫秒,那平均并发就是一百。如果耗时涨到两秒,同样的 QPS 就会压着一千个并发,线程池直接打满。

所以限流的对象得看你怕什么。怕下游被瞬时流量冲垮,限 QPS。怕线程池被慢请求占满,限并发。两者都要,就得分两层,入口限 QPS,进入处理逻辑前再拿一个信号量限并发。

我见过只限 QPS 的配置,接口平时很快,某天数据库慢了,QPS 没变,但并发翻了十倍,线程池堆满之后连健康检查都超时。

固定窗口:三行代码,边界上出事

最省事的做法是给每个时间窗口一个计数器。Redis 里一个 key,值随时间窗口递增,过期时间设成窗口长度。

def allow(redis, key, limit, window=60):
    k = f"limit:{key}:{int(time.time()) // window}"
    n = redis.incr(k)
    if n == 1:
        redis.expire(k, window)
    return n <= limit

问题出在窗口边界。假设限制是每分钟一千次。在 00:59 的最后一百毫秒打进一千个请求,全部通过。01:00 一刷新,计数器归零,又打进一千个。这两千个请求挤在两百毫秒里,实际速率是限制值的十倍。

有人会想那我把窗口切小一点,一秒一个窗口。边界问题还在,只是幅度从十倍变成两倍。而且窗口切小之后,本来想限制「一分钟一千次」的语义就变了,变成「每秒不超过十六次」,突发被削平,正常业务反而被误伤。

固定窗口只适合一种场景:你不要求精确,只需要一个粗糙的保险丝,防止某个调用方彻底失控。内部服务之间用它够了。

顺便说个实现上的坑。上面这段代码在并发下有问题:incr 之后 key 才第一次出现,如果这时候进程崩在 incr 和 expire 之间,这个 key 就永远不会过期,计数器只增不减,接口会被永久限死。所以要么用 Lua 把两步合成一次调用,要么改用 set 加 nx 和 ex 先建 key 再 incr。

滑动窗口:精确,但要看内存账单

滑动窗口日志的做法是记录窗口内每个请求的时间戳。Redis 用 ZSET,score 存毫秒时间戳,每次请求先删掉窗口外的成员,再数一下剩下多少。

LUA_SLIDING = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local win = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - win)
local n = redis.call('ZCARD', key)
if n < limit then
    redis.call('ZADD', key, now, ARGV[4])
    redis.call('PEXPIRE', key, win)
    return 1
end
return 0
"""

它的统计是真的准,任意一个时间点往前推六十秒,数出来的就是那个窗口内的真实请求量。

代价是内存。一千 QPS、六十秒窗口,一个 key 要存六万个成员,ZSET 每个成员带 score 和成员字符串,实测下来这个 key 占了将近四兆。如果有两千个限流维度(按接口乘按调用方),那就是八个 G。这个账单大部分人第一次算的时候会愣一下。

所以生产上更常见的是滑动窗口计数器。把六十秒切成六个十秒的格子,统计的时候用「当前格子已有的数 + 前五个格子按比例折算」。当前在格子的第 3 秒,就把上一个格子乘 0.7 加进来。误差上限是一个格子的量,格子越多越准,六个格子已经够用。

内存从六万成员降到六个整数,精度损失在可接受范围。这个方案我在两个项目里用过,没出过事。

漏桶:把流量磨平

漏桶的模型很直观。桶底有个洞,水以固定速率往下漏,上面怎么倒都行,漏出去的速度恒定。桶满了,新来的水直接溢掉。

它限制的是「流出速率」,不是「流入速率」。下游每秒只能处理十次,那漏桶就每秒放十个,多出来的排队,队满了就拒。

这个特性决定了它的适用面:下游能力是完全确定的、不能有任何波动的场景。比如对接第三方接口,人家白纸黑字写了每秒五次,多一次就返回错误,那你用令牌桶让它突发二十次,前面十五次全白打。

漏桶的短板也明显。它不接突发。用户点一下按钮触发三个请求,这三个请求落在同一个瞬间,漏桶会让后两个等,等的时间取决于队列深度。如果业务上这三个请求本来就没问题,那这个等待纯属自找。

还有个实现细节:漏桶如果做成阻塞队列,请求会在服务端线程里等着,QPS 高的时候线程全挂在等待上,等于把限流做成了排队,延迟反而更高。要么用非阻塞的 tryAcquire 直接拒,要么把等待交给客户端。

令牌桶:允许突发的那一种

令牌桶反过来。桶里按固定速率放令牌,放满了就停。请求进来先取令牌,取不到就拒。桶容量决定能突发多少,放令牌的速率决定长期平均速率。

它同时约束了两件事:长时间看平均速率不超,短时间允许冲到容量上限。对真实用户的接口,这个语义更贴近需求。人不会匀速点按钮,一秒钟点五下然后半分钟不点,是常态。

容量设多少,取决于你能容忍多大的瞬时冲击。容量一百、速率十,意味着一个空了很久的桶能一次性放一百个请求进来。如果下游扛不住这一百个,容量就得往下压。

Guava 的 RateLimiter 还有个预热模式,冷启动的实例不会一上来就满速放行,而是从低速率慢慢爬上去。这个设计在刚扩容完的场景里很有用,新实例的缓存是空的,直接满速会把数据库打一遍。

参数怎么定,不要凭感觉

限流的阈值不能拍脑袋。我的做法是先压测找拐点。

八核的机器跑一个读接口,压力从两百 QPS 往上加:

QPSP99 延迟错误率
40048ms0
80061ms0
100095ms0.02%
1200410ms1.8%
15001900ms12%

拐点在八百到一千之间。那单机限流设八百五,集群按实例数乘零点八,不要乘满,留出实例挂掉时其他机器吸收的空间。

这个表还有个用法:它告诉你超限之后延迟是怎么涨的。从一千到一千二,QPS 涨两成,延迟涨四倍多。这说明系统已经进入排队状态,这时候再放流量进来,每个人都在等,体验比直接拒绝更差。

分布式下的一致性

单机限流好办,进程内一个计数器就行。多实例部署就必须集中判断,否则五台机器各限八百五,实际放进来四千多。

集中式的标准做法是 Redis 加 Lua,把读、算、写合成一次原子操作。令牌桶的 Lua 大概长这样:

local key = KEYS[1]
local rate = tonumber(ARGV[1])      -- 每秒放多少令牌
local cap = tonumber(ARGV[2])       -- 桶容量
local now = tonumber(ARGV[3])       -- 毫秒
local want = tonumber(ARGV[4])      -- 本次要几个

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or cap
local ts = tonumber(data[2]) or now

local delta = math.max(0, now - ts) / 1000
tokens = math.min(cap, tokens + delta * rate)
local ok = 0
if tokens >= want then
    tokens = tokens - want
    ok = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(cap / rate * 1000) + 1000)
return ok

这段能跑,但每次请求都要一次网络往返。本机 Redis 的 RTT 大概零点二毫秒,跨机房就是一两毫秒。QPS 三千的接口,光限流就要吃掉三千次往返,Redis 那边单实例跑到八万次每秒就开始排队了。

流量再大就得换思路:配额预取。每个实例一次从中心领一百个令牌,本地扣完再领。中心只承受 QPS 除以一百的调用量,代价是各实例之间的余量不均匀,某台机器领多了用不掉,别的机器在等。实例数少、流量均匀的时候这个方案很好用,实例频繁上下线就不太合适。

限流之后返回什么

这块经常被忽略,但它直接决定用户体验。

直接拒的话,返回 429,带上 Retry-After 告诉客户端多久之后再来。别返回 500,那会让客户端以为是服务端故障,触发重试逻辑,正好是你最不想要的行为。

也可以排队。入口处等一小会儿,等到令牌就继续,等不到再拒。等待时间要设上限,超过一百毫秒的等待基本等于把延迟问题转移给了用户。

读接口还有个更软的做法:限流触发后返回上一次的缓存结果,或者降级版本的数据。用户看到的还是完整页面,只是数字旧了几秒。这个比一个错误页好太多。

我最后怎么选

四种都写过之后,我的判断是这样。

下游速率是硬约束、绝不能超的,用漏桶。对第三方接口做保护基本只有这一个选择。

面向真实用户、允许短时突发的,用令牌桶。参数直观,容量管突发,速率管平均。

只想粗糙地兜个底、不想引入额外依赖的,固定窗口够用。但记得把窗口边界那两倍突发算进去。

需要精确统计、要拿来做计费或者审计的,滑动窗口日志。流量不大(每秒几十次)的时候它是最省心的,量大就换计数器近似版。

最后一句,限流不是为了把流量挡在外面,是为了让系统在过载的时候还能体面地活着。阈值定得比能力低一点,拒掉百分之五的请求,好过全部请求一起超时。


本文为原创技术分享,涉及的代码与思路仅供学习与合法用途参考。请遵守相关法律法规与服务条款,勿将技术用于任何违规场景。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/xiaobaishu_520/article/details/168852246

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--