限流器单机算得再准,为什么一上集群就超发
限流器是在请求进入业务逻辑前做准入判断的组件,用单位时间配额把流量挡在系统能承受的范围内。常见实现有固定窗口、滑动窗口、令牌桶和漏桶,四者的差别集中在是否允许突发、输出是否平滑,而不是名字好不好听。很多人把它当成单机计数器,或者以为每个实例配同样的阈值就等于全局限流,结果副本一扩,真实上限跟着翻倍。读完你能说清几种算法的取舍,知道 Redis 加 Lua 为什么必须原子,也能判断该集中计数还是用配额租借来换延迟。
title: 限流器单机算得再准,为什么一上集群就超发 date: 2026-11-06T16:00:00+08:00 type: kb tags: [科普] status: published summary: 限流器是在请求进入业务逻辑前做准入判断的组件,用单位时间配额把流量挡在系统能承受的范围内。常见实现有固定窗口、滑动窗口、令牌桶和漏桶,四者的差别集中在是否允许突发、输出是否平滑,而不是名字好不好听。很多人把它当成单机计数器,或者以为每个实例配同样的阈值就等于全局限流,结果副本一扩,真实上限跟着翻倍。读完你能说清几种算法的取舍,知道 Redis 加 Lua 为什么必须原子,也能判断该集中计数还是用配额租借来换延迟。 ---
是什么
限流器是一层在请求到达业务逻辑之前做准入判断的组件:它给某类流量定一个单位时间的通过上限,超出的请求被拒绝、排队或降级。一句话直觉:它不是把门关上,而是给门装了一台按秒计数的闸机,决定这一秒能放多少人进去。
典型场景有四类:网关挡住刷接口的脚本;保护下游数据库或第三方 API 不被瞬时流量打穿;多租户系统里按套餐分配调用配额;以及给大模型调用设 token 预算。它和熔断、降级、并发控制常被混为一谈。熔断看的是依赖已经失败,降级是失败后的兜底,并发控制限制的是同时在途的请求数,而限流限制的是单位时间进入的速率。四者解决的不是同一个问题,混着答就会在面试里露怯。
核心原理
限流的本质是把"速率"换算成一个可判定的数。所有算法都在回答两个问题:现在还剩多少额度,这次请求扣不扣得起。
固定窗口最省事:给每个自然分钟建一个计数器,到点清零。代价是边界效应——上限设为每分钟 100,客户端在 12:00:59 打完 100 个,12:01:00 再打 100 个,两秒内实际通过了 200 个,下游看到的是双倍突发。滑动窗口日志把每次请求的时间戳记进有序集合,按"当前时间减窗口"滚动统计,精度最高,但每个 key 要存 O(上限) 条记录。滑动窗口计数做了折中:把窗口切成若干小格,用上一格的计数乘以剩余比例再加上当前格,误差约一个格子,内存只要两个计数器,是 Redis 场景最常见的默认选择。
令牌桶换了个思路:系统按固定速率 r 往桶里放令牌,桶容量 B 是上限;请求来了就取一枚,取不到就拒绝。它允许突发——空闲一段时间后桶是满的,最多可以一次性放行 B 个请求——但长期平均不会超过 r。漏桶则相反:请求先进队列,以恒定速率流出,队列满了就丢。它不奖励空闲,输出被抹平成一条直线,适合需要严格整形的场景,比如给处理能力固定的下游喂数据。
两者常被答成"一个存令牌、一个存请求",这只是表象。真正的区别是:令牌桶约束平均速率、允许有界突发;漏桶约束瞬时输出速率、不允许突发。判断该用哪个,先看下游能不能扛住一个桶容量的瞬时冲击。
单机实现只要一个结构体和一把锁,但生产环境几乎都是多实例。问题随之而来:每个实例各配 100 QPS,部署 10 个副本,真实上限就是 1000 QPS,而且负载均衡不会绝对均匀,某台机器可能先被打满。把计数集中到 Redis 能解决一致性,代价是每次请求多一次网络往返,热点 key 还会把单个分片打爆。常见的折中是"配额租借":中心一次发给实例 100 枚令牌,实例在本地扣减,快用完时再申请。这样大部分请求只走本地内存,代价是实例崩溃时手里没花完的令牌会被浪费,用精度换性能。
-- 令牌桶:KEYS[1]=桶, ARGV={速率/秒, 容量, 当前毫秒, 本次消耗}
local rate, cap = tonumber(ARGV[1]), tonumber(ARGV[2])
local now, cost = tonumber(ARGV[3]), tonumber(ARGV[4])
local v = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens, ts = tonumber(v[1]) or cap, tonumber(v[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate / 1000)
local ok = tokens >= cost
if ok then tokens = tokens - cost end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 2000))
return ok and 1 or 0
这段脚本的关键不在算法,而在"读、算、写"必须在一个原子操作里完成。若拆成 GET 再 SET,两个实例可能同时读到满桶、各扣一次,超发就发生了。时间也要用统一时钟,由调用方传入 Redis 的 TIME 而不是各实例的本地时间,否则时钟回拨会让桶瞬间回满。
常见考点与误区
- "令牌桶和漏桶差不多":面试官追问的是突发。令牌桶允许最多 B 个请求的突发,长期平均等于 r;漏桶的输出速率恒定,队列只是缓冲,不产生突发。答不出"谁平滑、谁允许突发",基本等于没答。
- "固定窗口够用了":边界双倍是最经典的追问。窗口越短,边界效应占比越小,但存储和过期成本越高。要能说出"两倍突发"这个量级,而不是笼统说"不太准"。
- "每个实例配一样的限额就行":这是集群超发的根源。总配额是单机配额乘以副本数,扩缩容时还会漂移。要么集中计数,要么按权重分配配额并接受误差。
- "限流就是熔断":限流控制进入速率,熔断在依赖已经大量失败时直接切断,避免继续排队。把两者混在一起,就答不出"依赖恢复后为什么要半开探测"。
- "INCR 加 EXPIRE 就是限流":两条命令不是原子的。若在 INCR 之后、EXPIRE 之前进程崩溃,这个 key 就永不过期,用户被永久封禁。必须用 Lua 脚本,或带 EX 参数的 SET 这类单条原子命令。
- "限流器越精确越好":精确意味着每个请求都要同步一次中心存储,延迟和热点成本都会上升。多数业务能接受百分之一到百分之五的超发,换来的是本地判断的微秒级延迟。
实践建议
- 先写清保护目标再选算法:保护自己用入口 QPS 加令牌桶;保护下游用并发信号量或漏桶整形;多租户公平用按 key 的配额;成本控制按 token 或调用量计费,别拿 QPS 硬套。
- 拒绝要可协商:返回 429,带上
Retry-After和剩余额度,让客户端知道等多久;不要静默丢弃,也不要无限排队,排队会把连接和线程占住,尾延迟比直接拒绝更糟。 - 热点 key 做两层:本地令牌桶挡住绝大部分判断,中心只负责按租约发放配额;租约大小按"一个往返内可能消耗的量"估算,太大浪费额度,太小退化成每请求一次往返。
- 键的设计要防爆炸:优先用 user_id 或 API key,不要用原始 IP(NAT 后面大量用户共用一个出口);给每个 key 设 TTL,并限制 key 基数,避免被随机参数刷出内存泄漏。
- 客户端必须配合:只对幂等请求重试,用指数退避加随机抖动,否则被限流的客户端会同时重试,把限流器变成流量放大器。
- 上线分两步:先只记录不拒绝,观察真实流量分布和被拒比例,再收紧阈值;同时监控桶水位、Redis p99 和租约申请失败率,这些指标比"被拒数"更早暴露问题。
一句话总结:限流器算的不是流量,是配额;单机算得再准,配额不集中就一定会超发。