《后端架构设计·高性能与高可用》12.2 缓存、异步与限流改造

在同一份 arch-lab 订单服务上加入商品快照缓存、outbox 异步通知、令牌桶限流,并把下游调用移出全局锁;用与 12.1 相同的开环负载真实对照:完成吞吐 747.03 升至 1313.89 请求每秒,P99 从 203.71ms 降到 15.58ms,商品调用 5322 次降到 200 次,同时列出每项改造的新失败模式与回滚开关。

让改造前后只差四个开关

12.1 的证据指向一个具体动作:Pay 在持有全局锁时调用库存、支付、通知三个下游,锁的临界区约 8ms。缓存、异步、限流都可以做,但如果每做一项就换一份代码、换一套负载,最后的数字无法归因。本节把改造收敛成四个独立开关:CacheTTL、Outbox、LimitRPS、Narrow。零值就是 12.1 的基线,打开开关才是改造版;两次运行共用同一个服务类型、同一套下游替身、同一份开环负载。

这不是为了代码漂亮,而是为了可回滚。线上出问题时,能把一项改造单独关掉,比“回滚整个版本”快得多。下面先看改造后的服务,再解释每项改造改变了什么语义,最后用真实输出对照。

改造后的服务:四个开关

缓存只缓存商品价格快照,按 SKU 存 TTL;outbox 把通知放进有界队列,由单个 goroutine 消费;令牌桶只拦创建请求;Narrow 决定支付时下游调用在锁内还是锁外。基线路径仍然保留,Narrow=false 时行为与 12.1 一致。

// file: internal/order/service.go
// tier: L2
// verify: go test ./...
package order

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type Status string

const (
	StatusCreated Status = "CREATED"
	StatusPaid    Status = "PAID"
)

type Item struct {
	SKU            string
	Quantity       int
	UnitPriceCents int64
}

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

var (
	ErrNotFound     = errors.New("order not found")
	ErrInvalidState = errors.New("invalid state")
	ErrRateLimited  = errors.New("rate limited")
)

type Deps struct {
	Price   func(ctx context.Context, sku string) (int64, error)
	Reserve func(ctx context.Context, sku string, qty int) error
	Charge  func(ctx context.Context, orderID string, cents int64) (string, error)
	Notify  func(ctx context.Context, orderID string) error
}

// Options 的零值是基线;四项分别对应缓存、异步通知、限流、缩小临界区。
type Options struct {
	CacheTTL time.Duration
	Outbox   bool
	LimitRPS float64
	Narrow   bool
}

type cacheEntry struct {
	cents  int64
	expire time.Time
}

type Service struct {
	mu     sync.Mutex
	seq    int
	orders map[string]*Order
	deps   Deps
	opt    Options
	now    func() time.Time

	cacheMu sync.Mutex
	cache   map[string]cacheEntry

	tokenMu sync.Mutex
	tokens  float64
	last    time.Time

	outbox chan string
	done   chan struct{}
}

func NewService(d Deps, opt Options, now func() time.Time) *Service {
	s := &Service{orders: map[string]*Order{}, deps: d, opt: opt, now: now,
		cache: map[string]cacheEntry{}, tokens: opt.LimitRPS * 2, last: now()}
	if opt.Outbox {
		s.outbox, s.done = make(chan string, 8192), make(chan struct{})
		go func() {
			defer close(s.done)
			for id := range s.outbox {
				_ = s.deps.Notify(context.Background(), id)
			}
		}()
	}
	return s
}

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

func copyOrder(o *Order) *Order {
	c := *o
	c.Items = append([]Item(nil), o.Items...)
	return &c
}

func (s *Service) Allow() bool {
	s.tokenMu.Lock()
	defer s.tokenMu.Unlock()
	now := s.now()
	s.tokens += now.Sub(s.last).Seconds() * s.opt.LimitRPS
	if burst := s.opt.LimitRPS * 2; s.tokens > burst {
		s.tokens = burst
	}
	s.last = now
	if s.tokens < 1 {
		return false
	}
	s.tokens--
	return true
}

