预算不是一个总超时
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 | 顺序 | 10ms | 10ms | 令牌校验,本地缓存命中 |
| validate | 顺序 | 20ms | 30ms | 地址、金额、状态校验 |
| precheck | 并行 | 50ms | 80ms | 库存 40ms、风控 50ms 同时发 |
| payment | 重试 | 280ms | 360ms | 2 次尝试 × 120ms + 40ms 退避 |
| persist-outbox | 顺序 | 15ms | 375ms | 订单状态 + outbox 同事务 |
| serialize | 顺序 | 5ms | 380ms | 响应组装 |
总需求 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 与并发数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。