提升多网关实例下的限流精度
当多个网关实例共同执行同一条限流规则时,limit-count-advanced 插件将计数器保存在 Redis 中。你可以让每个请求都同步 Redis(sync_interval: -1),也可以批量同步(sync_interval: 0.1 或更大)。批量同步为每个请求省去一次 Redis 往返,是高流量场景下的现实选择,但它意味着每个实例基于最长可能滞后一个同步间隔的计数数据做放行决策,由此引入一个有界的计数误差。
本文解释该误差的来源、量级边界,以及滑动窗口算法为何能把长期误差收敛到接近零。如果你在开启延迟同步的前提下对限流精度有要求,请使用 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 个/秒 | 每个窗口重复 +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(20%) | 约 −10% |
0.2 / 10(2%) | 每窗口约 −1%,全程累计 +0.2% |
要让滑动窗口贴近限值,请把 sync_interval 控制在 time_window 的小比例(约 5% 以内)。这个偏差本身偏向安全一侧:稳态下后端收到的流量略低于限值,而不是反复超限。全新窗口仍可能出现一次性超放(上界见前文 实例数 × count),但滑动窗口会在后续窗口把它追偿回来。
滑动窗口不能改变什么
在单个全新窗口内——例如计数器刚启用后的最初几秒内的突发——两种窗口类型的误差边界相同:每个实例在首次同步前最多可放行整个 count,因此一次突发最多可通过 实例数 × count 个请求。差别出现在其后:滑动窗口会在后续窗口中追偿这次超放,固定窗口则每个窗口都重演一次。
做容量规划时,只要开启了延迟同步(sync_interval > 0),无论使用哪种窗口类型,都应按每窗口 实例数 × count 的最坏情况预估后端压力。
配置建议
| 目标 | 配置 |
|---|---|
| 开启延迟同步的同时保证限流精度 | window_type: sliding |
| 平滑、可预期的后端负载 | window_type: sliding |
| 更小的短期误差 | 调小 sync_interval(最小 0.1,须小于 time_window) |
| 让滑动窗口贴近限值 | 把 sync_interval 控制 在 time_window 的约 5% 以内 |
| 精确限流,且可接受每请求一次 Redis 往返 | sync_interval: -1 |
配置示例:
{
"limit-count-advanced": {
"count": 1000,
"time_window": 10,
"window_type": "sliding",
"key": "consumer_name",
"key_type": "var",
"policy": "redis-cluster",
"redis_cluster_name": "my-cluster",
"redis_cluster_nodes": ["redis-node-1:6379", "redis-node-2:6379"],
"sync_interval": 0.2,
"rejected_code": 429
}
}
下一步
- 配置限流 —— 配置基础和高级限流。
limit-count-advanced插件参考 —— 全部配置项说明。