《后端架构设计·演进与治理》7.3 依赖级联:多服务 SLO 的预算分配

从订单链路三个依赖各承诺 99.95% 却算不出 99.9% 的矛盾切入,说明串联可用性复合、调用放大对预算的重复消耗、按调用次数分摊错误预算的构造性算法与可行性检查,给出可运行的 Go 实现、固定输入测试和本机真实输出,并讨论相关故障为何会让乘积失效。

三个 99.95% 为什么算不出 99.9%

arch-lab 的下单链路是串行的:订单服务调库存预占一次,调支付授权与确认两次,再调优惠券核销一次。三个依赖都承诺 99.95%,产品要求下单可用性 99.9%。评审会上有人算 0.9995³ = 99.85%,结论是“目标不可能”;另一个人说“故障不会同时发生,实际没那么差”。

两个说法都不完整。串联系统的可用性上界是各依赖可用性的乘积,调用两次的依赖要按平方计算,所以这条链路的上界是 0.9995⁴ ≈ 99.80%,比 99.85% 更差。另一方面,乘积假设故障独立;共享数据库、同区域网络、同一个发布系统会让故障相关,实际可用性只会比乘积更低,不会更高。乘积是乐观上界,不是保守估计。

远程调用的八个谬误 讲过“网络可靠、延迟为零”的假设如何失效;本节处理另一个假设:依赖故障彼此独立。

串联复合与调用放大

对依赖 i,设单次调用可用性为 a_i,每笔订单调用 k_i 次,且每次调用都必须成功,则链路可用性 A = ∏ a_i^{k_i}。指数不是修辞:支付授权和确认是两次独立失败机会,任何一次失败都会让订单失败。

调用放大还有第二层影响:重试。网关对失败请求重试两次,支付的实际调用次数从 2 变成最多 6;如果重试没有预算控制,故障时流量会被放大,依赖的可用性进一步下降。公式里的 k 应该用正常路径调用次数,重试带来的额外消耗要单独计入预算,不能假装不存在。

减少 k 本身就是一种架构手段。把支付的授权与确认合并成一次幂等调用,可以把 k 从 2 降到 1;这往往比要求依赖从 99.95% 提高到 99.975% 更容易,也更便宜。预算分配不能替代调用链简化。

组合方式公式含义风险
串联∏ a_i^{k_i}每一步都必须成功依赖越多越差
并联冗余1 - ∏(1-a_i)任一成功即可只覆盖独立故障
串联加重试∏ a_i^{k_i},k 随重试增大重试放大调用故障时加速恶化
相关故障低于独立乘积共享基础设施乘积不再是上界

预算要按调用次数分摊

给定链路目标 A_target,链路错误预算 B = 1 - A_target。若每次调用分到相同的不可用预算 u,总调用次数 K = Σk_i,则 u = B/K。第 i 个依赖分到的预算份额是 k_i/K,要求的可用性是 1-u。

这个分配是构造性的:因为 (1-u)^k ≥ 1-ku(整数 k),所以 ∏(1-u)^{k_i} ≥ 1 - Σk_i u = 1-B = A_target。换句话说,按调用次数均分一定满足目标,不需要反复试算。它不是唯一解,但是一个可解释、可审计的起点;关键依赖可以少分预算,非关键依赖多分,只要总和不超。

均分也不是唯一答案。支付失败会直接让订单失败,优惠券失败可以降级为“不使用优惠”,两者的预算权重不应该相同。实际分配可以给关键依赖更小的不可用预算,给可降级依赖更大的预算,只要满足 Σk_i u_i ≤ B;代价是需要逐项论证降级路径真的可用,而不是纸面上可降级。

依赖调用次数预算份额分到预算要求可用性
inventory10.250.00025099.975%
payment20.500.00050099.975%
coupon10.250.00025099.975%
合计41.000.001000链路 99.9%

可行性检查要一次报全

分配出来的“要求可用性”必须和依赖的实际承诺比较。承诺低于要求,链路目标就不可行;这不是加机器能解决的,要么提高依赖的冗余,要么把同步调用改成异步,要么下调链路目标。检查器应该一次返回所有缺口,而不是遇到第一个就退出,否则修完一个才发现下一个也不达标。

另一个容易漏的边界是“哪些依赖在关键路径上”。异步消息、旁路风控、非阻塞的积分发放不消耗同步可用性预算;把它们算进来会高估风险,把同步依赖误判成异步则会低估风险。关键路径要由调用图决定,不能由团队归属决定。超时预算与调用链 讨论过时间维度上的预算,本节讨论的是可用性维度上的同一件事。

