专栏文章
专栏文章
调度与限流系列
1. 调度与限流 #01:时间轮(Hashed Timing Wheel) 2. 调度与限流 #02:层级时间轮 3. 调度与限流 #03:最小堆定时器 4. 调度与限流 #04:令牌桶 5. 调度与限流 #05:漏桶 6. 调度与限流 #06:滑动窗口计数器 7. 调度与限流 #07:滑动窗口日志

调度与限流 #07:滑动窗口日志

发布于 2026-06-04 04:19 👁 9 次阅读
#算法#Redis#rate-limiting#distributed-systems#sliding-window

滑动窗口日志为每个请求精确记录时间戳,判断「最近 windowSize 时间内的请求数」是否超限,实现零误差的速率控制;Twitter、Stripe、Cloudflare 等 API 网关广泛采用此方案处理严格限流场景。

相关文章令牌桶 · 漏桶 · 滑动窗口计数器

sliding window log


目录

章节 说明
问题背景 滑动窗口计数器的近似误差,以及精确限流的需求来源
核心数据结构 有序队列(deque)存储时间戳,队头最旧、队尾最新
isAllowed 完整伪代码 清除过期时间戳、判断队列大小、加入新时间戳
执行追踪 windowSize=1s、limit=3 的完整数值演示,含拒绝场景
内存开销分析 每请求 8 字节,与滑动窗口计数器的内存对比
Redis 实现方案 ZADD+ZREMRANGEBYSCORE+ZCARD,Lua 脚本保证原子性
异常与边界场景 内存线性增长、时间戳精度、分布式共享、时钟不同步
精确度 vs 性能权衡 与滑动窗口计数器、令牌桶的对比及生产选型
参考资料 论文、官方文档、书籍

问题背景

滑动窗口计数器的近似误差

滑动窗口计数器将时间轴切成若干小格(sub-window),以格为粒度统计请求数。
这意味着它的精度受格宽约束——格宽内的请求分布被视为均匀,实际上并不均匀。

具体数字示例(windowSize = 1sN = 10 格,limit = 100,格宽 = 100ms):

格 0(T0 ~ T0+100ms):100 个请求,均匀分布在前 50ms
格 1(T0+100ms ~ T0+200ms):0 个请求

T0+150ms 时刻:窗口 = 格0 + 格1 + … + 格9 = 100 个请求,刚好通过

T0+199ms 时刻:下一个请求到来
  计数器看到 totalCount = 100,拒绝 ✗(正确)

但若格 0 的 100 个请求全集中在 T0+99ms(最后 1ms):
  T0+199ms 的窗口实际包含了几乎全部 100 个请求,计数器也是 100,正确拒绝 ✓

问题场景:格 0 的 100 个请求集中在 T0+0ms(最开始 1ms):
  T0+199ms 时,这 100 个请求实际上距今已 199ms,远在「真实滑动窗口」之外
  但计数器仍然统计格 0 的计数 = 100,错误地拒绝了新请求 ✗

这种格内均匀分布的假设导致了近似误差:实际请求更早发生,但计数器以为它们更新。

滑动窗口日志的解法

不做近似——为每个请求存储精确时间戳

将所有请求的时间戳存入一个有序队列(deque):

每次新请求到来时:

  1. 清除队头所有早于 timestamp - windowSize 的过期时间戳
  2. 检查队列大小是否已达 limit
  3. 未达则将新时间戳加入队尾,放行;已达则拒绝

无需任何近似假设,精度达到时间戳的最小单位(毫秒或微秒)。


核心数据结构

SlidingWindowLog {
    log:         Deque<long>  // 有序时间戳队列,队头最旧(最小)、队尾最新(最大)
    windowSize:  long         // 窗口大小,单位毫秒,例如 1000
    limit:       int          // 窗口内允许的最大请求数,例如 3
}

字段说明:

字段 类型 说明
log Deque<long> 存储每个通过请求的时间戳(毫秒);严格递增排列(同一毫秒可能有多个)
windowSize long (ms) 滑动窗口的时间跨度;超过此时长的请求时间戳视为过期并从队头移除
limit int 窗口内允许的最大请求数;log.size() >= limit 时拒绝新请求

与滑动窗口计数器的关键差异

对比项 滑动窗口日志 滑动窗口计数器
存储内容 每个请求的时间戳 每个格的聚合计数
内存复杂度 O(limit) 个时间戳 O(N) 个格计数
精度 精确到时间戳精度 误差 ≤ 1 个格宽
适用 limit 规模 limit 较小(≤ 10000) 任意 limit,内存固定

