预算算完只是开始
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:
| 位置 | 本地剩余 | 本跳耗时 | 传给下游 | 说明 |
|---|---|---|---|---|
| 网关入口 | 300ms | 30ms | 267ms | 解析头、鉴权、校验后转发 |
| 订单服务 | 267ms | 50ms | 217ms | 库存预占与风控并行 |
| 支付适配器 | 217ms | 120ms | 97ms | 单次授权,失败不重试 |
| 落库与响应 | 97ms | 15ms | 82ms | 本地收尾,不再下发 |
表中的 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 重试放大与退避抖动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。