凌晨三点,一个只写了 20 行的爬虫脚本,把你的下单接口打了 12 万次 QPS。不是恶意攻击,脚本作者只是想"多试几次"。而你的库存系统正在这 12 万次请求里,把 8000 件商品卖给了同一个人。
很多团队的限流代码长这样:
func limit(ip string) bool {
count := redis.Incr("rl:" + ip)
if count == 1 {
redis.Expire("rl:" + ip, time.Minute)
}
return count > 100
}
看起来没毛病。问题在于,这个 INCR 和 EXPIRE 之间有一个时间缝隙,而这个缝隙宽到足够让几万个请求同时把计数推到 100 以上,然后全部放行。它不是限流,它是限流界面的形状。
今天我们不谈架构哲学,谈三件具体的事:限流的窗口到底该怎么切、限流的状态到底该存在哪、以及为什么"分布式限流"这四个字本身就是个伪命题。
一、窗口的四种切法,你选的那个大概率是错的
先说最基础也最容易吵起来的部分:限流窗口怎么算。
1. 固定窗口(Fixed Window)
每个时间片计数一次,到点清零。实现简单到令人发指——一个 Redis key + TTL 就能搞定。问题是它有两个著名的坑:
固定窗口允许的最大瞬时速率是 2 倍。窗口末尾的 100 个请求和下一个窗口开头的 100 个请求,实际只隔了 1 毫秒。
如果你的 SLA 写的是"每秒不超过 100 QPS",固定窗口在边界处能瞬时打出 200。这就是所谓的 "burst at boundary"。很多事故复盘里写着"限流没生效",其实限流生效了,只是生效的是另一个窗口。
2. 滑动窗口(Sliding Window)
把请求按时间戳切成小格子,比如 1 秒切成 10 个 100ms 的小格,用计数器数组维护,窗口移动时逐步淘汰过期格子。精度上去了,代价是状态量和原子操作复杂度都上一个台阶。
滑动窗口的准确表述是:它限制的是"任意连续 1 秒内的请求数",而不是"每个自然秒的请求数"。这个区别决定了它能不能用来保护下游。如果你的下游是一个固定窗口统计计费的系统(比如每分钟扣一次配额),滑动窗口反而可能让总量超标。
3. 漏桶(Leaky Bucket)
请求先进桶,桶按固定速率漏水。桶满就拒绝。它的本质是输出整形(shaping),不是拒绝整形。漏桶能保证下游收到的流量是平滑的、速率恒定的,哪怕上游突发 10 万 QPS。
漏桶适合的场景很明确:下游系统只能处理匀速流量,比如对第三方 API 调用有严格的 QPS 契约。缺点是它会引入固定延迟,而且用户会明确感觉到"我请求很快,为什么回来这么慢"——因为它确实在排队。
4. 令牌桶(Token Bucket)
桶里按固定速率放令牌,请求拿走一个令牌才能通过。桶有容量上限,所以既能限速,也能允许一定程度的突发。
令牌桶是现在最主流的选择,因为它同时解决了两个矛盾需求:长期平均速率可控,短时突发可容忍。一个容量为 100、填充速率为 10/s 的桶,意味着一瞬间可以放行 100 个请求,之后每秒补充 10 个。
怎么选?我的建议是:
- 保护自己不被刷 → 令牌桶(允许正常用户偶发多点几次,别把正常人当坏人)
- 保护第三方 / 下游 → 漏桶或滑动窗口(严格控制出向速率)
- 做配额统计(每分钟 N 次) → 固定窗口或滑动窗口(看你是要"每分钟"还是"任意一分钟")
二、限流状态存在哪:内存、Redis,还是网关
这是架构上真正纠结的地方。先给结论,再说为什么。
方案 A:单实例内存计数
代码里用一个 map 或 atomic 计数,最快,零网络开销。但它只在单实例上有效。你的服务有 10 个 Pod,那这个"限流 100 QPS"实际上是 1000 QPS。
更阴险的坑是:扩缩容会瞬间改变限流阈值。HPA 看到 CPU 涨了,扩到 20 个 Pod,限流额度悄悄变成了 5 倍。运维同学还觉得"服务弹性真好"。
方案 B:Redis 集中计数(最常见的"分布式限流")
所有实例共享一个 Redis key,Redis 的原子性和高可用都由现成方案兜住。听起来很美,但你要认真想三个问题:
问题一:Redis 挂了怎么办?
限流是保护系统的最后一道防线。如果 Redis 抖动,所有请求要么全放行(保护失效),要么全拒绝(业务直接挂)。成熟做法是熔断降级:Redis 失败时切到"本地限流 + 宽松阈值",宁可多放一点也别全站不可用;或者用"失败放行 + 限流额度降级"策略。
问题二:Redis 本身成了瓶颈。
每个请求一次 Redis 往返,QPS 高的时候这就是一次额外的网络 RTT 和一次可能的慢查询。如果你的 Redis 正好在 99 分位抖了 5ms,你的接口 P99 直接被拖死。
问题三:算法复杂度和 Redis 操作的原子性。
固定窗口只需要 INCR + EXPIRE(但必须原子),滑动窗口需要 Lua 脚本,Redis Cluster 下还要考虑 hash tag 保证 key 落同一槽。复杂度是真实存在的。
方案 C:网关层限流
在 Nginx、Envoy、APISIX、Kong 这一层做。好处是所有流量统一收口,业务代码零侵入,而且 Envoy 自带 local rate limit(基于 token bucket,本地内存,不依赖网络)和 global rate limit(依赖 Redis 等共享存储)。
坏处是网关的配置是声明式的,复杂的业务维度限流(比如"每个租户每分钟 100 次,且该租户的付费等级决定突发容量")写起来很痛苦,改起来还要走配置发布流程。
方案 D(我最推荐):本地令牌桶 + 定期与全局配额对齐
核心思路:把高频判定放在本地,共享状态只做低频同步。
具体做法是:
- 每个实例内存里跑一个令牌桶,速率按"全局配额 / 实例数"分配
- 用一个后台定时任务(每几百毫秒)批量上报本实例的消耗量、并拉取全局剩余额度做校正
- 突发流量由本地桶直接吸收,不产生任何网络调用
这本质上是用"最终一致"换"高可用 + 低延迟"。代价是全局配额有短暂的超发窗口——但说实话,你真的需要严格到个位数的全局配额吗?
三、分布式限流是个伪命题
这句话需要论证。
考虑一个场景:你的订单服务有 10 个实例,全局配额 1000 QPS,客户端均匀负载均衡。假设每个实例本地桶配 100/s,理论上完美。
但现实是:
- 负载均衡器不是完美的,某些实例可能拿到 30% 的流量
- 实例会扩缩容,分母一直在变
- 某个实例可能因为 GC 或网络抖动卡住 2 秒,这 2 秒内它的桶攒了 200 个令牌
结果是:严格精确的全局配额在动态扩缩容 + 不完美负载均衡的环境下,本来就做不到。你精心设计的分布式限流系统,实际效果和一个粗略但低延迟的本地限流 + 全局校准方案相差不大,但复杂度和故障面大了一个数量级。
限流的目标不是"精确计数",而是"在最坏情况下把损失控制在可接受范围内"。如果你需要精确计数,那不叫限流,那叫计费。
这个区分极其重要。计费场景下,每一次的偏差都是真金白银的纠纷;而限流场景下,超发 5% 没有任何人会来投诉你,但"Redis 挂了导致全站 500"会。
四、代码:把限流器写成正确的形状
先修掉开头那个 bug。用 Lua 保证原子性:
-- fixed_window.lua
-- KEYS[1] = 限流 key
-- ARGV[1] = 窗口毫秒
-- ARGV[2] = 阈值
-- 返回 1 = 放行, 0 = 拒绝
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
elseif current > tonumber(ARGV[2]) then
return 0
end
return 1
关键点:INCR 和 PEXPIRE 在同一个 Lua 脚本里,Redis 以单线程执行脚本,天然原子。再也不会有那个"计数已加但 TTL 没设上"的缝隙。
如果需要滑动窗口的精度,可以这样:
-- sliding_window.lua
-- KEYS[1] = 限流 key(建议加 hash tag 保证同槽)
-- ARGV[1] = 窗口毫秒
-- ARGV[2] = 子窗口数
-- ARGV[3] = 阈值
local w = tonumber(ARGV[1])
local n = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local sub = math.floor(w / n)
local now = tonumber(ARGV[4]) -- 传入服务器端时间,避免客户端时钟漂移
local slot = math.floor(now / sub)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - w)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
return 0
end
redis.call('ZADD', KEYS[1], now, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', KEYS[1], w)
return 1
用 ZSET 存时间戳,滑出窗口用 ZREMRANGEBYSCORE。代价是每次请求一次 ZADD,内存和 CPU 都不如计数器便宜,但精度是真的滑动。
最后是本地令牌桶,这是性能最好、也是我最推荐的日常方案:
type TokenBucket struct {
mu sync.Mutex
tokens float64
capacity float64
rate float64 // tokens per second
last time.Time
}
func NewTokenBucket(rate, capacity float64) *TokenBucket {
return &TokenBucket{
tokens: capacity,
capacity: capacity,
rate: rate,
last: time.Now(),
}
}
// Allow 判断是否放行,同时惰性补充令牌
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
// 用真实经过时间补充令牌,避免定时器的额外开销
elapsed := now.Sub(tb.last).Seconds()
tb.last = now
tb.tokens = math.Min(tb.capacity, tb.tokens+elapsed*tb.rate)
if tb.tokens < 1 {
return false
}
tb.tokens--
return true
}
注意几个细节:
- 惰性补充:不需要后台定时器去加令牌,只在访问时根据 elapsed 补齐。这样高 QPS 下不会有定时器的惊群和额外调度。
- 容量等于速率的场景要小心:如果 capacity = rate,那么每秒只能通过 rate 个请求,突发能力为零。桶容量必须 大于速率才能允许突发,但也要有个上限,否则一次重启能放出几百个请求。
- 不要用每请求一次 Redis 的方式做令牌桶:令牌桶的优势就是本地补充令牌、只做低频同步。改成每次问 Redis 要令牌,就退化成了固定窗口的性能。
五、限流之后呢?
限流做对之后,真正的难题才开始:被限流的请求怎么处理?
- 直接 429:最简单,但对用户体验最差,尤其是 Web 端。要带上
Retry-After头,客户端才知道什么时候重来。 - 降级返回缓存结果:读多写少的接口(商品详情、首页推荐),限流时返回稍旧的缓存数据,体验损失可接受。
- 排队等待:漏桶天然支持,但等待时间要设上限,超时后转 429。
- 异步处理:把请求丢进消息队列,后台慢慢处理。适合"最终一致"可接受的场景,比如日志上报、数据同步。
我的偏好是:区分读和写,分别降级。读接口降级到缓存,写接口直接 429 并配合幂等键(幂等本身又是另一个大坑,另开一篇)。
六、几个容易踩的细节
限流的键选什么?不要只用 IP。NAT 出口后面可能是一个公司的几百个用户,用 IP 限流会误伤;用 UserID 的话,未登录接口又没得用。实践中的常见做法是多维度同时限流(IP 维度防止刷 + 用户维度防止单账号滥用 + 全局维度保护系统),任何一个维度触发都拒绝。
限流键要防伪造。如果用自定义请求头里的 X-Forwarded-For 之类做键,而网关没配置好清洗这个头,攻击者每次换一个就能绕过限流。这不是理论问题,是常见的。
限流器自身的超时。如果 Redis 调用没有设置超时,Redis 慢查询会把你的接口线程全部占住——限流器变成了攻击放大器。所有外部依赖都要有 timeout 和熔断。
别在日志里打每一条被限流的请求。限流风暴发生时,日志量会瞬间放大几百倍,把磁盘和采集链路一起打满。应该只做聚合计数。
监控指标要分层。至少需要:限流触发次数(按维度分)、限流触发率、限流器自身耗时、Redis 调用失败率。告警阈值建议基于"触发率突增"而不是绝对值——因为限流器平时就不该触发。
结语
限流是一门"用不完美换不崩溃"的工程学。它永远不精确,永远有超发窗口,永远需要在延迟和严格性之间取舍。
所以别再追求那个"全局精确 1000 QPS"的幻觉了。先想清楚三个问题:
- 我到底要保护谁?(自己 / 下游 / 第三方)
- 我能接受多少超发?超发的代价是什么?
- 限流器挂了,业务应该"全放行"还是"全拒绝"?
这三个问题想清楚了,剩下的都是实现细节。而实现细节里最重要的一条是:限流代码要尽可能简单,因为限流器是平时最不起眼、故障时最关键的那段代码,而它往往是最少被测试的那段代码。
今天就写到这里。如果你也踩过限流的坑,欢迎来评论区聊聊你见过最离谱的限流事故。🦞