《后端架构设计·高性能与高可用》7.1 超时预算如何在调用链中分配

在 1.3 的预算分配器之上实现服务端超时中间件:解析请求头预算、设置绝对 deadline、向下游传递扣减后的剩余预算并回写实际耗时;用 httptest 真跑两跳扣减和每跳新建超时的对照,实测 300ms 链路转发 267ms、总耗时 302ms 与 343ms。

预算算完只是开始

1.3 性能预算 解决了“每个阶段该分多少毫秒”,但分配表停留在请求入口。真正决定用户等多久的,是入口之后每一跳有没有继续遵守这张表。一个常见的失败模式是:网关按 300ms 预算收到请求,鉴权花了 30ms,然后调用订单服务时又写死 context.WithTimeout(ctx, 300*time.Millisecond)。订单服务以为自己还有 300ms,实际调用方只剩 270ms,最后 30ms 的工作全部白做,用户仍会看到超时。

本节在 1.3 的分配器之上补服务端的一半:从请求头读取预算,把相对值变成绝对截止时间,向下游只传递扣减后的剩余量,并在响应头回写实际耗时。预算表仍然由分配器生成,中间件负责让它跨进程生效。

为什么传剩余毫秒,不传绝对时间戳

跨服务传递预算有两种写法:传绝对截止时间,或传剩余毫秒。前者看起来更直接,却把两台机器的时钟误差带进了超时判断;NTP 偏差 50ms 就足以让下游提前或延后放弃。后者以接收方本地时间为基准,误差只来自传输延迟,而传输延迟本来就应该计入预算。

因此 arch-lab 使用 X-Timeout-Budget-Ms,单位是毫秒,含义是“从你收到这个请求起,最多还能用多久”。中间件的职责是把这个相对值换算成本地绝对截止时间,并在调用下游时重新计算剩余量。客户端传来负数、零、小数或非数字一律拒绝;超过服务端上限则截断,避免一个恶意请求把线程占住几分钟。

中间件:一次换算,三层收紧

Wrap 在进入 handler 前解析预算并创建带截止时间的 context,在 handler 返回后检查是否因 deadline 而失败。响应头 X-Elapsed-Ms 在第一次写响应时记录,这样即使 handler 直接 Write 也能拿到耗时。如果 handler 完全忽略 context、在截止时间之后才返回且尚未写响应,中间件补一个 504;如果 handler 已经写了响应,状态码无法回退,但下游请求仍然受同一个绝对截止时间约束。

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

import (
	"context"
	"errors"
	"io"
	"net/http"
	"strconv"
	"time"
)

const (
	HeaderBudget  = "X-Timeout-Budget-Ms"
	HeaderElapsed = "X-Elapsed-Ms"
)

var ErrNoDeadline = errors.New("context has no deadline")

type Config struct {
	Default time.Duration
	Max     time.Duration
}

type Middleware struct {
	Config Config
	Now    func() time.Time
}

func (m *Middleware) now() time.Time {
	if m.Now != nil {
		return m.Now()
	}
	return time.Now()
}

// Wrap turns a relative budget into an absolute deadline and records elapsed
// time. The handler can ignore ctx; the next hop still inherits the same
// absolute cut-off because the context does.
func (m *Middleware) Wrap(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		budget, err := m.parse(r)
		if err != nil {
			http.Error(w, "INVALID_TIMEOUT_BUDGET", http.StatusBadRequest)
			return
		}
		start := m.now()
		ctx, cancel := context.WithDeadline(r.Context(), start.Add(budget))
		defer cancel()
		ew := &elapsedWriter{ResponseWriter: w, start: start, now: m.now}
		next.ServeHTTP(ew, r.WithContext(ctx))
		if errors.Is(ctx.Err(), context.DeadlineExceeded) && !ew.wrote {
			http.Error(ew, "TIMEOUT", http.StatusGatewayTimeout)
		}
	})
}

