《后端架构设计·高性能与高可用》4.1 同步改异步的收益与代价

以 arch-lab 订单创建为样本,对照同步预留加通知与 outbox 异步发布两条路径,用 200 单真实 go test 量化用户等待和一致性延迟,并列出可异步步骤、绝不可异步步骤,以及失败可见性、补偿、对账与积压治理的代价,附本机单次运行数字。

一条下单链路里的两种时间

“把通知改成异步”通常被描述成一次解耦,真正被改变的却是两种时间口径:用户从点击到看到“下单成功”的等待时间,以及订单状态与下游副作用之间的一致性延迟。前者决定转化率,后者决定客服、对账和故障排查的成本。把两者混成一句“异步更快”,就会在事故里发现快只是把等待搬到了看不见的地方。

arch-lab 的下单链路是:校验、预留库存、订单落库、发通知。改造前四步都在请求线程里;改造后只把“发通知”移出请求,用 outbox 保证订单落库与事件写入同事务。异步化的通用模式与背压边界已在 异步化与背压控制 和 消息队列深度解析 中展开,本节只量化 arch-lab 这一条链路,并给出不能异步的清单。

同步路径:用户为所有下游买单

SyncFlow.Create 串行调用库存、订单库和通知。任何一步变慢,用户都要等;通知失败时订单已经预留,接口却返回错误,用户重试会制造第二笔预留。代码里没有重试和补偿,这恰恰是同步链路最常见的隐形成本。

用 outbox 把通知移出请求

AsyncFlow.Create 只同步做两件事:预留库存,以及把订单和 order.placed 写进同一个本地事务。通知由 Dispatcher 扫描 outbox 后执行。Dispatcher.RunOnce 在处理器失败时不标记事件,消息留在 pending,下一轮重试;这意味着投递语义是至少一次,消费端必须幂等,4.2 会补齐。

// file: internal/orderflow/flow.go
// tier: L1
// verify: go test ./internal/orderflow/
package orderflow

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

var ErrOutOfStock = errors.New("out of stock")

type Order struct {
	ID         string
	CustomerID string
	Status     string
}

// Event 是 outbox 行;EnqueuedAt 与 ProcessedAt 之差就是一致性延迟。
type Event struct {
	ID          string
	Topic       string
	OrderID     string
	EnqueuedAt  time.Time
	ProcessedAt *time.Time
	Payload     []byte
}

type Store interface {
	CreateOrder(ctx context.Context, o Order) error
	CreateOrderWithEvent(ctx context.Context, o Order, ev Event) error
	PendingEvents(ctx context.Context, limit int) ([]Event, error)
	MarkProcessed(ctx context.Context, id string, at time.Time) error
}

type Inventory interface {
	Reserve(ctx context.Context, o Order) error
	Release(ctx context.Context, orderID string) error
}

type Notifier interface {
	Notify(ctx context.Context, o Order) error
}

type EventHandler interface {
	Handle(ctx context.Context, ev Event) error
}

// SyncFlow 是改造前路径:三个依赖串行完成,用户等到通知返回。
type SyncFlow struct {
	Store     Store
	Inventory Inventory
	Notifier  Notifier
}

func (f SyncFlow) Create(ctx context.Context, o Order) (time.Duration, error) {
	start := time.Now()
	if err := f.Inventory.Reserve(ctx, o); err != nil {
		return time.Since(start), err
	}
	o.Status = "INVENTORY_RESERVED"
	if err := f.Store.CreateOrder(ctx, o); err != nil {
		_ = f.Inventory.Release(ctx, o.ID)
		return time.Since(start), err
	}
	if err := f.Notifier.Notify(ctx, o); err != nil {
		return time.Since(start), err
	}
	return time.Since(start), nil
}

// AsyncFlow 只同步做预留和本地事务;通知交给 Dispatcher。
type AsyncFlow struct {
	Store     Store
	Inventory Inventory
}

