一次 429 打挂全站,我连夜重写了整个熔断器

上周三下午,值班群弹了一条消息:「网关全站 429,所有人都在等」。

我第一反应是上游挂了,翻了一眼监控------上游好得很,响应时间曲线平得像一条直线。问题在我自己写的熔断器上。

那天晚上我把这块代码整个重写了一遍。这篇就是这次重构的完整记录,包括我最初那个设计到底有多糙、我是怎么想明白的、以及上线之后踩到的几个坑。

一、先说 v1 有多糙

我们内部有个自建的 LLM 网关,给公司几个业务线统一出 API。上游是一组不同权限的 API Key,请求按权重轮询发出去。

第一版熔断器我写得很「简洁」:一个全局开关。

go 复制代码
// v1:全局熔断
var (
    globalUntil atomic.Int64 // 熔断截止时间戳
)

func allowed() bool {
    return time.Now().Unix() < globalUntil.Load()
}

当时的想法很朴素:上游是稀缺资源,别把它打挂。任何一个 key 返回 429,网关整体静默 60 秒,等风头过了再说。

写的时候我还挺得意,三十行代码解决战斗。跑起来才发现,我把三件事一起搞砸了:

第一,故障影响面被放大了。 五个 key 里有一个 429,剩下四个好好的,但整个网关停了。用户那边看到的是「服务不可用」,而上游那边看到的是------一个安静的网关。等于我用 80% 的可用容量,去给 20% 的故障陪葬。

第二,该管的不该管的全没管。 401、403 这类明确的错误,网关压根没分类,还是傻乎乎地按权重往那个 key 上打。一晚上同一个失效凭证被打了两千多次,日志里刷了一屏。

第三,静默期一过,又是一波整齐的重试。 这个最要命------60 秒一到,所有排队请求同一毫秒涌上去,把上游再惹一遍,然后再 429,再静默。形成了一个特别规律的方波。

复盘的时候我在白板上写了句话:我在用「全班罚站」的方式,处理「某个学生上课讲话」的问题。

二、重新想清楚一件事:熔断的对象是谁

重构之前我先做了一件事:把上游返回的错误码全捞出来,按语义分类。

以前我把它们统一叫「失败」,其实它们说的是完全不同的事。

上游信号 真实语义 该做的事
401 凭证失效,这个 key 已经没用了 立刻摘除,别再打
403 + 权限类文案 这个 key 没有该模型的权限 软冷却,因为下游可能还在求这个模型
429 / 529 触发了上游的速率限制或过载 退避,而且必须带随机抖动
5xx 上游抖动(网关错误、超时) 短冷却,快速恢复
2xx 正常 计数器清零

这张表出来的那一刻,重构的方向就定了:熔断的对象不是「网关」,是「每一个上游 key」。

一句话总结这版设计:每个 key 是一条独立的熔断线,互相不共享状态。 一个 key 挂了,只有用它的人受影响,其他人无感。

三、账号级健康状态机

落到代码上,我给每个上游 key 配了一个三态状态机。

go 复制代码
type state int32

const (
    stOpen       state = iota // 正常
    stCool                    // 冷却中,到期自动恢复
    stQuarantine              // 隔离,需要人工介入或到期
)

type acct struct {
    mu        sync.RWMutex
    st        state
    until     time.Time // 冷却/隔离截止时间
    strikes   int32     // 连续命中次数
    r429      int64     // 累计限流次数
    rFatal    int64     // 累计硬错误次数
}

状态转移的规则:

  • 命中 429 / 529 → 进入 cool,冷却时长从 base 开始按 2^(strikes-1) 翻倍,封顶 maxCool。
  • 命中 401 / 423 / 403 硬错误 → 直接进 quarantine,起点 1 小时,连续命中翻倍,24 小时封顶。
  • 命中 403 软错误(权限类) → 进 cool,10 分钟起步。
  • 成功一次 → strikes 清零,回到 open。

核心逻辑大概长这样:

go 复制代码
func (a *acct) onFail(kind errKind) {
    a.mu.Lock()
    defer a.mu.Unlock()

    a.strikes++

    var d time.Duration
    switch kind {
    case errRateLimit: // 429 / 529
        d = baseCool << (a.strikes - 1)
        if d > maxCool {
            d = maxCool
        }
        a.st = stCool
        a.r429++
    case errSoftPerm: // 403 权限类
        d = softCool << (a.strikes - 1)
        if d > maxCool {
            d = maxCool
        }
        a.st = stCool
    case errFatal: // 401 / 423 / 403 硬错误
        d = fatalBase << (a.strikes - 1)
        if d > fatalMax {
            d = fatalMax
        }
        a.st = stQuarantine
        a.rFatal++
    }

    a.until = time.Now().Add(d)
}

strikes 的语义是「连续命中」而不是「累计命中」------成功一次就清零。这样偶发的 429 不会把一个健康的 key 一路推到 24 小时隔离里去。

四、节奏控制:比熔断更靠前的一道闸

熔断是事后的。请求已经打出去了,只是别打第二次。真正能让 429 少出现的,是事前的节奏控制。

我加了三层。

第一层:同 key 最小请求间隔。

go 复制代码
// pace-ms = 1000,带 ±25% 抖动
func (a *acct) pacd() time.Duration {
    base := time.Duration(cfg.PaceMS) * time.Millisecond
    if base <= 0 {
        return 0
    }
    jitter := 0.75 + rand.Float64()*0.5 // 0.75 ~ 1.25
    return time.Duration(float64(base) * jitter)
}

这里有个我一开始想错的点:不要用固定间隔。 我最早写的是严格 1 秒一次,跑了两天看监控发现请求节拍整齐得离谱------每一秒的偏移误差不到 3 毫秒。人手动点不出这种节奏,机器一眼就能看出来。加了 ±25% 抖动之后,请求间隔才像一个正常的使用者。

第二层:在途请求上限。

max-in-flight 每个 key 给到 1~2。这条线其实比限流本身更能说明问题:下游的并发用户数,永远不要超过上游的 key 数。 超了就是在排队,排队就是在攒 429。

第三层:每客户端的令牌桶。

按调用方 key 分配,60 次/分钟。这个数看着宽,其实对绝大多数业务都够------正常的交互式调用一天也就几百次。

三层叠起来的顺序是:令牌桶 → 在途上限 → 请求间隔 → 熔断。前面三层是「尽量不出事」,最后一层是「出了事别扩大」。

五、把状态暴露出来

熔断器最怕的一件事是:它出故障了,但没人知道。所以我加了两个东西。

一个 /stats 接口,把每个 key 的实时状态吐出来:

json 复制代码
{
  "accounts": [
    {"key": "sk-...a1", "state": "open",   "cool_left": 0,   "r429": 0, "fatal": 0},
    {"key": "sk-...b2", "state": "cool",   "cool_left": 52,  "r429": 3, "fatal": 0},
    {"key": "sk-...c3", "state": "quarantine", "cool_left": 3418, "r429": 1, "fatal": 2}
  ]
}

这个接口现在是我们的第一排查入口。出问题先看一眼:是 open 还是 cool,剩下的数字全能对上。

一个 /trip 手动拉闸接口:

bash 复制代码
curl "http://127.0.0.1:8317/trip?key=sk-...b2&seconds=3600"

场景是这样的:有时候我在上游的通知邮件里先看到某个 key 有问题,网关自己还没感知到。手工把它按下去,比等它自己撞南墙快得多。

日志也做了分级,运维同学只需要 grep 三个符号:

  • ⚠ 冷却中
  • 🛑 手动拉闸
  • 🚫 隔离,需要人工介入

规则很简单:看到 🚫 就要有人去看。

六、上线前先做冒烟

这套东西改的是故障路径,平时根本跑不到,所以必须造信号去测。

我写了四条冒烟用例,对着一个 mock upstream 跑:

  1. 连续 429 → 确认进入 cool,冷却时间按 1s、2s、4s 递增
  2. 401 → 确认进入 quarantine,且同一个 key 的后续请求被直接拒绝
  3. 403 权限类 → 确认走软冷却,不影响其他 key
  4. /trip 手动拉闸 → 确认立即生效,到期后自动恢复