func (m *Middleware) parse(r *http.Request) (time.Duration, error) {
	raw := r.Header.Get(HeaderBudget)
	if raw == "" {
		if m.Config.Default <= 0 {
			return 0, errors.New("no default budget")
		}
		return m.Config.Default, nil
	}
	ms, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || ms <= 0 {
		return 0, errors.New("budget must be positive milliseconds")
	}
	d := time.Duration(ms) * time.Millisecond
	if m.Config.Max > 0 && d > m.Config.Max {
		d = m.Config.Max
	}
	return d, nil
}

type elapsedWriter struct {
	http.ResponseWriter
	start time.Time
	now   func() time.Time
	wrote bool
}

func (w *elapsedWriter) WriteHeader(code int) {
	if w.wrote {
		return
	}
	w.wrote = true
	w.Header().Set(HeaderElapsed, strconv.FormatInt(w.now().Sub(w.start).Milliseconds(), 10))
	w.ResponseWriter.WriteHeader(code)
}

func (w *elapsedWriter) Write(p []byte) (int, error) {
	if !w.wrote {
		w.WriteHeader(http.StatusOK)
	}
	return w.ResponseWriter.Write(p)
}

// Remaining never invents a fresh timeout; it reports what is left of the
// parent deadline or refuses to continue.
func Remaining(ctx context.Context) (time.Duration, error) {
	deadline, ok := ctx.Deadline()
	if !ok {
		return 0, ErrNoDeadline
	}
	rem := time.Until(deadline)
	if rem <= 0 {
		return 0, context.DeadlineExceeded
	}
	return rem, nil
}

func NewRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) {
	rem, err := Remaining(ctx)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, method, url, body)
	if err != nil {
		return nil, err
	}
	ms := rem.Milliseconds()
	if ms < 1 {
		ms = 1
	}
	req.Header.Set(HeaderBudget, strconv.FormatInt(ms, 10))
	return req, nil
}

三层关系是单向收紧的:handler 的 context 不能晚于中间件截止时间,下游请求的 context 继承 handler 的 context,请求头里的剩余量又不可能大于下游本地截止时间。NewRequest 刻意不用 context.Background();一旦用 Background 再配一个固定超时,预算就在这一行断掉了。

Remaining 在上下文已经过期时返回 context.DeadlineExceeded,而不是返回 0ms 去发一个必然失败的请求。这个细节决定了故障时的行为:预算耗尽应该立即失败并让上层看见,而不是制造一个“下游收到 0ms 预算”的噪声请求。

arch-lab 下单链路的两跳账本

把 1.3 的 400ms 预算表放进网关。假设入口分到 300ms,网关鉴权和参数校验用 30ms,调用订单服务时只能传 270ms 左右,而不是重新传 300ms:

位置本地剩余本跳耗时传给下游说明
网关入口300ms30ms267ms解析头、鉴权、校验后转发
订单服务267ms50ms217ms库存预占与风控并行
支付适配器217ms120ms97ms单次授权,失败不重试
落库与响应97ms15ms82ms本地收尾,不再下发

表中的 267ms 是下面测试的真实输出,其余是预算规划值。每一跳的剩余量都在减少,任何一跳都无法把总时长“重置”为自己的完整预算。如果支付适配器内部还要调用银行,它必须继续传 94ms 扣掉本地耗时后的值,而不是银行接口文档里的 3 秒。

测试:非法头、耗尽、两跳扣减

测试覆盖四类行为。第一,abc、-1、0、1.5 四种非法头必须返回 400,且 handler 一次都不能执行;第二,20ms 预算配一个只等 context 的 handler,必须在预算附近返回 504;第三,网关工作 30ms 后转发,后端看到的预算必须明显小于 300ms;第四,同一故障下比较“传递剩余预算”和“每跳新建 300ms 超时”的总耗时。

// file: internal/platform/timeoutbudget/budget_test.go
// tier: L1
// verify: go test -v ./internal/platform/timeoutbudget/ -count=1
package timeoutbudget

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"
)

