《后端架构设计·高性能与高可用》1.3 性能预算:把端到端延迟拆到每个调用

实现一个支持顺序、并行和重试预留的延迟预算分配器,用 context.WithDeadline 把剩余预算传给下游,并用真实测试证明超时不会被重试放大、预留不会被并发叠加,最后给出 arch-lab 下单链路的预算表、降级优先级与退出条件,附五次真实运行输出。

预算不是一个总超时

1.1 说明了端到端延迟要按尾部分位数看,1.2 说明了时间花在 CPU、内存、I/O 还是锁。现在把两件事接起来:给一个端到端目标,比如下单 P99 不超过 400ms,怎样把它拆到鉴权、校验、库存、风控、支付、落库每个调用,并且保证任何一层变慢都不会让总时间悄悄翻倍。

最常见的做法是每层各写一个 context.WithTimeout(ctx, 3*time.Second)。这等于没有预算:三层各 3 秒,最坏情况是 9 秒,而且下层拿到的是新的 3 秒,不是“上层还剩多少”。预算的核心不是给每个调用一个超时,而是给每个调用一个绝对截止时间,并让下游继承剩余量。

本节实现一个分配器,输入是调用列表和端到端预算,输出是每个阶段的累计截止时间;再用 context.WithDeadline 把截止时间传下去。预留只加一次,重试不能把预算乘起来。

三种阶段与一条时间轴

分配器把调用分成三类:

模式时间怎么算例子
顺序直接累加到时间轴鉴权、校验、落库
并行同组取最大值,只占一段库存预占与风控检查同时发
重试每次尝试有独立超时,另加退避预留支付授权允许重试一次

重试的预算是 Attempts × Estimate + Reserve,不是 Attempts × 整段预算。Estimate 用 P99 而不是均值:用均值做预算,等于假设一半请求会超过预算。并行组只在关键路径上占一次时间,组内各调用共享同一个阶段截止时间;这正是并行能省预算的原因,也是并行调用必须各自带截止时间的原因。

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

import (
	"context"
	"sort"
	"time"
)

type Mode string

const (
	Sequential Mode = "sequential"
	Parallel   Mode = "parallel"
	Retry      Mode = "retry"
)

type Step struct {
	Name       string
	Mode       Mode
	Group      string
	Estimate   time.Duration
	Attempts   int
	Reserve    time.Duration
	Degradable bool
	Priority   int
}

type Plan struct{ Steps []Step }

type StepAllocation struct {
	Name           string
	Deadline       time.Duration
	AttemptTimeout time.Duration
	Attempts       int
}

type StageAllocation struct {
	Deadline time.Duration
	Budget   time.Duration
	Steps    []StepAllocation
}

type Allocation struct {
	Total        time.Duration
	Need         time.Duration
	Feasible     bool
	Shortfall    time.Duration
	Degradations []string
	Stages       []StageAllocation
}

type stage struct {
	steps []Step
	need  time.Duration
}

func buildStages(steps []Step) []stage {
	var out []stage
	for i := 0; i < len(steps); i++ {
		s := steps[i]
		if s.Mode == Parallel && s.Group != "" {
			j := i + 1
			for j < len(steps) && steps[j].Mode == Parallel && steps[j].Group == s.Group {
				j++
			}
			group := steps[i:j]
			need := group[0].Estimate
			for _, g := range group[1:] {
				if g.Estimate > need {
					need = g.Estimate
				}
			}
			out = append(out, stage{steps: group, need: need})
			i = j - 1
			continue
		}
		need := s.Estimate
		if s.Mode == Retry {
			attempts := s.Attempts
			if attempts < 1 {
				attempts = 1
			}
			need = time.Duration(attempts)*s.Estimate + s.Reserve
		}
		out = append(out, stage{steps: []Step{s}, need: need})
	}
	return out
}

func totalNeed(stages []stage) time.Duration {
	var total time.Duration
	for _, st := range stages {
		total += st.need
	}
	return total
}

