4. 计数器算法与滑动窗口:固定窗口计数器、滑动窗口日志、滑动窗口计数器实现

在分布式系统与微服务架构中,限流是保障服务稳定性的第一道防线。计数器算法与滑动窗口算法是限流领域最基础、最直观的实现方式。本章将深入剖析固定窗口计数器、滑动窗口日志与滑动窗口计数器三种实现,从原理、缺陷到Go代码实战,帮助你在“限流熔断骨架”中准确选择边界策略。

4.1 固定窗口计数器

固定窗口计数器是最简单的限流实现。它将时间划分为固定的窗口(如1秒),每个窗口内维护一个计数器,请求到达时计数器加1;当计数器超过阈值时,拒绝后续请求;窗口结束时计数器重置。

核心特征: 实现简单、内存占用极低(仅需一个计数器和时间戳)、适合对突发容忍度较高的场景。

4.1.1 固定窗口计数器实现

package limiter

import (
    "sync"
    "time"
)

// FixedWindowLimiter 固定窗口计数器限流器
type FixedWindowLimiter struct {
    mu        sync.Mutex
    limit     int           // 窗口内最大请求数
    window    time.Duration // 窗口大小
    count     int           // 当前窗口计数
    windowStart time.Time   // 当前窗口起始时间
}

func NewFixedWindowLimiter(limit int, window time.Duration) *FixedWindowLimiter {
    return &FixedWindowLimiter{
        limit:       limit,
        window:      window,
        windowStart: time.Now(),
    }
}

func (l *FixedWindowLimiter) Allow() bool {
    l.mu.Lock()
    defer l.mu.Unlock()

    now := time.Now()
    // 如果当前时间已超出窗口范围,重置窗口
    if now.Sub(l.windowStart) >= l.window {
        l.count = 0
        l.windowStart = now
    }

    if l.count >= l.limit {
        return false
    }
    l.count++
    return true
}

4.1.2 固定窗口的临界突变问题

固定窗口计数器存在一个严重缺陷:窗口边界处的流量突刺。假设限流阈值为100次/秒,在第一个窗口的最后100ms内通过了100个请求,紧接着第二个窗口开始的100ms内又通过了100个请求。实际上,在200ms内通过了200个请求,远超每秒100次的限制。

避坑提示: 固定窗口计数器不适用于对流量平滑度要求高的场景。在微服务网关、API限流等边界处,临界突变可能导致下游系统瞬间过载。

4.2 滑动窗口日志

滑动窗口日志(Sliding Window Log)通过记录每个请求的时间戳来解决临界突变问题。它维护一个有序的时间戳列表,每次请求时移除窗口范围之外的时间戳,然后判断当前窗口内的请求数是否超过阈值。

核心特征: 精确限流、无边界突刺、但内存占用随请求量线性增长。

4.2.1 滑动窗口日志实现

package limiter

import (
    "sync"
    "time"
)

// SlidingWindowLogLimiter 滑动窗口日志限流器
type SlidingWindowLogLimiter struct {
    mu       sync.Mutex
    limit    int           // 窗口内最大请求数
    window   time.Duration // 窗口大小
    logs     []time.Time   // 请求时间戳日志
}

func NewSlidingWindowLogLimiter(limit int, window time.Duration) *SlidingWindowLogLimiter {
    return &SlidingWindowLogLimiter{
        limit:  limit,
        window: window,
        logs:   make([]time.Time, 0, limit+1),
    }
}

func (l *SlidingWindowLogLimiter) Allow() bool {
    l.mu.Lock()
    defer l.mu.Unlock()

    now := time.Now()
    windowStart := now.Add(-l.window)

    // 移除窗口之外的时间戳
    cutIdx := 0
    for i, t := range l.logs {
        if t.After(windowStart) {
            break
        }
        cutIdx = i + 1
    }
    l.logs = l.logs[cutIdx:]

    // 判断是否超过阈值
    if len(l.logs) >= l.limit {
        return false
    }

    l.logs = append(l.logs, now)
    return true
}

4.2.2 滑动窗口日志的优缺点

维度 说明
精度 理论精度最高,完全消除边界突刺
内存开销 O(请求数),高并发下内存占用大,需定期GC
性能 每次请求需遍历日志列表,时间复杂度O(n)
适用场景 低并发、对精度要求极高的场景

4.3 滑动窗口计数器

滑动窗口计数器(Sliding Window Counter)是固定窗口与滑动窗口日志的折中方案。它将窗口划分为多个小格子(如1秒窗口划分为10个100ms的格子),每个格子独立计数。滑动时,只移动一个格子的粒度,通过加权计算当前窗口的总请求数。

核心特征: 内存固定、精度可调、性能优异,是生产环境中最常用的限流实现之一。

4.3.1 滑动窗口计数器实现

package limiter

import (
    "sync"
    "time"
)