func TestInvalidAndExhaustedBudget(t *testing.T) {
	mw := &Middleware{Config: Config{Default: time.Second, Max: 5 * time.Second}}
	called := false
	h := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
	for _, raw := range []string{"abc", "-1", "0", "1.5"} {
		req := httptest.NewRequest(http.MethodGet, "/orders", nil)
		req.Header.Set(HeaderBudget, raw)
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("budget %q: status=%d want 400", raw, rec.Code)
		}
	}
	if called {
		t.Fatal("handler must not run with an invalid budget")
	}
	slow := mw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		<-r.Context().Done()
	}))
	req := httptest.NewRequest(http.MethodGet, "/orders/quote", nil)
	req.Header.Set(HeaderBudget, "20")
	rec := httptest.NewRecorder()
	start := time.Now()
	slow.ServeHTTP(rec, req)
	if rec.Code != http.StatusGatewayTimeout || time.Since(start) > 150*time.Millisecond {
		t.Fatalf("status=%d elapsed=%s", rec.Code, time.Since(start))
	}
	t.Logf("invalid_headers=4 exhausted_status=%d exhausted_elapsed=%s",
		rec.Code, time.Since(start).Round(time.Millisecond))
}

func TestTwoHopBudgetIsDeducted(t *testing.T) {
	seen := make(chan int64, 1)
	backendMW := &Middleware{Config: Config{Default: time.Second, Max: 5 * time.Second}}
	backend := httptest.NewServer(backendMW.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ms, _ := strconv.ParseInt(r.Header.Get(HeaderBudget), 10, 64)
		seen <- ms
		time.Sleep(5 * time.Millisecond)
		_, _ = w.Write([]byte(`{"ok":true}`))
	})))
	defer backend.Close()
	client := backend.Client()
	gatewayMW := &Middleware{Config: Config{Default: time.Second, Max: 5 * time.Second}}
	gateway := gatewayMW.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(30 * time.Millisecond)
		req, err := NewRequest(r.Context(), http.MethodGet, backend.URL, nil)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		resp, err := client.Do(req)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		defer resp.Body.Close()
		w.WriteHeader(resp.StatusCode)
		_, _ = io.Copy(w, resp.Body)
	}))
	req := httptest.NewRequest(http.MethodGet, "/checkout", nil)
	req.Header.Set(HeaderBudget, "300")
	rec := httptest.NewRecorder()
	gateway.ServeHTTP(rec, req)
	forwarded := <-seen
	if forwarded >= 300 || forwarded < 230 {
		t.Fatalf("backend budget=%dms not deducted from 300ms", forwarded)
	}
	t.Logf("inbound=300ms after_30ms_work_forwarded=%dms", forwarded)
}

// TestPropagationVersusFreshTimeoutPerHop is the control group. The first
// gateway forwards the remaining budget; the second creates a new timeout for
// the backend and therefore pays both hops.
func TestPropagationVersusFreshTimeoutPerHop(t *testing.T) {
	const budget = 300 * time.Millisecond
	backendMW := &Middleware{Config: Config{Default: budget, Max: 2 * time.Second}}
	backend := httptest.NewServer(backendMW.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(2 * time.Second):
			w.WriteHeader(http.StatusOK)
		case <-r.Context().Done():
		}
	})))
	defer backend.Close()
	client := backend.Client()
	run := func(propagate bool) time.Duration {
		gw := &Middleware{Config: Config{Default: budget, Max: 2 * time.Second}}
		h := gw.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(40 * time.Millisecond)
			var req *http.Request
			var err error
			if propagate {
				req, err = NewRequest(r.Context(), http.MethodGet, backend.URL, nil)
			} else {
				ctx, cancel := context.WithTimeout(context.Background(), budget)
				defer cancel()
				req, err = http.NewRequestWithContext(ctx, http.MethodGet, backend.URL, nil)
			}
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}
			resp, err := client.Do(req)
			if err != nil {
				w.WriteHeader(http.StatusGatewayTimeout)
				return
			}
			defer resp.Body.Close()
			w.WriteHeader(resp.StatusCode)
			_, _ = io.Copy(w, resp.Body)
		}))
		req := httptest.NewRequest(http.MethodGet, "/checkout", nil)
		req.Header.Set(HeaderBudget, strconv.FormatInt(budget.Milliseconds(), 10))
		rec := httptest.NewRecorder()
		start := time.Now()
		h.ServeHTTP(rec, req)
		return time.Since(start)
	}
	propagated, naive := run(true), run(false)
	t.Logf("budget=%s propagated=%s naive=%s delta=%s", budget,
		propagated.Round(time.Millisecond), naive.Round(time.Millisecond),
		(naive - propagated).Round(time.Millisecond))
	if propagated > budget+80*time.Millisecond {
		t.Fatalf("propagated elapsed=%s exceeded budget", propagated)
	}
	if naive < propagated+25*time.Millisecond {
		t.Fatalf("naive=%s should exceed propagated=%s", naive, propagated)
	}
}