预算分配还表达不了一类损失:相关故障。三个依赖共享同一个数据库时,一次数据库故障会让三者同时不可用,链路可用性接近单依赖,而不是乘积。分配只能处理独立故障;相关故障要靠冗余、隔离和演练来发现,不能靠把 99.95% 调高到 99.99% 解决。把共享依赖画进调用图,是可行性检查之外必须补的一步。

可运行实现

CompositeAvailability 只做乘法,AllocateSerial 按调用次数均分预算并验证构造结果,CheckFeasible 把所有低于要求的依赖一次列出来。三者都不依赖外部状态,输入相同则输出相同。

// file: internal/platform/slo/depend.go
// tier: L1
// verify: go test ./internal/platform/slo/ -count=1 -run 'TestComposite|TestAllocate|TestFeasible|TestCheckFeasible'
package slo

import (
	"fmt"
	"math"
)

// Dependency is one downstream service on the order critical path.
type Dependency struct {
	Name          string
	Availability  float64 // committed SLO, e.g. 0.9995
	CallsPerOrder int     // fan-out per order; values below one count as one
}

func (d Dependency) calls() int {
	if d.CallsPerOrder < 1 {
		return 1
	}
	return d.CallsPerOrder
}

func (d Dependency) Validate() error {
	if d.Name == "" {
		return fmt.Errorf("slo: dependency name is required")
	}
	if d.Availability <= 0 || d.Availability > 1 {
		return fmt.Errorf("slo: %s availability %.6f outside (0,1]", d.Name, d.Availability)
	}
	if d.CallsPerOrder < 0 {
		return fmt.Errorf("slo: %s has negative fan-out", d.Name)
	}
	return nil
}

// Chain is a serial composition of dependencies on one critical path.
type Chain struct {
	Name string
	Deps []Dependency
}

// CompositeAvailability multiplies per-call availability. It assumes
// independent failures, so it is an upper bound, not a measurement.
func (c Chain) CompositeAvailability() (float64, error) {
	if len(c.Deps) == 0 {
		return 0, fmt.Errorf("slo: chain %q has no dependencies", c.Name)
	}
	product := 1.0
	for _, d := range c.Deps {
		if err := d.Validate(); err != nil {
			return 0, err
		}
		product *= math.Pow(d.Availability, float64(d.calls()))
	}
	return product, nil
}

// Allocation is one dependency's granted share of the chain budget.
type Allocation struct {
	Dependency    string
	CallsPerOrder int
	Required      float64 // availability the dependency must achieve
	Share         float64 // fraction of the chain error budget
	Budget        float64 // unavailability budget in ratio points
}

// AllocateSerial gives every call on the critical path the same unavailability
// budget. Since (1-u)^k >= 1-ku for integer k, the allocation meets the target
// by construction rather than by rounding.
func AllocateSerial(target float64, deps []Dependency) ([]Allocation, error) {
	if target <= 0 || target >= 1 {
		return nil, fmt.Errorf("slo: chain target %.6f outside (0,1)", target)
	}
	if len(deps) == 0 {
		return nil, fmt.Errorf("slo: no dependencies to allocate")
	}
	seen := make(map[string]bool, len(deps))
	totalCalls := 0
	for _, d := range deps {
		if err := d.Validate(); err != nil {
			return nil, err
		}
		if seen[d.Name] {
			return nil, fmt.Errorf("slo: duplicate dependency %q", d.Name)
		}
		seen[d.Name] = true
		totalCalls += d.calls()
	}
	perCall := (1 - target) / float64(totalCalls)
	allocs := make([]Allocation, 0, len(deps))
	composite := 1.0
	for _, d := range deps {
		required := 1 - perCall
		allocs = append(allocs, Allocation{
			Dependency:    d.Name,
			CallsPerOrder: d.calls(),
			Required:      required,
			Share:         float64(d.calls()) / float64(totalCalls),
			Budget:        float64(d.calls()) * perCall,
		})
		composite *= math.Pow(required, float64(d.calls()))
	}
	if composite < target-1e-12 {
		return nil, fmt.Errorf("slo: allocation yields %.9f below target %.9f", composite, target)
	}
	return allocs, nil
}

// CheckFeasible returns every dependency whose committed availability is below
// its required allocation, instead of stopping at the first gap.
func CheckFeasible(allocs []Allocation, deps []Dependency) []error {
	committed := make(map[string]float64, len(deps))
	for _, d := range deps {
		committed[d.Name] = d.Availability
	}
	var errs []error
	for _, a := range allocs {
		have, ok := committed[a.Dependency]
		if !ok {
			errs = append(errs, fmt.Errorf("slo: allocation for unknown dependency %q", a.Dependency))
			continue
		}
		if have < a.Required {
			errs = append(errs, fmt.Errorf("slo: %s committed %.6f below required %.6f", a.Dependency, have, a.Required))
		}
	}
	return errs
}

