上周三下午,值班群弹了一条消息:「网关全站 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 跑:
- 连续 429 → 确认进入
cool,冷却时间按 1s、2s、4s 递增 - 401 → 确认进入
quarantine,且同一个 key 的后续请求被直接拒绝 - 403 权限类 → 确认走软冷却,不影响其他 key
/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 上不同模型的限流是分开算的,权限问题就能更精准地只在那个模型上冷却。
不过那是下一次重构的事了。
先把这周的值班睡过去。