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。

它演示了什么

装配:限流与幂等挂在中间件槽上

这段来自 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 断言的是这些具体事实:

go test ./examples/resilience

相关

稳定性防护概念页 四件工具的规则、失败语义与组合顺序。 matex-setup-resilience 让 AI 按规范把限流/幂等/熔断/重试接进你的项目。 示例索引 全部 20 个可运行示例。

相关示例