《后端架构设计·高性能与高可用》13.2 订单系统必测的三个故障场景

把数据库延迟、缓存整体失效、单实例丢失三个订单系统必测的 L2 故障做成进程内可重复回归:用内存适配器和逻辑时钟记录成功、降级、失败、超时、P99 与恢复判定,给出可审计的判定边界和重试代价,并说明哪些结论必须留给卷四的真实混沌与网络分区验证。

三个场景,三条判定线

容量规划给出部署数,故障场景回答这些实例真的坏掉时系统怎么退化。三个场景都选在订单写路径上:数据库变慢、缓存整体不可用、单个订单实例丢失。它们属于 L2:进程内可重复、依赖可控故障注入、不依赖真实网络分区。平台级混沌和跨节点网络故障放到卷四,本节不编造生产故障。

测试用内存适配器和逻辑时钟,不 sleep、不连真实中间件。所有延迟都是注入值,所有结果都是确定性计算;同一份代码运行多次必须得到完全相同的 Report,测试用 reflect.DeepEqual 守住这一点。

判定分四类:success 正常完成;degraded 走了降级路径但仍成功;failed 依赖返回错误;timeout 超过调用方截止时间。P99 用排序后的第 ceil(0.99n) 个样本,失败请求的延迟也进入样本,避免只统计成功请求把长尾抹掉。

故障注入器与判定规则

代码分三部分:Summarize 做计数与 P99;Dependency 同时充当数据库和缓存;Service 实现先缓存后数据库的订单写入。三个 Run* 入口分别固定输入与故障注入点,测试只断言关键计数、P99 和可重复性。

// file: internal/faultlab/faultlab.go
package faultlab

import (
	"context"
	"errors"
	"fmt"
	"math"
	"sort"
	"time"
)

type Status string

const (
	Success  Status = "success"
	Degraded Status = "degraded"
	Failed   Status = "failed"
	TimedOut Status = "timeout"
)

var ErrUnavailable = errors.New("dependency unavailable")

type Result struct {
	ID      string
	Status  Status
	Latency time.Duration
}
type Summary struct {
	Total, Success, Degraded, Failed, TimedOut int
	P99                                        time.Duration
}

// Summarize 用精确排序算 P99;失败请求也留作延迟样本。
func Summarize(rs []Result) Summary {
	s := Summary{Total: len(rs)}
	lat := make([]time.Duration, 0, len(rs))
	counts := map[Status]int{}
	for _, r := range rs {
		counts[r.Status]++
		lat = append(lat, r.Latency)
	}
	s.Success, s.Degraded = counts[Success], counts[Degraded]
	s.Failed, s.TimedOut = counts[Failed], counts[TimedOut]
	if len(lat) > 0 {
		sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
		s.P99 = lat[int(math.Ceil(0.99*float64(len(lat))))-1]
	}
	return s
}

// Dependency 同时模拟数据库和缓存;Down 表示整体不可用。
type Dependency struct {
	Latency time.Duration
	Down    bool
	Calls   int
	Errors  int
}

func (d *Dependency) Get(ctx context.Context) (time.Duration, error) {
	d.Calls++
	if err := ctx.Err(); err != nil {
		return 0, err
	}
	if d.Down {
		d.Errors++
		return d.Latency, ErrUnavailable
	}
	return d.Latency, nil
}

type Service struct {
	Cache, DB *Dependency
	Timeout   time.Duration
}

// Place 先查缓存;缓存不可用时降级到数据库,数据库超时返回 timeout。
func (s *Service) Place(ctx context.Context, id string) Result {
	r := Result{ID: id}
	elapsed := time.Duration(0)
	degraded := false
	if s.Cache != nil {
		lat, err := s.Cache.Get(ctx)
		if err == nil {
			r.Status, r.Latency = Success, lat
			return r
		}
		degraded = true
		elapsed += lat
	}
	lat, err := s.DB.Get(ctx)
	if elapsed+lat > s.Timeout {
		r.Status, r.Latency = TimedOut, s.Timeout
		return r
	}
	elapsed += lat
	if err != nil {
		r.Status, r.Latency = Failed, elapsed
		return r
	}
	r.Status = Success
	if degraded {
		r.Status = Degraded
	}
	r.Latency = elapsed
	return r
}

type Phase struct {
	Name          string
	Summary       Summary
	DBCalls       int
	CacheErrors   int
	BackendErrors int
}
type Report struct {
	Name     string
	Phases   []Phase
	Recovery string
}

