《后端架构设计·架构基础与边界》7.1 同步调用链的隐性耦合

把 arch-lab 下单链路拆成库存预留、支付授权、订单落库三段,从 800ms 总预算拆出 200/400/100ms 子预算,说明超时如何逐级收缩、失败如何分类传播;给出可重试与不可重试边界、补偿策略、五组假端口测试的真实输出,以及支付结果不应留在同步响应里的 ADR。

同步链的账本

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到点即中止
库存预留90ms200ms可重试 1 次,需补偿
支付授权260ms400ms不重试,结果待对账
订单落库45ms100ms版本冲突走重试

预算不是把 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 事件、命令与消息的语义差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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