《后端架构设计·演进与治理》4.3 网关失败模式:超时预算、连接池与级联故障

从一次 3 秒网关超时在重试与连接池排队中被放大成 30 秒的事故出发,说明绝对超时预算如何向子调用传递、取消后的工作为何仍占连接,以及级联故障的离散事件模拟;给出 Go 实现并实测健康、池饥饿与三次重试三组配置在成功数、尝试次数和连接占用上的差异,并说明模型不能替代真实压测。

3 秒超时怎么变成 30 秒

arch-lab 的网关给订单详情设了 3 秒超时。库存服务在促销期间从 120ms 涨到 2.4s,网关的重试策略是失败后最多再试两次;连接池 50 个连接,取消后的请求仍然占着连接直到库存服务返回。结果是:单个请求最多等 9 秒,50 个连接被慢请求占满,新的请求在池外排队,排队时间又计入下一次重试的超时。监控上看到的是网关 504 上升,但根因不在网关,而在“超时预算没有向下传递”和“取消没有释放连接”。

这一节把三个失败模式放在一起看:超时预算传递、连接池耗尽、重试放大。它们单独看都像配置问题,叠在一起才会形成级联故障。相关的通用讨论可以对照 超时预算与调用链 和 慢调用如何扩散 ;本节给出可运行的模型和判据。

超时预算:传绝对截止时间,不传每跳常量

每跳都写“3 秒超时”是最常见的错误。父请求只剩 200ms 时,子调用仍然可以等 3 秒,父请求早已超时返回,子调用的结果没人接收,但连接和线程还在工作。正确做法是传递绝对截止时间:父请求在 T 时刻开始,总预算 800ms,那么所有子调用的 deadline 都不能晚于 T+800ms。

网关还要给自己留一段 reserve,用于序列化响应、写访问日志和返回错误。下面的表是一个示例分配,不是生产常数:

层预算保留耗尽动作
网关入口800ms50ms返回 504,不再向下调用
BFF 聚合750ms0取消未完成 section
订单服务300ms30ms返回已确认的部分状态
库存/支付各 250ms0取消并标记降级

预算分配的关键不是数字,而是关系:子预算之和不能超过父预算减去保留;任何一层发现剩余时间小于保留,就应当立即失败,而不是继续发起注定超时的调用。Budget.Child 把剩余时间切成若干份,并拒绝“剩余时间已经不够保留”的情况,这样调用方不用在每个下游重复写判断。

连接池耗尽:排队时间也是请求时间

连接池的容量不是“并发上限”的同义词。请求在池外排队时,客户端仍然在等;如果超时从进入池开始算,排队会吃掉预算;如果超时从拿到连接开始算,排队可以无限长。两种口径没有绝对对错,但必须和重试策略一起定义,否则会出现“每次尝试都成功,但用户总超时”的怪象。

更隐蔽的是取消语义。请求超时后,如果下游调用没有真正取消,工作会继续占着连接直到完成。本节的 Pool 用带缓冲 channel 做信号量,Acquire 支持 context 取消;但模拟器刻意保留了“已取消的调用仍占用连接直到服务完成”的行为,因为真实客户端不一定能立刻中断下游。

症状指标处置
池外排队增长acquire p99、waiting 数降并发、缩短超时、扩容下游
连接长期占用in-use / size、取消后释放延迟检查下游是否响应取消
重试次数上升attempts / requests限制重试预算,加退避与抖动
尾延迟抬升p99、超时率先止损,再定位慢依赖

级联模拟:离散事件能回答什么

SimulateCascade 是一个刻意简化的离散事件模型:请求按固定间隔到达,每个请求选择最早空闲的连接,服务时间固定;每次尝试有独立超时;超时后立即重试;已经开始的服务即使被取消,也占着连接直到结束。它不能预测真实系统的 p99,也不能替代压测,但能回答三个配置问题:池子够不够、重试会不会放大占用、超时应该设多长。

配置请求池服务时间超时重试结果
健康20820ms100ms120 成功,0 超时
池饥饿8140ms30ms10 成功,8 超时
重试放大8140ms30ms324 次尝试,占用翻倍

这三组数字来自本节的实测输出,不是生产容量结论。它们说明的是方向:池子小于“到达率 × 服务时间”时,排队会迅速超过超时;在同样的池子下把重试从 1 次加到 3 次,失败请求数不变,但尝试次数和连接占用都会上升。重试不是免费的,它把下游的慢放大成网关的忙。

可运行实现

下面的代码包含三部分:Budget 用绝对截止时间做预算切分;Pool 是支持 context 取消的固定大小连接池;SimulateCascade 是上一节表格对应的离散事件模拟器。三者都只依赖标准库。

