API 限流方案选择指南

49 阅读 1830 字 · 约 7 分钟

限流是个听起来很简单的问题:请求太多,拦一拦就行。但真到实现的时候,你会发现「怎么拦」的答案一直在变——被业务规模一层层逼着升级。

起点:固定窗口计数器

最直觉的方案:在内存里记一个计数器,当前这一秒来了多少请求,超过阈值就拒。

# 伪代码,不是让你抄
counter = 0
window_start = now()
if now() - window_start > 1s:
    counter = 0
    window_start = now()
if counter > limit:
    reject()
counter += 1

这个方案够简单,十几行代码就搞定。单机、流量均匀、对准确性要求不高的时候完全够用。

但它有两个天生的缺陷。第一,窗口边界的突刺:上一秒的最后 100ms 和下一秒的最初 100ms,在窗口计数器看来是两个不同的窗口,但实际加在一起能过 2 倍的阈值。第二,计数器在内存里,重启就丢,多实例也不共享。

滑动窗口:被窗口边界逼出来的

窗口边界的问题逼出了滑动窗口。不再按「这一秒」来计数,而是记下每个请求的时间戳,看过去 1 秒内有多少个。

精度上去了,但代价也跟着来了:你要为每个请求存一条时间戳记录。高 QPS 下这个记录集合本身就会膨胀成内存黑洞。而且清理过期记录的逻辑也从 O(1) 变成了 O(n)。

这时候你就得想清楚了:是为了精确限流多花这些内存,还是够用就行?

分布式限流:单机不够用了

等你上了多实例,单机计数器的问题就暴露了:每个实例各自计数,三个实例每个限 100,实际能过 300。

你当然可以把阈值除以实例数,但实例数是动态的——扩缩容、重启、发版,数量一直在变。静态除法永远不准。

这时候自然会想到一个集中的计数器,放在 Redis 里。

最简单的 Redis 限流:INCR key + EXPIRE key 1。但这又回到固定窗口的问题——窗口边界依然有突刺。而且 INCREXPIRE 是两次调用,中间断了 key 就永远不清,你得用 Lua 脚本把两条命令包成原子操作。

滑动窗口也可以放 Redis:用 ZADD 记录每个请求的时间戳,ZREMRANGEBYSCORE 清理过期的,ZCARD 数个数。每个请求三次 Redis 操作,都成功才放过。延迟增加了,Redis 的负载也增加了。

这里的问题是:你的 QPS 多高?每个请求都要三次 Redis 调用,Redis 能不能撑住?

令牌桶:换个思路,不记录请求

滑动窗口的痛点是每个请求都要记录和清理。令牌桶换了个思路:不记录请求,记录令牌。

以固定速率往桶里加令牌,请求来了先拿令牌,拿不到就拒。桶的大小就是你能承受的突发上限。没令牌等于被限流,逻辑简单。

单机实现极其轻量:一个计数变量加一个定时器补充令牌。Redis 实现也简单:DECR 一个 key 看是不是负数就行。

但令牌桶有个不太容易一开始就注意到的问题:突发和速率的平衡在桶大小上。桶太大,突发能过很多,保护不了下游;桶太小,正常波动也被误杀。这个值很难一次性设对,要靠线上观察和调参。

漏桶:连突发也不允许

令牌桶是「固定速率放行」,漏桶是「固定速率排出」。

想象一个队列,请求进来先排队,按固定速率从队列里取出处理。进来的速度可以忽高忽低,出去的速度始终均匀。

它解决了令牌桶的问题:下游收到的请求速率绝对平滑,不会有任何突发。但它引入了新的代价:排队等于延迟。请求量大的时候,排在后面的请求可能已经超时了,但漏桶还在老老实实处理它。

怎么选

到这儿你可能会觉得漏桶最完善,但实际工程中令牌桶才是用得最多的。不是因为谁更好,是因为大多数场景下,你不需要绝对的平滑,你需要的是一套够简单、能扛住典型突发、Redis 能撑住的方案。

回过头来看,不是比哪个方案更精确,是想清楚你卡在哪:

  • 单机跑、流量不大、计数器丢了也无所谓 → 固定窗口。简单到没理由不用。
  • 单机但想精确一点 → 滑动窗口。多出来的内存就当换精度。
  • 多实例且要精确的分布式限流 → Redis 滑动窗口。但要掂量一下每个请求三次 Redis 调用的开销。
  • QPS 高、Redis 不想被频繁调用 → 令牌桶。一个 DECR 就完事,轻量到你甚至可以在网关层就做了。
  • 下游极其脆弱、一点流量尖刺都不能有 → 漏桶。比如你在调按量付费的第三方 API,多打一次就多收一次钱。
  • 不需要精确限流,只是想做个流控保护 → 回到令牌桶。这就是为什么大多数网关(Nginx、Kong、APISIX)默认都是令牌桶——够用、够轻、够简单。

最终选什么不取决于「滑动窗口比固定窗口好」,而是你当前服务的 QPS、实例数、对 Redis 的依赖程度、以及下游能承受多大的突发。