《后端架构设计·高性能与高可用》12.3 改造前后同一口径的数字与失败注入结果

把基线、缓存、outbox、令牌桶和窄临界区收进同一个单文件程序,固定种子和 4,500 个请求,真实跑正常、下游延迟、下游错误三种场景:基线三种场景都超过 4,200 次超时,改造版正常 P99 14.637ms,延迟 P99 60.721ms,错误 89 次失败且零超时。

固定种子,固定 4,500 个请求

12.1 用三份 profile 找到了瓶颈,12.2 把改造拆成四个开关;12.3 只回答一个问题:在同一份代码、同一份负载、同一台机器上,基线和全开版在正常、下游延迟、下游错误三种场景下各是什么数字。本节把服务、开环生成器和下游替身收进一个 main.go,请求混合固定为 60% 创建、40% 支付。它和 12.2 的 40/35/25 不同:这里去掉查询分支,把预算集中在支付和故障注入上,因此不要把 12.2 的表和本节的表逐行相减。

负载固定为 4,500 个请求,按 1,500 请求/秒的节拍释放,客户端超时 200ms;随机种子固定为 20261021,请求序号经一次乘法混合决定创建还是支付。延迟场景在每个第 10 次 Charge 调用上增加 50ms,错误场景让每个第 20 次 Charge 返回错误;判断依据是 d.seq.Add(1) 的调用序号,不是墙钟,所以同一份代码重跑时故障次数可复现,延迟和调度顺序不可复现。

单文件:服务、负载与注入

New 的参数就是 12.2 的四个开关:缓存 TTL、outbox、令牌桶速率、窄临界区;零值就是基线。预建 2,000 笔订单后,支付按序号唯一取单;下游用 time.Sleep 模拟 1ms 查价、2ms 预占、4ms 扣款、2ms 通知。令牌桶的 now 可注入,便于用虚拟时钟验证补充逻辑;本次运行为了测真实延迟使用 time.Now。

// file: cmd/lab/main.go
// tier: L2
// verify: go run ./cmd/lab
package main

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"sync"
	"sync/atomic"
	"time"
)

const seed = uint64(20261021)
const timeout = 200 * time.Millisecond

type Item struct {
	SKU string
	Qty int
}

type Order struct {
	ID     string
	Status string
	Items  []Item
	Total  int64
}

type Deps struct {
	price   func(context.Context, string) (int64, error)
	reserve func(context.Context, string, int) error
	charge  func(context.Context, string, int64) (string, error)
	notify  func(context.Context, string) error
}

type cacheEntry struct {
	cents int64
	exp   time.Time
}

type Service struct {
	mu       sync.Mutex
	seq      int
	orders   map[string]*Order
	deps     Deps
	now      func() time.Time
	cacheTTL time.Duration
	rps      float64
	narrow   bool
	cache    map[string]cacheEntry
	tokens   float64
	last     time.Time
	ch       chan string
	done     chan struct{}
}

func New(d Deps, ttl time.Duration, outbox bool, rps float64, narrow bool, now func() time.Time) *Service {
	s := &Service{orders: map[string]*Order{}, deps: d, now: now, cacheTTL: ttl,
		rps: rps, narrow: narrow, cache: map[string]cacheEntry{}, tokens: rps * 2, last: now()}
	if outbox {
		s.ch, s.done = make(chan string, 8192), make(chan struct{})
		go func() {
			defer close(s.done)
			for id := range s.ch {
				_ = d.notify(context.Background(), id)
			}
		}()
	}
	return s
}

func (s *Service) Close() {
	if s.ch != nil {
		close(s.ch)
		<-s.done
	}
}

