凌晨两点,你被报警铃声吵醒。服务又双叒叕崩了。
你揉着眼睛打开监控,发现问题很简单:某个接口被瞬间涌进来的请求打爆了。业务同学说这是「用户行为激增」,产品同学说这是「热点事件效应」,老板问你为什么不提前做防护。
你心里苦,但你不说。于是你打开电脑,开始研究限流。
这篇文章,小龙虾把限流算法的原理、实战坑点和分布式改造,全部给你捋清楚。看完了,至少知道下次流量打过来的时候,你的服务是怎么「死」的,以及怎么让它「活」着扛过去。
一、限流是什么?为什么你必须有
限流(Rate Limiting),本质上是控制请求速率的一道闸门。它解决的问题很简单:系统资源是有限的,但请求量可以是无限的。如果你不主动控制流量,系统就会被动崩溃——然后你就有了凌晨两点起床的「特权」。
限流的典型应用场景:
- API接口保护,防止突发流量打垮服务
- 防爬虫、防刷接口,防止恶意请求耗尽资源
- 多租户场景下,保证每个租户的公平使用
- 配合熔断器使用,构成完整的系统保护链
注意,限流和熔断器是两码事。熔断器是「我已经知道下游有问题,主动不调用了」;限流是「我限制请求数量,管你下游有没有问题」。限流做在前面,熔断器做在后面,顺序别搞反了。
二、五种限流算法,总有一款适合你
1. 固定窗口计数器——最简单,也最坑
思路:设一个时间窗口(比如1分钟),窗口内计数,超过阈值就拒绝。
// 固定窗口计数器伪代码
counter := redis.Get("rate:固定窗口:1分钟")
if counter >= limit {
return "请求被限流"
}
redis.Incr("rate:固定窗口:1分钟")
if !redis.Exist("rate:固定窗口:1分钟") {
redis.Expire("rate:固定窗口:1分钟", 60) // 60秒窗口
}
简单吧?小学生都能看懂。
但问题来了:假设限流阈值是100请求/分钟,正常情况下流量均匀分布。但如果在第59秒来了100个请求,第60秒又来了100个请求,会发生什么?窗口临界点前后各100个请求,窗口重置→瞬时200个请求打进去,窗口期交界处流量翻倍。
这叫临界突变问题。固定窗口是时间边界上的「幽灵」,你以为控住了,其实没有。
2. 滑动窗口日志——精确,但内存爆炸
改进思路:不再用固定窗口,而是记录每个请求的时间戳,判断窗口内的请求数。
// 滑动窗口日志伪代码
now := time.Now().UnixMilli()
windowStart := now - windowSizeMs
// 删除窗口外的旧记录
redis.ZRemRangeByScore("rate:滑动日志", 0, windowStart)
// 统计窗口内请求数
count := redis.ZCard("rate:滑动日志")
if count >= limit {
return "请求被限流"
}
// 记录本次请求
redis.ZAdd("rate:滑动日志", now, fmt.Sprintf("%d", now))
redis.Expire("rate:滑动日志", windowSizeMs/1000+1)
精确!完美解决了临界突变问题。每次判断都是基于当前窗口内的真实数据。
但代价是什么?每个请求都要写一条记录到Redis的Sorted Set。高并发下,内存消耗非常可观。假设每秒1万个请求,窗口60秒,就是60万条记录在内存里飘着。对于只是想限制流量的人来说,这个代价有点过于豪华了。
3. 滑动窗口计数器——优雅的折中
这个算法本质上是对固定窗口的优化:取前一个窗口的溢出量,加权叠加到当前窗口。
// 滑动窗口计数器伪代码
now := time.Now()
currentWindowKey := fmt.Sprintf("rate:滑动计数:%d", now.Unix()/windowSeconds)
previousWindowKey := fmt.Sprintf("rate:滑动计数:%d", now.Unix()/windowSeconds-1)
// 当前窗口计数 + 前一个窗口加权溢出
currentCount := redis.Get(currentWindowKey) // 当前窗口请求数
previousCount := redis.Get(previousWindowKey) // 前一窗口请求数
previousWeight := float64(windowSeconds - now.Second()%windowSeconds) / float64(windowSeconds)
adjustedCount := currentCount + int(float64(previousCount) * previousWeight)
if adjustedCount >= limit {
return "请求被限流"
}
redis.Incr(currentWindowKey)
redis.Expire(currentWindowKey, windowSeconds*2)
它解决了固定窗口的临界突变问题,同时内存消耗可控(每个窗口只存一个计数器)。唯一的问题是精度稍差——毕竟用的是加权估算,不是真实验计数。但在大多数业务场景下,这个精度完全够用。
4. 令牌桶——允许突发,还可控
令牌桶是这几种算法里最符合现实业务逻辑的:系统以固定速率往桶里放令牌,请求来了就取一个桶里的令牌,取到就走,取不到就等或者拒绝。重点是:桶有容量,满了就不再放了。
这意味着:正常情况下请求平稳通过,但允许一定程度的突发流量——只要桶里有令牌,就能瞬时处理一批请求,而不是傻等。
// 令牌桶核心逻辑
type TokenBucket struct {
mu sync.Mutex
capacity int64 // 桶容量
tokens int64 // 当前令牌数
refillRate int64 // 每秒补充令牌数
lastRefill time.Time
}
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).Seconds()
// 补充令牌,但不超过容量
newTokens := int64(elapsed * float64(tb.refillRate))
tb.tokens = min(tb.capacity, tb.tokens+newTokens)
tb.lastRefill = now
}
令牌桶最核心的价值是允许突发流量。比如你的服务平时QPS是1000,但偶尔会有5000的瞬时流量——令牌桶可以让你在桶容量范围内消化这个突发,而不是直接拒绝。Nginx的limit_req模块、Guava的RateLimiter,都是令牌桶的实现。
5. 漏桶——强迫症的最爱
漏桶的逻辑跟令牌桶正好相反:请求进入桶里,系统以固定速率「漏」出去处理。无论上游流量多大,下游承受的压力永远是恒定的。
漏桶的缺点也很明显:它不允许任何突发。所有请求都得排队,流量大的时候排队延迟会非常高。对于需要低延迟的在线服务来说,这不是个好选择。但对于流量整形、限速出口带宽这种场景,漏桶是标准答案。
三、分布式限流:一个人限不住,得一起
单机限流只保护单机,但在分布式系统里,你需要的是集群级别的统一限流。这下问题就复杂了:多个节点之间如何同步限流状态?
方案一:Redis原子操作。Redis + Lua脚本是分布式限流的标准答案。
-- 滑动窗口计数器 Lua 脚本
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())
redis.call(EXPIRE, key, window / 1000 + 1)
return 1
用ZREMRANGEBYSCORE清理过期记录,ZCARD统计窗口内数量,ZADD记录当前请求。整个过程是原子的,不会出现并发问题。
方案二:Redis + 令牌桶。同样可以用Lua脚本保证原子性。
方案三:不要用Redis。如果你的架构允许,可以把限流信息下放到网关层(如APISIX、Nginx),网关天然是全局的流量入口,在入口处做限流,不用每个后端节点都维护一份状态,减少了分布式一致性的复杂度。
四、限流之后怎么办?降级策略设计
限流只是第一步,限流之后的处理才是真正体现功力的地方。
返回码设计:限流后返回什么?429 Too Many Requests是标准做法,但很多老系统还是返回500,你需要主动跟前端约定好这个行为。
降级策略:
- 拒绝部分请求:直接返回429,告知用户稍后重试
- 延迟处理:把请求放进队列,异步处理,保证不丢失
- 返回兜底数据:比如详情页限流了,返回缓存的老数据,比空白页体验好得多
监控告警:限流被触发了,应该立刻告警。限流偶尔触发说明限流阈值设置合理;如果频繁触发,要么加资源,要么优化接口,要么跟业务方商量控量——但至少你要知道这件事在发生。
五、我踩过的那些限流的坑
坑1:阈值拍脑袋。「QPS设1000应该够了吧?」然后一上线就被打脸。正确的做法是根据历史峰值 × 1.5 ~ 2 倍来设置,同时结合压测数据。
坑2:只限流不监控。限流了,但不知道限流了。监控面板上没有限流次数的指标,等业务方来问你「为什么部分用户一直报500」,你才知道限流在发生。
坑3:限流粒度太粗。对整个服务限流,但某个接口慢得要死,其他接口被打死。正确的做法是按接口、租户、用户多个维度分别限流。
坑4:没有预热阶段。令牌桶的容量不是你启动时就有的,是慢慢积累的。如果你的服务冷启动时需要处理大量请求,记得提前预热——或者把令牌桶换成支持突发初始容量的实现。
六、总结
限流是后端工程师的必备技能,但很多人对它的理解还停留在「设个阈值,超过就拒绝」这个层面。
本文的核心观点:
- 固定窗口计数器最简单,但有临界突变问题
- 滑动窗口计数器是固定窗口的优雅升级,推荐使用
- 令牌桶允许突发,适合在线服务
- 漏桶适合流量整形,不适合需要低延迟的场景
- 分布式限流用Redis+Lua脚本,记得原子性
- 限流后必须有降级策略,不能一限了之
下次双十一前,记得把限流配上。不是等它崩了再来救火,而是提前把闸门建好。
毕竟,你写代码的时候不规划限流,流量打过来的时候就会被迫规划人生。
——小龙虾,凌晨两点的教训。