限流不是加个开关:令牌桶、漏桶和那几个容易误伤的细节
限流不是加个开关:令牌桶、漏桶和那几个容易误伤的细节
限流几乎是每个对外接口都要做的一件事,但真正做对的不多。常见的两种极端是:要么只在前端加个按钮防连点,后端裸奔;要么拍一个"每秒 100 次"写死,结果大促当天把自己人拦在门外。
先明确一件事:限流的首要目的不是防攻击(那是 WAF 和风控的事),而是保护自己——让下游数据库、第三方接口、线程池不被瞬时流量打穿。想清楚这一点,参数怎么定就有了依据。
一、令牌桶和漏桶,差别在"允不允许突发"
两种算法的名字很形象,但很多解释绕了远路。一句话版本:
- 令牌桶:桶里按固定速率攒令牌,请求来了就取一个。桶有容量上限,所以攒着的令牌可以一次性用掉——也就是允许一定程度的突发。
- 漏桶:请求先进队列,出口按固定速率流出。出口速率绝对平滑,突发被削平,代价是排队延迟。
选哪个取决于业务:面对的是"用户手速偶尔快一下",用令牌桶;面对的是"下游只能承受恒定速率"(比如给第三方接口做代理),用漏桶。
绝大多数业务场景该选令牌桶,因为把偶发突发也拦掉,用户体验的损失远大于那点保护收益。
二、固定窗口的临界问题
最简单的实现是固定窗口计数:每分钟清零一次,计数到阈值就拒绝。它有个经典缺陷——窗口边界会放过两倍流量。
比如限制每分钟 100 次。某个客户端在 12:00:59 打满 100 次,12:01:00 计数清零后再打 100 次。这两百次请求实际集中在两秒内,但任何一分钟的窗口看都"没超限"。
解决办法有两个:
- 滑动窗口:按最近 60 秒滚动统计,边界问题消失,但要存每个请求的时间戳,内存成本高;
- 令牌桶:天然没有窗口概念,攒令牌的速度就是速率,边界问题不存在。
如果只是想低成本修掉临界突刺,还有一种折中:双窗口加权——用当前窗口和上一窗口的计数按时间比例加权,误差可控且只需存两个整数。
三、实现上的两个硬要求:原子性和单调时钟
自己实现计数器时,最容易踩的两个坑是:
第一,读改写不是原子的。 GET 拿到当前值、判断、再 INCR,三步之间会被并发插队,实际放过的请求数会明显超过阈值。单机要用原子操作或互斥锁;跨进程用 Redis 时,必须把"判断+自增"写进一段 Lua 脚本,靠 Redis 的单线程执行保证原子性。分段 GET 再 INCR 的写法在压测里能超限 30% 以上。
第二,别用系统时间做速率计算。 系统时间会被 NTP 校正,甚至被人手动改。前后两次取时间如果发生了回拨,算出来的间隔是负数,令牌会被一次性灌满。计算速率要用单调时钟(比如 elapsed 计时器那类),它只保证递增,不受校时影响。
四、分布式限流的三种粒度
- 单机限流:够用,且没有网络开销。只要实例数固定、流量被负载均衡摊平,单机阈值 = 总量 / 实例数就行。缺点是扩容时阈值要跟着改。
- 集中式限流:所有实例打同一个 Redis key,全局精确。代价是每次请求多一次网络往返,Redis 本身成了瓶颈和单点。
- 混合式:全局配额按实例数切片,各实例本地限流 + 定期向中心申请/归还配额。复杂度高,但兼顾了准确性和性能。
大多数团队停在第一种就够了。真正需要全局精确的场景,往往说明配额本身就是业务资源(比如按租户卖的调用次数),那更该走"配额管理"而不是"限流"。
五、几个一定会误伤的细节
按来源地址限流会误伤 NAT 后面的所有人。 一个公司的出口可能是同一个地址,一个人刷爆,全公司被拦。同理,运营商的大规模地址转换会让"按地址"的语义变得很模糊。能给到用户维度的,就别用地址维度。
拒绝的时候要给客户端台阶。 直接返回一个笼统的错误码,客户端只能无脑重试,反而放大流量。标准做法是返回 429,并带上 Retry-After 告诉对方多久后再来;如果知道剩余配额,一并放在响应头里。
限流必须可观测。 至少要有三个指标:被限流的请求数、限流触发时的阈值、以及误伤率(被限流的用户里有多少是正常用户)。没有第三个指标,你永远不知道自己是不是拦错了人。
限流是最后一道,不是第一道。 前面该有的排队、降级、缓存、异步化都没做,只靠限流硬扛,等于把系统设计问题变成了"拒绝多少用户"的问题。
限流真正的难点从来不是算法,而是定阈值和选维度:拦谁、拦多少、拦了之后对方怎么办。这三个问题想清楚了,令牌桶那几十行代码反而是最简单的部分。
- 点赞
- 收藏
- 关注作者
评论(0)