第一条我第一次就没过。原因很蠢:mock 里我用了 time.Sleep 制造间隔,但断言用的是 time.Now(),两个时钟源在 sleep 精度上对不齐,边界上偶发失败。后来统一换成 time.Since(start) 差值比较才稳定。

四条全绿才发的版。这句话写出来很简单,但当时为了第四条「到期自动恢复」,我在 until 的时间比较上翻过一次车------用了 wall clock 之后,本机时间同步一调整,隔离时间莫名其妙缩水了半小时。后来改成单调时钟才彻底干净。

七、上线一周后的几个数

  • 全站静默次数:日均 3 次 → 0 次。 这一个指标就够了。
  • 单 key 故障的影响面:全体用户 → 该 key 的流量份额(约 20%)。
  • 平均恢复时间: 从「等 60 秒静默期」变成「按 2 的幂退避」,健康 key 全程无感。
  • 误伤率: 一周内 quarantine 触发 4 次,3 次是真凭证失效,1 次是上游把权限文案改了导致误判,人工放行。

八、几个我自己踩过的坑

1. 熔断和重试会打架。 客户端的重试逻辑要能感知网关侧的状态,别在 quarantine 状态下还继续重试------那是把一个错误放大 N 倍。正确做法是网关侧直接返回一个明确的、带 Retry-After 的错误,让客户端自己退。

2. 状态存内存,重启就没了。 我一开始把状态放在 map 里,进程一重启全部 open,冷启动瞬间所有 key 一起挨打。现在改成启动时读一个持久化的快照文件,宁可保守一点,先按历史状态恢复。

3. 抖动不要写成 0 ~ 2x。 看着均匀,实际上有一半概率落在 (0, 1x) 区间,等于把最小间隔废掉了。要写 0.75 ~ 1.25。

4. 时间比较一律用单调时钟。 这条我吃过两次亏,都是隔了几天才查出来的那种。

5. /stats 要能抗住高频轮询。 我一开始直接在状态机的读锁里做聚合,监控一上 10 秒一次的轮询,直接把正常请求拖慢了。现在改成后台协程定时快照,接口只读快照。

九、如果再来一次

现在回头看,v1 那三十行代码最大的问题不是写得糙,是我压根没想清楚「故障的边界应该画在哪」。

我当时下意识地把「网关」当成一个整体,所以熔断也做成了整体。但网关的本质是一堆独立的上游资源的调度器------调度器的第一职责就是隔离,把一个坏节点的影响圈在它自己身上。

这个思路其实可以再往外推一层。现在我的熔断粒度是「key」,如果往上走一层,按「业务线 × key」做配额,一个业务线的突发流量就不会挤占别人的余量;往下走一层,按「模型」拆,同一个 key 上不同模型的限流是分开算的,权限问题就能更精准地只在那个模型上冷却。

不过那是下一次重构的事了。

先把这周的值班睡过去。

相关推荐
ServBay2 小时前
写代码到底用哪个 AI 不花冤枉钱?最贵和最便宜差了250倍
chatgpt·claude·deepseek
孟健3 小时前
Claude Code 工程化实战:从保守问答到 3 个月 27 万行代码的全自动重构
架构·ai编程·claude
AI砖家7 小时前
DeepSeek V4.1 Flash vs. GPT-5.6 Sol:前沿闭源与高效开源,路线已经分叉
gpt·开源·deepseek
小粉粉hhh8 小时前
大模型RAG和Agent智能体(一)——初了解
gpt·langchain·aigc
雨中飘荡的记忆8 小时前
CLAUDE.md 怎么写?我整理了 10 个模板
claude
ZzT9 小时前
Claude Code 和 Codex 组队协作 12 天:1648 条消息里,真讨论只占 15%–25%
ai编程·claude
kkkkkkkkkk_Z9 小时前
新手自学嵌入式 | 学习日记:EPIT/GPT定时器、输入捕获与PWM、UART通信与Modbus协议
单片机·gpt·学习
302wanger1 天前
Anthropic 内部报告:当 AI Agent 的工作时长是人类的 6 倍,公司还招初级工程师吗?
claude
小七-七牛开发者1 天前
周一上线 | DeepSeek 开始卷“许愿式编程”,Agent 手机来了,Character.AI 申请破产
ai·大模型·agent·claude·codex·deepseek·ai coding