提升多网关实例下的限流精度
从 API7 网关 3.10.1 起,limit-count 插件可以在多个网关实例之间使用 Redis 保存共享限流计数器。你可以让每个请求都同步 Redis(sync_interval: -1),也可以批量同步(sync_interval: 0.1 或更大)。批量同步为每个请求省去一次 Redis 往返,是高流量场景下的现实选择,但它意味着每个实例基于最长可能滞后一个同步间隔的计数数据做放行决策,由此引入一个有界的计数误差。
旧版 limit-count-advanced 插件仍作为同一实现的兼容包装器保留,但新配置应使用 limit-count。本指南介绍的延迟同步、滑动窗口和基于规则的功能在 3.10.0 中不可用。
本文解释该误差的来源、量级边界,以及滑动窗口算法为何能把长期误差收敛到接近零。如果你在开启延迟同步的前提下对限流精度有要求,请使用 window_type: sliding。
延迟同步的工作方式
当 sync_interval 为正值时,每个网关实例会:
- 基于共享内存中的本地计数器处理请求:当
缓存的全局剩余额度 − 上次同步以来的本地增量 > 0时放行请求。 - 每隔
sync_interval秒,把本地增量以一次原子操作写入 Redis,并缓存返回的全局剩余额度。
在两次同步之间,实例之间看不到彼此的本地增量。所有已放行的请求最终都会写入 Redis,因此共享计数器总会收敛到真实的放行总量——但每个实例要到下一次同步时才能得知其他实例的消耗。
◀── sync_interval ──▶
网关 A ──────●━━━━━━━━━━━━━━━━━━━●━━━━━━━━━━━━━━━━━━━●──▶
Redis ──────●───────────────────●───────────────────●──▶
网关 B ──────●━━━━━━━━━━━━━━━━━━━●━━━━━━━━━━━━━━━━━━━●──▶
● = 把本地增量原子写入 Redis,并刷新缓存的全局剩余额度
两个 ● 之间:各实例只凭「缓存额度 − 本地增量」放行,彼此不可见
误差的来源
数据的滞后窗口会产生确定且有界的超放:
- 持续流量:每个实例始终能看到自己的放行量,它看不到的只是其他实例在一个同步间隔内的放行量。设均衡分流的实例数为
N、单实例请求速率为r、同步间隔为s秒,每个窗口的总超放约为(N − 1) × r × s。下文双实例实测与之吻合:1 × 400 × 0.2 = 80,即每窗口约多放 80 个。 - 最坏情况:如果一次突发在新窗口开始后的一个同步间隔内到达所有实例,最多可放行
实例数 × count个请求,因为每个实例都从满额的缓存额度起步。
这个误差是设计上的权衡,而非缺陷:批量同步把每请求一次的 Redis 往返变成每实例每间隔一次。你可以调小 sync_interval(最小 0.1,且必须小于 time_window)来收窄误差,或用 sync_interval: -1 彻底消除延迟同步引入的误差,代价是每个请求都要承担 Redis 往返延迟。
固定窗口让误差逐窗重复
固定窗口在每个窗口边界把计数器清零。每次清零后,所有实例又都从满额的缓存额度起步,因此超放会在每个窗口重复出现,且不会被追偿。
以下结果来自对两个网关实例(前置轮询负载均衡器,均匀分流,共享 Redis)的压测:持续约 800 QPS,sync_interval: 0.2,1 秒窗口场景压测 30 秒、10 秒窗口场景压测 90 秒。数值是该负载形态下的实测观察,用于展示两种窗口类型的行为差异:
| 限流配置 | 固定窗口放行量 | 误差 |
|---|---|---|
| 每 1 秒 500 个 | 每个窗口约 579 个(约 579 个/秒) | 每个窗口重复 +16% |
| 每 10 秒 1000 个 | 每个窗口 1085–1087 个 | 每个窗口重复 +8.5% |
窗口内的流量形态同样糟糕:额度在窗口开始时被突发耗尽,之后所有请求都被拒绝,直到下一个边界。在 10 秒窗口的测试中,后端在每个窗口的前 2 秒收到约 1085 个请求,之后 8 秒没有请求被放行。
每秒放行量 —— 固定窗口,限流 1000 个 / 10 秒
600 ┤ ▐█ ▐█ ▐█
400 ┤ ▐█ ▐█ ▐█
200 ┤ ▐█ ▐█ ▐█
0 ┼ ▐█▁▁▁▁▁▁▁▁▁▁▁ ▐█▁▁▁▁▁▁▁▁▁▁▁ ▐█▁▁▁▁▁▁▁▁▁▁▁ ──▶
└─── 窗口 1 ─────┴─── 窗口 2 ─────┴─── 窗口 3 ───
1085 (+8.5%) 1086 (+8.6%) 1085 (+8.5%)
窗口开头突发、随后 8 秒放行为 0 —— 每个窗口重演
滑动窗口吸收并追偿误差
滑动窗口把上一窗口的放行量按比例计入当前预算,消耗历史能够跨越窗口边界存续。任何短期超放都会记录在案:它会扣减下一个窗口的预算,长期平均值因此收敛到配置的限值。
相同测试改用 window_type: sliding:
| 限流配置 | 滑动窗口放行量 | 误差 |
|---|---|---|
| 每 1 秒 500 个 | 约 445 个/秒 | 稳态平均约 −10%;测试期间的长期平均值低于限值 |
| 每 10 秒 1000 个 | 首窗口 +8.2%,后续窗口在 ±3.5% 内震荡 | 全程累计 +0.2% |
流量形态也随之改善:不再是“突发—饥饿”循环,网关以稳定的 count / time_window 速率放行——在 10 秒窗口的测试中约为每秒 100 个。滑动窗口还避免了固定窗口经典的边界问题:跨越两个窗口的短时间内最多可能通过两倍限值的请求。
每秒放行量 —— 滑动窗口,限流 1000 个 / 10 秒
600 ┤
400 ┤
200 ┤
0 ┼ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ──▶
└─── 窗口 1 ─────┴─── 窗口 2 ─────┴─── 窗口 3 ───
1082 (+8.2%) 1004 (+0.4%) 980 (−2.0%)
每秒稳定约 100 个;首窗口的超放由后续窗口逐步追偿
为什么 1 秒窗口的测试低于限值约 10%
滑动窗口的预算随上一窗口请求的老化而持续释放,但实例只有在下一次同步时才能看到新释放的预算。因此,间隔中途释放的额度最长可能要等一个完整的 sync_interval,实例才能使用它。此外,实例在同步时如果发现窗口已满,会在下一次同步之前拒绝所有请求——即使这段时间里预算仍在持续释放。
这两个效应都与 sync_interval 占 time_window 的比例同尺度——即“过期视图”覆盖窗口的比例:
sync_interval : time_window | 实测向下偏差 |
|---|---|
0.2 / 1 |