让改造前后只差四个开关
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 改造前后同一口径的数字与失败注入结果 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。