func (f AsyncFlow) Create(ctx context.Context, o Order) (time.Duration, error) {
	start := time.Now()
	if err := f.Inventory.Reserve(ctx, o); err != nil {
		return time.Since(start), err
	}
	o.Status = "INVENTORY_RESERVED"
	at := time.Now()
	ev := Event{
		ID: "evt-" + o.ID + "-placed", Topic: "order.placed", OrderID: o.ID,
		EnqueuedAt: at, Payload: []byte(fmt.Sprintf(`{"order_id":%q}`, o.ID)),
	}
	if err := f.Store.CreateOrderWithEvent(ctx, o, ev); err != nil {
		_ = f.Inventory.Release(ctx, o.ID)
		return time.Since(start), err
	}
	return time.Since(start), nil
}

type Dispatcher struct {
	Store   Store
	Handler EventHandler
}

// RunOnce 处理失败时不标记,消息留在 pending,下一轮重试。
func (d *Dispatcher) RunOnce(ctx context.Context, limit int) (int, error) {
	events, err := d.Store.PendingEvents(ctx, limit)
	if err != nil {
		return 0, err
	}
	done := 0
	for _, ev := range events {
		if err := d.Handler.Handle(ctx, ev); err != nil {
			continue
		}
		if err := d.Store.MarkProcessed(ctx, ev.ID, time.Now()); err != nil {
			return done, err
		}
		done++
	}
	return done, nil
}

关键不是“少调用一次”,而是把不可回滚的副作用移出事务。库存预留仍然同步,因为它决定这笔订单能不能成立;通知失败不再影响下单成功,但订单已经进入 INVENTORY_RESERVED,通知必须最终送达。这个“先成功、后补齐”的承诺,就是最终一致。

测量设计与真实结果

测量代码用 2ms 的库存延迟和 5ms 的通知延迟模拟两个下游,串行创建 200 单,分别记录同步路径的用户等待和异步路径的提交耗时;随后一次性 drain outbox,记录事件从入队到处理完成的延迟。延迟是模拟值,不是线上服务实测;样本是本机单次运行,不能当作稳定结论。

// file: internal/orderflow/flow_test.go
// tier: L1
// verify: go test -v ./internal/orderflow/
package orderflow

import (
	"context"
	"fmt"
	"sort"
	"testing"
	"time"
)

func pct(ds []time.Duration, p float64) time.Duration {
	sort.Slice(ds, func(i, j int) bool { return ds[i] < ds[j] })
	return ds[int(float64(len(ds)-1)*p)]
}

// memStore 是顺序测量用的单线程测试替身,因此不加锁。
type memStore struct {
	orders map[string]Order
	events map[string]*Event
}

func newMemStore() *memStore {
	return &memStore{orders: map[string]Order{}, events: map[string]*Event{}}
}

func (s *memStore) CreateOrder(ctx context.Context, o Order) error {
	s.orders[o.ID] = o
	return nil
}

func (s *memStore) CreateOrderWithEvent(ctx context.Context, o Order, ev Event) error {
	s.orders[o.ID] = o
	e := ev
	s.events[ev.ID] = &e
	return nil
}

func (s *memStore) PendingEvents(ctx context.Context, limit int) ([]Event, error) {
	out := make([]Event, 0, limit)
	for _, ev := range s.events {
		if ev.ProcessedAt == nil && len(out) < limit {
			out = append(out, *ev)
		}
	}
	return out, nil
}

func (s *memStore) MarkProcessed(ctx context.Context, id string, at time.Time) error {
	s.events[id].ProcessedAt = &at
	return nil
}

func (s *memStore) lags() []time.Duration {
	out := []time.Duration{}
	for _, ev := range s.events {
		if ev.ProcessedAt != nil {
			out = append(out, ev.ProcessedAt.Sub(ev.EnqueuedAt))
		}
	}
	return out
}

type timedInventory struct{ delay time.Duration }

func (i timedInventory) Reserve(ctx context.Context, o Order) error { time.Sleep(i.delay); return nil }
func (i timedInventory) Release(ctx context.Context, orderID string) error {
	return nil
}

type timedNotifier struct {
	delay time.Duration
	calls int
}

func (n *timedNotifier) Notify(ctx context.Context, o Order) error {
	time.Sleep(n.delay)
	n.calls++
	return nil
}

