小Cの已经记不起来的博客

后端接口限流:令牌桶、漏桶和滑动窗口实现对比

最近在给一个接口加限流,本来想着随便找个库糊上去完事,结果翻了几篇文章发现讲得都挺玄乎的,什么“令牌桶允许突发、漏桶恒定速率”,看完还是不知道代码该怎么写。索性自己把三种常见的实现都撸了一遍,顺便记一下踩过的坑,省得下次再翻。

先说清楚,这里聊的限流都是单机内存版的,分布式那种基于 Redis 的方案改天再单独写,不然一篇塞不下。

令牌桶:最常用的那个

令牌桶的思路其实很直白,就是有个桶,按固定速率往里塞令牌,桶满了就丢弃新令牌,请求来了就从桶里拿一个令牌,拿不到就拒绝或者排队。

为什么它最常用?因为它允许一定程度的突发流量。比如你桶容量 100,每秒补 10 个令牌,那在空闲一段时间后,突然来 100 个请求也能扛住,只要桶里攒够了令牌。

下面是我用 Go 写的一个简化版,没上锁优化,看懂逻辑就行:

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

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

    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    refill := int64(elapsed * float64(tb.rate))
    if refill > 0 {
        tb.tokens = min(tb.capacity, tb.tokens+refill)
        tb.lastRefill = now
    }

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

这里有个小坑,lastRefill 的更新时机,如果你每次请求都更新,那补充的令牌数会因为浮点误差慢慢漂移;只在真正补充了令牌时才更新会稳一点,但也有人干脆用定时器去补。看你的精度要求。

实际用的时候我一般不会自己写,golang.org/x/time/rate 就够用了,rate.NewLimiter(rate.Limit(10), 100) 一行搞定,原理就是这个。

漏桶:流量整形的好帮手

漏桶和令牌桶经常被拿来对比,但我觉得它们定位其实不太一样。漏桶是请求进来先丢桶里,桶底有个洞按固定速率漏水,桶满了新请求就溢出被拒。

它不关心你请求来得多猛,出去的速率永远恒定,所以更适合“流量整形”而不是单纯的“限流”。比如你要调第三方接口,对方要求每秒最多 5 次,那漏桶比令牌桶合适,因为你不会在一瞬间啪一下发 5 个出去。

简易实现:

type LeakyBucket struct {
    capacity int
    queue    chan struct{}
    rate     time.Duration // 每个请求的处理间隔
}

func NewLeakyBucket(capacity int, rate time.Duration) *LeakyBucket {
    lb := &LeakyBucket{
        capacity: capacity,
        queue:    make(chan struct{}, capacity),
        rate:     rate,
    }
    go func() {
        ticker := time.NewTicker(rate)
        for range ticker.C {
            select {
            case <-lb.queue:
            default:
            }
        }
    }()
    return lb
}

func (lb *LeakyBucket) Allow() bool {
    select {
    case lb.queue <- struct{}{}:
        return true
    default:
        return false
    }
}

实际上很多人用漏桶的时候会做成阻塞式的,也就是请求来了往桶里塞,塞不进去就等待,而不是直接拒绝。这个取决于你是想保护下游还是想平滑流量,别搞混了。

滑动窗口:比固定窗口靠谱一点

固定窗口的问题大家都知道,窗口边界那一秒可能扛双倍流量。比如限制每秒 100 次,结果 0.9 秒来了 100 次,1.1 秒又来了 100 次,虽然每秒都没超,但跨窗口那一瞬间实际是 200 次。

滑动窗口就是把这个窗口切成很多小格子,比如 1 秒切成 10 个 100ms 的格子,每次统计最近 10 个格子的总量。格子切得越细越准,但内存和计算也越多。

type SlidingWindow struct {
    limit     int
    window    time.Duration
    slots     []int
    slotSize  time.Duration
    lastIndex int
    lastTime  time.Time
    mu        sync.Mutex
}

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

    now := time.Now()
    elapsed := now.Sub(sw.lastTime)
    steps := int(elapsed / sw.slotSize)
    for i := 0; i < steps && i < len(sw.slots); i++ {
        sw.lastIndex = (sw.lastIndex + 1) % len(sw.slots)
        sw.slots[sw.lastIndex] = 0
    }
    if steps > 0 {
        sw.lastTime = now
    }

    total := 0
    for _, v := range sw.slots {
        total += v
    }
    if total >= sw.limit {
        return false
    }
    sw.slots[sw.lastIndex]++
    return true
}

这段代码写得不复杂,但实际落地时格子数量和窗口大小的取舍挺烦人的。格子太少精度不够,太多又浪费。我一般 1 秒切 10 个就差不多了,再细意义不大。

到底选哪个

我的经验是,先用令牌桶,因为它能应对突发,绝大多数业务接口都吃这套。如果是调用外部服务、需要严格控制速率,就用漏桶。如果对窗口边界的精度很敏感,比如风控或者计费相关,那就得上滑动窗口。

还有一点,别一上来就上分布式限流,很多项目单机限流加个负载均衡就已经够了,Redis 那套反而带来新的瓶颈和一致性问题。等真扛不住了再说。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放