同样是每秒限流,为什么效果不同:固定窗口、滑动窗口、令牌桶与漏桶
用同一组跨秒请求,讲清固定窗口的边界突发、滑动窗口的精确与近似、令牌桶的突发预算、漏桶的排队代价,再解释 Redis 分布式限流的原子判断和故障取舍。
一个接口配置了“每秒最多 100 次”,监控里却出现了很尖的一波请求。另一个接口也配置 100 次,用户连续点几下就被拒绝。第三个接口没有马上拒绝,却让请求等了几百毫秒。
这几个表现都可能来自正常工作的限流器。“100 次”还没有说明怎么统计时间、空闲时能不能攒额度,以及超额之后是拒绝还是等候。算法不同,这些规则就不同。
限流属于高并发下的容量保护。只有当多个实例需要共享一个额度时,它才进一步涉及分布式协调。单机也要限流,采用微服务也不代表每个限流器都必须访问 Redis。
下面用一个商品查询接口讲清四种算法。业务需求和数字都是用于推演的假设;具体实现边界参考 Redis、Go 和 NGINX 的官方文档。限流与熔断、并发控制怎样组合,可以接着看过载治理那篇文章。
一、先说清楚,限流器到底在管什么
假设一个查询接口能承受长期每秒 100 次请求,但活动入口打开时,访问可能集中到某几个毫秒。每个请求在执行查询前先经过限流器。限流器维护一份额度状态,判断这次能不能进入;能进入才继续访问数据库,不能进入就返回限流结果,或者按事先约定短暂排队。
限流器不会因为拒绝请求就让请求凭空消失。网关仍要收包、解析必要的信息、查找限流规则;客户端仍需要收到明确结果。因此,保护数据库的业务限流不能替代网络入口的防护。
“每秒 100 次”至少有三种意思
第一种是每个整秒最多 100 次。00:00:00 到 00:00:01 算一个窗口,下一秒另算。它容易实现,也容易解释账单周期,但相邻窗口互不关心。
第二种是无论什么时候回头看,最近一秒内最多放行 100 次。00:00:01.200 看的是 00:00:00.200 到当前。这就需要滑动统计,不能只在整秒清零。
第三种是长期按每秒 100 次恢复额度,但允许短时间集中花掉积攒的额度。例如可以突然进入 20 次,此后额度逐步恢复。它适合容忍小突发的在线接口,但与“任何一秒最多 100 次”并不等价。
还有一个独立问题:拿到额度后,是马上进入业务,还是按固定间隔进入?统计最近一秒的总量,并没有要求这一秒里的请求均匀分布。100 次全挤在同一毫秒,也可能符合某种窗口规则。
这些要求需要分别写进配置含义,不能只给一个 QPS。QPS 是每秒请求数,通常作为速率指标使用;它本身没有指定限流算法。
限速和限制并发要一起分清
请求进入速度受控,仍可能积累大量正在处理的请求。
每秒进入 100 次,平均耗时 10 毫秒时,平均在途请求大约是 1 个;数据库变慢到 2 秒,平均在途请求就可能接近 200 个。这是稳定条件下用“平均速率 × 平均耗时”做的容量估计,不能当成每个时刻都成立的硬上限。
如果需要保证最多 50 个请求同时访问数据库,还应在调用前拿一份并发许可,结束后归还。速率额度通常消费掉就不会归还;并发许可在请求结束时归还。二者记录的状态不同。
本文先统一采用一个简单口径:每个请求消耗 1 份额度,只有放行的请求写入用量,被拒绝的不计入。登录防刷也可以统计所有尝试,包括失败和被拒绝的尝试,但那是另一种计数政策,不能混在推演里。
二、固定窗口:整秒没超额,为什么瞬间还是挤进来很多
先把时间分成长度为 1 秒的格子,每个格子存一个计数:
[0.000, 1.000) 计数最多 100
[1.000, 2.000) 计数最多 100
[2.000, 3.000) 计数最多 100
左边包含、右边不包含。因此恰好 1.000 秒的请求属于第二个格子。
开始时计数为 0。请求进来,限流器找到当前格子;计数小于 100,就加 1 并放行;已经达到 100,就拒绝。切换到新格子后,使用新格子的计数。这就是固定窗口计数器。
用下面这组请求观察它:
此前没有请求
t = 0.990 秒:100 次请求到达
t = 1.010 秒:100 次请求到达
此后暂时没有请求
第一批属于第一个窗口,100 次全被放行。第二批属于第二个窗口,也全部放行。两个窗口分别没有超额,但两批只隔了 20 毫秒,数据库在这一小段时间里收到 200 次查询。
问题来自整秒边界清空了之前的记忆。1.010 秒时,限流器没有检查 0.990 秒的那批请求是否仍在“最近一秒”内,因为它的规则只要求当前整秒不过量。
如果“100 次”是产品明确约定的整秒额度,这个结果并没有违约。若目标是压住很短时间内的尖峰,固定窗口就不够;它不仅有跨边界问题,单个窗口内的 100 次也可以集中到达。
为什么简单计数也会在并发下出错
假设计数是 99,两个线程同时查询,都读到 99,都判断可以放行,然后分别更新。数据库实际收到两个请求,但计数最终可能只有 100。即使更新不丢失,变成 101,如果放行判断发生在更新之前,也已经多放了一次。
判断和占用额度必须连在一起。本地可以在同一把锁内完成;Redis 可以用短 Lua 脚本一次完成读取、判断和更新。
另一个常见实现是先用 INCR 原子自增,得到结果后,只有结果不大于 100 才放行。这个方式计入了所有尝试,包括被拒绝的请求。它也能保证当前窗口最多放行 100 次,但计数可能远大于 100,不能拿这个计数当成实际放行量。
计数键还需要过期,防止旧窗口一直占内存。若 INCR 成功后应用崩溃,EXPIRE 没有执行,键就可能长期留下。Redis 的 INCR 文档专门说明了计数与过期之间的这类问题。这里可以把设置过期也放进脚本。
固定窗口的边界由时间格子决定,过期主要负责清理。如果每个格子有独立键,就不要把“这个键什么时候被删”误当成窗口定义。旧键必须活到它所属的窗口结束,新窗口必须使用新状态;多个机器也要按同一套时间依据划分格子。
固定窗口适合规则本身就是固定周期、边界突发可以接受的额度管理。用它保护一个对瞬时流量很敏感的下游,还需要更细的速率或并发限制。
三、滑动窗口:把刚刚发生的请求也算上
还是刚才的两批请求,但判断规则改成“最近一秒最多 100 次”。
0.990 秒时,之前没有请求,第一批 100 次被放行。1.010 秒时,最近一秒的区间是:
(0.010, 1.010]
0.990 秒的 100 次仍在区间内。因此第二批被拒绝。到了 1.990 秒,第一批恰好越过左边界,才腾出额度。这里约定左边界不包含,所以恰好一秒之前的请求不再计入。
窗口没有在整秒处重置,它跟着每次判断的时间往前移动。这是“滑动”的含义。
精确滑动日志:逐条记住放行时间
要准确回答“最近一秒有多少次”,最直接的办法是保存每次放行的时间。
本地可以用按时间排列的队列。新请求进来,删除队头中不晚于“当前时间减一秒”的记录;剩余数量小于 100,就追加当前时间并放行,否则拒绝。
Redis 里可以用 ZSet,也就是按分数排序的集合。时间戳作为分数,每个请求使用唯一标识作为成员。操作顺序相同:删除过期记录、检查数量、按条件新增记录。Redis 的算法示例给出了这种滑动日志实现。
这里用到了 ZSet 的按时间范围删除能力。给请求排序是为了找出谁已经离开窗口,并不涉及业务排行榜的名次。
成员不能只写时间戳。多个请求可能在同一毫秒到达,如果成员都叫“1010”,新增就会覆盖同一成员,100 次被记成 1 次。需要为每次计数的请求生成唯一标识,时间相同也保留多条记录。
删除、计数、新增必须属于同一次原子判断。如果数量是 99,两个调用各自查到 99 后都新增,窗口里就会有 101 条。把单条命令做成原子操作,并不能让这三步自动变成一个整体。
这种实现记住了每条记录,所以状态比固定计数器多。只记录放行请求时,每个限流维度在有效窗口内最多保存上限数量的记录;假设有百万个活跃用户,每人上限 100 次,理论上就可能有很大规模的记录和键。用户数、窗口长度、额度必须一起估算,不能只看单个集合很小。
精确滑动窗口也允许突发。刚空出的窗口可以在同一时刻放行 100 次。它保证的是任意回看一秒的总量,不保证相邻请求间隔 10 毫秒。
近似滑动计数:省掉时间戳,就要接受误差
保存每条时间戳比较贵,可以改成只保存当前整秒和前一整秒的计数。
假设现在是 1.200 秒。最近一秒是 0.200 到 1.200,覆盖前一个整秒的后 80%,以及当前整秒已经走过的 20%。一种常见估算是:
估计用量 = 前一整秒计数 × 0.8 + 当前整秒计数
前一秒有 100 次,当前还没有,就估计用了 80 次。按“估计用量加上本次不超过 100”的规则,它可以继续放行 20 次。
这一步隐含了一个假设:前一秒的请求大致均匀分布。但原来的 100 次全在 0.990 秒,实际还一条都没过期。估算却已经打了八折,于是多放了 20 次。
反过来,如果前一秒的 100 次都在 0.100 秒,它们在 1.200 秒时已经全部过期;估算仍算了 80 次,这次就少给了额度。
同一个计数“100”,对应的真实时间分布可以很不一样。丢掉逐条时间戳以后,算法无法恢复这份信息。因此,近似计数并不能严格保证任意一秒最多 100 次,突发越集中,误差越需要认真考虑。
另一种办法是把一秒划成十个 100 毫秒的小格,保存各格计数。每次向前移动,淘汰旧格并合计还有效的格子。它比只有两个整秒计数更细,但窗口左边界通常会切过一个小格;这个格子里究竟有多少记录已经过期,仍然不知道。完整保留会偏保守,按比例估算仍然有误差,直接丢掉可能多放。
选滑动窗口前要问清实现是哪一种。产品需要严格的滚动次数上限,精确日志更贴近规则;入口只希望减少固定窗口的边界波动,且计数维度很多,可以考虑近似计数,并给后端容量留出余量。
四、令牌桶:为什么能容忍突然点几下,又不会一直无限放行
有些在线请求天然会集中到达。用户打开一个页面,前端同时请求几个接口;一小批消息到达,消费者同时准备执行。只要下游有余量,这种短突发未必需要全部拒绝。
可以给限流器一个最多容纳 20 份额度的桶,以每秒 100 份的速度补充。一次请求拿走 1 份;没有额度就拒绝;桶满以后,多出来的额度不再累积。
额度通常叫令牌,这个结构就叫令牌桶。它的两个参数分别管两件事:
- 补充速度 r:长期能放行多少工作。
- 桶容量 B:空闲后最多能攒多少,下一波能突然花掉多少。
Go 的 rate.Limiter明确采用这种模型:桶初始为满,按指定速度补充,并区分立即判断、预约未来额度和等待额度的操作。本文先讨论拿不到令牌就立即拒绝的版本。
沿用两批请求,看看额度怎么变
桶初始有 20 个令牌,补充速度是每秒 100 个。此前一直空闲,所以 0.990 秒时仍然只有 20 个,不能攒成 99 个或几千个。
第一批 100 次到达,20 次拿到令牌进入,80 次被拒绝。到了 1.010 秒,过了 20 毫秒,恢复了 2 个令牌。第二批只放行 2 次,拒绝 98 次。
这个例子中,令牌桶放得比两个窗口算法都少,原因是我们给它的突发容量只有 20。若把容量改成 100,第一批会放行 100 次,第二批放行 2 次。容量变大,能吸收的突发就变大,下游也必须承受更多同时进入的工作。
在持续时间为 T 秒的区间里,按每次消耗 1 个令牌计算,理想模型最多放行约 B + r × T 次:区间开始可以有一整桶,之后时间又带来新令牌。因此 r = 100、B = 20 并不承诺任意一秒最多 100 次,一秒内可以用掉初始的 20 份和陆续补充的 100 份。
令牌桶表达的是“持续预算加突发预算”。需求若是严格滚动上限,不能把补充速度直接当成那个上限。
令牌一定要有后台线程不断往桶里放吗
不需要。可以在每次请求进来时,根据距离上次更新经过多久,算出应当补了多少。这叫按需补充,桶只需要保存“剩余令牌”和“上次更新时间”。
以下步骤作为一个原子操作执行,时间单位统一为秒:
elapsed = max(0, now - last_time)
tokens = min(capacity, tokens + elapsed * refill_rate)
last_time = max(now, last_time)
if tokens >= request_cost:
tokens = tokens - request_cost
保存 tokens、last_time
返回放行
else:
保存 tokens、last_time
返回拒绝及预计等待时间
例如上次更新后剩 0 个,经过 0.1 秒,按每秒 100 个就补了 10 个。无论期间有没有请求,都得到同样的结果;无需每 10 毫秒真的执行一次加令牌任务。
上面的伪代码允许小数令牌,避免每次计算直接取整丢掉不足 1 个的积累。实际实现也可以采用整数单位,保留余数,但不能更新了时间又把余数丢掉。参数需先校验,例如速率为正、成本为正、一次成本不大于容量;否则有些请求永远拿不到令牌。
本地应该用适合计算时长的单调时钟,避免系统校时造成“突然多补一大批”的问题。多个机器共享状态时,要统一时间依据,不能让各机器用自己的墙上时钟随意补额度。时间回退可以把经过时间截为 0,但还要保证记录的更新时间不会跟着回退;大幅向前跳仍会影响补充,需要按时钟与故障假设确定容忍范围。
被拒绝时,也要正确维护补充后的状态。不能更新更新时间,却把刚算出的令牌余额丢掉;也不能保存了新余额,却留着旧时间,让下一次把同一段时间重复补一次。
状态过期也有含义。如果键删除后会按满桶重新创建,而旧桶只空闲了 50 毫秒就过期,会凭空多出一桶额度。按满桶重建的设计,应至少等到空闲时间足够自然补满,也就是 B / r;或者保存恢复状态,采用另一套明确的初始化政策。
成本不同的请求,可以消费不同数量的令牌
查一条商品和批量导出十万条记录,对系统的压力不同。全部计成一次,就可能让大请求绕过容量预算。
令牌可以代表工作量:普通查询扣 1 份,小批量查询扣 5 份。这里的权重需要来自资源成本和业务规则,不能随意认为“导出就一定是查询的五倍”。若任务成本在执行前无法估计,还需要任务大小上限、并发限制或分段执行。
桶容量同时决定大请求能否进入。例如成本是 50,容量只有 20,即使长时间空闲也永远不够。按成本计量以后,容量与速率的单位都应写成“额度”,不能继续含糊地解释成请求数。
五、漏桶:让请求慢慢出去,代价落在哪里
令牌桶有令牌时,多个请求可以立刻进入。若下游要求调用间隔大致稳定,例如发送设备命令或调用容易被突发打满的外部接口,可以在前面放一个有界队列,按固定节奏取出请求。
请求像水一样进入容器,容器的出口按指定速度流出,多出的请求在里面等,容器满了就拒绝。这个排队整形模型常用“漏桶”来解释。整形指把集中到达的请求变成较平缓的输出。
先规定一个明确版本:队列最多存 20 个待发送请求,每 10 毫秒最多启动一次调用;同一批请求先完成入队判断,再启动发送器,后续调度不把错过的时段一次性补发。忽略运行时调度开销,0.990 秒的 100 次先有 20 次入队,80 次被拒绝;发送器立即发出第一个,这 20 次在 0.990、1.000、1.010……依次发出。
第二批在 1.010 秒到达时,能进几个取决于该时刻出队与入队的先后顺序。若先完成 1.010 秒的出队,队列已经腾出 3 个位置,第二批可以再接收 3 次。这种同一时刻的执行顺序也属于实现约定,不能脱离队列状态直接说“第二批一定全部接收”。
与前几个立即放行或拒绝的算法相比,这里还有第三种结果:接收了,但尚未调用下游。排队最后一个请求大约等待 190 毫秒,实际还要叠加业务处理时间。若客户端只允许等待 100 毫秒,它可能在执行前就已经放弃。
队列不会增加下游的处理能力
发送速度为每秒 100 次,若长期每秒进入 150 次,队列每秒多出约 50 项,迟早会满。扩大容量只会推迟拒绝,同时延长等待时间。
队列需要同时约束长度和等待时长。准备出队时,应检查请求是否已经取消、是否超过截止时间;过期请求应移除,不再调用下游。否则入口看起来没有报错,下游却忙着处理用户已经不需要的结果。
同步请求排队时,还占有连接、上下文或响应缓冲区。不要为了等漏桶额度,让大量请求一直占着业务线程睡眠;可以用异步调度,但异步也不会让内存占用消失。
进程重启还可能丢掉内存队列里的请求。限流器说“已入队”不能等同于业务已完成,也不能等同于可靠保存。若任务必须在重启后继续执行,应使用具备持久化、确认和重试语义的任务系统,再在消费端控制调用速率。
定时器也不保证物理上每隔 10 毫秒准时运行。进程暂停之后,如果把“欠了 30 个发送时段”立即全部补发,又会制造突发。要平滑启动调用,应限制恢复后的发出节奏。下游完成速度则另受耗时和并发影响:平滑发送不保证平滑完成。
看到“漏桶”,不能默认它一定在排真实请求
漏桶还有不保存真实请求的计量版本。它保存一个“积压额度”,根据经过的时间减去应当漏掉的数量;新请求到来时增加成本,超过容量就拒绝。计数里的积压是虚拟状态,并不意味着请求正在某个队列等待。
如果判断通过就立即调用下游,这个版本仍可能放行容量范围内的一批突发。要实现平滑输出,还得根据计算出的等待时间真正延迟调度。只保存数字不会自动改变请求发送时间。
NGINX 的 limit_req 文档明确区分了延迟处理和 nodelay:配置允许的突发额度后,是否立即处理突发请求还取决于参数。因此比较漏桶和令牌桶时,应同时说明拒绝、等待和突发设置;不能仅凭名字判断流量一定均匀。
六、把同一组请求放回业务里,应该选哪个
到这里,原来两批请求的差异可以直接解释:
| 算法与本文参数 | 0.990 秒的 100 次 | 1.010 秒的 100 次 | 它实际约束什么 |
|---|---|---|---|
| 固定窗口,每整秒 100 次 | 放行 100 次 | 放行 100 次 | 每个固定时间格的总量 |
| 精确滑动窗口,最近一秒 100 次 | 放行 100 次 | 全部拒绝 | 任意滚动一秒的放行总量 |
| 令牌桶,100 份/秒、容量 20 | 放行 20 次 | 放行 2 次 | 持续速率与可积攒的突发 |
| 排队漏桶,100 次/秒、队列 20 | 接收 20 次,分批发送 | 示例调度顺序下再接收 3 次 | 出队节奏与待发送队列上限 |
这张表不能当成性能排名。令牌桶用了较小的突发容量,漏桶允许等待,窗口算法则可以立即集中放行 100 次。参数和服务承诺不同,“谁拒绝更少”无法说明谁更好。
选择时先写业务规则,再决定算法。
如果商家 API 的合同就是每个自然分钟 6,000 次,且允许额度在分钟内集中使用,固定窗口与合同一致。但后端不能因为合同额度没超就任其瞬间压垮,可以再加本地令牌桶和并发保护。
如果验证码规则是任意 60 秒最多发送 3 次,精确滑动窗口容易直接表达。这个额度很小,逐条保存三个时间戳的成本也相对可控。还需要手机号码等维度的规则以及日额度;按 IP 限制可能把同一网络下的正常用户一起拦住。
如果商品查询希望限制长期压力,又愿意接收页面加载造成的小突发,可以用令牌桶。容量根据允许增加的短期工作量确定,补充速度根据持续处理能力确定。两者都应随下游容量评估,不能只把平均 QPS 填进去。
如果给第三方发送任务,下游对请求节奏敏感,并且业务允许排队,可以采用排队整形。必须同时确定最长等待时间和任务可靠保存方式。对很短的在线查询,排长队往往比立即返回可重试结果更糟。
若百万用户的入口只需要大致控制频率,近似滑动计数能降低状态成本;若规则明确要求“任意一秒绝不超过上限”,近似就不能冒充精确。即使算法精确,后面的存储故障与状态丢失仍可能打破承诺。
同一接口还可以有多个保护维度:全局保护集群,租户额度保护公平性,用户额度保护滥用,单实例并发保护具体资源。共享一个全局额度,只能限制总量,不能自动保证每个租户得到公平份额。
七、部署十个实例后,怎样让额度仍然算数
算法确定以后,还有状态放在哪里的问题。
十个实例各自配置每秒 100 次,整个集群最多可以放行大约每秒 1,000 次。扩容到二十个,配额也随之增大。对保护每台机器来说,这可能合理;对“一个商家不管请求落在哪台机器,每秒总共只能调用 100 次”的合同来说,就不合理。
后者需要多个实例看到同一份额度。常见做法是各实例向 Redis 请求判断,由 Redis 维护商家对应的窗口记录或桶状态。本地限流负责当前实例,全局限流负责共享配额,两个层次可以一起使用。
共享数据,还要共享一次完整判断
假设桶里最后剩 1 个令牌。A、B 两台机器分别读取,都看到 1,各自返回放行,再写回 0。共享桶最终看起来没有负数,下游实际却收到了两次请求。
一次判断要包含读状态、计算时间变化、判断是否够用、扣除或记录用量、设置清理时间和返回结果。对同一个额度,这些步骤要作为一个整体执行。
Redis Lua 脚本的执行说明保证脚本执行期间其他命令不会插入,因此可以把这段短逻辑放在 Redis 里运行。脚本应保持小而有界,避免扫描无界记录或做长时间计算,因为执行期间也会阻塞其他请求。
使用多个键的脚本还要符合 Redis Cluster 的槽位约束。比如近似滑动计数要读前后两个窗口,它们应使用同一组 hash tag,让相关键位于同一槽。不要为了脚本方便,把所有商家都放进一个全局大键;热门商家和全局计数本来就可能成为热点,集中更多数据会进一步扩大影响。
原子执行解决的是同一状态的并发插入问题,不保证业务调用与扣额度一起提交。拿到令牌后进程崩溃,这份额度可能已经用掉,却没有发起查询;查询失败后通常也不自动退令牌,因为失败调用仍然消耗了资源。若把速率额度当成库存或账务余额,需要另外设计业务一致性,不能直接使用这种简单限流状态。
Redis 超时后,不能确定“肯定没扣”
A 发出判断请求,Redis 扣了额度,但响应在网络中丢了。A 看到超时,无法知道额度是否已经扣除。重新执行一次,可能重复消耗;直接放行,则可能没有经过有效判断。
接口应先明确按“请求尝试”还是“逻辑操作”计数。容量保护通常按实际尝试计数,重试也产生负载,重新扣额度可以是合理政策。如果需要限流判断本身可重试不重复扣,可以用请求标识保存判断结果,在有效时间内对同一标识返回原结果,同时验证标识对应的调用内容,避免随意重复利用许可。它需要额外状态和明确的使用边界,不能简单地把订单 ID 作为永久免计数凭证。
Redis 不可用时,也必须预先选好动作。直接拒绝比较保守,但正常请求也会被挡;继续放行能维持入口可用,却可能让保护失效;改用较低的本地上限能保留部分保护,但所有实例上限之和才是故障期间的集群预算,扩容也会改变它。
对于保护昂贵下游的接口,我会优先保留保守的本地速率与并发边界;对于严格外部合同额度,无法取得共享额度时是否允许超额,需要产品与业务一起确定。不能用一句“失败就放行”覆盖所有场景。
共享状态重启、被淘汰或在主从切换中丢失,也会重新产生额度。状态的保存、淘汰策略和故障容忍范围属于配额保证的一部分。脚本正确不等于故障时额度绝不会超。
如果每次访问共享状态的成本太高,也可以让实例批量领取一小份额度,再本地消耗。好处是减少网络调用;代价是有的实例拿着额度却没用,别的实例已经耗尽。若额度有租约,到期回收以后还要防止旧实例继续使用已回收的份额,否则会重复发放。它适合允许一定误差的预算分配,严格实时额度则需要更谨慎的协议。
八、拒绝之后,要让调用方知道该怎么办
限流结果应该和业务错误区分开。例如查询没有进入数据库,就不能回复“商品不存在”;任务只是等待额度,也不能回复“发送成功”。
HTTP 可以返回 429,表示请求过于频繁,并按需要带上 Retry-After,告诉调用方何时再尝试。RFC 6585定义了这个状态。这个等待提示并不是预留了未来额度:其他请求也在竞争,到了提示时间仍可能被拒绝。
固定窗口可以提示下一窗口时间;精确滑动日志可以根据最旧记录何时移出推算;令牌桶可以根据缺少的令牌数除以补充速度估计。近似窗口的提示也只是估计,不能承诺恢复瞬间一定有额度。
调用方应等待、增加少量随机抖动,并限制重试次数与总时长。若一万个客户端都在下一整秒立即重试,就会一起撞到新窗口。限流器仍然工作,入口和依赖却要反复承担这一波处理成本。
上线后至少分别观察到达、放行、拒绝、实际启动调用与完成的数量。排队整形还要看队列长度、等待时间、超时取消数量。只看“放行率”,可能遗漏已经接收却没有及时执行的请求;只看平均 QPS,也可能遗漏几毫秒内的突发。
规则调整也应检查新旧状态如何衔接。把桶容量从 100 改成 20,需要把已有余额截到新容量;把窗口长度改大,旧的记录可能不够覆盖新窗口;清空所有键再启用满桶,会让所有用户同时获得新的突发额度。这些都是配置变更带来的行为,不是算法名称能替我们决定的。
对于一个普通查询接口,最终配置可以写成这样的业务约定:每个租户长期恢复 100 份/秒,最多积攒 20 份;拿不到立即返回限流;实例内部最多同时执行 50 次查询;共享判断失败时进入保守本地限制。至于这几个数是否合适,需要依据真实请求成本和下游容量决定。规则写到这个程度,才知道限流器放过了什么、挡住了什么,以及故障时还有哪一层保护。
如果这篇文章对你有帮助