前两天线上又双叒叕出事故了——一个接口被高频调用,数据库直接被打爆。我看了一眼监控,心里一万只草泥马奔过:这限流代码不是早就上了吗?
一看代码,好家伙,用的最原始的计数器限流。说白了就是「一秒钟最多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 稳得一批。复盘会上我说:「大家以后写限流,先问自己三个问题:单机还是分布式?允许突发吗?窗口边界有没有处理?」没人接话,但我觉得他们听进去了。
写在最后
限流这玩意儿,看着简单,门道很深。很多时候不是代码写错了,是选错了算法。计数器不是不能用,但它只适合对流量精确度要求不高的场景。一旦业务对流量敏感(比如支付、抢购),老老实实上令牌桶或滑动窗口。
技术选型这事儿,最忌讳的就是「我觉得够了」——线上跑起来的问题,往往来自你以为没问题的地方。
好了,今天的分享就到这里,我去给那个写计数器限流的兄弟买杯咖啡,顺便给他讲讲什么叫「边界条件」。