// Allocate converts a plan into cumulative deadlines. Over budget, it drops
// degradable steps by descending Priority until the plan fits.
func (p Plan) Allocate(total time.Duration) Allocation {
	steps := append([]Step(nil), p.Steps...)
	alloc := Allocation{Total: total}
	stages := buildStages(steps)
	alloc.Need = totalNeed(stages)

	if total > 0 && alloc.Need > total {
		candidates := make([]Step, 0)
		for _, s := range steps {
			if s.Degradable {
				candidates = append(candidates, s)
			}
		}
		sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].Priority > candidates[j].Priority })
		for _, cand := range candidates {
			filtered := steps[:0]
			for _, s := range steps {
				if s.Name != cand.Name {
					filtered = append(filtered, s)
				}
			}
			steps = filtered
			alloc.Degradations = append(alloc.Degradations, cand.Name)
			stages = buildStages(steps)
			alloc.Need = totalNeed(stages)
			if alloc.Need <= total {
				break
			}
		}
	}
	alloc.Feasible = alloc.Need <= total
	if !alloc.Feasible {
		alloc.Shortfall = alloc.Need - total
	}

	var cursor time.Duration
	for _, st := range stages {
		stage := StageAllocation{Deadline: cursor + st.need, Budget: st.need}
		for _, s := range st.steps {
			attempts := s.Attempts
			if attempts < 1 {
				attempts = 1
			}
			stage.Steps = append(stage.Steps, StepAllocation{
				Name: s.Name, Deadline: stage.Deadline, AttemptTimeout: s.Estimate, Attempts: attempts,
			})
		}
		alloc.Stages = append(alloc.Stages, stage)
		cursor = stage.Deadline
	}
	return alloc
}

// WithRemaining takes min(parent deadline, now+budget); it never extends the
// parent, so downstream cannot spend budget the caller no longer has.
func WithRemaining(parent context.Context, budget time.Duration) (context.Context, context.CancelFunc) {
	deadline := time.Now().Add(budget)
	if d, ok := parent.Deadline(); ok && d.Before(deadline) {
		deadline = d
	}
	return context.WithDeadline(parent, deadline)
}

type AttemptFunc func(ctx context.Context, attempt int) error

// RunRetries gives each attempt its own timeout and spends Reserve only on
// gaps. The stage deadline bounds the whole sequence, so retries cannot
// multiply the caller's timeout.
func RunRetries(parent context.Context, stageDeadline time.Time, attemptTimeout time.Duration, attempts int, reserve time.Duration, fn AttemptFunc) error {
	if attempts < 1 {
		attempts = 1
	}
	stageCtx, cancel := context.WithDeadline(parent, stageDeadline)
	defer cancel()
	gap := time.Duration(0)
	if attempts > 1 {
		gap = reserve / time.Duration(attempts-1)
	}
	var last error
	for i := 1; i <= attempts; i++ {
		attemptCtx, attemptCancel := context.WithTimeout(stageCtx, attemptTimeout)
		err := fn(attemptCtx, i)
		attemptCancel()
		if err == nil {
			return nil
		}
		last = err
		if i == attempts {
			break
		}
		if gap > 0 {
			t := time.NewTimer(gap)
			select {
			case <-stageCtx.Done():
				t.Stop()
				return last
			case <-t.C:
			}
		}
	}
	return last
}

Allocate 输出的截止时间是相对时间轴,调用方把它加到请求开始时间上就是绝对截止时间。WithRemaining 再取一次 min(parent deadline, now+budget),所以即使上层传入的预算更长,子上下文也不会把父截止时间往后推。RunRetries 先建一个带阶段截止时间的 stageCtx,每次尝试再从它派生;attempt 的超时不能超过 stage,stage 又不能超过 parent,三层是单向收紧的。

arch-lab 下单链路的预算表

把 arch-lab 的下单路径按上面的模型排一遍。目标 P99 400ms,数字是分配器的输出,不是线上测量:

阶段模式P99 估算累计截止说明
auth顺序10ms10ms令牌校验,本地缓存命中
validate顺序20ms30ms地址、金额、状态校验
precheck并行50ms80ms库存 40ms、风控 50ms 同时发
payment重试280ms360ms2 次尝试 × 120ms + 40ms 退避
persist-outbox顺序15ms375ms订单状态 + outbox 同事务
serialize顺序5ms380ms响应组装

总需求 380ms,预算 400ms,剩 20ms 作为调度和时钟误差的缓冲。注意 precheck 占 50ms 而不是 90ms,因为库存和风控并行;payment 占 280ms 而不是 120ms,因为允许一次重试并预留退避。把这两处算错,预算表会凭空多出 200ms。

