稳定性防护
四个工具,两个作用点:限流与幂等挂在 HTTP 入站中间件上,熔断与重试包住某一次出站调用。 选哪个只看一个问题——你要保护的到底是自己、数据、下游,还是这一次请求的成败。
| 工具 | 包 | 作用点 | 防的是什么 |
|---|---|---|---|
| 限流 | pkg/core/ratelimit | httpx 中间件(入站) | 一个调用方打得太快——保护自己 |
| 幂等 | pkg/core/idempotency | httpx 中间件(入站) | 同一个写请求来了两次——保护数据 |
| 熔断 | pkg/core/breaker | 包住出站调用 | 一直打一个已经挂掉的下游——保护它 |
| 重试 | pkg/core/retry | 包住出站调用 | 瞬时失败——把它藏起来 |
一条贯穿全部四件的规则:降级优先
防护组件自己故障时,放行,只记一条 warn。限流器连不上 redis、幂等存储读不出来, 都不会拒掉正常流量:丢掉限流和去重是坏事,但为此让所有请求一起失败更坏。
// pkg/core/ratelimit: 限流器故障 → 放行
ok, err := l.Allow(ctx, k)
if err != nil {
obs.Warn(ctx, "rate limiter unavailable; letting the request through", "err", err)
return next(ctx, r)
}
// pkg/core/idempotency: 存储故障 → 不带保护地执行
claimed, err := store.Begin(ctx, key, cfg.TTL)
if err != nil {
obs.Warn(ctx, "idempotency store unavailable; running unguarded", "err", err)
return next(ctx, r)
}
这条规则有一个例外,而且在中间件里写死了:重复请求重放阶段读存储失败,
返回 500 {"code":50002} 而不是放行。因为跑到这一步说明 key 已经被占住,
再执行一遍正是调用方要求你防止的事——这里失败关闭。
限流:加在路由的边缘
两种策略共用一个接口,选哪个是在回答"限谁的额度":
| 构造 | 算法 | 额度范围 | 代价 |
|---|---|---|---|
NewLocal(rate, burst) |
进程内按 key 的令牌桶 | 每个实例各算各的:N 个副本就是 N × rate | 空闲够久的 key 会被丢弃,IP 这种无界键空间撑不爆内存 |
NewRedis(client, limit, window) |
固定窗口,Lua 脚本原子 incr + pexpire | 整个集群共享一份计数 | 窗口边界最多能过 2 × limit,换来一次往返与原子性 |
srv.Use(ratelimit.Middleware(ratelimit.NewLocal(s.cfg.LoginRate, s.cfg.LoginBurst), ratelimit.ByIP))
srv.Handle("POST", "/api/v1/login", s.login)
key 由 KeyFunc 给出:ByIP(用 RemoteAddr,
在负载均衡后面那是代理的地址)、ByHeader("X-API-Key")、
ByRoute、ByIPAndRoute,或者自己写一个。
返回 "" 表示这个请求不限流。
ratelimit.ByHeader("X-Forwarded-For")——
并且只在你确实控制边界网关时才这么做,否则调用方可以随便伪造这个头来绕过限流。
超限返回 429 {"code":42901,"msg":"too many requests"},handler 完全不执行。
幂等:写操作的必备件
客户端送一个 Idempotency-Key。第一个请求占住这个 key 并执行,结果存下来;
带同一个 key 的后续请求重放那个结果,不再执行。
srv.Use(idempotency.Middleware(idempotency.NewRedis(rc), idempotency.Config{}))
srv.Handle("POST", "/api/v1/payments", s.pay)
首轮结果决定后续请求看到什么:
| 首轮结果 | 同 key 的后续请求 |
|---|---|
| 成功(含 204) | 重放相同响应 |
| 4xx(确定性拒绝) | 重放相同错误 |
| 5xx | 释放 key,允许重试成功 |
| 超时 / 取消 | 释放 key——副作用可能发生了也可能没有,不能让调用方在整个 TTL 内重放 504 |
| 首轮还在跑 | 409 {"code":40902},而不是并行跑第二遍 |
Config 的默认值:Header 为 Idempotency-Key,
TTL 为 24 小时,Prefix 为 "idem:",
Methods 为 POST / PATCH / DELETE——
GET 本来就幂等,带 key 是噪音。
Outcome.Data 回读时走 json.Decoder 加
UseNumber()。普通 Unmarshal 会把超过 253 的
uint64 id 悄悄四舍五入成 float64,回给调用方一个看着像真的、其实错了的值。
NewMemory() 只适合单实例与测试:多副本下每个实例只去重自己那份流量,
看起来生效、实际漏一半。生产用 NewRedis(rc),rc 在
Setup 里从 env.Redis() 取。
熔断:停止打一个已经挂掉的下游
三态机,与教科书写法一致:
stateDiagram-v2
[*] --> closed
closed --> open: 触发条件满足
(连续失败 / 错误率滑窗超阈值)
open --> halfopen: 冷却期结束,放行一个探测
halfopen --> open: 探测失败,重新计时冷却
halfopen --> closed: 连续 Successes 次探测成功
note right of open
期间每次调用直接返回 ErrOpen
不再给下游加负载
end note
note right of halfopen
同一时刻只放一个探测
避免又变成一次小爆发
end note
Cooldown(默认 10s),关回条件用 Successes
(默认 2)。OnStateChange 在每次迁移时调用,指标从这里上报。
两种触发模型
| 模型 | 配置 | 什么时候打开 | 适合 |
|---|---|---|---|
| 错误率滑窗 | Ratio: 0.5, Window: 60s, MinSamples: 20 |
窗口内至少 MinSamples 次调用,且失败率 ≥ Ratio |
线上默认。稳态流量里几个 5xx 就能触发,低流量里的偶发抖动不会 |
| 连续失败 | Failures: 5(Ratio 留零) |
连续 Failures 次失败 |
调用频次很低、没有"比率"可言的下游 |
滑窗用 10 个固定槽实现,每次调用 O(1)、内存有界;槽按 Window / 10
的宽度对齐,一次长空闲会把窗口整体清空。Ratio > 1 会被夹到 1,
负值当作 0(即退回连续失败模型)。滑窗模型在打开和关闭时都会清空窗口:
恢复后的流量从零开始统计,打开前的旧失败不会在刚关上的瞬间把它再顶开。
什么算失败:按 errs 种类分类
默认 IsFailure 只看服务端错误:errs.Status(err) >= 500。
非 matex 的普通 Go error 被 errs 映射成 500,所以也算失败。
400/404 这类客户端错误是调用方的问题,不打开熔断——否则一条写错参数的上游
就能把你的下游熔断掉。
// pkg/core/breaker: 默认分类
func defaultIsFailure(err error) bool {
if err == nil {
return false
}
status, _, _ := errs.Status(err)
return status >= 500
}
需要别的口径就传自己的 IsFailure。使用方式:
b := breaker.New(breaker.Config{Failures: 3, Successes: 1, Cooldown: 5 * time.Second})
err := b.Do(ctx, func(ctx context.Context) error {
return payments.Charge(ctx, order)
})
if errors.Is(err, breaker.ErrOpen) {
return nil, errs.Unavailable(50302, "payment service is unavailable")
}
ErrOpen 与"试过了但失败了"是两件事,用 errors.Is 区分。
带返回值时用泛型版本 breaker.DoValue[T],State() 读当前状态。
obs,指标(如 breaker_state{name} gauge)
在回调里自己上报。
重试:只对幂等操作,且必须在熔断外面
延迟是 BaseDelay × Multiplier^(N-1),上限 MaxDelay,
再在 [0, delay) 上摊开——全抖动。同一瞬间失败的一批客户端若无抖动,
会在同一瞬间重试,把一个瞬时抖动放大成尖峰。默认值:Attempts: 3、
BaseDelay: 100ms、Multiplier: 2、MaxDelay: 5s。
默认 Retryable 只排除已取消或已超时的 ctx——睡一觉再试救不了它。
// 出站调用:retry 在外,breaker 在内,包裹范围只有这一次调用
payload, err := retry.DoValue(ctx, retry.Config{
Attempts: 3,
BaseDelay: 5 * time.Millisecond,
Retryable: func(err error) bool {
return !errors.Is(err, breaker.ErrOpen) // 熔断了就别再重试
},
}, func(ctx context.Context) (string, error) {
return breaker.DoValue(ctx, s.circuit, func(context.Context) (string, error) {
return s.downstream.call()
})
})
flowchart LR
H[handler] --> R["retry.DoValue
Attempts: 3"]
R --> B["breaker.DoValue"]
B -->|"circuit closed"| D[下游调用]
B -->|"circuit open"| EO["ErrOpen"]
EO --> R
R -.->|"Retryable 判 false
立即停止"| H
D -->|"5xx / 传输错误"| R
D -->|"成功"| H
classDef stop fill:#fdf6f0,stroke:#c8641f,color:#2b2722
class EO stop
两条必须同时满足的参数关系:
-
熔断阈值 ≥ 重试次数。阈值小于重试预算时,熔断器会在重试循环中间打开,
最后一次尝试连机会都没有。示例里
Failures: 3对Attempts: 3是最紧的配置。 -
Retryable要显式放行ErrOpen。默认的Retryable只看 ctx,会把ErrOpen当成可重试的普通错误, 于是熔断一打开就是三次空转。
还有一条纪律:包裹范围要小。熔断与重试只包住那一次出站调用,
不要包整个 handler——那样熔断器会统计到一堆与下游无关的失败。
重试只对幂等操作安全;写操作配 idempotency,不要赌第二次无害。
组合起来的顺序
func (s *resilienceService) BuildRouter(srv *httpx.Server) error {
// Use 只护它之后注册的路由,所以防护按书写顺序叠加
srv.Use(ratelimit.Middleware(ratelimit.NewLocal(s.cfg.LoginRate, s.cfg.LoginBurst), ratelimit.ByIP))
srv.Handle("POST", "/api/v1/login", s.login)
srv.Use(idempotency.Middleware(idempotency.NewMemory(), idempotency.Config{}))
srv.Handle("POST", "/api/v1/payments", s.pay)
return nil
}
入站防护的叠加语义由 httpx.Server.Use 决定:先注册的中间件在最外层。
所以限流先于幂等——被 429 挡掉的请求不会占住一个幂等 key。
相关示例
- examples/resilience:四个工具串在一条链路上,含"熔断打开后不再调用下游"的断言。
- HTTP 与路由:
Use的累积语义与两个中间件槽。 - 包索引:四个包的导出符号一览。