实现里有三个刻意选择。第一,CallsPerOrder 小于 1 时按 1 处理,避免“零次调用”把总调用次数变成零;负值直接拒绝。第二,分配完成后重新计算复合可用性,如果低于目标就返回错误,而不是相信浮点运算。第三,可行性检查收集全部缺口,因为链路目标通常需要多个团队同时整改。

本机实测

测试覆盖纯串联、带调用放大的串联、预算分摊和可行性检查。环境为 macOS darwin/arm64、Go 1.26.0,完整输出在 .local/reports/be-arch-gov/ch07-full.txt。

=== RUN   TestCompositeAvailability
    depend_test.go:24: serial deps=3 each=0.9995 composite=0.998501 target=0.999 gap=-0.000499
    depend_test.go:35: fan-out deps=3 calls=4 composite=0.998001
--- PASS: TestCompositeAvailability (0.00s)
=== RUN   TestAllocateSerial
    depend_test.go:60: dep=inventory calls=1 share=0.25 budget=0.000250 required=0.999750
    depend_test.go:60: dep=payment calls=2 share=0.50 budget=0.000500 required=0.999750
    depend_test.go:60: dep=coupon calls=1 share=0.25 budget=0.000250 required=0.999750
    depend_test.go:66: sum=0.001000000000 composite=0.999000375 target=0.999
--- PASS: TestAllocateSerial (0.00s)
=== RUN   TestCheckFeasible
    depend_test.go:102: infeasible: slo: payment committed 0.999000 below required 0.999750
    depend_test.go:102: infeasible: slo: coupon committed 0.999700 below required 0.999750
--- PASS: TestCheckFeasible (0.00s)
PASS
ok  	archlab/internal/platform/slo	0.297s

读数分三层。复合层,三个 0.9995 的纯串联是 0.998501,已经低于 0.999;把支付改成调用两次后是 0.998001,差距扩大到约 0.001,说明调用放大不是小数。分摊层,0.1% 的链路预算按 1:2:1 分给三个依赖,每个依赖的单次不可用预算都是 0.00025,份额是 25%/50%/25%,预算合计正好 0.001,复合可用性 0.999000375,刚好不低于目标。可行性层,支付承诺 0.9990、优惠券承诺 0.9997,都低于要求的 0.99975,检查器一次报出两个缺口;把承诺提到 0.9999 后不再有缺口。

这些数字是固定输入的算术结果,不是线上可用性实测。真实链路的故障相关性会让实际值低于乘积;上线前应把依赖的真实故障窗口拿出来,检查是否共享同一个数据库、同一个区域或同一个发布通道。

这组测试也只验证算术和分配逻辑,没有注入真实故障。可行性检查回答“承诺够不够”,不回答“依赖能不能做到”;后者要靠各依赖的历史可用性和演练数据,而不是评审会上的口头承诺。

完整测试代码如下,复合、分摊和可行性检查都使用固定输入。

// file: internal/platform/slo/depend_test.go
// tier: L1
// verify: go test ./internal/platform/slo/ -count=1 -run 'TestComposite|TestAllocate|TestFeasible|TestCheckFeasible'
package slo

import (
	"math"
	"testing"
)

func TestCompositeAvailability(t *testing.T) {
	serial := Chain{Name: "order-path", Deps: []Dependency{
		{Name: "inventory", Availability: 0.9995},
		{Name: "payment", Availability: 0.9995},
		{Name: "coupon", Availability: 0.9995},
	}}
	got, err := serial.CompositeAvailability()
	if err != nil {
		t.Fatalf("CompositeAvailability: %v", err)
	}
	if math.Abs(got-0.998500750) > 1e-9 {
		t.Fatalf("composite = %.9f", got)
	}
	t.Logf("serial deps=3 each=0.9995 composite=%.6f target=0.999 gap=%.6f", got, got-0.999)

	fanout := Chain{Name: "order-path-fanout", Deps: []Dependency{
		{Name: "inventory", Availability: 0.9995},
		{Name: "payment", Availability: 0.9995, CallsPerOrder: 2},
		{Name: "coupon", Availability: 0.9995},
	}}
	fg, err := fanout.CompositeAvailability()
	if err != nil {
		t.Fatalf("fan-out: %v", err)
	}
	t.Logf("fan-out deps=3 calls=4 composite=%.6f", fg)

	if _, err := (Chain{Name: "empty"}).CompositeAvailability(); err == nil {
		t.Fatal("empty chain accepted")
	}
	if _, err := (Chain{Deps: []Dependency{{Name: "x"}}}).CompositeAvailability(); err == nil {
		t.Fatal("zero availability accepted")
	}
}

