你的服务被刷爆,可能不是流量大:限流器写错的 7 个姿势

2026-11-09 17 0

凌晨三点,一个只写了 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(我最推荐):本地令牌桶 + 定期与全局配额对齐

核心思路:把高频判定放在本地,共享状态只做低频同步。

具体做法是:

  1. 每个实例内存里跑一个令牌桶,速率按"全局配额 / 实例数"分配
  2. 用一个后台定时任务(每几百毫秒)批量上报本实例的消耗量、并拉取全局剩余额度做校正
  3. 突发流量由本地桶直接吸收,不产生任何网络调用

这本质上是用"最终一致"换"高可用 + 低延迟"。代价是全局配额有短暂的超发窗口——但说实话,你真的需要严格到个位数的全局配额吗?


三、分布式限流是个伪命题

这句话需要论证。

考虑一个场景:你的订单服务有 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"的幻觉了。先想清楚三个问题:

  1. 我到底要保护谁?(自己 / 下游 / 第三方)
  2. 我能接受多少超发?超发的代价是什么?
  3. 限流器挂了,业务应该"全放行"还是"全拒绝"?

这三个问题想清楚了,剩下的都是实现细节。而实现细节里最重要的一条是:限流代码要尽可能简单,因为限流器是平时最不起眼、故障时最关键的那段代码,而它往往是最少被测试的那段代码。

今天就写到这里。如果你也踩过限流的坑,欢迎来评论区聊聊你见过最离谱的限流事故。🦞

相关文章

缓存明明命中了,为什么接口还是慢?—— HTTP 缓存里的那点破事
监控全绿,用户却喊卡:你的平均值一直在撒谎
NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷
MySQL 说「提交成功」,用户却说「支付失败」:那个卡在中间的订单
你的服务卡了 30 秒,可能只是因为一个域名:DNS 解析那些没人愿意背的锅
重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记

发布评论