初始状态:log 为空队列。


isAllowed 完整伪代码

function isAllowed(log, timestamp, limit, windowSize):
    lock(log)
    try:
        // --- 步骤 1:清除队头所有过期时间戳 ---
        windowStart = timestamp - windowSize    // 窗口起始边界(不含)

        while log.isNotEmpty() AND log.peekFirst() <= windowStart:
            log.removeFirst()                   // 弹出队头过期时间戳

        // --- 步骤 2:判断当前窗口内请求数是否达到上限 ---
        if log.size() < limit:
            // --- 步骤 3:允许,将本次时间戳加入队尾 ---
            log.addLast(timestamp)
            return true    // 放行

        else:
            // 已达上限,拒绝——注意:不将 timestamp 加入 log
            return false   // 拒绝
    finally:
        unlock(log)

设计要点


执行追踪

参数windowSize = 1000mslimit = 3
初始状态:log = []

─────────────────────────────────────────────────────────────────
T = 100ms:请求 R1 到来
  windowStart = 100 - 1000 = -900ms
  清除队头 <= -900ms:log 为空,无需清除
  log.size() = 0 < 3
  → addLast(100),log = [100]
  → 放行 ✓

─────────────────────────────────────────────────────────────────
T = 400ms:请求 R2 到来
  windowStart = 400 - 1000 = -600ms
  清除队头 <= -600ms:log[0]=100 > -600,无需清除
  log.size() = 1 < 3
  → addLast(400),log = [100, 400]
  → 放行 ✓

─────────────────────────────────────────────────────────────────
T = 800ms:请求 R3 到来
  windowStart = 800 - 1000 = -200ms
  清除队头 <= -200ms:log[0]=100 > -200,无需清除
  log.size() = 2 < 3
  → addLast(800),log = [100, 400, 800]
  → 放行 ✓

─────────────────────────────────────────────────────────────────
T = 900ms:请求 R4 到来(超限,应被拒绝)
  windowStart = 900 - 1000 = -100ms
  清除队头 <= -100ms:log[0]=100 > -100,无需清除
  log.size() = 3,已达 limit=3
  → 不加入 log,log = [100, 400, 800](不变)
  → 拒绝 ✗

─────────────────────────────────────────────────────────────────
T = 1200ms:请求 R5 到来(T=100 的时间戳已过期)
  windowStart = 1200 - 1000 = 200ms
  清除队头 <= 200ms:
    log[0] = 100 <= 200  → removeFirst(),log = [400, 800]
    log[0] = 400 > 200   → 停止清除
  log.size() = 2 < 3
  → addLast(1200),log = [400, 800, 1200]
  → 放行 ✓

─────────────────────────────────────────────────────────────────
T = 1500ms:请求 R6 到来(T=400 已过期)
  windowStart = 1500 - 1000 = 500ms
  清除队头 <= 500ms:
    log[0] = 400 <= 500  → removeFirst(),log = [800, 1200]
    log[0] = 800 > 500   → 停止清除
  log.size() = 2 < 3
  → addLast(1500),log = [800, 1200, 1500]
  → 放行 ✓

─────────────────────────────────────────────────────────────────
T = 1600ms:请求 R7 到来(窗口内已有 800、1200、1500 三个)
  windowStart = 1600 - 1000 = 600ms
  清除队头 <= 600ms:log[0]=800 > 600,无需清除
  log.size() = 3,已达 limit=3
  → 拒绝 ✗

─────────────────────────────────────────────────────────────────
T = 1900ms:请求 R8 到来(T=800 已过期)
  windowStart = 1900 - 1000 = 900ms
  清除队头 <= 900ms:
    log[0] = 800 <= 900  → removeFirst(),log = [1200, 1500]
    log[0] = 1200 > 900  → 停止清除
  log.size() = 2 < 3
  → addLast(1900),log = [1200, 1500, 1900]
  → 放行 ✓
─────────────────────────────────────────────────────────────────

关键观察


内存开销分析

单个时间戳的存储成本

内存复杂度

最坏情况:log 内有 limit 个时间戳(窗口内全部请求都通过)
内存上界 = limit × (每条记录大小)

