一条下单链路里的两种时间
“把通知改成异步”通常被描述成一次解耦,真正被改变的却是两种时间口径:用户从点击到看到“下单成功”的等待时间,以及订单状态与下游副作用之间的一致性延迟。前者决定转化率,后者决定客服、对账和故障排查的成本。把两者混成一句“异步更快”,就会在事故里发现快只是把等待搬到了看不见的地方。
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 消息投递语义与消费幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。