func (r Report) String() string {
	s := "scenario=" + r.Name
	for _, p := range r.Phases {
		s += fmt.Sprintf(" | %s total=%d success=%d degraded=%d failed=%d timeout=%d p99=%s db_calls=%d cache_errors=%d backend_errors=%d", p.Name, p.Summary.Total, p.Summary.Success, p.Summary.Degraded, p.Summary.Failed, p.Summary.TimedOut, p.Summary.P99, p.DBCalls, p.CacheErrors, p.BackendErrors)
	}
	return s + " | recovery=" + r.Recovery
}
func runService(svc *Service, n int) []Result {
	out := make([]Result, 0, n)
	for i := 0; i < n; i++ {
		out = append(out, svc.Place(context.Background(), fmt.Sprintf("o-%d", i)))
	}
	return out
}

// RunDBLatency:30ms 正常 -> 120ms 超时 -> 20ms 恢复。
func RunDBLatency() Report {
	db := &Dependency{Latency: 30 * time.Millisecond}
	svc := &Service{DB: db, Timeout: 100 * time.Millisecond}
	healthy := runService(svc, 20)
	healthyDB := db.Calls
	db.Latency = 120 * time.Millisecond
	faulty := runService(svc, 20)
	db.Latency = 20 * time.Millisecond
	back := runService(svc, 1)
	return Report{Name: "db_latency", Phases: []Phase{
		{"healthy_30ms", Summarize(healthy), healthyDB, 0, 0},
		{"fault_120ms", Summarize(faulty), 20, 0, 0},
		{"recovered_20ms", Summarize(back), 1, 0, 0},
	}, Recovery: "first success after latency restored: requests=1 latency=20ms"}
}

// RunCacheOutage:缓存整体不可用时全部请求降级到数据库。
func RunCacheOutage() Report {
	db := &Dependency{Latency: 15 * time.Millisecond}
	cache := &Dependency{Latency: 2 * time.Millisecond}
	svc := &Service{Cache: cache, DB: db, Timeout: 100 * time.Millisecond}
	healthy := runService(svc, 100)
	cache.Down = true
	outage := runService(svc, 100)
	errors := cache.Errors
	cache.Down = false
	back := runService(svc, 1)
	return Report{Name: "cache_outage", Phases: []Phase{
		{"healthy_cache", Summarize(healthy), 0, 0, 0},
		{"cache_down", Summarize(outage), 100, errors, 0},
		{"cache_back", Summarize(back), 0, 0, 0},
	}, Recovery: "first cache hit after cache restored: requests=1 latency=2ms"}
}

type Endpoint struct {
	Name             string
	Latency          time.Duration
	Failing, Ejected bool
}
type LoadBalancer struct {
	Endpoints     []*Endpoint
	Next          int
	Retry         bool
	RetryCost     time.Duration
	Failures      map[string]int
	Calls         int
	FirstFailure  int
	EjectedAt     int
	BackendErrors int
}

func (lb *LoadBalancer) pick() *Endpoint {
	for i := 0; i < len(lb.Endpoints); i++ {
		ep := lb.Endpoints[lb.Next%len(lb.Endpoints)]
		lb.Next++
		if !ep.Ejected {
			return ep
		}
	}
	return nil
}

// Call 在失败实例上最多重试一次;重试不改变健康检测,否则故障实例会被永久留在池子里。
func (lb *LoadBalancer) Call(ctx context.Context, id string) Result {
	call := lb.Calls
	lb.Calls++
	r := Result{ID: id}
	if ctx.Err() != nil {
		r.Status = Failed
		return r
	}
	elapsed := time.Duration(0)
	for attempt := 0; attempt < 2; attempt++ {
		ep := lb.pick()
		if ep == nil {
			r.Status, r.Latency = Failed, elapsed
			return r
		}
		elapsed += ep.Latency
		if !ep.Failing {
			r.Status, r.Latency = Success, elapsed
			if attempt > 0 {
				r.Status = Degraded
			}
			return r
		}
		lb.BackendErrors++
		lb.Failures[ep.Name]++
		if lb.FirstFailure < 0 {
			lb.FirstFailure = call
		}
		if lb.Failures[ep.Name] >= 3 {
			ep.Ejected = true
			if lb.EjectedAt < 0 {
				lb.EjectedAt = call
			}
		}
		if !lb.Retry || attempt == 1 {
			break
		}
		elapsed += lb.RetryCost
	}
	r.Status, r.Latency = Failed, elapsed
	return r
}

