稳定性防护

四个工具,两个作用点:限流与幂等挂在 HTTP 入站中间件上,熔断与重试包住某一次出站调用。 选哪个只看一个问题——你要保护的到底是自己、数据、下游,还是这一次请求的成败。

工具 包 作用点 防的是什么
限流pkg/core/ratelimithttpx 中间件(入站)一个调用方打得太快——保护自己
幂等pkg/core/idempotencyhttpx 中间件(入站)同一个写请求来了两次——保护数据
熔断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,或者自己写一个。 返回 "" 表示这个请求不限流。

ByIP 不默认信任 X-Forwarded-For 要按真实客户端 IP 限流,得显式写 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 是噪音。

重放的整数用 json.Number 解码 存下来的 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() 读当前状态。

OnStateChange 在锁内调用 回调运行期间熔断器持锁,所以只做记日志、打点这类短操作,不要回调 Breaker 本身。 熔断器不依赖 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
每次尝试都过一遍熔断;熔断打开后重试循环立刻停,不再补上三次注定失败的调用。 若把顺序反过来(breaker 在外),一次熔断会被放大成三倍的调用量。

两条必须同时满足的参数关系:

还有一条纪律:包裹范围要小。熔断与重试只包住那一次出站调用, 不要包整个 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。

相关示例