func (n *timedNotifier) Handle(ctx context.Context, ev Event) error {
	time.Sleep(n.delay)
	n.calls++
	return nil
}

func TestSyncVersusAsync(t *testing.T) {
	const orders = 200
	const invDelay, notifyDelay = 2 * time.Millisecond, 5 * time.Millisecond
	ctx := context.Background()

	notifier := &timedNotifier{delay: notifyDelay}
	syncFlow := SyncFlow{Store: newMemStore(), Inventory: timedInventory{invDelay}, Notifier: notifier}
	syncWait := make([]time.Duration, 0, orders)
	for i := 0; i < orders; i++ {
		d, err := syncFlow.Create(ctx, Order{ID: fmt.Sprintf("s-%03d", i)})
		if err != nil {
			t.Fatal(err)
		}
		syncWait = append(syncWait, d)
	}

	asyncStore := newMemStore()
	asyncFlow := AsyncFlow{Store: asyncStore, Inventory: timedInventory{invDelay}}
	accept := make([]time.Duration, 0, orders)
	for i := 0; i < orders; i++ {
		d, err := asyncFlow.Create(ctx, Order{ID: fmt.Sprintf("a-%03d", i)})
		if err != nil {
			t.Fatal(err)
		}
		accept = append(accept, d)
	}
	if notifier.calls != orders {
		t.Fatalf("before drain calls=%d want %d from sync path", notifier.calls, orders)
	}

	dispatcher := &Dispatcher{Store: asyncStore, Handler: notifier}
	for {
		n, err := dispatcher.RunOnce(ctx, 64)
		if err != nil {
			t.Fatal(err)
		}
		if n == 0 {
			break
		}
	}
	lags := asyncStore.lags()
	if len(lags) != orders {
		t.Fatalf("processed=%d want %d", len(lags), orders)
	}

	sP50, sP99 := pct(syncWait, .5), pct(syncWait, .99)
	aP50, aP99 := pct(accept, .5), pct(accept, .99)
	lP50, lP99 := pct(lags, .5), pct(lags, .99)
	t.Logf("orders=%d inv=%s notify=%s", orders, invDelay, notifyDelay)
	t.Logf("sync_wait    p50=%s p99=%s", sP50, sP99)
	t.Logf("async_accept p50=%s p99=%s", aP50, aP99)
	t.Logf("async_lag    p50=%s p99=%s", lP50, lP99)
	if sP50 <= aP50 {
		t.Fatalf("sync p50 %s must exceed async %s", sP50, aP50)
	}
	if lP50 < notifyDelay {
		t.Fatalf("lag p50 %s should include notify %s", lP50, notifyDelay)
	}
	if notifier.calls != 2*orders {
		t.Fatalf("calls=%d want %d", notifier.calls, 2*orders)
	}
}

type failingHandler struct{}

func (failingHandler) Handle(ctx context.Context, ev Event) error {
	return fmt.Errorf("provider down")
}

func TestFailedEventStaysPending(t *testing.T) {
	store := newMemStore()
	flow := AsyncFlow{Store: store, Inventory: timedInventory{}}
	if _, err := flow.Create(context.Background(), Order{ID: "o-fail"}); err != nil {
		t.Fatal(err)
	}
	d := &Dispatcher{Store: store, Handler: failingHandler{}}
	if n, err := d.RunOnce(context.Background(), 10); err != nil || n != 0 {
		t.Fatalf("run n=%d err=%v", n, err)
	}
	if pending, _ := store.PendingEvents(context.Background(), 10); len(pending) != 1 {
		t.Fatalf("pending=%d want 1", len(pending))
	}
}

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在独立模块 .local/checks/be-arch-lab/pages/be-arch-perf-04-1-sync-to-async-tradeoffs/ 执行,原始输出保存在 .local/reports/be-arch/be-arch-perf-04-1-sync-to-async-tradeoffs-sync-vs-async.txt:

$ go test -v ./... -count=1
=== RUN   TestSyncVersusAsync
    flow_test.go:135: orders=200 inv=2ms notify=5ms
    flow_test.go:136: sync_wait    p50=8.687417ms p99=12.30575ms
    flow_test.go:137: async_accept p50=2.536625ms p99=6.024375ms
    flow_test.go:138: async_lag    p50=911.377833ms p99=1.590266042s