func (s *Service) Create(ctx context.Context, items []Item) (string, error) {
	if s.rps > 0 {
		s.mu.Lock()
		now := s.now()
		s.tokens += now.Sub(s.last).Seconds() * s.rps
		if burst := s.rps * 2; s.tokens > burst {
			s.tokens = burst
		}
		s.last = now
		ok := s.tokens >= 1
		if ok {
			s.tokens--
		}
		s.mu.Unlock()
		if !ok {
			return "", errors.New("rate limited")
		}
	}
	total := int64(0)
	for _, it := range items {
		cents := int64(0)
		hit := false
		if s.cacheTTL > 0 {
			s.mu.Lock()
			e, ok := s.cache[it.SKU]
			s.mu.Unlock()
			if ok && s.now().Before(e.exp) {
				cents, hit = e.cents, true
			}
		}
		if !hit {
			var err error
			cents, err = s.deps.price(ctx, it.SKU)
			if err != nil {
				return "", err
			}
			if s.cacheTTL > 0 {
				s.mu.Lock()
				s.cache[it.SKU] = cacheEntry{cents, s.now().Add(s.cacheTTL)}
				s.mu.Unlock()
			}
		}
		total += int64(it.Qty) * cents
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.seq++
	id := fmt.Sprintf("ord-%06d", s.seq)
	s.orders[id] = &Order{ID: id, Status: "CREATED",
		Items: append([]Item(nil), items...), Total: total}
	return id, nil
}

func (s *Service) downstream(ctx context.Context, id string, items []Item, total int64) error {
	for _, it := range items {
		if err := s.deps.reserve(ctx, it.SKU, it.Qty); err != nil {
			return err
		}
	}
	if _, err := s.deps.charge(ctx, id, total); err != nil {
		return err
	}
	if s.ch != nil {
		select {
		case s.ch <- id:
		default:
		}
		return nil
	}
	return s.deps.notify(ctx, id)
}

func (s *Service) Pay(ctx context.Context, id string) error {
	if s.narrow {
		return s.payNarrow(ctx, id)
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	o, ok := s.orders[id]
	if !ok {
		return errors.New("not found")
	}
	if o.Status != "CREATED" {
		return errors.New("invalid state")
	}
	if err := s.downstream(ctx, id, o.Items, o.Total); err != nil {
		return err
	}
	o.Status = "PAID"
	return nil
}

func (s *Service) payNarrow(ctx context.Context, id string) error {
	s.mu.Lock()
	o, ok := s.orders[id]
	if !ok || o.Status != "CREATED" {
		s.mu.Unlock()
		if !ok {
			return errors.New("not found")
		}
		return errors.New("invalid state")
	}
	items, total := append([]Item(nil), o.Items...), o.Total
	s.mu.Unlock()
	if err := s.downstream(ctx, id, items, total); err != nil {
		return err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	cur := s.orders[id]
	if cur.Status != "CREATED" {
		return errors.New("invalid state")
	}
	cur.Status = "PAID"
	return nil
}

type result struct {
	done, timedout, failed int64
	lat                    []time.Duration
	wall                   time.Duration
}

func (r result) p(q float64) time.Duration {
	slices.Sort(r.lat)
	return r.lat[min(int(q*float64(len(r.lat))), len(r.lat)-1)]
}

func mix(n uint64) uint64 { return (n*0x9e3779b97f4a7c15 + seed) >> 17 }

func items(n uint64) []Item {
	return []Item{{fmt.Sprintf("sku-%03d", mix(n)%200), 1}, {fmt.Sprintf("sku-%03d", mix(n+1)%200), 1},
		{fmt.Sprintf("sku-%03d", mix(n+2)%200), 1}}
}

func load(task func(context.Context) error) result {
	var r result
	var mu sync.Mutex
	var wg sync.WaitGroup
	tick := time.NewTicker(time.Second / 1500)
	defer tick.Stop()
	start := time.Now()
	for range 4500 {
		at := <-tick.C
		wg.Add(1)
		go func() {
			defer wg.Done()
			ctx, cancel := context.WithTimeout(context.Background(), timeout)
			defer cancel()
			err := task(ctx)
			lat := time.Since(at)
			mu.Lock()
			r.lat = append(r.lat, lat)
			if lat > timeout {
				r.timedout++
			} else if err != nil {
				r.failed++
			} else {
				r.done++
			}
			mu.Unlock()
		}()
	}
	wg.Wait()
	r.wall = time.Since(start)
	return r
}

type down struct {
	base   time.Duration
	fault  string
	charge atomic.Int64
	seq    atomic.Int64
}

func wait(ctx context.Context, d time.Duration) error {
	select {
	case <-time.After(d):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func (d *down) Price(ctx context.Context, _ string) (int64, error) {
	return 1000, wait(ctx, d.base)
}

func (d *down) Reserve(ctx context.Context, _ string, _ int) error {
	return wait(ctx, 2*d.base)
}

func (d *down) Notify(ctx context.Context, _ string) error {
	return wait(ctx, 2*d.base)
}

func (d *down) Charge(ctx context.Context, _ string, _ int64) (string, error) {
	n := d.seq.Add(1)
	d.charge.Add(1)
	if d.fault == "error" && n%20 == 0 {
		return "", errors.New("charge declined")
	}
	dur := 4 * d.base
	if d.fault == "latency" && n%10 == 0 {
		dur += 50 * time.Millisecond
	}
	return fmt.Sprintf("pay-%d", n), wait(ctx, dur)
}

func run(scenario string, refactor bool) {
	label := "baseline"
	d := &down{base: time.Millisecond, fault: scenario}
	ttl, outbox, rps, narrow := time.Duration(0), false, 0.0, false
	if refactor {
		label, ttl, outbox, rps, narrow = "refactored", 5*time.Second, true, 1200, true
	}
	s := New(Deps{price: d.Price, reserve: d.Reserve, charge: d.Charge, notify: d.Notify},
		ttl, outbox, rps, narrow, time.Now)
	defer s.Close()
	ids := make([]string, 2000)
	for i := range ids {
		id, err := s.Create(context.Background(), items(uint64(i)))
		if err != nil {
			panic(err)
		}
		ids[i] = id
	}
	var seq, pay atomic.Int64
	r := load(func(ctx context.Context) error {
		n := mix(uint64(seq.Add(1)))
		if n%100 < 60 {
			_, err := s.Create(ctx, items(n))
			return err
		}
		i := pay.Add(1) - 1
		if i >= int64(len(ids)) {
			return nil
		}
		return s.Pay(ctx, ids[i])
	})
	fmt.Printf("| %s | %s | %d | %d | %d | %.2f | %s | %s | %s | %d |\n",
		scenario, label, r.done, r.timedout, r.failed,
		float64(r.done)/r.wall.Seconds(), r.p(.5).Round(time.Microsecond),
		r.p(.99).Round(time.Microsecond), r.p(.999).Round(time.Microsecond), d.charge.Load())
}

func main() {
	fmt.Println("| scenario | variant | completed | timeout | failed | rps | p50 | p99 | p999 | charges |")
	fmt.Println("|---|---|---|---|---|---|---|---|---|---|")
	for _, sc := range []string{"normal", "latency", "error"} {
		run(sc, false)
		run(sc, true)
	}
}

同一张表

以下输出来自 macOS(darwin/arm64,Apple M1 Pro,Go 1.26.0),命令 go run ./cmd/lab,单次运行;原始输出保存在 .local/reports/be-arch/be-arch-perf-12-3-before-after-numbers-fault-injection-run.txt。每一行的 completed、timeout、failed 之和都是 4,500,因为请求总数固定,不随 ticker 丢拍变化。

| scenario | variant | completed | timeout | failed | rps | p50 | p99 | p999 | charges |
|---|---|---|---|---|---|---|---|---|---|
| normal | baseline | 229 | 4271 | 0 | 65.80 | 201.489ms | 214.473ms | 225.186ms | 51 |
| normal | refactored | 4500 | 0 | 0 | 1401.49 | 213µs | 14.637ms | 28.828ms | 1798 |
| latency | baseline | 221 | 4279 | 0 | 66.70 | 201.463ms | 206.445ms | 213.855ms | 34 |
| latency | refactored | 4500 | 0 | 0 | 1415.28 | 208µs | 60.721ms | 64.081ms | 1798 |
| error | baseline | 233 | 4266 | 1 | 68.30 | 201.493ms | 206.121ms | 209.54ms | 42 |
| error | refactored | 4411 | 0 | 89 | 1379.58 | 236µs | 13.688ms | 25.468ms | 1798 |

基线三行几乎一样:completed 229、221、233,timeout 4,271、4,279、4,266,P50 都在 201.4ms 左右,P99 在 206ms 到 214ms 之间,吞吐 65.80 到 68.30 请求/秒。原因是每笔支付在全局锁内做 3 次预占(3×2ms)、1 次扣款(4ms)和 1 次通知(2ms),临界区约 12ms;1,800 笔支付需要 21.6 秒的锁时间,而窗口只有 3 秒,排队直接吞掉几乎所有截止时间。三种故障场景之间十几个 completed 的差异是调度噪声,故障注入在锁饱和时不可见。

改造版正常场景 4,500 个请求全部完成,零超时,1,401.49 请求/秒,是基线的 21.3 倍;P99 从 214.473ms 降到 14.637ms,改善约 14.6 倍。支付只在下游调用前后短暂持锁,通知进入 outbox,1,798 次扣款全部完成;基线只有 51 次扣款走到下游,其余在锁队列里超时。

延迟场景的 P99 是 60.721ms,是正常场景 14.637ms 的 4.15 倍,但仍然是零超时;每 10 次扣款有 1 次多 50ms,P99 正好把这一小撮慢请求捞出来。错误场景完成 4,411、失败 89、零超时,89/1,798 等于 4.95%,与每 20 次失败一次的注入比例一致。基线错误场景只记录 1 次失败,因为只有 42 次扣款真正走到 Charge,故障同样被队列掩盖。

哪些结论只在本机单次运行成立

这是单次运行:下游是本进程里的 time.Sleep,没有网络、连接池、TLS、数据库,绝对吞吐不能当生产容量;同一台机器上并行任务会改变 ticker 对齐和 GC 时机,P999 最容易漂。固定种子和调用序号保证的是故障次数(89/1,798)和请求混合可复现,不是延迟分位可复现。请求数固定为 4,500 只消除了“丢拍”这一项噪声,wall、rps 和分位仍是单次样本。

常见错误与边界

第一,把 completed 当吞吐。基线 229 个 completed 之外还有 4,271 个超时请求,它们同样进入过锁、消耗过服务时间;65.80 请求/秒是完成吞吐,不是系统只处理了 229 个请求。第二,在基线已经饱和时比较故障类型:229、221、233 的差异是噪声,必须先看改造版。第三,用 P50 代表体验:基线 P50 201.489ms 已经越过 200ms 超时线,改造版 P50 213µs 与 P99 14.637ms 相差约 68.7 倍,平均值没有意义。第四,把限流当成本次的收益来源:改造版三种场景的失败数分别是 0、0、89,没有一次是限流拒绝,1,200 请求/秒的桶没有被触发。第五,把 21.3 倍当成生产收益:这是一个进程内的 sleep 替身,真实系统会先撞到数据库、连接池或网络。

小结

本节用固定种子、固定 4,500 个请求和调用序号注入,把六种组合放进同一张表:基线在三种场景下都是 4,200 次以上超时、P50 约 201ms;改造版正常场景 4,500 完成、1,401.49 请求/秒、P99 14.637ms,延迟场景 P99 60.721ms,错误场景 89 次失败(4.95%)、零超时。最重要的结论不是某个倍数,而是故障签名只有在缩短临界区之后才可见:锁饱和时,正常、延迟、错误看起来都一样。下一节把这张表换成容量规划:需要多少实例、多少余量,才能在下游延迟和错误率变化时守住 SLO。

阅读导航:上一节:12.2 缓存、异步与限流改造 · 下一节:13.1 一次完整的容量规划 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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