示例:
  limit = 1000 req/s,纯时间戳存储:1000 × 8 = 8 KB(极低)
  limit = 1000 req/s,Redis ZSET:1000 × 120 ≈ 120 KB(仍可接受)
  limit = 100000 req/s,Redis ZSET:100000 × 120 ≈ 12 MB(需关注)

关键结论:内存用量与 limit 成正比(O(limit)),与 windowSize 无关(过期记录随时清除)。

与滑动窗口计数器的内存对比

场景 滑动窗口日志 滑动窗口计数器(N=60 格)
limit=100, 1s 窗口 100 × 8B = 800B 60 × 4B = 240B
limit=10000, 1s 窗口 10000 × 8B = 80KB 60 × 4B = 240B(不变!)
limit=1000000, 1s 窗口 1M × 8B = 8MB 60 × 4B = 240B(不变!)

核心差异

limit 很小(≤ 10000)且需要精确控制时,滑动窗口日志的额外内存完全可接受;
limit 达到百万级时,滑动窗口日志的内存开销使其不再适用。


Redis 实现方案

数据结构选型

使用 Sorted Set(ZSET) 天然支持按分数(时间戳)排序,与滑动窗口日志的有序队列语义完美对应:

key:    rate_limit:{user_id}
member: 请求唯一 ID(UUID 或 timestamp + 随机数)
score:  请求时间戳(毫秒)

member 唯一性:同一毫秒内可能有多个请求,若 member 直接用时间戳字符串,同毫秒请求会覆盖彼此(ZADD 对相同 member 是更新 score,非追加)。解决方案:

Lua 脚本(原子性保证)

-- KEYS[1] = rate limit key(如 rate_limit:user123)
-- ARGV[1] = now(当前时间戳,毫秒)
-- ARGV[2] = windowSize(窗口大小,毫秒)
-- ARGV[3] = limit(允许的最大请求数)
-- ARGV[4] = member(本次请求唯一 ID)

local now         = tonumber(ARGV[1])
local windowSize  = tonumber(ARGV[2])
local limit       = tonumber(ARGV[3])
local member      = ARGV[4]
local windowStart = now - windowSize

-- 步骤 1:删除窗口外(score <= windowStart)的过期成员
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', windowStart)

-- 步骤 2:统计当前窗口内的请求数
local count = tonumber(redis.call('ZCARD', KEYS[1]))

-- 步骤 3:判断是否允许
if count < limit then
    -- 允许:将本次请求加入 ZSET
    redis.call('ZADD', KEYS[1], now, member)
    -- 设置 key 过期时间(windowSize 后自动删除,避免内存泄漏)
    redis.call('PEXPIRE', KEYS[1], windowSize + 1000)
    return 1   -- allowed
else
    return 0   -- rejected
end

Lua 保证原子性的原因:Redis 单线程执行模型,Lua 脚本在执行期间不会被其他命令打断,整个 ZREMRANGEBYSCORE + ZCARD + ZADD 序列是原子的。

Java 调用示例

// Spring Data Redis + Lua 脚本
private static final String LUA_SCRIPT =
    "local now = tonumber(ARGV[1])\n" +
    "local windowSize = tonumber(ARGV[2])\n" +
    "local limit = tonumber(ARGV[3])\n" +
    "local member = ARGV[4]\n" +
    "local windowStart = now - windowSize\n" +
    "redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', windowStart)\n" +
    "local count = tonumber(redis.call('ZCARD', KEYS[1]))\n" +
    "if count < limit then\n" +
    "  redis.call('ZADD', KEYS[1], now, member)\n" +
    "  redis.call('PEXPIRE', KEYS[1], windowSize + 1000)\n" +
    "  return 1\n" +
    "else\n" +
    "  return 0\n" +
    "end";

public boolean isAllowed(String userId, long limit, long windowSizeMs) {
    String key    = "rate_limit:" + userId;
    long   now    = System.currentTimeMillis();
    String member = now + ":" + ThreadLocalRandom.current().nextInt(1_000_000);

    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
        List.of(key),
        String.valueOf(now),
        String.valueOf(windowSizeMs),
        String.valueOf(limit),
        member
    );
    return Long.valueOf(1L).equals(result);
}

异常与边界场景

7.1 高 limit 下内存线性增长

问题limit = 1,000,000 req/s 时,log 最多存储 100 万条时间戳。
Redis ZSET 每条成员约 120 字节,峰值内存 = 120MB,多个用户同时达峰则内存压力极大。

处理方式

