三个 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;代价是需要逐项论证降级路径真的可用,而不是纸面上可降级。
| 依赖 | 调用次数 | 预算份额 | 分到预算 | 要求可用性 |
|---|---|---|---|---|
| inventory | 1 | 0.25 | 0.000250 | 99.975% |
| payment | 2 | 0.50 | 0.000500 | 99.975% |
| coupon | 1 | 0.25 | 0.000250 | 99.975% |
| 合计 | 4 | 1.00 | 0.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 故障注入的层次:进程、网络、依赖与区域 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。