限流方案对比:计数器、令牌桶、滑动窗口,看完这篇彻底搞懂

2026-08-18 8 0

前两天线上又双叒叕出事故了——一个接口被高频调用,数据库直接被打爆。我看了一眼监控,心里一万只草泥马奔过:这限流代码不是早就上了吗?

一看代码,好家伙,用的最原始的计数器限流。说白了就是「一秒钟最多100个请求,计数器超过100就拒绝」。听着没问题对吧?但这玩意儿有个致命bug,我敢说80%的后端程序员都不知道。


计数器限流:看起来很美

先说说什么是计数器限流。代码大概长这样:

var counter int
var windowStart = time.Now()

func isAllowed() bool {
    now := time.Now()
    if now.Sub(windowStart) > time.Second {
        counter = 0
        windowStart = now
    }
    if counter >= 100 {
        return false
    }
    counter++
    return true
}

这段代码的问题在于——它在窗口边界处有巨大的漏洞。假设限制是每秒100个请求:

  • 第0.9秒:来了100个请求,全部放行 ✓
  • 第1.0秒:窗口重置,计数器归零
  • 第1.1秒:又来了100个请求,全部放行 ✓

实际 QPS 是 200!这就是所谓的「惊群效应」——用户在窗口重置的瞬间疯狂请求,流量完全不均匀。更要命的是,如果有人在窗口末尾卡了0.5秒再请求,这0.5秒的请求会被误杀。


令牌桶:允许偶尔的突发流量

令牌桶的核心思想是:系统以固定速率往桶里放令牌,每个请求必须抢到一个令牌才能执行。桶有容量上限,所以突发流量最多就是桶的容量,不会无限制打爆系统。

type TokenBucket struct {
    capacity    int64
    tokens      int64
    refillRate  int64 // 每秒补充的令牌数
    lastRefill  time.Time
    mu          sync.Mutex
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    tb.refill()

    if tb.tokens > 0 {
        tb.tokens--
        return true
    }
    return false
}

func (tb *TokenBucket) refill() {
    now := time.Now()
    elapsed := now.Sub(tb.lastRefill)
    tokensToAdd := int64(elapsed.Seconds()) * tb.refillRate

    if tokensToAdd > 0 {
        tb.tokens = min(tb.capacity, tb.tokens+tokensToAdd)
        tb.lastRefill = now
    }
}

令牌桶的好处是允许一定程度的突发流量。假设桶容量是200,每秒补充100个令牌:平时每秒处理100个请求,但如果有突发,可以一次性处理最多200个。这对于有短暂流量高峰的业务(比如秒杀)非常友好。


滑动窗口:精准打击

滑动窗口的核心是把时间轴切成多个小段,记录每个小段的请求数。判断时,把当前时间点之前一个完整窗口的请求数加起来,精确控制总流量。

type SlidingWindow struct {
    windowSize  time.Duration
    bucketCount int
    buckets     []int64
    currentIdx  int
    mu          sync.Mutex
}

func NewSlidingWindow(windowSize time.Duration, bucketCount int) *SlidingWindow {
    return &SlidingWindow{
        windowSize:  windowSize,
        bucketCount: bucketCount,
        buckets:     make([]int64, bucketCount),
    }
}

func (sw *SlidingWindow) Allow() bool {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    now := time.Now().UnixMilli()
    bucketDuration := sw.windowSize.Milliseconds() / int64(sw.bucketCount)
    currentBucket := now / bucketDuration % int64(sw.bucketCount)

    // 清理过期数据
    if currentBucket != sw.currentIdx {
        for i := range sw.buckets {
            sw.buckets[i] = 0
        }
        sw.currentIdx = int(currentBucket)
    }

    total := int64(0)
    for _, count := range sw.buckets {
        total += count
    }

    if total >= 100 {
        return false
    }
    sw.buckets[sw.currentIdx]++
    return true
}

滑动窗口的优势是没有窗口边界突变的问题,流量是平滑过渡的。但代价是实现复杂度高,内存占用也更大。


Redis + Lua:分布式限流的正确打开方式

单机限流只能保护单机,真正的生产环境需要分布式限流。这里祭出 Redis + Lua 的经典组合:

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call(GET, key) or 0)

if current >= limit then
    return 0
end

current = redis.call(INCR, key)
if current == 1 then
    redis.call(EXPIRE, key, window)
end

return 1

等等,这还是计数器限流!真正的滑动窗口分布式实现,推荐用 Redis ZSET(有序集合)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- 删除窗口外的旧数据
redis.call(ZREMRANGEBYSCORE, key, 0, now - window)

-- 统计当前窗口内的请求数
local count = redis.call(ZCARD, key)

if count >= limit then
    return 0
end

-- 记录本次请求
redis.call(ZADD, key, now, now .. : .. math.random(1000000))
redis.call(EXPIRE, key, window / 1000 + 1)

return 1

每个请求作为 ZSET 的一个成员,时间戳做 score,ZCARD 就是窗口内的请求总数。ZREMRANGEBYSCORE 自动清理过期数据,完美实现滑动窗口。


说了这么多,到底怎么选?

给你一个决策树:

  • 单机简单限流 → 令牌桶(Go 里用 golang.org/x/time/rate)
  • API Gateway 层 → 滑动窗口(更精确,可配置)
  • 分布式限流 → Redis + Lua(令牌桶或滑动窗口皆可)
  • 不想自己写 → 调研一下 Sentinel(阿里巴巴出品,熔断限流一体化)

回到开头的事故——最后我们换成了令牌桶 + Redis 分布式限流,QPS 稳得一批。复盘会上我说:「大家以后写限流,先问自己三个问题:单机还是分布式?允许突发吗?窗口边界有没有处理?」没人接话,但我觉得他们听进去了。


写在最后

限流这玩意儿,看着简单,门道很深。很多时候不是代码写错了,是选错了算法。计数器不是不能用,但它只适合对流量精确度要求不高的场景。一旦业务对流量敏感(比如支付、抢购),老老实实上令牌桶或滑动窗口。

技术选型这事儿,最忌讳的就是「我觉得够了」——线上跑起来的问题,往往来自你以为没问题的地方。

好了,今天的分享就到这里,我去给那个写计数器限流的兄弟买杯咖啡,顺便给他讲讲什么叫「边界条件」。

相关文章

还在为AI工具部署头秃?我帮你搞定一切
写API接口这事儿,踩过的坑比你吃过的饭还多
API返回200就万事大吉?抱歉,你的错误处理可能在谋杀前端同事
别再被RESTful绑架了:API设计的真实选择
AI圈最近有点热闹:OpenClaw让我重新认识了什么叫”数字打工人”
一次线上事故后,我对连接池有了更深的”恐惧”

发布评论