后端接口限流:令牌桶、漏桶和滑动窗口实现对比
最近在给一个接口加限流,本来想着随便找个库糊上去完事,结果翻了几篇文章发现讲得都挺玄乎的,什么“令牌桶允许突发、漏桶恒定速率”,看完还是不知道代码该怎么写。索性自己把三种常见的实现都撸了一遍,顺便记一下踩过的坑,省得下次再翻。
先说清楚,这里聊的限流都是单机内存版的,分布式那种基于 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 那套反而带来新的瓶颈和一致性问题。等真扛不住了再说。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。