病根:格子是死的
上一篇结尾的突刺场景值得再细看一遍:9:59:59.9 到 10:00:00.1,200 毫秒里放行 400 个请求。病根不在计数器,在「格子是死的」——窗口边界是一条免检通道,边界两侧各有一整格的额度,扎堆打在边界上就翻倍。磨平突刺有两条路:要么让统计区间跟着时间滑(任意时刻回看最近 1 秒),要么把死格子切碎、统计最近若干格的总和——两条路都叫滑动窗口,工程上是同一思想的两种精度。
实现一:环形格子,切细了加总
把 1 秒切成 10 个 100 毫秒的小格子,每个格子一个计数器,限流判定时把最近 10 个格子的计数加总,超过阈值就拒绝。格子随时间滚动覆盖,过去的过期出窗,新的滚动进来:
// 环形格子滑动窗口(简化版)
private final long[] buckets = new long[10]; // 10 个 100ms 的格子
private long sum = 0; // 窗口内总数
private int head = 0; // 最老的格子下标
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
roll(now); // 把过期格子的计数清出窗口
if (sum >= LIMIT) return false; // 最近 1 秒总量超阈值
buckets[(int) ((now / 100) % 10)]++; // 落进当前格子
sum++;
return true;
}突刺为什么被磨平:边界时刻,上一格只贡献它真实 received 的量(比如 20 个),而不是整格 200 的额度——「跨边界双倍放行」变成了「跨边界按实际量累加」。误差从最坏 2 倍缩到最坏 1.1 倍(一个格子的占比)。格子越细,越平滑、越精确,内存与加总成本越高——1 秒 10 格是常用折中,Sentinel 内部的统计结构正是这套环形格子的精致版。
实现二:ZSET 记时间戳,精确但贵
多实例场景要在 Redis 里做滑动窗口,最常见的写法用 ZSET:每个请求记一个成员(时间戳),判定时先清掉窗口外的旧成员,再数剩余个数:
-- ZSET 滑动窗口(Lua 原子执行)
local key = KEYS[1] -- 限流标识
local now = tonumber(ARGV[1]) -- 当前毫秒
local win = tonumber(ARGV[2]) -- 窗口毫秒
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - win) -- 清出窗成员
if redis.call("ZCARD", key) >= limit then
return 0 -- 超阈值
end
redis.call("ZADD", key, now, now .. "-" .. math.random()) -- 记录本次
redis.call("PEXPIRE", key, win)
return 1这是真·滑动窗口:任意时刻的判定都是「最近 N 毫秒的真实请求数」,零误差。代价也直白——每个请求一条 ZSET 成员,内存消耗与流量成正比,万级 QPS 的接口上这是百万级成员的常驻内存,还得靠 PEXPIRE 兜底清理。所以 ZSET 版适合低频高价值场景(支付接口、短信发送),高频接口老实用环形格子或下一篇的令牌桶。
两种精度的取舍
| 方案 | 精度 | 内存 | 适用 |
|---|---|---|---|
| 固定窗口 | 最坏 2 倍突刺 | 一个计数器,极低 | 粗粒度防线,网关初步拦截 |
| 环形格子滑动窗口 | 误差为一个格子占比 | 格子数 × 8 字节,低 | 单机高频限流、指标统计 |
| ZSET 时间戳窗口 | 零误差 | 与请求数成正比,高 | 低频高价值接口的分布式限流 |
滑动窗口解决的是「统计得准」,但它的内心戏是回顾历史:数过去一秒来了多少,超了就拒。还有一种思路根本不数历史,而是控制发放节奏——像发号施令一样,按恒定速率发通行证。这就是下一篇的主角:漏桶与令牌桶,限流算法家族里最经典的两位。
评论 (0)