func (s *Service) price(ctx context.Context, sku string) (int64, error) {
	if s.opt.CacheTTL > 0 {
		s.cacheMu.Lock()
		e, ok := s.cache[sku]
		s.cacheMu.Unlock()
		if ok && s.now().Before(e.expire) {
			return e.cents, nil
		}
	}
	cents, err := s.deps.Price(ctx, sku)
	if err != nil {
		return 0, err
	}
	if s.opt.CacheTTL > 0 {
		s.cacheMu.Lock()
		s.cache[sku] = cacheEntry{cents: cents, expire: s.now().Add(s.opt.CacheTTL)}
		s.cacheMu.Unlock()
	}
	return cents, nil
}

func (s *Service) notify(ctx context.Context, id string) error {
	if s.outbox == nil {
		return s.deps.Notify(ctx, id)
	}
	select {
	case s.outbox <- id:
	default:
	}
	return nil
}

func (s *Service) Create(ctx context.Context, customerID string, items []Item) (*Order, error) {
	if s.opt.LimitRPS > 0 && !s.Allow() {
		return nil, ErrRateLimited
	}
	total := int64(0)
	for _, it := range items {
		cents, err := s.price(ctx, it.SKU)
		if err != nil {
			return nil, err
		}
		total += int64(it.Quantity) * cents
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.seq++
	o := &Order{ID: fmt.Sprintf("ord-%06d", s.seq), CustomerID: customerID,
		Status: StatusCreated, Items: append([]Item(nil), items...), TotalCents: total}
	s.orders[o.ID] = o
	return copyOrder(o), nil
}

func (s *Service) Get(_ context.Context, id string) (*Order, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	o, ok := s.orders[id]
	if !ok {
		return nil, ErrNotFound
	}
	return copyOrder(o), 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.Quantity); err != nil {
			return err
		}
	}
	if _, err := s.deps.Charge(ctx, id, total); err != nil {
		return err
	}
	return s.notify(ctx, id)
}

