单机限流好好的,8 个副本把下游打挂了

事故的形状通常是这样的:服务用 golang.org/x/time/rate 做了限流,压测单实例 125 QPS 一切正常。流量上来后扩到 8 个副本,每实例限 125,集群总量变成 1000 QPS——下游 MySQL 连接池当场打满,主从延迟飙升。

有人反应快:总量 1000,那就每实例 1000 ÷ 8 = 125。问题是两个前提都不成立:

  • 滚动发布那几分钟新旧副本共存,N 从 8 变成 9,总额度跟着漂到 1125
  • HPA 把副本扩到 16,每实例还是 125,总量直接 2000;而流量并不均匀,忙的实例饿死、闲的额度浪费

静态均分假设「N 固定、流量均匀」,发布和弹性伸缩随时把这两个假设打破。单机限流器继续留着保护实例本身,但总量控制必须另做一层。单机中间件的用法四月那篇限流入门写得很全,这篇直接从分布式的问题开始。

先写定义,再谈选型

选型吵架多数是因为没先定义。四个问题回答完,方案基本自己浮现:

  • 限流维度:全局一口径,还是按用户、按租户分层叠加?大租户会不会被小租户的流量挤死?
  • 限什么:QPS(速率)还是并发数(在途数量)?写接口和读接口额度不该相同
  • 误差容忍:全局额度偏差 10% 可接受,还是必须精确到个位?前者能用本地批量配额,后者要集中计数
  • 超限行为:拒绝(429)、排队等待还是降级返回缓存?对外用户和内部调用方的答案通常不同

本地兜底:x/time/rate 的三种语义

Limiter 有三种用法,语义差别经常被忽略:

limiter := rate.NewLimiter(rate.Limit(125), 250) // 125 QPS,允许 250 的瞬时突发

// 1) Allow:非阻塞。拿不到令牌立刻返回 false,适合在线接口快速失败
if !limiter.Allow() {
    // 返回 429
}

// 2) Wait:阻塞到拿到令牌或 ctx 结束。适合能排队的后台任务;
//    在线接口用它要小心——排队本身就在堆延迟
if err := limiter.Wait(ctx); err != nil {
    // ctx 超时或取消
}

// 3) Reserve:不消费令牌,先问「最早多久以后能拿到」
r := limiter.Reserve()
if !r.OK() {
    // 申请数量超过 burst 时永远拿不到;单请求场景不会走到这
}
// time.Sleep(r.Delay()) 之前,可以把 Delay() 换算成 Retry-After 头还给客户端

它的定位是单机额度:进程重启令牌清零、副本之间互不知情。做「实例级保护 + Redis 故障时的本地兜底」很合适,做全局额度不可靠。

集中计数:固定窗口的边界突刺与滑动窗口

全局额度放进 Redis,最直觉的是固定窗口计数:INCR 一下,超过限额就拒绝。它有个经典缺陷:限额 100/s 时,第 1 秒的最后 10ms 放进 100 个,第 2 秒的头 10ms 又放进 100 个——任意滑动的 1 秒区间里实际通过了 200 个。下游看到的是周期性的双倍突刺。

滑动窗口把判断改成「过去 1 秒内发生过多少请求」,用 ZSET 实现。原子性靠 Lua:Redis 按单线程命令执行模型运行,脚本执行期间不会插入其他客户端的命令,读计数和写记录是一个不可分割的动作。

-- KEYS[1] 限流 key,如 rl:tenant:42:write
-- ARGV[1] 窗口内限额
-- ARGV[2] 当前毫秒时间戳(调用方传入,避免多副本叠加 Redis 服务器时钟差)
-- ARGV[3] 窗口长度(毫秒)
-- ARGV[4] 本次请求的唯一 ID
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[2] - ARGV[3])

if redis.call('ZCARD', KEYS[1]) < tonumber(ARGV[1]) then
    redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
    redis.call('PEXPIRE', KEYS[1], ARGV[3]) -- 每次刷新 TTL,无流量后 key 自动过期
    return 1
end
return 0

两个容易写错的点:member 必须每次唯一,否则并发请求会被 ZADD 去重合并,限额形同虚设;时间戳由调用方传入而不是在脚本里取服务器时间,时钟偏差至少不会和 Redis 自己的叠加。

package ratelimit

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "time"

    "github.com/redis/go-redis/v9"
)

var slidingWindow = redis.NewScript(`
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[2] - ARGV[3])
if redis.call('ZCARD', KEYS[1]) < tonumber(ARGV[1]) then
    redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
    redis.call('PEXPIRE', KEYS[1], ARGV[3])
    return 1
end
return 0
`)

type Limiter struct {
    rdb    *redis.Client
    limit  int64
    window time.Duration
}

func (l *Limiter) Allow(ctx context.Context, key string) (bool, error) {
    id := make([]byte, 8)
    _, _ = rand.Read(id)

    n, err := slidingWindow.Run(ctx, l.rdb, []string{key},
        l.limit,                 // ARGV[1] 限额
        time.Now().UnixMilli(),  // ARGV[2] 当前毫秒
        l.window.Milliseconds(), // ARGV[3] 窗口毫秒
        hex.EncodeToString(id),  // ARGV[4] 唯一 member
    ).Int()
    if err != nil {
        return false, err // 注意:这里上报的是「限流器不可用」,不是「超限」,两者语义完全不同
    }
    return n == 1, nil
}

摊薄中心调用:每 100ms 领一批配额

每请求一次 eval 的代价不小:5 万 QPS 的入口意味着 Redis 要扛 5 万 eval/s,还给每个请求加一次 RTT。与其如此,不如每个实例每 100ms 集中领一批配额,请求只碰本地原子计数:

-- KEYS[1] 计数 key;ARGV[1] 本次申领量;ARGV[2] 窗口限额;ARGV[3] TTL 毫秒
local used = redis.call('INCRBY', KEYS[1], ARGV[1])
if used > tonumber(ARGV[2]) then
    redis.call('DECRBY', KEYS[1], ARGV[1]) -- 超领的部分退回去
    redis.call('PEXPIRE', KEYS[1], ARGV[3])
    return math.max(0, tonumber(ARGV[2]) - (used - tonumber(ARGV[1]))) -- 只给剩余额度
end
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return tonumber(ARGV[1]) -- 足额领到
type CreditPool struct {
    credits atomic.Int64
    batch   int64 // 全局额度 × 领取周期 × 少量裕量,例如 5000 × 0.1s × 1.2
    lease   func(ctx context.Context, batch int64) (int64, error)
}

func (p *CreditPool) Allow() bool {
    for {
        left := p.credits.Load()
        if left <= 0 {
            return false // 本地配额耗尽:语义是拒绝,不是等待
        }
        if p.credits.CompareAndSwap(left, left-1) {
            return true
        }
    }
}

func (p *CreditPool) StartRefill(ctx context.Context, every time.Duration) {
    ticker := time.NewTicker(every)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            granted, err := p.lease(ctx, p.batch)
            if err != nil {
                continue // 领取失败不补配额;连续失败要靠指标暴露,不能默默放行
            }
            p.credits.Add(granted)
        }
    }
}

代价是精度:最坏情况下全局瞬时额度会超出约「一个 batch × 副本数」。batch 按 100ms 的额度切,误差通常在几个百分点——这正是「误差容忍」要先定义的原因:能接受批量配额的粗,就不必为每个请求付一次 RTT。

Redis 挂了:放行还是拒绝,按业务定

集中式限流把 Redis 变成了关键依赖,故障时的行为必须是显式决策,不能是碰运气:

type FailPolicy int

const (
    FailOpen FailPolicy = iota // 放行,退回本地粗粒度限流:推荐流、搜索
    FailClosed                 // 拒绝:支付、扣库存这类超限有直接资损的
)

func (g *Guard) Allow(ctx context.Context, key string) bool {
    ok, err := g.redis.Allow(ctx, key)
    if err == nil {
        return ok
    }
    g.redisErrors.Add(1) // 限流器自身的错误率是独立指标,别和业务错误率混在一起

    if g.policy == FailOpen {
        return g.local.Allow() // 本地兜底额度按「全局 ÷ 预期副本数」粗配,宁粗勿无
    }
    return false
}

fail-open 不是「不限制」,是退化到粗粒度;fail-closed 也不是「永远安全」,拒绝本身就是用户可见的失败。两个方向都要配告警:Redis 错误率抬升时,fail-open 的服务在裸奔、fail-closed 的服务在全面 429,两种情况都需要有人知道。

超限响应是协议,不是一句报错

429 只是状态码,真正让客户端行为收敛的是配套响应头:

func writeLimited(w http.ResponseWriter, retryAfter time.Duration, limit, remaining int64, resetAt time.Time) {
    // Retry-After 是标准头,单位秒;X-RateLimit-* 是业界事实约定(标准化草案演进中)
    w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()+0.5)))
    w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limit, 10))
    w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(remaining, 10))
    w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(resetAt.Unix(), 10))
    w.WriteHeader(http.StatusTooManyRequests)
    _, _ = w.Write([]byte(`{"error":"rate_limited"}`))
}

内部服务之间,超限应当被当作背压回传:gRPC 返回 RESOURCE_EXHAUSTED 并附带 retry delay 信息,调用方据此降速或熔断,而不是自己加速重试。网关拿到下游的 429/Retry-After 后,正确的动作是把它换算成对自己入站流量的限流——保护下游,而不是把重试砸回去。

验证:限流曲线,以及限流器自己的指标

上线前压两组:

  • 单实例压到 2× 额度:通过量曲线应该平在额度附近。固定窗口会看到周期性突刺(边界双倍),滑动窗口应该是平线——这条曲线直接验证算法选型
  • 多实例同时压同一个租户:验证的是全局边界,而不是每实例各自达标

上线后盯限流器自身:决策耗时直方图(本地原子操作应在微秒级,eval 在毫秒级)、Redis 错误率、按维度的拒绝比例。最后一条最容易被忽略——某租户拒绝比例持续 30%,要么额度配低了,要么客户端在死循环重试,两种都需要人处理。

常见坑点

1. key 设计把基数引爆

user_id + endpoint 的 key,百万用户就是百万个 ZSET。TTL 能兜底回收,但峰值内存和维护成本都在。长尾维度(个人用户)考虑本地 LRU 加定期同步,Redis 只存聚合后的热点维度。

2. 重试流量没有计入限额

客户端按退避重试,每次重试都是一次新请求、都消耗限流额度,更糟的是它同时挤占正常流量的份额。把重试尝试单独标记计数,或者用重试预算限制重试占比,别让重试和首发抢同一口锅里的饭。

3. 静态均分在发布期间漂移

「全局 ÷ N」在 N 变化的窗口(滚动发布、HPA 扩缩容、实例 OOM 重启)必然漂移。要么用批量领配额,让 N 只影响领取频率不影响总量;要么监听成员变化重新均分——前者简单得多。

总结

分布式限流的方案光谱很清楚:本地令牌桶保实例、Redis 滑动窗口保全局、批量领配额摊薄中心压力、故障策略按业务资损定放行还是拒绝。

真正决定成败的是那四个定义:限谁、限什么、容忍多少误差、超限怎么办。这几个问题回答不了,再漂亮的算法也只是把混乱换了个形状。


限流不是把请求挡在门外,是在额度、精度与可用性之间,替团队选一个大家都认账的交点。