// SlidingWindowCounterLimiter 滑动窗口计数器限流器
type SlidingWindowCounterLimiter struct {
    mu          sync.Mutex
    limit       int           // 窗口内最大请求数
    window      time.Duration // 窗口大小
    bucketNum   int           // 格子数量
    buckets     []int         // 每个格子的计数
    bucketSize  time.Duration // 每个格子的时间跨度
    currentIdx  int           // 当前格子索引
    lastTick    time.Time     // 上次更新时间
}

func NewSlidingWindowCounterLimiter(limit int, window time.Duration, bucketNum int) *SlidingWindowCounterLimiter {
    return &SlidingWindowCounterLimiter{
        limit:      limit,
        window:     window,
        bucketNum:  bucketNum,
        buckets:    make([]int, bucketNum),
        bucketSize: window / time.Duration(bucketNum),
        lastTick:   time.Now(),
    }
}

func (l *SlidingWindowCounterLimiter) Allow() bool {
    l.mu.Lock()
    defer l.mu.Unlock()

    now := time.Now()
    // 计算当前时间对应的格子索引
    elapsed := now.Sub(l.lastTick)
    if elapsed >= l.bucketSize {
        // 需要滑动多个格子
        slideNum := int(elapsed / l.bucketSize)
        if slideNum > l.bucketNum {
            slideNum = l.bucketNum
        }
        // 重置滑过的格子
        for i := 0; i < slideNum; i++ {
            l.currentIdx = (l.currentIdx + 1) % l.bucketNum
            l.buckets[l.currentIdx] = 0
        }
        l.lastTick = l.lastTick.Add(time.Duration(slideNum) * l.bucketSize)
    }

    // 计算当前窗口总请求数
    total := 0
    for _, count := range l.buckets {
        total += count
    }

    if total >= l.limit {
        return false
    }

    l.buckets[l.currentIdx]++
    return true
}

4.3.2 滑动窗口计数器精度与性能对比

格子数 精度 内存占用 推荐场景
10 中等(误差约10%) 极低 高并发、允许少量突刺
20 较高(误差约5%) 低 通用生产环境
50 高(误差约2%) 中等 对精度要求较高的场景
100 极高(误差约1%) 较高 接近滑动窗口日志精度

4.4 三种算法对比与选型

算法 精度 内存 性能 边界突刺 实现复杂度
固定窗口计数器 低 O(1) 极高 有 低
滑动窗口日志 极高 O(n) 低 无 中
滑动窗口计数器 高(可调) O(k) 高 极小 中
选型建议: 在Go微服务限流熔断骨架中,推荐默认使用滑动窗口计数器(格子数20~50)。它兼顾了性能与精度,内存固定可控,适合作为限流器的核心实现。固定窗口计数器仅用于对精度无要求的简单场景;滑动窗口日志则适用于低并发、需要精确审计的场景。

4.5 滑动窗口计数器原理图

下图展示了滑动窗口计数器在时间轴上的滑动过程。窗口被划分为10个格子,每个格子独立计数。当时间推进时,最左侧的格子被移出窗口,新格子加入,窗口整体向右滑动。

滑动窗口计数器原理图 滑动窗口计数器滑动过程 时间 旧窗口位置 当前滑动窗口 1 2 3 4 5 6 7 8 9 10 滑动方向 格子说明 绿色:低负载(0~2请求) 橙色:中等负载(3~5请求) 红色:高负载(6+请求)

4.6 滑动窗口日志与滑动窗口计数器对比图

下图从内存占用和精度两个维度对比滑动窗口日志与滑动窗口计数器。滑动窗口日志精度极高但内存线性增长;滑动窗口计数器通过格子数调节精度,内存固定可控。

滑动窗口日志 vs 滑动窗口计数器 滑动窗口日志 vs 滑动窗口计数器 滑动窗口日志 • 记录每个请求的时间戳 • 内存:O(n),随请求量增长 • 精度:理论最高,无边界突刺 • 性能:每次需遍历日志列表 • 适用:低并发、精确审计场景 滑动窗口计数器 • 划分固定格子,每个格子独立计数 • 内存:O(k),k为格子数,固定可控 • 精度:高(可调,误差约1%~10%) • 性能:O(k),k通常为10~100 • 适用:高并发生产环境 精度高 内存低

4.7 生产环境选型建议

  • 固定窗口计数器:仅用于内部测试、非关键路径的简单限流,或对突发流量不敏感的场景。
  • 滑动窗口日志:适用于需要精确审计、低并发(如管理后台API)、或需要记录每个请求时间戳的场景。
  • 滑动窗口计数器:推荐作为Go限流熔断骨架的默认实现。格子数建议20~50,在精度与性能之间取得平衡。
Go限流熔断骨架边界提示: 限流算法是“熔断骨架”的边界守卫者。选择滑动窗口计数器作为限流核心,配合后续章节的令牌桶、漏桶算法,可以构建多层级、可配置的限流策略。在实现时,注意使用sync.Mutex或原子操作保证并发安全,避免在高并发下出现计数竞态。