超预算时降级什么

预算 370ms 时,计划超出 10ms。分配器按 Priority 从高到低尝试删除可降级步骤:风控检查优先级 10,库存预占优先级 5。删掉风控后,precheck 的关键路径从 50ms 变成 40ms,总需求 370ms,刚好可行;库存预占保留,因为它决定订单能否成立。

降级项触发代价退出条件
风控转异步预算 370ms事后拦截,可能已发货异步结果回写延迟 < 5min
库存乐观预占预算更低超卖后补偿补偿成功率 > 99.9%
支付重试降为 0支付通道抖动失败率上升通道错误率连续 5min < 0.1%
响应裁剪极端峰值字段缺失客户端兼容旧版本

降级不是删掉一个调用就结束。每一项都要有触发条件、代价和退出条件,否则“临时降级”会变成永久行为。分配器只负责算得下或算不下,是否接受代价由业务决定。

真实运行:超时没有被放大

下面的测试让下游一直等到 context 结束,模拟最坏情况:每次尝试都耗尽自己的 20ms,三次尝试,退避预留 30ms,阶段截止时间 120ms。命令与原始输出在 .local/reports/be-arch/be-arch-perf-01-3-budget-tests.txt,连续运行 5 次;下面为对齐略作空格调整,数值未改:

$ go test ./internal/platform/budget/ -count=5 -v
attempts=3 elapsed=95.707625ms stage_deadline=120ms attempt_timeout=20ms reserve=30ms
attempts=3 elapsed=95.171ms    stage_deadline=120ms attempt_timeout=20ms reserve=30ms
attempts=3 elapsed=96.411125ms stage_deadline=120ms attempt_timeout=20ms reserve=30ms
attempts=3 elapsed=96.294792ms stage_deadline=120ms attempt_timeout=20ms reserve=30ms
attempts=3 elapsed=96.909333ms stage_deadline=120ms attempt_timeout=20ms reserve=30ms

三次尝试共 60ms,两次退避共 30ms,加上调度误差,实测 95–97ms,全部落在 120ms 阶段截止时间之内。如果每层都把 20ms 当成新的总超时,或者重试时重建一个完整的 120ms,耗时会变成 3 倍;这里的上界由 stageCtx 而不是尝试次数决定。

同一组测试还验证了分配语义:400ms 预算下总需求 380ms,各阶段截止时间为 10/30/80/360/375/380ms;支付阶段预算 280ms,两次尝试各 120ms;370ms 预算下只删除风控,库存预占保留。测试文件与前一个代码块一起抽取到独立模块,gofmt、go vet、go test 均通过。

常见错误与边界

第一,把并行调用按顺序相加。库存 40ms 加风控 50ms 不是 90ms,而是关键路径 50ms;如果两者有依赖,就不该放在同一并行组。

第二,用均值做预算。均值 20ms、P99 120ms 的支付调用,按 20ms 分配预算,正常流量下看似够用,峰值时会把整条链路拖垮。

第三,重试不预留退避。两次尝试之间没有预算,重试会在阶段末尾被硬切断,表现为“第二次尝试总是超时”,而不是“重试失败”。

第四,只设置超时不传播截止时间。每层 WithTimeout 都会新建一个相对时间,父层剩 50ms、子层仍拿 3 秒,预算在最关键的下游失效;必须用 WithDeadline 或 WithRemaining 继承。

第五,降级没有退出条件。临时跳过风控、临时关闭重试,如果没有指标和恢复条件,会永久留在代码里,成为下一次故障的隐藏原因。

小结

性能预算把端到端目标拆成每个阶段的绝对截止时间:顺序累加、并行取最大、重试按“尝试次数 × P99 + 退避预留”计算。arch-lab 下单链路的目标是 400ms,计划需求 380ms;预算是 370ms 时优先把风控转异步,保留库存预占。WithRemaining 和 RunRetries 保证截止时间单向收紧,真实测试中三次 20ms 尝试加 30ms 退避耗时 95–97ms,没有突破 120ms 阶段上界。下一章从业务指标出发,讨论峰值 QPS 与并发数怎样换算成容量。

阅读导航:上一节:1.2 瓶颈定位:CPU、内存、I/O、锁 · 下一节:2.1 从业务指标到 QPS 与并发数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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