7.2 时间戳精度(毫秒 vs 微秒)

问题System.currentTimeMillis() 精度为毫秒,同一毫秒内的多个请求 score 相同,ZADD 对相同 member 是覆盖,可能导致计数偏少。

处理方式

7.3 分布式多实例如何共享同一日志

问题:多个服务实例各自维护本地 log,相互不可见,全局请求数可能超过 limit 但每个实例单独看都未超。

集中化方案(推荐)

本地近似方案(高性能场景)

双层限流方案(生产推荐)

7.4 时钟不同步导致 member 排序错乱

问题:分布式环境中,不同实例的系统时钟可能有几毫秒到几秒的漂移(NTP 调整、闰秒等)。
实例 A 的时间戳为 T+500ms,实例 B 的时间戳为 T+300ms(时钟落后),两者写入同一 ZSET 后,ZREMRANGEBYSCORE 清除的范围基于「当前时间」,而不同实例对「当前时间」的判断不一致。

具体危害

处理方式

local time   = redis.call('TIME')          -- {秒, 微秒}
local now    = time[1] * 1000 + math.floor(time[2] / 1000)  -- 转为毫秒
-- 后续逻辑不变

精确度 vs 性能权衡

三种限流方案核心对比

维度 滑动窗口日志 滑动窗口计数器 令牌桶
精度 精确到时间戳精度(ms/μs) 误差 ≤ 1 格宽(通常 10~100ms) 精确(持续令牌生成)
内存 O(limit) 个时间戳 O(N) 个格计数(固定 O(1)(仅存令牌数和时间戳)
高 limit 适用性 差(limit=100万时 80MB+) 好(N=60 时仅 240B) 极好(始终 O(1))
突发处理 窗口内配额用完即拒绝 窗口内配额用完即拒绝 桶容量决定允许的突发量
实现复杂度 低(deque + 头部清理) 中(环形数组 + 滚动求和) 中(懒惰填充 + 原子更新)
Redis 实现 ZSET + Lua(成员数 = 请求数) 多 key 或 ZSET(key 数 = 格数) 单 key(令牌数 + 上次填充时间)
典型使用方 Stripe API、金融 API 严格限流 Nginx limit_req、Sentinel Guava RateLimiter、AWS API GW

生产选型指南

limit 较小(≤ 10000)且要求精确无误差?
  → 滑动窗口日志(Redis ZSET + Lua)

limit 较大(> 10000)且可接受 ≤ 1 格宽的近似误差?
  → 滑动窗口计数器(Redis 多 key 或本地环形数组)

需要允许短时突发、长期平均达标(如 CDN 回源、批量写入)?
  → 令牌桶(内存 O(1),无近似误差,支持突发配置)

需要严格匀速输出、保护下游不受突发冲击?
  → 漏桶(固定出队速率,队列满则丢弃)

实际工程中的组合策略


参考资料

  1. 《System Design Interview Vol.1》Alex Xu — Chapter 4: Design a Rate Limiter — 固定窗口、滑动窗口日志、滑动窗口计数器三种方案的完整工程对比,本文核心参考。

  2. Figma Engineering:An alternative approach to rate limiting https://www.figma.com/blog/an-alternative-approach-to-rate-limiting/ — 详细对比滑动窗口日志(ZSET)与计数器方案的内存开销与精度差异。

  3. Redis 官方文档:Sorted Sets https://redis.io/docs/data-types/sorted-sets/ — ZADDZREMRANGEBYSCOREZCARDPEXPIRE 命令详解及复杂度分析。

  4. Redis 官方文档:TIME 命令 https://redis.io/commands/time/ — 获取 Redis 服务器当前时间,用于消除分布式客户端时钟漂移。

  5. Stripe Engineering:Rate Limiters https://stripe.com/blog/rate-limiters — Stripe 生产环境中滑动窗口日志的实践经验,包括 Redis Lua 脚本设计与多层限流架构。

  6. Cloudflare:How we built rate limiting capable of scaling to millions of domains https://blog.cloudflare.com/counting-things-a-lot-of-different-things/ — 超大规模下从精确日志到近似计数器的演进过程,解释了精确度与性能的工程权衡。

  7. RFC 6585 — Additional HTTP Status Codes(429 Too Many Requests) https://datatracker.ietf.org/doc/html/rfc6585 — 速率限制的 HTTP 标准响应码定义,包含 Retry-After 头的规范用法。

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论