// file: internal/platform/gateway/cascade.go
// tier: L1
// verify: go test ./internal/platform/gateway/ -count=1
package gateway

import (
	"context"
	"errors"
	"fmt"
	"sync/atomic"
	"time"
)

var ErrBudgetExhausted = errors.New("gateway: timeout budget exhausted")

// Budget is an absolute deadline, so a child cannot silently outlive its
// parent.
type Budget struct{ deadline time.Time }

func NewBudget(now time.Time, total time.Duration) (Budget, error) {
	if total <= 0 {
		return Budget{}, fmt.Errorf("gateway: budget must be positive, got %v", total)
	}
	return Budget{deadline: now.Add(total)}, nil
}
func (b Budget) Deadline() time.Time { return b.deadline }
func (b Budget) Remaining(now time.Time) time.Duration {
	if d := b.deadline.Sub(now); d > 0 {
		return d
	}
	return 0
}

// Child divides the remaining budget into parts and keeps reserve for the
// gateway's own response path.
func (b Budget) Child(now time.Time, parts int, reserve time.Duration) (Budget, time.Duration, error) {
	if parts < 1 || reserve < 0 {
		return Budget{}, 0, errors.New("gateway: parts must be positive and reserve non-negative")
	}
	rem := b.Remaining(now)
	if rem <= reserve {
		return Budget{}, 0, ErrBudgetExhausted
	}
	share := (rem - reserve) / time.Duration(parts)
	if share <= 0 {
		return Budget{}, 0, ErrBudgetExhausted
	}
	return Budget{deadline: now.Add(share)}, share, nil
}

// Pool is a fixed-size connection pool. Acquire waits for a slot or for ctx
// cancellation, whichever comes first.
type Pool struct {
	sem   chan struct{}
	inUse atomic.Int64
	peak  atomic.Int64
}

func NewPool(size int) (*Pool, error) {
	if size < 1 {
		return nil, fmt.Errorf("gateway: pool size must be at least 1, got %d", size)
	}
	return &Pool{sem: make(chan struct{}, size)}, nil
}

// Acquire takes one slot. A canceled context returns ctx.Err() without
// consuming a slot.
func (p *Pool) Acquire(ctx context.Context) error {
	select {
	case p.sem <- struct{}{}:
		n := p.inUse.Add(1)
		for {
			peak := p.peak.Load()
			if n <= peak || p.peak.CompareAndSwap(peak, n) {
				return nil
			}
		}
	case <-ctx.Done():
		return ctx.Err()
	}
}

// Release returns one slot. Releasing without Acquire panics so a double
// release cannot quietly create capacity.
func (p *Pool) Release() {
	select {
	case <-p.sem:
		p.inUse.Add(-1)
	default:
		panic("gateway: Release without Acquire")
	}
}
func (p *Pool) Stats() (inUse, peak, size int) {
	return int(p.inUse.Load()), int(p.peak.Load()), cap(p.sem)
}

type CascadeConfig struct {
	Requests      int
	PoolSize      int
	ArrivalEvery  time.Duration
	ServiceTime   time.Duration
	ClientTimeout time.Duration
	MaxAttempts   int
}
type CascadeReport struct {
	Requests  int
	Succeeded int
	Failed    int
	Attempts  int
	TimedOut  int
	MaxWait   time.Duration
	BusyFor   time.Duration
}

// SimulateCascade runs a deterministic model: requests arrive evenly, take the
// earliest free slot, and time out if service cannot finish before their
// attempt deadline. A timed-out service still occupies the slot until it
// finishes, which is what turns a slow dependency into pool exhaustion.
func SimulateCascade(cfg CascadeConfig) (CascadeReport, error) {
	if cfg.Requests < 1 || cfg.PoolSize < 1 || cfg.ArrivalEvery < 0 ||
		cfg.ServiceTime <= 0 || cfg.ClientTimeout <= 0 || cfg.MaxAttempts < 1 {
		return CascadeReport{}, fmt.Errorf("gateway: invalid cascade config %+v", cfg)
	}
	free := make([]time.Duration, cfg.PoolSize)
	rep := CascadeReport{Requests: cfg.Requests}
	for i := 0; i < cfg.Requests; i++ {
		arrival := time.Duration(i) * cfg.ArrivalEvery
		begin := arrival
		succeeded := false
		for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
			rep.Attempts++
			deadline := begin + cfg.ClientTimeout
			slot := 0
			for j := 1; j < len(free); j++ {
				if free[j] < free[slot] {
					slot = j
				}
			}
			start := free[slot]
			if start < begin {
				start = begin
			}
			if start >= deadline {
				if wait := deadline - begin; wait > rep.MaxWait {
					rep.MaxWait = wait
				}
				rep.TimedOut++
				begin = deadline
				continue
			}
			if wait := start - begin; wait > rep.MaxWait {
				rep.MaxWait = wait
			}
			finish := start + cfg.ServiceTime
			free[slot] = finish
			rep.BusyFor += cfg.ServiceTime
			if finish <= deadline {
				succeeded = true
				break
			}
			rep.TimedOut++
			begin = deadline
		}
		if succeeded {
			rep.Succeeded++
		} else {
			rep.Failed++
		}
	}
	return rep, nil
}
// file: internal/platform/gateway/cascade_test.go
// tier: L1
// verify: go test ./internal/platform/gateway/ -count=1
package gateway