// RunInstanceLoss:第 30 个请求让 order-2 失败,对比有/无重试。
func RunInstanceLoss(retry bool) Report {
	eps := []*Endpoint{
		{Name: "order-1", Latency: 10 * time.Millisecond},
		{Name: "order-2", Latency: 10 * time.Millisecond},
		{Name: "order-3", Latency: 10 * time.Millisecond},
	}
	lb := &LoadBalancer{Endpoints: eps, Retry: retry, RetryCost: 2 * time.Millisecond,
		Failures: map[string]int{}, FirstFailure: -1, EjectedAt: -1}
	results := make([]Result, 0, 90)
	lat := make([]time.Duration, 0, 90)
	for i := 0; i < 90; i++ {
		if i == 30 {
			eps[1].Failing = true
		}
		r := lb.Call(context.Background(), fmt.Sprintf("o-%d", i))
		results, lat = append(results, r), append(lat, r.Latency)
	}
	rec := "first backend failure to ejection"
	if lb.FirstFailure >= 0 && lb.EjectedAt >= 0 {
		sum := time.Duration(0)
		for i := lb.FirstFailure; i <= lb.EjectedAt; i++ {
			sum += lat[i]
		}
		rec = fmt.Sprintf("%s: requests=%d latency=%s", rec, lb.EjectedAt-lb.FirstFailure+1, sum)
	}
	return Report{Name: fmt.Sprintf("instance_loss_retry_%v", retry), Phases: []Phase{
		{"90_requests", Summarize(results), 0, 0, lb.BackendErrors},
	}, Recovery: rec}
}
// file: internal/faultlab/faultlab_test.go
package faultlab

import (
	"reflect"
	"testing"
	"time"
)

func TestFaultScenarios(t *testing.T) {
	db, cache := RunDBLatency(), RunCacheOutage()
	noRetry, retry := RunInstanceLoss(false), RunInstanceLoss(true)
	for _, r := range []Report{db, cache, noRetry, retry} {
		t.Log(r)
	}
	if db.Phases[0].Summary.Success != 20 || db.Phases[1].Summary.TimedOut != 20 ||
		db.Phases[2].Summary.Success != 1 || db.Phases[1].Summary.P99 != 100*time.Millisecond {
		t.Fatalf("db=%+v", db)
	}
	if cache.Phases[0].Summary.Success != 100 || cache.Phases[1].Summary.Degraded != 100 ||
		cache.Phases[1].DBCalls != 100 || cache.Phases[1].Summary.P99 != 17*time.Millisecond ||
		cache.Phases[2].DBCalls != 0 {
		t.Fatalf("cache=%+v", cache)
	}
	if noRetry.Phases[0].Summary.Failed == 0 || retry.Phases[0].Summary.Failed != 0 ||
		retry.Phases[0].Summary.Degraded == 0 {
		t.Fatalf("loss no=%+v retry=%+v", noRetry.Phases[0].Summary, retry.Phases[0].Summary)
	}
	if retry.Phases[0].Summary.P99 <= noRetry.Phases[0].Summary.P99 {
		t.Fatalf("retry p99=%s no=%s", retry.Phases[0].Summary.P99, noRetry.Phases[0].Summary.P99)
	}
	if !reflect.DeepEqual(db, RunDBLatency()) || !reflect.DeepEqual(retry, RunInstanceLoss(true)) {
		t.Fatal("scenario is not repeatable")
	}
}

场景一:数据库延迟

数据库延迟从 30ms 拉到 120ms,调用方超时 100ms,然后恢复到 20ms。20 个请求在健康期全部成功,P99 为 30ms;故障期 20 个请求全部超时,P99 被截止时间截断在 100ms;恢复后第一个请求成功,延迟 20ms。这里没有降级路径:订单写入在数据库超时后不能假装成功,只能把失败暴露给调用方或进入重试队列。db_calls 仍是 20,因为超时是在 Get 返回后判定的,调用已经发生。

场景二:缓存整体失效