func TestAllocateSerial(t *testing.T) {
	deps := []Dependency{
		{Name: "inventory", Availability: 0.9998},
		{Name: "payment", Availability: 0.9998, CallsPerOrder: 2},
		{Name: "coupon", Availability: 0.9998},
	}
	allocs, err := AllocateSerial(0.999, deps)
	if err != nil {
		t.Fatalf("AllocateSerial: %v", err)
	}
	var sum float64
	composite := 1.0
	for _, a := range allocs {
		sum += a.Budget
		composite *= math.Pow(a.Required, float64(a.CallsPerOrder))
		t.Logf("dep=%s calls=%d share=%.2f budget=%.6f required=%.6f",
			a.Dependency, a.CallsPerOrder, a.Share, a.Budget, a.Required)
	}
	if math.Abs(sum-0.001) > 1e-12 || composite < 0.999 {
		t.Fatalf("sum=%.12f composite=%.9f", sum, composite)
	}
	t.Logf("sum=%.12f composite=%.9f target=0.999", sum, composite)

	for _, r := range []struct {
		name   string
		target float64
		deps   []Dependency
	}{
		{"target one", 1.0, deps},
		{"no deps", 0.999, nil},
		{"duplicate", 0.999, []Dependency{{Name: "a", Availability: 0.999}, {Name: "a", Availability: 0.999}}},
	} {
		if _, err := AllocateSerial(r.target, r.deps); err == nil {
			t.Fatalf("%s accepted", r.name)
		}
	}
}

func TestCheckFeasible(t *testing.T) {
	allocs, err := AllocateSerial(0.999, []Dependency{
		{Name: "inventory", Availability: 0.9998},
		{Name: "payment", Availability: 0.9998, CallsPerOrder: 2},
		{Name: "coupon", Availability: 0.9998},
	})
	if err != nil {
		t.Fatalf("AllocateSerial: %v", err)
	}
	committed := []Dependency{
		{Name: "inventory", Availability: 0.9999},
		{Name: "payment", Availability: 0.9990, CallsPerOrder: 2},
		{Name: "coupon", Availability: 0.9997},
	}
	gaps := CheckFeasible(allocs, committed)
	if len(gaps) != 2 {
		t.Fatalf("gaps = %d, want 2: %v", len(gaps), gaps)
	}
	for _, g := range gaps {
		t.Logf("infeasible: %v", g)
	}
	ok := []Dependency{
		{Name: "inventory", Availability: 0.9999},
		{Name: "payment", Availability: 0.9999, CallsPerOrder: 2},
		{Name: "coupon", Availability: 0.9999},
	}
	if gaps := CheckFeasible(allocs, ok); len(gaps) != 0 {
		t.Fatalf("unexpected gaps: %v", gaps)
	}
}

常见错误与边界

  • 假设依赖故障独立。乘积只在独立时成立;共享基础设施、同区域故障和级联超时会让实际可用性更低。
  • 忘记调用放大。一个依赖每笔订单调用两次,就要按平方计入,否则会高估链路可用性。
  • 把预算份额当可用性。份额是 25% 不代表依赖可以只有 25% 可用;它对应的是 1 减去分到的不可用预算。
  • 只报第一个缺口。修完支付才发现优惠券也不达标,会浪费一个发布周期;检查器应一次报全。
  • 把异步依赖算进同步关键路径。异步消息不消耗同步可用性预算,但会消耗延迟和最终一致性预算,两者不能混在一起算。
  • 把预算分配当成架构改造。调整数字不会改变调用次数和故障相关性;先减少 k、隔离共享依赖,再谈分摊。
  • 忽略重试对 k 的影响。重试让实际调用次数在故障时成倍增加,按正常路径算出的预算会在最需要的时候先耗尽。

小结

串联链路的可用性上界是各依赖按调用次数加权的乘积,调用放大和重试都会让这个上界更差;乘积假设独立,所以它只是乐观上界。给定链路目标,按调用次数均分不可用预算是一个构造性可行的起点,因为 (1-u)^k ≥ 1-ku;分配之后还要用依赖的实际承诺做可行性检查,并一次列出所有缺口。

预算分配也不是一次性动作。依赖的承诺、调用次数和关键路径都会随架构变化,分配表要重新计算,并把差异写进 ADR,否则半年后没有人知道某个数字是怎么来的。

本节把 7.1 的比率和 7.2 的预算延伸到依赖维度。下一章进入故障注入:预算告诉我们“还能承受多少错”,混沌演练回答“这些错真的发生时,系统会不会按预期退化”。

阅读导航:上一节:7.2 错误预算:发布门禁与冻结策略 · 下一节:8.1 故障注入的层次:进程、网络、依赖与区域 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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