import (
	"context"
	"errors"
	"testing"
	"time"
)

func TestBudgetPropagation(t *testing.T) {
	now := time.Date(2026, 10, 28, 17, 50, 0, 0, time.UTC)
	b, err := NewBudget(now, 300*time.Millisecond)
	if err != nil {
		t.Fatalf("NewBudget: %v", err)
	}
	if got := b.Remaining(now.Add(40 * time.Millisecond)); got != 260*time.Millisecond {
		t.Fatalf("remaining=%v want 260ms", got)
	}
	child, share, err := b.Child(now.Add(40*time.Millisecond), 3, 20*time.Millisecond)
	if err != nil {
		t.Fatalf("Child: %v", err)
	}
	if share != 80*time.Millisecond || !child.Deadline().Equal(now.Add(120*time.Millisecond)) {
		t.Fatalf("share=%v deadline=%v want 80ms/now+120ms", share, child.Deadline())
	}
	if got := child.Remaining(now.Add(100 * time.Millisecond)); got != 20*time.Millisecond {
		t.Fatalf("child remaining=%v want 20ms", got)
	}
	if _, _, err := child.Child(now.Add(120*time.Millisecond), 1, 0); !errors.Is(err, ErrBudgetExhausted) {
		t.Fatalf("exhausted child err=%v want ErrBudgetExhausted", err)
	}
}

func TestPoolExhaustion(t *testing.T) {
	p, err := NewPool(2)
	if err != nil {
		t.Fatalf("NewPool: %v", err)
	}
	for i := 0; i < 2; i++ {
		if err := p.Acquire(context.Background()); err != nil {
			t.Fatalf("acquire %d: %v", i+1, err)
		}
	}
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Millisecond)
	defer cancel()
	if err := p.Acquire(ctx); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("third acquire err=%v want DeadlineExceeded", err)
	}
	if inUse, peak, size := p.Stats(); inUse != 2 || peak != 2 || size != 2 {
		t.Fatalf("stats=%d/%d/%d want 2/2/2", inUse, peak, size)
	}
	p.Release()
	if err := p.Acquire(context.Background()); err != nil {
		t.Fatalf("acquire after release: %v", err)
	}
	p.Release()
	p.Release()
	if inUse, _, _ := p.Stats(); inUse != 0 {
		t.Fatalf("inUse=%d after releases want 0", inUse)
	}
}

func TestCascadeHealthy(t *testing.T) {
	rep, err := SimulateCascade(CascadeConfig{Requests: 20, PoolSize: 8,
		ArrivalEvery: 10 * time.Millisecond, ServiceTime: 20 * time.Millisecond,
		ClientTimeout: 100 * time.Millisecond, MaxAttempts: 1})
	if err != nil {
		t.Fatalf("SimulateCascade: %v", err)
	}
	t.Logf("healthy: succeeded=%d failed=%d attempts=%d timedOut=%d maxWait=%v", rep.Succeeded, rep.Failed, rep.Attempts, rep.TimedOut, rep.MaxWait)
	if rep.Succeeded != 20 || rep.Failed != 0 || rep.TimedOut != 0 || rep.Attempts != 20 {
		t.Fatalf("healthy report=%+v", rep)
	}
}

func TestCascadePoolStarvation(t *testing.T) {
	rep, err := SimulateCascade(CascadeConfig{Requests: 8, PoolSize: 1,
		ArrivalEvery: 5 * time.Millisecond, ServiceTime: 40 * time.Millisecond,
		ClientTimeout: 30 * time.Millisecond, MaxAttempts: 1})
	if err != nil {
		t.Fatalf("SimulateCascade: %v", err)
	}
	t.Logf("starvation: succeeded=%d failed=%d attempts=%d timedOut=%d maxWait=%v", rep.Succeeded, rep.Failed, rep.Attempts, rep.TimedOut, rep.MaxWait)
	if rep.Succeeded != 0 || rep.Failed != 8 || rep.TimedOut != 8 || rep.MaxWait != 30*time.Millisecond {
		t.Fatalf("starvation report=%+v", rep)
	}
}