缓存整体不可用时,100 个请求全部转到数据库:100 个 degraded、0 个 failed、P99 为 17ms、db_calls=100、cache_errors=100。17ms 由缓存失败的 2ms 加数据库的 15ms 组成。恢复后第一个请求重新命中缓存,延迟 2ms。这个场景的关键不是“降级成功”,而是数据库是否接得住 100% 的流量;如果数据库余量不足,degraded 会迅速变成 timeout。

场景三:单实例丢失

单实例丢失在第 30 个请求注入:order-2 开始返回错误,负载均衡器连续 3 次后端错误后将其摘除。不开重试时,90 个请求中 87 成功、3 失败,P99 为 10ms;从第一次后端失败到摘除经过 7 个请求,累计 70ms。开重试时,3 个请求在重试后成功并记为 degraded,失败为 0,但 P99 升到 22ms,摘除窗口缩短到 5 个请求,累计 86ms。重试改善可用性,代价是尾延迟;它不能替代健康检测,否则故障实例会一直被重试打中。

真实运行输出

命令:go test ./internal/faultlab/ -count=1 -v。环境为 macOS darwin/arm64、Go 1.26.0;测试串行执行,并发为 1,运行 1 次。完整输出保存在 .local/reports/be-arch/be-arch-perf-13-2-three-failure-scenarios-tests.txt。

=== RUN   TestFaultScenarios
    faultlab_test.go:13: scenario=db_latency | healthy_30ms total=20 success=20 degraded=0 failed=0 timeout=0 p99=30ms db_calls=20 cache_errors=0 backend_errors=0 | fault_120ms total=20 success=0 degraded=0 failed=0 timeout=20 p99=100ms db_calls=20 cache_errors=0 backend_errors=0 | recovered_20ms total=1 success=1 degraded=0 failed=0 timeout=0 p99=20ms db_calls=1 cache_errors=0 backend_errors=0 | recovery=first success after latency restored: requests=1 latency=20ms
    faultlab_test.go:13: scenario=cache_outage | healthy_cache total=100 success=100 degraded=0 failed=0 timeout=0 p99=2ms db_calls=0 cache_errors=0 backend_errors=0 | cache_down total=100 success=0 degraded=100 failed=0 timeout=0 p99=17ms db_calls=100 cache_errors=100 backend_errors=0 | cache_back total=1 success=1 degraded=0 failed=0 timeout=0 p99=2ms db_calls=0 cache_errors=0 backend_errors=0 | recovery=first cache hit after cache restored: requests=1 latency=2ms
    faultlab_test.go:13: scenario=instance_loss_retry_false | 90_requests total=90 success=87 degraded=0 failed=3 timeout=0 p99=10ms db_calls=0 cache_errors=0 backend_errors=3 | recovery=first backend failure to ejection: requests=7 latency=70ms
    faultlab_test.go:13: scenario=instance_loss_retry_true | 90_requests total=90 success=87 degraded=3 failed=0 timeout=0 p99=22ms db_calls=0 cache_errors=0 backend_errors=3 | recovery=first backend failure to ejection: requests=5 latency=86ms
--- PASS: TestFaultScenarios (0.00s)
PASS
ok  	archlab/internal/faultlab	0.448s

这些数字证明什么、不证明什么

这些数字证明判定逻辑、降级路径和恢复定义可以在 CI 里稳定复现,不是生产环境的延迟实测。三个场景都运行在单进程内,没有真实网络、连接池、线程调度和 GC 干扰;P99 是注入延迟的确定值,不是采样统计。真实网络分区、进程被 kill、时钟漂移和跨节点一致性要放到卷四的混沌实验里验证,本节不引用任何生产故障数字。

另一个边界是重试放大:instance_loss_retry_true 的 backend_errors 仍是 3,说明重试请求也打到了故障实例。生产上要配合退避、重试预算和幂等键,否则重试会把局部故障放大成下游雪崩。

小结

三个 L2 场景给出可回归的结论:数据库超时全量失败且 P99 被截止时间截断;缓存全失效时 100% 请求降级到数据库并把 P99 推到 17ms;单实例丢失在 3 次后端错误后摘除;无重试暴露 3 个失败,有重试把失败转成 degraded 但 P99 从 10ms 升到 22ms;恢复判定分别用首个成功请求和摘除窗口固定;所有结果重复运行一致。它们补齐了容量规划之后的“坏了会怎样”,但离生产混沌还有一步。

阅读导航:上一节:13.1 一次完整的容量规划 · 下一节:13.3 进入卷三:分布式与一致性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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