resilience — 稳定性防护
四个工具,两个作用点:限流与幂等挂在 httpx 的中间件槽上保护入站流量,
熔断与重试包住具体的出站调用。这个示例把它们全部接在一条真实的请求路径上,
你会看到第三个快速请求如何被 429 挡掉、重复的付款请求如何被重放而不是重复创建,
以及熔断打开后重试为什么不继续徒劳地补刀。
运行
go run ./examples/resilience
# 限流:第三个快速请求被 429 挡掉
for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' \
-X POST localhost:8082/api/v1/login -d '{"user":"alice"}'; done; echo
# 幂等:两个请求返回同一个 payment id,只创建一次
curl -s -X POST localhost:8082/api/v1/payments -H 'Idempotency-Key: k-1' -d '{"amount":10}'
curl -s -X POST localhost:8082/api/v1/payments -H 'Idempotency-Key: k-1' -d '{"amount":10}'
# 重试 + 熔断:第一次请求耗尽重试预算,之后立即失败
curl -s localhost:8082/api/v1/flaky/1
curl -s localhost:8082/api/v1/flaky/2
没有外部依赖,不需要 make dev。监听 :8082
(避开默认的 8080),-conf 默认指向
examples/resilience/config.yaml。
它演示了什么
- 限流:
ratelimit.NewLocal(rate, burst)进程内按 key 的令牌桶,ratelimit.ByIP决定 key。 - 幂等:
idempotency.Middleware(store, cfg),客户端带Idempotency-Key,重复请求重放首次结果而不是重跑。 - 熔断:
breaker.New(breaker.Config{…}),连续失败到阈值后快速失败,冷却后放探测请求。 - 退避重试:
retry.DoValue(ctx, cfg, fn),默认全抖动。 - 顺序很关键:retry 在外、breaker 在内,且熔断阈值不得小于重试次数。
- 降级优先:限流器与幂等存储自身故障时放行,只记日志。
装配:限流与幂等挂在中间件槽上
这段来自 examples/resilience/main.go 的 BuildRouter。
因为 Use 有累积语义,payments 与 flaky
同时受限流与幂等保护,而 login 只受限流。
func (s *resilienceService) BuildRouter(srv *httpx.Server) error {
// Use applies to every route registered after it, so the protections
// stack in the order they are declared.
srv.Use(ratelimit.Middleware(
ratelimit.NewLocal(s.cfg.LoginRate, s.cfg.LoginBurst),
ratelimit.ByIP,
))
srv.Handle("POST", "/api/v1/login", s.login)
// Payments get the rate limit above plus idempotency. The memory store
// suits a single instance; a fleet needs idempotency.NewRedis(rc) with
// rc from env.Redis().
srv.Use(idempotency.Middleware(idempotency.NewMemory(), idempotency.Config{}))
srv.Handle("POST", "/api/v1/payments", s.pay)
srv.Handle("GET", "/api/v1/flaky/{id}", s.flaky)
return nil
}
出站调用:retry 包住 breaker,breaker 包住下游
同样来自 examples/resilience/main.go。这段是整个示例的核心:
Retryable 明确把 breaker.ErrOpen 判为不可重试——
熔断已经打开时,再补三次注定失败的调用毫无意义。
// flaky calls a dependency that is having a bad time. Retry sits outside
// the breaker: each attempt passes through the circuit, and once the
// circuit is open the retry loop stops instead of hammering.
func (s *resilienceService) flaky(ctx context.Context, r *http.Request) (any, error) {
payload, err := retry.DoValue(ctx, retry.Config{
Attempts: 3,
BaseDelay: 5 * time.Millisecond,
Retryable: func(err error) bool {
// An open circuit will not close inside a retry loop.
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()
})
})
switch {
case errors.Is(err, breaker.ErrOpen):
return nil, errs.Unavailable(50302, "downstream circuit is open")
case err != nil:
return nil, errs.Internal(50010, "downstream failed: %v", err)
}
熔断器本身在 Setup 里构造,注释点明了一条容易踩的约束:
阈值必须至少等于重试预算,否则熔断器会在重试循环中间打开,最后一次尝试连机会都没有。
s.circuit = breaker.New(breaker.Config{
// The trip threshold has to be at least the retry budget, or the
// breaker opens in the middle of a retry loop and the last attempt
// never gets a chance to succeed.
Failures: 3,
Successes: 1,
Cooldown: 5 * time.Second,
OnStateChange: func(from, to breaker.State) {
obs.Info(context.Background(), "circuit state changed", "from", from.String(), "to", to.String())
},
})
包裹关系
flowchart LR
REQ([请求 /api/v1/flaky/1]) --> RL["ratelimit 中间件
ByIP · 令牌桶"]
RL --> ID["idempotency 中间件
POST/PATCH/DELETE"]
ID --> H["handler"]
H --> R["retry.DoValue
Attempts=3 · 全抖动"]
R --> B["breaker.DoValue"]
B --> D["下游调用"]
D -- 失败 --> A{"还有尝试次数吗?"}
A -- 有 --> R
A -- "没有 / ErrOpen" --> O["熔断打开
→ 503 code 50302"]
classDef guard fill:#fdf6f0,stroke:#c8641f,color:#2b2722
classDef bad fill:#fdf0f0,stroke:#a33,color:#2b2722
class RL,ID guard
class O bad
Use 槽里按注册顺序叠加;出站两件包在具体那次调用外面,
包裹范围刻意收窄——包住整个 handler 会让熔断器统计到一堆无关的失败。
验证
# 默认 config.yaml:login_rate: 2、login_burst: 2
for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' \
-X POST localhost:8082/api/v1/login -d '{"user":"alice"}'; done; echo
# 200 200 429 ← 第三个被限流拒绝
# 幂等:两次调用返回同一个 id
curl -s -X POST localhost:8082/api/v1/payments -H 'Idempotency-Key: k-1' -d '{"amount":10}'
# {"code":0,"msg":"ok","data":{"amount":10,"id":"pay-1"}}
curl -s -X POST localhost:8082/api/v1/payments -H 'Idempotency-Key: k-1' -d '{"amount":10}'
# {"code":0,"msg":"ok","data":{"amount":10,"id":"pay-1"}} ← 还是 pay-1,没有创建第二个
# 不带 key 就是两次真实写入:会拿到 pay-2 这样的新 id
# 重试 + 熔断(默认 downstream_failures: 10,失败数量超过重试预算)
curl -s localhost:8082/api/v1/flaky/1
# 500,"downstream failed" —— 三次尝试全部失败,熔断器此时已打开
curl -s localhost:8082/api/v1/flaky/2
# 503 {"code":50302,"msg":"downstream circuit is open"} —— 快速失败,没有再去打下游
把 config.yaml 的 downstream_failures 调成 2
能看到另一种现象:重试把瞬时故障吃掉,请求正常返回 200,响应体里的
downstream_calls 是 3(两次失败 + 一次成功),
circuit 是 "closed"。
测试
main_test.go 断言的是这些具体事实:
TestRateLimitRejectsBurst:同一客户端连打三次,状态码依次是200, 200, 429。测试把RemoteAddr固定成10.0.0.1:1234,因为ByIP看的是它。TestRateLimitIsPerClient:同一个 IP 打到第三次是 429 之后, 换一个 IP 仍然是 200——限额是按 key 算的。TestIdempotentPayments:两个同 key 请求返回同一个 id, 且svc.payments.next == 1(重复请求没到达存储层); 换 key 后next == 2。TestPaymentWithoutKeyIsNotDeduplicated:不带Idempotency-Key的两次请求会创建两笔付款。TestRetryAbsorbsATransientFailure:下游失败两次,downstream_calls == 3、circuit == "closed",状态 200。TestBreakerOpensAfterRetriesAreExhausted:第一次请求 500, 且下游恰好被调 3 次(用满整个重试预算);第二次请求 503 且响应体含"code":50302,同时下游调用次数没有增加——熔断期间真的没打。TestValidationStillApplies:amount: 0返回 400; 确定性拒绝可以被安全重放,第二次同样是 400。TestConfigDefaults:默认值是login_rate=2、login_burst=2、downstream_failures=10。
go test ./examples/resilience
相关
相关示例
- examples/http:
Use槽与封套的基础。 - examples/errors:幂等按 4xx/5xx 分流依据的 Kind。
- examples/observability:熔断状态变化打进了日志。