3 秒超时怎么变成 30 秒
arch-lab 的网关给订单详情设了 3 秒超时。库存服务在促销期间从 120ms 涨到 2.4s,网关的重试策略是失败后最多再试两次;连接池 50 个连接,取消后的请求仍然占着连接直到库存服务返回。结果是:单个请求最多等 9 秒,50 个连接被慢请求占满,新的请求在池外排队,排队时间又计入下一次重试的超时。监控上看到的是网关 504 上升,但根因不在网关,而在“超时预算没有向下传递”和“取消没有释放连接”。
这一节把三个失败模式放在一起看:超时预算传递、连接池耗尽、重试放大。它们单独看都像配置问题,叠在一起才会形成级联故障。相关的通用讨论可以对照 超时预算与调用链 和 慢调用如何扩散 ;本节给出可运行的模型和判据。
超时预算:传绝对截止时间,不传每跳常量
每跳都写“3 秒超时”是最常见的错误。父请求只剩 200ms 时,子调用仍然可以等 3 秒,父请求早已超时返回,子调用的结果没人接收,但连接和线程还在工作。正确做法是传递绝对截止时间:父请求在 T 时刻开始,总预算 800ms,那么所有子调用的 deadline 都不能晚于 T+800ms。
网关还要给自己留一段 reserve,用于序列化响应、写访问日志和返回错误。下面的表是一个示例分配,不是生产常数:
| 层 | 预算 | 保留 | 耗尽动作 |
|---|---|---|---|
| 网关入口 | 800ms | 50ms | 返回 504,不再向下调用 |
| BFF 聚合 | 750ms | 0 | 取消未完成 section |
| 订单服务 | 300ms | 30ms | 返回已确认的部分状态 |
| 库存/支付 | 各 250ms | 0 | 取消并标记降级 |
预算分配的关键不是数字,而是关系:子预算之和不能超过父预算减去保留;任何一层发现剩余时间小于保留,就应当立即失败,而不是继续发起注定超时的调用。Budget.Child 把剩余时间切成若干份,并拒绝“剩余时间已经不够保留”的情况,这样调用方不用在每个下游重复写判断。
连接池耗尽:排队时间也是请求时间
连接池的容量不是“并发上限”的同义词。请求在池外排队时,客户端仍然在等;如果超时从进入池开始算,排队会吃掉预算;如果超时从拿到连接开始算,排队可以无限长。两种口径没有绝对对错,但必须和重试策略一起定义,否则会出现“每次尝试都成功,但用户总超时”的怪象。
更隐蔽的是取消语义。请求超时后,如果下游调用没有真正取消,工作会继续占着连接直到完成。本节的 Pool 用带缓冲 channel 做信号量,Acquire 支持 context 取消;但模拟器刻意保留了“已取消的调用仍占用连接直到服务完成”的行为,因为真实客户端不一定能立刻中断下游。
| 症状 | 指标 | 处置 |
|---|---|---|
| 池外排队增长 | acquire p99、waiting 数 | 降并发、缩短超时、扩容下游 |
| 连接长期占用 | in-use / size、取消后释放延迟 | 检查下游是否响应取消 |
| 重试次数上升 | attempts / requests | 限制重试预算,加退避与抖动 |
| 尾延迟抬升 | p99、超时率 | 先止损,再定位慢依赖 |
级联模拟:离散事件能回答什么
SimulateCascade 是一个刻意简化的离散事件模型:请求按固定间隔到达,每个请求选择最早空闲的连接,服务时间固定;每次尝试有独立超时;超时后立即重试;已经开始的服务即使被取消,也占着连接直到结束。它不能预测真实系统的 p99,也不能替代压测,但能回答三个配置问题:池子够不够、重试会不会放大占用、超时应该设多长。
| 配置 | 请求 | 池 | 服务时间 | 超时 | 重试 | 结果 |
|---|---|---|---|---|---|---|
| 健康 | 20 | 8 | 20ms | 100ms | 1 | 20 成功,0 超时 |
| 池饥饿 | 8 | 1 | 40ms | 30ms | 1 | 0 成功,8 超时 |
| 重试放大 | 8 | 1 | 40ms | 30ms | 3 | 24 次尝试,占用翻倍 |
这三组数字来自本节的实测输出,不是生产容量结论。它们说明的是方向:池子小于“到达率 × 服务时间”时,排队会迅速超过超时;在同样的池子下把重试从 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 配置分层、版本与灰度发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。