--- PASS: TestSyncVersusAsync (3.50s)
=== RUN   TestFailedEventStaysPending
--- PASS: TestFailedEventStaysPending (0.00s)
PASS
ok  	archlab/internal/orderflow	3.955s

这组数字的含义比“快了 3 倍”更重要:异步提交的 P50 约 2.5ms,接近库存延迟;同步 P50 约 8.7ms,接近库存加通知。但异步延迟 P50 约 912ms,因为 200 条事件是批量 drain 的,前面的订单在排队。单条订单的延迟不会凭空消失,它变成了队列里的等待。若生产消费者数量不足,P99 会继续向积压方向上漂。

哪些步骤可以异步,哪些绝对不能

判断标准不是“慢不慢”,而是“失败后用户是否必须立刻知道结果”。异步只适合那些失败可以延后处理、且业务能接受最终一致的步骤。

步骤能否异步关键理由
参数校验、幂等键检查不能决定请求是否被接受,失败要立刻返回 4xx
库存预留不能结果决定订单能否成立,OUT_OF_STOCK 必须回传
支付授权/扣款不能金额与失败原因必须同步告知用户
订单落库与 outbox 写入不能拆开必须同一本地事务,否则会丢事件或写幽灵订单
短信、邮件、站内信可以失败可重试,用户可稍后看到
积分、推荐、搜索索引可以延迟可接受,且有对账兜底
发票、对账文件生成可以批处理语义,天然允许分钟级延迟
取消、退款发起可以但有条件需要状态机、幂等键和补偿,失败必须可追踪

最容易被误判的是“库存预留”。它看起来只是一个 RPC,但它是订单成立的前置条件;把它异步化,接口只能返回“处理中”,用户无法知道是否买到了商品。反之,通知即使晚到十分钟,用户也不会因此付两次钱。

代价清单

第一是失败可见性。同步失败会变成 HTTP 错误码;异步失败只留在 outbox、消费者日志或 DLQ 里。必须补齐 pending 数、最老事件年龄、消费失败率和 DLQ 深度四类指标,否则“异步成功”只是没人看。

第二是一致性延迟。本文测量里 P50 已经接近 1 秒,生产环境还要叠加网络、序列化和消费者扩容时间。产品文案不能写“实时到账”,客服也要知道延迟窗口。

第三是补偿与对账。通知失败可以重试,但库存预留成功、订单创建失败时必须释放库存;订单已支付、事件丢失时必须靠定时对账补发。对账不是可选项,它是异步链路的最后一道正确性防线,4.3 会实现最小版本。

第四是运维和调试成本。多一个 broker、多一套 DLQ、多一条重放流程;跨进程后 trace 上下文必须显式传播,否则只能靠订单号拼日志。把同步改异步却不加这些设施,等于把故障从请求线程搬到了无人值守的后台。

常见错误与边界

第一,把“发消息”写进数据库事务。网络调用不受事务控制,提交与发送无法原子完成;事务内只写 outbox。

第二,只测量吞吐,不测量一致性延迟。用户等待下降不代表系统更快,可能只是把延迟转移给了下游。

第三,认为异步后就不需要错误处理。outbox 只保证事件不丢,不保证消费者成功;重试、DLQ、对账缺一不可。

第四,把所有步骤都异步化。库存、支付这类决定业务结果的动作必须同步,否则接口无法给出确定答复。

小结

同步改异步的收益是用户等待时间从“库存加通知”降到“库存加本地事务”,代价是一致性延迟、失败可见性和补偿成本一起上升。arch-lab 的测量显示,本机单次运行中 P50 从约 8.7ms 降到约 2.5ms,但事件 P50 延迟约 912ms;这两个数字必须同时出现在设计评审里。下一篇处理异步化之后必然出现的重复投递:至少一次语义下,消费者如何做到业务上只生效一次。

阅读导航:上一节:3.3 穿透、击穿、雪崩与热 key 治理 · 下一节:4.2 消息投递语义与消费幂等 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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