同步链的账本
POST /api/v1/orders 在 arch-lab 卷一里是一条同步链:HTTP 处理器先调库存预留,再调支付授权,最后把订单落库。三段都成功才返回 201。代码读起来只有三行调用,但它同时借用了三个依赖的延迟、可用性和故障模式。站内 微服务通信模式
讨论过同步与异步通信的选型,这里只算一笔更具体的账:这条链到底向用户承诺了什么。
先看观测值。下面是本机压测前的基线假设,不是生产 SLA:库存预留 p99 约 90ms,支付授权 p99 约 260ms,订单落库 p99 约 45ms。相加是 395ms。如果每段都“顺手加一个 1 秒超时”,最坏情况就是 3 秒,而 HTTP 网关只给 800ms,用户在 800ms 时已经收到 504,后端却还在替一个没人等待的请求调用支付。同步链的第一条隐性耦合不是代码依赖,而是延迟预算没有所有者。
| 阶段 | p99 观测 | 分配预算 | 失败策略 |
|---|---|---|---|
| HTTP 总预算 | — | 800ms | 到点即中止 |
| 库存预留 | 90ms | 200ms | 可重试 1 次,需补偿 |
| 支付授权 | 260ms | 400ms | 不重试,结果待对账 |
| 订单落库 | 45ms | 100ms | 版本冲突走重试 |
预算不是把 800ms 平均切三份。支付最慢且不可重试,拿 400ms;库存和落库快,各拿 200ms 和 100ms;剩下 100ms 不分配给任何依赖,专门吸收运行时抖动。更关键的是,这些数字必须由同一个父 deadline 派生,而不是三个互不知情的 context.WithTimeout。
超时要逐级收缩,失败要分类
父预算和子预算的区别在边界上才会暴露。假设库存步骤分到 200ms,但请求进入编排器时父 deadline 只剩 30ms。此时给库存一个独立的 200ms 超时,实际等待上限仍是 30ms,可错误类型却会变成“库存步骤超时”,监控会把一次总预算耗尽记到库存头上。编排器必须做两件事:把子预算钳制为 min(step.Budget, remaining);并在分类错误时区分“步骤自己的预算到期”和“父预算到期”。
失败传播也要分类。ErrUnavailable 表示依赖暂时不可用,幂等步骤可以有限重试;ErrStepTimeout 表示这一步等不到了,后续步骤不再执行;ErrBudgetExhausted 表示整条链没有时间了,重试没有意义。支付授权是特殊点:请求超时不等于支付失败,服务端可能已经扣款,只能当作“结果未知”,由对账或异步回调收敛,绝不能在同一请求里重试。
编排器:父预算、子预算与补偿
// file: internal/order/order.go
// tier: L1
// verify: go test ./internal/orchestration/
package order
type Item struct {
SKU string
Quantity int
UnitPriceCents int64
}
type Order struct {
ID string
Items []Item
TotalCents int64
}
const StatusCreated = "CREATED"
端口仍然由消费方定义能力,不暴露实现。ErrUnavailable 是唯一允许触发自动重试的分类。
// file: internal/ports/ports.go
// tier: L1
// verify: go test ./internal/orchestration/
package ports
import (
"context"
"errors"
"archlab/internal/order"
)
// ErrUnavailable 表示依赖暂时不可用,调用方可以有限重试。
var ErrUnavailable = errors.New("dependency unavailable")
type Inventory interface {
Reserve(ctx context.Context, orderID string, items []order.Item) (string, error)
Release(ctx context.Context, reservationID string) error
}
type Payment interface {
Authorize(ctx context.Context, orderID string, amountCents int64) (string, error)
}
type Repository interface {
Save(ctx context.Context, o order.Order) error
}
编排器是本节的核心产物。Run 接收一个总预算和有序步骤,逐步下发子 context;classify 负责把 context 错误翻译成领域可判断的分类;PlaceOrder 把库存、支付、落库串成一条 800ms 的链,并在失败时尽力释放预留。
// file: internal/orchestration/plan.go
// tier: L1
// verify: go test -race -v ./internal/orchestration/
package orchestration
import (
"context"
"errors"
"fmt"
"time"
"archlab/internal/order"
"archlab/internal/ports"
)
var ErrBudgetExhausted = errors.New("plan budget exhausted")
var ErrStepTimeout = errors.New("step timed out")
type Step struct {
Name string
Budget time.Duration
Idempotent bool
MaxAttempts int
Do func(context.Context) error
}
type Plan struct {
Total time.Duration
Steps []Step
}
// Run 把父 context 的剩余时间当作总预算,逐步下发更小的子预算。
// 任何一步失败都立刻停止,不在同一请求里继续调用后续依赖。
func Run(parent context.Context, p Plan) error {
runCtx := parent
if p.Total > 0 {
var cancel context.CancelFunc
runCtx, cancel = context.WithTimeout(parent, p.Total)
defer cancel()
}
for _, s := range p.Steps {
if err := runCtx.Err(); err != nil {
return fmt.Errorf("%w: before step %q: %v", ErrBudgetExhausted, s.Name, err)
}
attempts := max(1, s.MaxAttempts)
var stepErr error
for attempt := 1; attempt <= attempts; attempt++ {
budget, clamped := s.Budget, false
if deadline, ok := runCtx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining <= 0 {
stepErr = fmt.Errorf("%w: before step %q", ErrBudgetExhausted, s.Name)
break
}
if budget <= 0 || remaining < budget {
budget, clamped = remaining, true
}
}
stepCtx, cancel := context.WithTimeout(runCtx, budget)
err := s.Do(stepCtx)
ctxErr := stepCtx.Err()
cancel()
if err == nil {
stepErr = nil
break
}
stepErr = classify(runCtx, ctxErr, clamped, s.Name, err)
// 只有幂等步骤可重试;支付授权可能已在服务端成功,
// 盲目重试会造成二次扣款。
if !s.Idempotent || attempt == attempts || !errors.Is(err, ports.ErrUnavailable) {
break
}
}
if stepErr != nil {
return stepErr
}
}
return nil
}
func classify(parent context.Context, ctxErr error, clamped bool, name string, err error) error {
if parent.Err() != nil || (clamped && errors.Is(ctxErr, context.DeadlineExceeded)) {
return fmt.Errorf("%w: step %q", ErrBudgetExhausted, name)
}
if errors.Is(ctxErr, context.DeadlineExceeded) {
return fmt.Errorf("%w: step %q", ErrStepTimeout, name)
}
return fmt.Errorf("step %q: %w", name, err)
}
// PlaceOrder 总预算 800ms:库存预留可重试,支付授权不可重试,
// 任何一步失败都会中止后续调用并补偿已完成的预留。
func PlaceOrder(ctx context.Context, inv ports.Inventory, pay ports.Payment, repo ports.Repository, o order.Order) error {
var reservationID string
err := Run(ctx, Plan{
Total: 800 * time.Millisecond,
Steps: []Step{
{
Name: "reserve-inventory", Budget: 200 * time.Millisecond,
Idempotent: true, MaxAttempts: 2,
Do: func(ctx context.Context) error {
id, err := inv.Reserve(ctx, o.ID, o.Items)
if err == nil {
reservationID = id
}
return err
},
},
{
Name: "authorize-payment", Budget: 400 * time.Millisecond,
Do: func(ctx context.Context) error {
_, err := pay.Authorize(ctx, o.ID, o.TotalCents)
return err
},
},
{
Name: "save-order", Budget: 100 * time.Millisecond,
Do: func(ctx context.Context) error { return repo.Save(ctx, o) },
},
},
})
if err != nil && reservationID != "" {
// 补偿与主请求解耦:释放失败写 outbox 由后台重试。
_ = inv.Release(context.WithoutCancel(ctx), reservationID)
}
return err
}
有三个细节值得单独指出。第一,clamped 变量记录子预算是否被父 deadline 截短:如果被截短且步骤以 DeadlineExceeded 结束,错误归因为 ErrBudgetExhausted,而不是库存或支付的“步骤超时”。第二,重试条件同时要求 s.Idempotent、还有剩余次数、错误是 ErrUnavailable;支付步骤没有设置 Idempotent,所以即使写了 MaxAttempts: 2 也不会重试。第三,补偿调用使用 context.WithoutCancel,避免父请求已取消导致释放动作立刻失败;释放本身仍可能失败,正确做法是写入 outbox 由后台重试,而不是让用户请求继续等待。
假端口与五组测试
测试里的假端口只做两件事:计数和按配置制造故障。hang 模式阻塞到 ctx.Done() 再返回 ctx.Err(),这样超时测试不依赖真实网络,也不会因为机器慢而偶发失败;failFirst 用来验证“只有幂等步骤才重试”。
// file: internal/orchestration/plan_test.go
// tier: L1
// verify: go test -race -v ./internal/orchestration/
package orchestration
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"archlab/internal/order"
"archlab/internal/ports"
)
type invFake struct {
calls, releases atomic.Int32
hang bool
failFirst int32
}
func (f *invFake) Reserve(ctx context.Context, id string, _ []order.Item) (string, error) {
n := f.calls.Add(1)
if f.hang {
<-ctx.Done()
return "", ctx.Err()
}
if n <= f.failFirst {
return "", ports.ErrUnavailable
}
return "res-" + id, nil
}
func (f *invFake) Release(context.Context, string) error { f.releases.Add(1); return nil }
type payFake struct {
calls atomic.Int32
failFirst int32
}
func (f *payFake) Authorize(context.Context, string, int64) (string, error) {
if f.calls.Add(1) <= f.failFirst {
return "", ports.ErrUnavailable
}
return "pay-1", nil
}
type repoFake struct{ calls atomic.Int32 }
func (f *repoFake) Save(context.Context, order.Order) error { f.calls.Add(1); return nil }
func sampleOrder() order.Order {
return order.Order{ID: "o-1", Items: []order.Item{{SKU: "sku-1", Quantity: 2}}, TotalCents: 1000}
}
func TestPlaceOrderHappyPath(t *testing.T) {
inv, pay, repo := &invFake{}, &payFake{}, &repoFake{}
if err := PlaceOrder(context.Background(), inv, pay, repo, sampleOrder()); err != nil {
t.Fatal(err)
}
if inv.calls.Load() != 1 || pay.calls.Load() != 1 || repo.calls.Load() != 1 {
t.Fatalf("calls inv=%d pay=%d repo=%d", inv.calls.Load(), pay.calls.Load(), repo.calls.Load())
}
}
func TestInventoryTimeoutStopsLaterCalls(t *testing.T) {
inv, pay, repo := &invFake{hang: true}, &payFake{}, &repoFake{}
start := time.Now()
err := PlaceOrder(context.Background(), inv, pay, repo, sampleOrder())
if elapsed := time.Since(start); elapsed > 300*time.Millisecond {
t.Fatalf("elapsed = %v, want near 200ms budget", elapsed)
}
if !errors.Is(err, ErrStepTimeout) || pay.calls.Load() != 0 || repo.calls.Load() != 0 {
t.Fatalf("err=%v pay=%d repo=%d", err, pay.calls.Load(), repo.calls.Load())
}
}
func TestPlanDeadlineExhaustedBeforeStep(t *testing.T) {
slow, repo := &invFake{hang: true}, &repoFake{}
err := Run(context.Background(), Plan{Total: 40 * time.Millisecond, Steps: []Step{
{Name: "fast", Budget: time.Millisecond, Do: func(context.Context) error { return nil }},
{Name: "slow", Budget: time.Second, Do: func(ctx context.Context) error {
_, err := slow.Reserve(ctx, "o-1", nil)
return err
}},
{Name: "never", Do: func(context.Context) error { return repo.Save(context.Background(), order.Order{}) }},
}})
if !errors.Is(err, ErrBudgetExhausted) || repo.calls.Load() != 0 {
t.Fatalf("err=%v third=%d", err, repo.calls.Load())
}
}
func TestRetryOnlyIdempotentSteps(t *testing.T) {
inv := &invFake{failFirst: 1}
err := Run(context.Background(), Plan{Steps: []Step{{
Name: "reserve", Budget: time.Second, Idempotent: true, MaxAttempts: 2,
Do: func(ctx context.Context) error { _, err := inv.Reserve(ctx, "o-1", nil); return err },
}}})
if err != nil || inv.calls.Load() != 2 {
t.Fatalf("err=%v calls=%d, want nil and 2", err, inv.calls.Load())
}
pay := &payFake{failFirst: 1}
err = Run(context.Background(), Plan{Steps: []Step{{
Name: "authorize", Budget: time.Second, MaxAttempts: 2,
Do: func(ctx context.Context) error { _, err := pay.Authorize(ctx, "o-1", 1000); return err },
}}})
if !errors.Is(err, ports.ErrUnavailable) || pay.calls.Load() != 1 {
t.Fatalf("err=%v calls=%d, want ErrUnavailable and 1", err, pay.calls.Load())
}
}
func TestPaymentFailureReleasesReservation(t *testing.T) {
inv, pay, repo := &invFake{}, &payFake{failFirst: 1}, &repoFake{}
if err := PlaceOrder(context.Background(), inv, pay, repo, sampleOrder()); err == nil {
t.Fatal("want payment error")
}
if inv.releases.Load() != 1 || repo.calls.Load() != 0 {
t.Fatalf("releases=%d save=%d", inv.releases.Load(), repo.calls.Load())
}
}
五组测试分别覆盖:正常链路每步只调用一次;库存在 200ms 子预算处超时且支付、落库都没有发生;父预算 40ms 先耗尽时归因为 ErrBudgetExhausted 且第三步不执行;库存首次不可用会重试、支付首次不可用不会重试;支付失败后预留被释放且订单没有落库。第三组里慢步骤的 Budget 是 1 秒,远大于父预算 40ms,专门用来逼出“钳制后归因”分支。
真实运行结果
以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在独立模块 .local/checks/ch07-be-arch-base-07-1-sync-call-coupling 中执行。gofmt -l . 无输出,go vet ./... 通过。
$ go test -race -v ./internal/orchestration/
--- PASS: TestPlaceOrderHappyPath (0.00s)
--- PASS: TestInventoryTimeoutStopsLaterCalls (0.20s)
--- PASS: TestPlanDeadlineExhaustedBeforeStep (0.04s)
--- PASS: TestRetryOnlyIdempotentSteps (0.00s)
--- PASS: TestPaymentFailureReleasesReservation (0.00s)
PASS
ok archlab/internal/orchestration 1.516s
超时测试耗时 0.20s,与库存步骤 200ms 的子预算一致;父预算测试耗时 0.04s,与 40ms 总预算一致。这两个数字不是装饰:如果实现漏掉了父 deadline 传递,耗时和错误分类会同时偏离。
ADR:支付结果是否留在同步响应里
背景:创建订单时是否同步等待支付授权成功,再返回 201。备选 A:保持同步,支付失败直接回滚库存并返回 402。备选 B:创建订单只做库存预留,返回 202 和支付链接,支付结果走 webhook 异步回填。决定:B。后果:预期创建接口 p99 从约 395ms 降到约 150ms(估算,未压测),支付超时不再把订单拖进未知状态;代价是前端要处理“待支付”,运营要能看到超时未支付订单,且 webhook 必须幂等。不选 A 的理由:支付服务的外部延迟和结果不确定性不适合放进用户请求路径,同步重试又可能造成二次扣款。
常见错误与边界
第一,给每个依赖单独设超时,却不从父 deadline 派生。单看每段都没超,合起来必然超过网关预算;正确做法是只创建一个总 deadline,所有子 context 从它派生。
第二,超时后同步补偿。补偿本身也是远程调用,用户请求已经超时,再等一次释放只会拉长尾延迟;补偿应写入 outbox 或本地事务表,由后台异步完成。把“支付超时”当成“支付失败”并重试同样危险:外部支付没有幂等键时,重试可能产生两笔扣款。
第三,认为同步链越短越好。强一致的库存扣减、余额校验仍然需要同步返回;边界不是“能异步就异步”,而是“这个结果是否决定本次响应”。通知、积分、报表、ERP 同步这类不决定响应的动作,才应该移出同步链,相关模式见 消息驱动架构 。
小结
同步调用链的隐性耦合有三层:延迟相加、可用性相乘、故障沿调用方向传播。arch-lab 用一条 800ms 的父预算把三层同时收住:子预算只能更小,失败按 ErrStepTimeout、ErrBudgetExhausted、ErrUnavailable 分类,重试只对幂等步骤开放,补偿与主请求解耦。代码只有一百多行,却把“哪些调用可以同步、超时算谁的、失败之后谁负责”写成了可执行约束。下一节处理另一个边界:当调用从同步变成异步,事件、命令和消息到底差在哪里。
阅读导航:上一节:6.3 订单读模型:CQRS 的引入判据与退出条件 · 下一节:7.2 事件、命令与消息的语义差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。