func TestCascadeRetryAmplifies(t *testing.T) {
	base := CascadeConfig{Requests: 8, PoolSize: 1, ArrivalEvery: 5 * time.Millisecond,
		ServiceTime: 40 * time.Millisecond, ClientTimeout: 30 * time.Millisecond, MaxAttempts: 1}
	single, err := SimulateCascade(base)
	if err != nil {
		t.Fatalf("single attempt: %v", err)
	}
	base.MaxAttempts = 3
	retry, err := SimulateCascade(base)
	if err != nil {
		t.Fatalf("retry attempts: %v", err)
	}
	t.Logf("single: attempts=%d timedOut=%d busy=%v", single.Attempts, single.TimedOut, single.BusyFor)
	t.Logf("retry: attempts=%d timedOut=%d busy=%v", retry.Attempts, retry.TimedOut, retry.BusyFor)
	if retry.Attempts <= single.Attempts || retry.BusyFor < single.BusyFor || retry.Failed != 8 {
		t.Fatalf("single=%+v retry=%+v: retries must add attempts but not failures", single, retry)
	}
}

本机实测

环境同上:macOS darwin/arm64、Go 1.26.0,gofmt -l . 与 go vet ./... 均无输出。运行:

$ go test ./internal/platform/gateway/ -run 'TestBudget|TestPool|TestCascade' -count=1 -v
=== RUN   TestBudgetPropagation
--- PASS: TestBudgetPropagation (0.00s)
=== RUN   TestPoolExhaustion
--- PASS: TestPoolExhaustion (0.02s)
=== RUN   TestCascadeHealthy
    cascade_test.go:73: healthy: succeeded=20 failed=0 attempts=20 timedOut=0 maxWait=0s
--- PASS: TestCascadeHealthy (0.00s)
=== RUN   TestCascadePoolStarvation
    cascade_test.go:86: starvation: succeeded=0 failed=8 attempts=8 timedOut=8 maxWait=30ms
--- PASS: TestCascadePoolStarvation (0.00s)
=== RUN   TestCascadeRetryAmplifies
    cascade_test.go:104: single: attempts=8 timedOut=8 busy=80ms
    cascade_test.go:105: retry: attempts=24 timedOut=24 busy=160ms
--- PASS: TestCascadeRetryAmplifies (0.00s)
PASS
ok  	archlab/internal/platform/gateway	0.254s

读数分三层。预算层:300ms 的父预算在 40ms 后剩 260ms,切三份并保留 20ms 后每份 80ms,子截止时间正好是起点后 120ms;剩余时间归零后再切分返回 ErrBudgetExhausted。连接池层:容量 2 的池在第三个 Acquire 上等 15ms 后返回 context.DeadlineExceeded,Stats 仍显示 2 个在用、峰值 2,释放后才能再拿。模拟层:健康配置 20 个请求全部成功、零超时;池饥饿配置 8 个请求全部失败、8 次超时、最大排队 30ms;把重试从 1 次提高到 3 次后,尝试次数从 8 变成 24,连接占用从 80ms 变成 160ms,失败请求仍是 8 个。

这些数字的边界要说清楚:服务时间固定、到达均匀、没有网络抖动、没有下游队列,也没有真实客户端的取消延迟。它证明的是模型内部的自洽和配置之间的相对差异,不是生产 p99。上线前应当用真实流量回放或压测替换这些常数,再决定池大小和重试预算。

常见错误与边界

  • 每跳写固定超时。父请求已经超时,子调用还在等,连接被无效工作占住。
  • 重试没有总预算。每次尝试都“独立超时”,总耗时是尝试次数乘以超时,还会放大下游压力。
  • 认为取消会立刻释放连接。下游不响应取消时,工作会继续占用连接直到完成。
  • 用连接池容量当并发上限。池外排队同样是请求时间,必须和超时、重试一起定义。
  • 把离散模拟当容量规划。它只能比较配置方向,不能替代真实压测和尾延迟测量。

小结

网关的级联故障通常不是某个组件“坏了”,而是三个正确性假设同时失效:超时可以每跳独立、取消会立刻释放资源、重试能提高成功率。把超时改成绝对截止时间并预留响应预算,把连接池的排队和取消语义写清楚,把重试限制在总预算内,才能让单个慢依赖只影响单个请求。

模拟器的价值在于把配置差异变成可比较的数字:池饥饿时 0/8 成功,重试从 1 次加到 3 次会让尝试次数变成 3 倍、连接占用翻倍,但不会救回已经超时的请求。真实系统还需要网络抖动、下游队列和客户端取消延迟的数据;这些必须用压测和线上观测补齐,不能从模型里外推。

阅读导航:上一节:4.2 BFF:按端聚合与契约裁剪 · 下一节:5.1 配置分层、版本与灰度发布 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查