func (s *Service) Pay(ctx context.Context, id string) (*Order, error) {
	if s.opt.Narrow {
		return s.payNarrow(ctx, id)
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	o, ok := s.orders[id]
	if !ok {
		return nil, ErrNotFound
	}
	if o.Status != StatusCreated {
		return nil, ErrInvalidState
	}
	if err := s.downstream(ctx, id, o.Items, o.TotalCents); err != nil {
		return nil, err
	}
	o.Status = StatusPaid
	return copyOrder(o), nil
}

// payNarrow 把下游调用移出锁;提交阶段需要 CAS 才能防重复支付,12.3 处理。
func (s *Service) payNarrow(ctx context.Context, id string) (*Order, error) {
	s.mu.Lock()
	o, ok := s.orders[id]
	if !ok || o.Status != StatusCreated {
		s.mu.Unlock()
		if !ok {
			return nil, ErrNotFound
		}
		return nil, ErrInvalidState
	}
	items, total := append([]Item(nil), o.Items...), o.TotalCents
	s.mu.Unlock()
	if err := s.downstream(ctx, id, items, total); err != nil {
		return nil, err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	cur := s.orders[id]
	cur.Status = StatusPaid
	return copyOrder(cur), nil
}

三个细节值得先指出。第一,缓存命中路径不调用下游,未命中才调用 Price 并回填;TTL 由调用方注入的 now 决定,测试里可以用虚拟时钟。第二,outbox 的入队是 select 加 default,队列满时直接丢弃而不是阻塞支付;这是一个明确的取舍,后文会把它列为失败模式。第三,payNarrow 在锁外完成预占、扣款、通知,再重新加锁提交状态;状态检查与提交之间没有 CAS,并发支付同一订单时可能双重扣款,这一点留到 12.3 用幂等键和 CAS 解决。

// file: internal/order/service_test.go
// tier: L2
// verify: go test ./...
package order

import (
	"context"
	"sync"
	"testing"
	"time"
)

type fake struct {
	mu            sync.Mutex
	prices, notes int
}

func (f *fake) Price(context.Context, string) (int64, error) {
	f.mu.Lock()
	f.prices++
	f.mu.Unlock()
	return 1000, nil
}
func (f *fake) Reserve(context.Context, string, int) error            { return nil }
func (f *fake) Charge(context.Context, string, int64) (string, error) { return "p", nil }
func (f *fake) Notify(context.Context, string) error {
	f.mu.Lock()
	f.notes++
	f.mu.Unlock()
	return nil
}

func TestSwitches(t *testing.T) {
	f := &fake{}
	now := time.Unix(0, 0)
	s := NewService(Deps{f.Price, f.Reserve, f.Charge, f.Notify},
		Options{CacheTTL: time.Minute, Outbox: true, LimitRPS: 1000}, func() time.Time { return now })
	items := []Item{{SKU: "a", Quantity: 1}, {SKU: "a", Quantity: 1}}
	o, err := s.Create(context.Background(), "c", items)
	if err != nil || f.prices != 1 {
		t.Fatalf("cache prices=%d err=%v", f.prices, err)
	}
	if _, err := s.Pay(context.Background(), o.ID); err != nil {
		t.Fatal(err)
	}
	s.Close()
	if f.notes != 1 {
		t.Fatalf("outbox notes=%d", f.notes)
	}
	limit := NewService(Deps{}, Options{LimitRPS: 2.5}, func() time.Time { return now })
	n := 0
	for i := 0; i < 50; i++ {
		if limit.Allow() {
			n++
		}
	}
	if n != 5 {
		t.Fatalf("burst=%d", n)
	}
}

测试用虚拟时钟验证三件事:同一 SKU 的两次创建只加载一次价格;outbox 在 Close 时把通知送达一次;令牌桶在速率 2.5、初始 5 个令牌时只放行 5 个请求。它检查的是开关语义,不是性能;性能由下面的开环负载测。

同一负载的真实对照

负载与 12.1 保持同一口径:开环固定间隔 1500 请求/秒、持续 3 秒、客户端超时 200ms,请求混合为 40% 创建、35% 查询、25% 支付,商品 SKU 从 200 个中确定性选取,每个创建请求包含 3 个条目。改造版打开 CacheTTL=5s、Outbox=true、LimitRPS=900、Narrow=true;基线四项全关。命令为 go test ./internal/order -run 'TestBeforeAfter|TestBucketVirtualClock' -count=1 -v,单次运行,原始输出保存在 .local/reports/be-arch/be-arch-perf-12-2-cache-async-rate-limit-refactor-run.txt:

=== RUN   TestBeforeAfter
    workload_test.go:164: mode=baseline offered=4444 completed=2391 timed_out=2053 rejected=0 wall=3.201s rps=747.03 p50=199.527136ms p99=203.708845ms p999=209.551829ms
    workload_test.go:164: mode=baseline catalog=5322 inventory=1567 payment=137 notify=123
    workload_test.go:165: mode=refactored offered=3946 completed=3946 timed_out=0 rejected=0 wall=3.003s rps=1313.89 p50=194.849µs p99=15.575926ms p999=20.21793ms
    workload_test.go:165: mode=refactored catalog=200 inventory=2967 payment=989 notify=989
--- PASS: TestBeforeAfter (6.22s)

同样 3 秒、同一台机器、同一份下游延迟,基线完成 2,391 个请求,改造版完成 3,946 个;完成吞吐从 747.03 升到 1,313.89 请求/秒,提高 75.9%。基线有 2,053 个请求超时,占计划到达的 46.2%,P50 已经贴在 200ms 超时线上;改造版零超时,P50 194.85µs,P99 从 203.71ms 降到 15.58ms,约为原来的十三分之一。

下游计数解释了收益来自哪里。商品价格调用从 5,322 次降到 200 次,正好等于 200 个 SKU 各加载一次,缓存命中率约 96.2%。支付链路则相反:库存预占从 1,567 次升到 2,967 次,扣款从 137 次升到 989 次,通知从 123 次升到 989 次。基线不是“支付慢”,而是大量支付在排队后被 200ms 超时截断,只有 137 次走到扣款;改造后 989 次扣款全部完成通知,库存调用恰好是 989 的 3 倍,与每个订单 3 个条目一致。

基线还有一个不显眼但危险的数字:扣款 137 次、通知 123 次,中间 14 次已经扣款却没有完成通知和 PAID 提交。客户端看到的是超时,资金侧看到的是成功。这正是长临界区加上无幂等的组合后果,也是 12.3 要把故障注入和幂等键放进同一张表的原因。

每项改造的新失败模式与回滚

缓存的第一风险是旧价格:TTL 5 秒内商品调价不会生效,快照里的 UnitPriceCents 只是展示值,结算必须以支付侧金额为准。第二个风险是过期瞬间的并发回源:本节没有 singleflight,多个请求可能同时调用 Price;本次 200 个 SKU 的计数没有出现重复回源,但不能据此认为实现免疫。回滚开关是 CacheTTL=0,立即回到每次创建都查价,无需迁移数据。

outbox 的第一风险是丢消息:队列容量 8192,select 的 default 在队满时静默丢弃,支付仍然返回成功。第二风险是进程退出:队列在内存里,崩溃会丢掉尚未投递的通知;Close 只保证正常关闭时排空,不保证崩溃恢复。第三风险是关闭变慢:队列里积压越多,Close 阻塞越久。回滚开关是 Outbox=false,通知回到同步调用,通知失败会直接让支付返回错误,延迟也会重新进入支付路径。

令牌桶的第一风险是单进程视角:每个实例各自持有 900 令牌/秒和 1,800 的突发额度,集群总放行量是实例数乘以 900,不是全局 900。第二风险是拒绝语义:超过速率的创建返回 ErrRateLimited,客户端如果没有退避重试,会把限流放大成重试风暴。第三风险是时钟:令牌补充依赖 now,系统时钟回拨会让补充量变成负数。回滚开关是 LimitRPS=0。本次 40% 的创建流量约 600 请求/秒,低于 900,所以 rejected=0;限流在这里是护栏,不是吞吐来源。

Narrow 的风险最隐蔽:payNarrow 先读状态、释放锁、调用下游、再提交,两个并发支付可能都通过状态检查,造成双重扣款。它换来的是锁临界区从约 8ms 降到微秒级,代价是正确性需要额外的 CAS 或幂等键兜底。回滚开关是 Narrow=false,行为回到全局锁版本,吞吐下降但语义简单。这四项改造里,只有 Narrow 会改变正确性边界,所以它必须和 12.3 的幂等设计一起上线。

本机单次运行的边界

以上数字来自 macOS(darwin/arm64,Apple M1 Pro,Go 1.26.0),服务、压测器和下游替身在同一进程,下游用 time.Sleep 模拟 1ms 查价、2ms 预占、4ms 扣款、2ms 通知,没有真实网络、连接池、TLS 或数据库。它只能证明“同一台机器上,同一份负载下,改造版比基线快”,不能当作生产容量。offered 也会漂移:基线 4,444、改造版 3,946,都低于 3 秒乘以 1,500 的 4,500,因为 ticker 在调度繁忙时会丢拍;所以对照看 completed/wall 和分位,不要把 offered 当成受控输入。

改造版也没有达到 1,500 请求/秒的目标。25% 的支付请求每个要做 3 次预占、1 次扣款,下游串行延迟约 10ms,3 秒窗口内 989 次支付已经接近这条路径的上限;P99 15.58ms 与 P50 194.85µs 的差距,主要就是支付请求在下游和调度上的排队。

常见错误与边界

第一,把四项改造一起打开,然后宣称每一项都有效。本节测到的是组合效果;商品调用次数的下降可以直接归因于缓存,支付吞吐的提升同时来自 Narrow、outbox 和缓存,若要拆分,需要逐项打开做消融实验,本次没有做。第二,用 offered 计算吞吐。offered 受 ticker 丢拍影响,completed/wall 才是本次对照的口径。第三,把 outbox 的 default 当成“队列满会阻塞”。它不会阻塞,它会丢消息;要么接受可丢通知,要么改成阻塞加超时并监控队列深度。第四,把限流当容量方案。限流只能把超额请求挡在门外,不能提升服务能力;本次限流没有触发,不能据此认为 900 请求/秒是安全容量。

小结

本节在同一份服务上只改四个开关,用同一套开环负载得到同机对照:完成吞吐 747.03 到 1,313.89 请求/秒,P99 从 203.71ms 降到 15.58ms,商品调用从 5,322 次降到 200 次,支付从 137 次升到 989 次且零超时。收益来自缩短锁临界区、缓存价格快照和异步通知;限流是护栏,本次未触发。每项改造都带来新的失败模式:旧快照、丢通知、单机限流和并发双扣,因此都必须有独立回滚开关。下一节把基线、改造版和三种故障场景放进同一张表,并用固定种子和虚拟时钟让注入可重复。

阅读导航:上一节:12.1 压测基线与瓶颈分析 · 下一节:12.3 改造前后同一口径的数字与失败注入结果 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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