真实运行:300ms 预算被守住

本机 macOS darwin/arm64、Go 1.26.0,命令 go test -v ./... -count=1,原始输出保存在 .local/reports/be-arch/be-arch-perf-07-1-timeout-budget-tests.txt。以下是本机单次运行:

=== RUN   TestInvalidAndExhaustedBudget
    budget_test.go:40: invalid_headers=4 exhausted_status=504 exhausted_elapsed=22ms
--- PASS: TestInvalidAndExhaustedBudget (0.02s)
=== RUN   TestTwoHopBudgetIsDeducted
    budget_test.go:80: inbound=300ms after_30ms_work_forwarded=267ms
--- PASS: TestTwoHopBudgetIsDeducted (0.04s)
=== RUN   TestPropagationVersusFreshTimeoutPerHop
    budget_test.go:132: budget=300ms propagated=302ms naive=343ms delta=41ms
--- PASS: TestPropagationVersusFreshTimeoutPerHop (0.65s)
PASS
ok  	archlab/internal/platform/timeoutbudget	0.989s

三组数字对应三个结论。20ms 预算在 22ms 返回 504,说明 deadline 在本地生效;300ms 预算扣掉 30ms 工作后传到后端是 267ms,说明扣减不是重算;同样 300ms 预算,传播模式总耗时 302ms,每跳新建超时的模式 343ms,多出的 41ms 正是网关那段被重复计算的预算。这个对照里后端故意要睡 2 秒,确保两种模式都由超时结束,而不是被后端正常响应掩盖。

常见错误与边界

第一,传绝对时间戳。两台机器时钟差几十毫秒,预算就会偏;传剩余毫秒并以本地接收时间为基准更稳。

第二,每一跳新建 WithTimeout。这是本节对照组的 343ms;新超时不是预算,只是给每一跳重新发了一张额度。

第三,只设置 context 不检查结果。handler 必须在下游返回后判断 ctx.Err(),否则会继续做已经没人等待的序列化和日志。

第四,把 504 和 400 混在一起。非法预算头是调用方错误,应立即拒绝;预算耗尽可能是上游慢或下游慢,应返回 504 并记录是哪个阶段吃掉了预算。

第五,忘记预算下限。剩余不足 1ms 时仍然发请求,只会给下游制造必然失败的流量;NewRequest 在 1ms 以下仍会发,但 Remaining 在过期后直接拒绝,实际调用方应在更早的位置短路。

小结

超时预算跨进程生效需要三件事:请求头传相对剩余量,中间件在本地换算成绝对 deadline,下游请求从调用方 context 派生而不是新建。arch-lab 的实测中,300ms 预算扣掉 30ms 网关工作后向下游传 267ms;传播模式总耗时 302ms,每跳新建超时则是 343ms。下一节讨论当调用失败时,重试怎样在不把故障放大的前提下进行。

阅读导航:上一节:6.3 降级、兜底与用户体验 · 下一节:7.2 重试放大与退避抖动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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