做后端开发这么多年,最让我睡不着觉的不是代码写不出来,是线上突然跑进来一批请求,然后我的服务就像被踩了一脚的蚂蚁——原地去世。
限流,这个词大家肯定听过。但你真的懂它吗?
我见过太多团队一拍脑袋决定用 Redis SETNX 做限流,结果上线第一天就被脉冲流量打穿。也见过有人信誓旦旦说「我用令牌桶,稳的」,结果高并发下一秒崩给你看。
今天我们来好好聊聊限流这件事,把几个经典算法的底裤都扒掉。
固定窗口:最简单的方案,也是最容易翻车的
固定窗口大概是很多人入门限流的第一站。思路简单粗暴:在单位时间内计数,超过阈值就拒绝。
// 固定窗口伪代码
func isAllowed(key string, limit int, windowSec int) bool {
count := redis.Get("rate:" + key)
if count >= limit {
return false
}
redis.Incr("rate:" + key)
redis.Expire("rate:" + key, windowSec)
return true
}
看起来没问题对吧?单位时间内最多 N 个请求,很合理。
但这里有个巨大的陷阱——窗口边界问题。
假设你设置的是每秒 100 个请求。流量在 0.9s 涌进来 100 个,然后在 1.1s 又涌进来 100 个。这两个窗口分别是 [0s,1s) 和 [1s,2s),各自都没超过阈值,看起来一切安好。
但实际上在 0.9s 到 1.1s 这 0.2s 里,你处理了 200 个请求。这就是「脉冲流量」攻击,两倍的 QPS 在你毫无察觉的情况下涌进来。
固定窗口算法的问题是:它把流量切割成了独立的窗口,但真实的流量是连续的。窗口边界处没有任何保护。
滑动窗口:固定窗口的升级版,但还不够
滑动窗口的思路是把时间轴连续化,不再有硬性的窗口边界。你每次请求都计算过去 N 秒内的请求数。
// 滑动窗口伪代码(基于 Redis Sorted Set)
func isAllowed(key string, limit int, windowSec int) bool {
now := time.Now().UnixMilli()
windowStart := now - windowSec*1000
// 清理过期数据
redis.ZRemRangeByScore("rate:"+key, 0, windowStart)
// 统计当前请求数
count := redis.ZCard("rate:" + key)
if count >= limit {
return false
}
// 记录当前请求
redis.ZAdd("rate:"+key, now, fmt.Sprintf("%d", now))
return true
}
滑动窗口解决了窗口边界问题,因为每次检查的都是「过去 N 秒」,而不是某个固定窗口。
但它也有代价:需要存储每一条请求的时间戳,而且每次请求都要清理过期数据。QPS 极高的情况下,ZCard 操作会成为性能瓶颈。
另外,滑动窗口的实现精度和存储精度有关。如果用秒级时间戳,在边界处还是可能出现一点误差。
令牌桶:最优雅的算法,但最容易被错误实现
令牌桶是我最喜欢的限流模型,因为它符合真实流量的特征——允许一定程度的突发流量,同时又能保证长期速率。
想象一个桶,以固定速率往里面放令牌,每来一个请求就从桶里取一个令牌,桶空了就拒绝。桶的容量就是你能承受的突发大小。
// 令牌桶核心逻辑(单机版)
type TokenBucket struct {
rate float64 // 每秒补充的令牌数
capacity int // 桶容量
tokens float64 // 当前令牌数
lastUpdate time.Time
mu sync.Mutex
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
// 补充令牌
tb.tokens += now.Sub(tb.lastUpdate).Seconds() * tb.rate
if tb.tokens > float64(tb.capacity) {
tb.tokens = float64(tb.capacity)
}
tb.lastUpdate = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
但这里有个坑——并发安全。上面代码用了 mutex 来保护,但在高并发下 mutex 会成为瓶颈。更好的做法是用 atomic 操作,或者用 Redis 的 Lua 脚本实现无锁的令牌桶。
-- Redis 令牌桶 Lua 实现
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒补充令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local lastUpdate = tonumber(redis.call(HGET, key, lastUpdate) or now)
local tokens = tonumber(redis.call(HGET, key, tokens) or capacity)
-- 计算应该补充的令牌
local elapsed = (now - lastUpdate) / 1000
local addTokens = elapsed * rate
tokens = math.min(capacity, tokens + addTokens)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call(HSET, key, tokens, tokens)
redis.call(HSET, key, lastUpdate, now)
redis.call(EXPIRE, key, 60)
return allowed
这个 Lua 脚本保证了令牌补充和消费的原子性,是生产环境可用的令牌桶实现。
漏桶:平整流量的一把好手,但不适合互联网场景
漏桶的思想是:流量以任意速率进入桶,但以固定速率流出。当桶满时,新请求被丢弃。
漏桶和令牌桶的区别在于:漏桶强制「匀速输出」,而令牌桶允许突发。
// 漏桶实现
type LeakyBucket struct {
rate float64 // 漏出速率(每秒)
current float64 // 当前水量
lastLeak time.Time
mu sync.Mutex
}
func (lb *LeakyBucket) Allow() bool {
lb.mu.Lock()
defer lb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(lb.lastLeak).Seconds()
lb.current = math.Max(0, lb.current - elapsed*lb.rate)
lb.lastLeak = now
if lb.current < lb.capacity {
lb.current++
return true
}
return false
}
漏桶的问题是:它会把所有突发流量都抹平。对于 API 服务来说,这可能过于严格——用户的一次正常突发请求不应该被当作攻击。
漏桶更适合下游系统的保护,比如数据库写入。当你需要以固定速率往数据库写数据时,漏桶是个好选择。
组合方案:线上环境不会只用一种算法
真实的限流策略往往是多层组合的:
- 入口限流:在最外层用令牌桶或滑动窗口,应对大量突发流量
- 服务限流:在应用层用计数器,配合熔断机制
- 资源限流:对数据库、Redis 等下游资源单独限流,防止压垮依赖
另外,限流一定要考虑分布式场景。单机限流只能保护单机,但在集群环境下,需要统一的限流节点或者分布式协调(Redis + Lua 是常见方案)。
踩坑心得
说几个我踩过的坑:
1. 不要用 GET 类型的 Redis 命令做限流
SETNX + EXPIRE 看起来很美,但如果你在 GET 之后 EXPIRE 之前挂了,请求计数就丢了。用 SET + EXPIRE 把两个操作合并成一个。
2. 限流拒绝的 response 要有明确的标识
返回 429 Too Many Requests,并带上 Retry-After 头。不要只返回一个冷冰冰的错误码,用户不知道要等多久。
3. 监控比限流本身更重要
限流的目的是保护系统,但如果你不知道限流触发了多少次、被限掉的请求特征是什么,你就没办法优化。限流 metrics 要单独打出来。
4. 测试的时候要用真实的流量模型
别用 curl 循环跑测试,那样太均匀了。用 wrk 或者 k6,模拟真实的请求分布,才能看出限流算法在边界的表现。
写在最后
限流不是一个「加上就行」的配置,是需要对业务流量有深刻理解的设计。你得知道:正常流量长什么样、峰值是什么时候、突发场景允许多大的延迟。
每个算法都有自己的适用场景,没有银弹。固定窗口适合内部服务的简单限流,滑动窗口适合需要精确控制的 API 限流,令牌桶适合需要处理突发流量的入口限流,漏桶适合下游保护。
选对了算法,系统稳如老狗。选错了,上线就是灾难片。
希望这篇文章能帮你少踩几个坑。