《后端架构设计·高性能与高可用》6.3 降级、兜底与用户体验

在 arch-lab 订单链路上实现可组合的降级策略链:订单查询按快照、默认值、只读顺序兜底,支付回调把验签、金额、幂等落账列为不可降级步骤,只有下游暂时不可用才进入持久队列稍后重试;用契约测试证明降级响应仍合法、快照落后于已提交版本时必须显式报错而不能掩盖不一致,并给出错误码、用户文案与恢复后的补偿动作。

降级链的排序原则

降级不是“出错就返回空”,而是按“保住契约、保住信息量、保住可写性”的顺序逐级退让。arch-lab 订单查询的链条固定为:主库实时读 → 副本快照 → 契约合法的默认值;支付回调的链条是:验签 → 幂等落账 → 金额核对 → 排队稍后重试。验签、金额和幂等不可降级,只有账本“暂时不可用”才允许改道。通用的熔断、降级与限流取舍在 熔断、降级与限流 中已有讨论,本节只写 arch-lab 里能跑起来的那一条链。

订单查询:快照、默认值与只读守卫

代码把三种结果收敛成同一个 OrderView,并用 Validate 保证任何一条路径返回的结构都满足前端契约:状态必须属于枚举,UNKNOWN 只能在降级时出现,降级视图必须带来源、提示和只读标记。Writable 是写路径的闸门,降级视图一律拒绝取消、退款等操作,避免用旧状态做新决策。

// file: internal/platform/degrade/degrade.go
package degrade

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

var (
	ErrInconsistent   = errors.New("STALE_SNAPSHOT")
	ErrAmountMismatch = errors.New("AMOUNT_MISMATCH")
)

type OrderView struct {
	OrderID    string
	Status     string
	TotalCents int64
	Version    int64
	AsOf       time.Time
	Degraded   bool
	ReadOnly   bool
	Source     string
	Warning    string
	Code       string
	RetryAfter time.Duration
}

func (v OrderView) Validate() error {
	if v.OrderID == "" || v.TotalCents < 0 {
		return errors.New("contract: bad identity or total")
	}
	switch v.Status {
	case "CREATED", "INVENTORY_RESERVED", "PAID", "FULFILLED", "CANCELLED", "REFUNDED":
	case "UNKNOWN":
		if !v.Degraded {
			return errors.New("contract: UNKNOWN only when degraded")
		}
	default:
		return fmt.Errorf("contract: bad status %q", v.Status)
	}
	if v.Degraded && (v.Source == "" || v.Warning == "" || !v.ReadOnly) {
		return errors.New("contract: degraded view needs source, warning, read-only")
	}
	return nil
}

// Writable is the write-path guard: a degraded view must not cancel or refund.
func (v OrderView) Writable() bool { return !v.ReadOnly }

type Reader interface {
	Get(ctx context.Context, orderID string) (OrderView, error)
}
type SnapshotStore interface {
	Latest(orderID string) (OrderView, bool)
}
type Watermark interface {
	Committed(orderID string) (int64, bool)
}

type QueryService struct {
	primary    Reader
	snapshots  SnapshotStore
	watermarks Watermark
	now        func() time.Time
}

func NewQueryService(primary Reader, snapshots SnapshotStore, watermarks Watermark, now func() time.Time) *QueryService {
	return &QueryService{primary: primary, snapshots: snapshots, watermarks: watermarks, now: now}
}

func defaultView(orderID string, now time.Time) OrderView {
	return OrderView{OrderID: orderID, Status: "UNKNOWN", AsOf: now, Degraded: true, ReadOnly: true,
		Source: "default", Warning: "订单服务暂时不可用,请稍后重试", Code: "OVERLOADED", RetryAfter: 2 * time.Second}
}

func (s *QueryService) Get(ctx context.Context, orderID string) (OrderView, error) {
	if v, err := s.primary.Get(ctx, orderID); err == nil {
		return v, nil
	}
	snap, ok := s.snapshots.Latest(orderID)
	if !ok {
		// No snapshot: a contract-valid default, never a fabricated order.
		return defaultView(orderID, s.now()), nil
	}
	if wm, ok := s.watermarks.Committed(orderID); ok && snap.Version < wm {
		// A committed write is newer than the snapshot. Serving it as fresh
		// would hide the inconsistency, so fail explicitly instead.
		return OrderView{}, fmt.Errorf("%w: snapshot v%d < committed v%d", ErrInconsistent, snap.Version, wm)
	}
	snap.Degraded = true
	snap.ReadOnly = true
	snap.Source = "snapshot"
	snap.AsOf = s.now()
	snap.Code = "OK_DEGRADED"
	snap.Warning = "订单状态来自副本快照,可能延迟数秒"
	return snap, nil
}

type Callback struct {
	PaymentID   string
	OrderID     string
	AmountCents int64
	Signature   string
}

type Ledger interface {
	Apply(ctx context.Context, cb Callback) (applied bool, err error)
}
type Queue interface {
	Enqueue(cb Callback) error
	Drain(ctx context.Context, apply func(context.Context, Callback) error) (int, error)
}

type PayResult struct {
	Accepted   bool
	Queued     bool
	Code       string
	Message    string
	RetryAfter time.Duration
}

type PaymentHandler struct {
	verify func(Callback) error
	ledger Ledger
	queue  Queue
}

func NewPaymentHandler(verify func(Callback) error, ledger Ledger, queue Queue) *PaymentHandler {
	return &PaymentHandler{verify: verify, ledger: ledger, queue: queue}
}

func (h *PaymentHandler) Handle(ctx context.Context, cb Callback) PayResult {
	if err := h.verify(cb); err != nil {
		return PayResult{Code: "REJECTED_SIGNATURE", Message: "回调签名校验失败"}
	}
	if _, err := h.ledger.Apply(ctx, cb); err == nil {
		return PayResult{Accepted: true, Code: "OK", Message: "回调已处理"}
	} else if errors.Is(err, ErrAmountMismatch) {
		return PayResult{Code: "AMOUNT_MISMATCH", Message: "金额与订单不一致,已拒绝"}
	}
	if err := h.queue.Enqueue(cb); err != nil {
		return PayResult{Code: "OVERLOADED", Message: "暂时不可用,请稍后重试", RetryAfter: time.Second}
	}
	return PayResult{Accepted: true, Queued: true, Code: "QUEUED", Message: "已受理,稍后处理", RetryAfter: time.Second}
}

func (h *PaymentHandler) Drain(ctx context.Context) (int, error) {
	return h.queue.Drain(ctx, func(ctx context.Context, cb Callback) error {
		_, err := h.ledger.Apply(ctx, cb)
		return err
	})
}

快照路径的关键不是“有快照就用”,而是先看已提交水位 Committed。快照版本落后于水位,说明有写入已经成功但副本没追上;这时把旧快照当新数据返回,等于用 200 掩盖数据不一致。代码选择返回 STALE_SNAPSHOT,让调用方明确失败,而不是悄悄展示 CREATED。

用契约测试锁住降级行为

测试不验证“降级了”,而验证“降级后仍然合法”:每个分支的返回值都要过 Validate,降级视图必须不可写,默认值必须是 UNKNOWN 而不是伪造订单。

// file: internal/platform/degrade/degrade_test.go
package degrade

import (
	"context"
	"errors"
	"testing"
	"time"
)

var base = time.Date(2026, 10, 18, 16, 0, 0, 0, time.UTC)

type fakeStore struct {
	view OrderView
	ok   bool
	err  error
	wm   int64
	wmOK bool
}

func (f *fakeStore) Get(context.Context, string) (OrderView, error) { return f.view, f.err }
func (f *fakeStore) Latest(string) (OrderView, bool)                { return f.view, f.ok }
func (f *fakeStore) Committed(string) (int64, bool)                 { return f.wm, f.wmOK }

func TestQueryFallbackContract(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{err: errors.New("primary down"), ok: true, wm: 5, wmOK: true,
		view: OrderView{OrderID: "o-1", Status: "PAID", TotalCents: 9900, Version: 5}}
	svc := NewQueryService(store, store, store, func() time.Time { return base })

	v, err := svc.Get(ctx, "o-1")
	if err != nil {
		t.Fatal(err)
	}
	if !v.Degraded || !v.ReadOnly || v.Source != "snapshot" || v.Code != "OK_DEGRADED" || v.Validate() != nil {
		t.Fatalf("view=%+v validate=%v", v, v.Validate())
	}
	if v.Writable() {
		t.Fatal("degraded view must reject writes")
	}
	t.Logf("degraded source=%s status=%s version=%d read_only=%v warning=%q", v.Source, v.Status, v.Version, v.ReadOnly, v.Warning)

	empty := &fakeStore{err: errors.New("down again")}
	d, err := NewQueryService(empty, empty, empty, func() time.Time { return base }).Get(ctx, "o-2")
	if err != nil || d.Status != "UNKNOWN" || d.Code != "OVERLOADED" || d.Validate() != nil {
		t.Fatalf("default view=%+v err=%v", d, err)
	}
	t.Logf("default status=%s code=%s retry_after=%s", d.Status, d.Code, d.RetryAfter)
}

func TestQueryDoesNotMaskInconsistency(t *testing.T) {
	ctx := context.Background()
	store := &fakeStore{err: errors.New("primary down"), ok: true, wm: 5, wmOK: true,
		view: OrderView{OrderID: "o-1", Status: "CREATED", TotalCents: 9900, Version: 4}}
	v, err := NewQueryService(store, store, store, func() time.Time { return base }).Get(ctx, "o-1")
	if !errors.Is(err, ErrInconsistent) {
		t.Fatalf("got view=%+v err=%v want STALE_SNAPSHOT", v, err)
	}
	t.Logf("snapshot v4 < committed v5 -> %v", err)
}

type fakeLedger struct {
	down       bool
	amounts    map[string]int64
	applied    map[string]bool
	applyCalls int
}

func (l *fakeLedger) Apply(_ context.Context, cb Callback) (bool, error) {
	l.applyCalls++
	if l.down {
		return false, errors.New("ledger unavailable")
	}
	if want, ok := l.amounts[cb.OrderID]; ok && want != cb.AmountCents {
		return false, ErrAmountMismatch
	}
	if l.applied[cb.PaymentID] {
		return false, nil
	}
	l.applied[cb.PaymentID] = true
	return true, nil
}

type fakeQueue struct {
	items []Callback
}

func (q *fakeQueue) Enqueue(cb Callback) error {
	q.items = append(q.items, cb)
	return nil
}
func (q *fakeQueue) Drain(ctx context.Context, apply func(context.Context, Callback) error) (int, error) {
	n := 0
	for len(q.items) > 0 {
		if err := apply(ctx, q.items[0]); err != nil {
			return n, err
		}
		q.items = q.items[1:]
		n++
	}
	return n, nil
}

func TestPayment(t *testing.T) {
	ctx := context.Background()
	ledger := &fakeLedger{amounts: map[string]int64{"o-1": 9900}, applied: map[string]bool{}}
	queue := &fakeQueue{}
	h := NewPaymentHandler(func(cb Callback) error {
		if cb.Signature != "valid" {
			return errors.New("bad signature")
		}
		return nil
	}, ledger, queue)

	r := h.Handle(ctx, Callback{PaymentID: "p0", OrderID: "o-1", AmountCents: 9900, Signature: "forged"})
	if r.Accepted || r.Code != "REJECTED_SIGNATURE" || len(queue.items) != 0 {
		t.Fatalf("signature result=%+v queue=%d", r, len(queue.items))
	}
	t.Logf("forged signature code=%s queue=%d", r.Code, len(queue.items))

	r = h.Handle(ctx, Callback{PaymentID: "p1", OrderID: "o-1", AmountCents: 100, Signature: "valid"})
	if r.Accepted || r.Code != "AMOUNT_MISMATCH" || len(queue.items) != 0 {
		t.Fatalf("amount result=%+v queue=%d", r, len(queue.items))
	}
	t.Logf("amount mismatch code=%s queue=%d", r.Code, len(queue.items))

	ledger.down = true
	good := Callback{PaymentID: "p1", OrderID: "o-1", AmountCents: 9900, Signature: "valid"}
	r = h.Handle(ctx, good)
	if !r.Accepted || !r.Queued || r.Code != "QUEUED" || len(queue.items) != 1 {
		t.Fatalf("outage result=%+v queue=%d", r, len(queue.items))
	}
	t.Logf("ledger down -> code=%s queued=%v queue=%d", r.Code, r.Queued, len(queue.items))

	ledger.down = false
	n, err := h.Drain(ctx)
	if err != nil || n != 1 || len(queue.items) != 0 || !ledger.applied["p1"] {
		t.Fatalf("drain n=%d err=%v left=%d applied=%v", n, err, len(queue.items), ledger.applied)
	}
	t.Logf("after recovery drain_applied=%d queue=0 applied=%v apply_calls=%d", n, ledger.applied, ledger.applyCalls)
}

运行方式与真实输出(本机单次运行,计数确定、耗时不确定):

$ cd .local/checks/be-arch-ch06/06-3 && go test -v ./... -count=1
=== RUN   TestQueryFallbackContract
    degrade_test.go:40: degraded source=snapshot status=PAID version=5 read_only=true warning="订单状态来自副本快照,可能延迟数秒"
    degrade_test.go:47: default status=UNKNOWN code=OVERLOADED retry_after=2s
--- PASS: TestQueryFallbackContract (0.00s)
=== RUN   TestQueryDoesNotMaskInconsistency
    degrade_test.go:58: snapshot v4 < committed v5 -> STALE_SNAPSHOT: snapshot v4 < committed v5
--- PASS: TestQueryDoesNotMaskInconsistency (0.00s)
=== RUN   TestPayment
    degrade_test.go:118: forged signature code=REJECTED_SIGNATURE queue=0
    degrade_test.go:124: amount mismatch code=AMOUNT_MISMATCH queue=0
    degrade_test.go:132: ledger down -> code=QUEUED queued=true queue=1
    degrade_test.go:139: after recovery drain_applied=1 queue=0 applied=map[p1:true] apply_calls=3
--- PASS: TestPayment (0.00s)
PASS
ok  	archlab/internal/platform/degrade	0.461s

三条测试分别证明:快照和默认值都通过 Validate 且不可写;快照落后时返回 STALE_SNAPSHOT;伪造签名与金额不符在进入队列之前就被拒绝。

支付回调:哪些步骤不能降级

支付回调最容易犯的错,是把“下游不可用”当成“什么都可以先收下”。Handle 的判定顺序是刻意固定的:

步骤失败时能否降级
验签REJECTED_SIGNATURE否
幂等落账已处理返回 OK否
金额核对AMOUNT_MISMATCH否
账本暂时不可用QUEUED 入持久队列是
队列也不可用OVERLOADED拒绝并让上游重试

验签、金额、幂等性决定“这笔钱是不是真的、是不是这笔”,任何一项降级都会把资损风险引进来;只有账本连接失败、超时这类可恢复故障,才允许把回调放进持久队列,稍后由 Drain 补偿。QUEUED 必须同时返回 RetryAfter,并保证入队成功才回 Accepted,否则上游会误以为已处理。

错误码、文案与恢复补偿

错误码给机器,文案给人,补偿动作给值班同学。OK_DEGRADED 和 QUEUED 都是“成功但降级”,HTTP 层仍返回 200,但响应体必须带 degraded、read_only、warning 或 retry_after;OVERLOADED、STALE_SNAPSHOT、AMOUNT_MISMATCH、REJECTED_SIGNATURE 则明确失败。恢复后先跑 Drain 把队列清空,再核对 apply_calls 与幂等结果,确认没有重复入账,最后才撤掉页面上的降级提示。

常见错误与边界

  • 把空对象当兜底:字段缺失的响应会让前端渲染出“金额 0 元”的订单,比直接报错更危险。
  • 用快照覆盖已提交写入:副本延迟期间返回旧状态,用户会看到“已取消”又“已支付”的来回跳变。
  • 对支付回调做“先收后补”:验签和金额核对一旦后置,队列里就会混入伪造或错额请求。
  • 队列不持久:进程重启后 QUEUED 的回调丢失,上游却已经收到 200。
  • 降级不设出口:只写降级条件,不写恢复条件,流量恢复后仍长期停在只读模式。
  • 把限流或熔断的拒绝当成降级成功:CIRCUIT_OPEN 应该快速失败,而不是伪装成默认值。
  • 边界:快照与水位来自不同存储时,比较本身可能读到不一致的一对值;生产上应让二者同源或带版本令牌,并接受“宁可报错,不可装新”的取舍。

小结

降级的目标是“少给一点,但不给错”。订单查询用快照和默认值保住可读性,用 Validate 和只读守卫保住契约;支付回调用固定判定顺序把验签、金额、幂等挡在降级之外,只把可恢复的账本故障放进持久队列。衡量标准不是降级次数,而是降级期间有没有产生错误状态、有没有把不一致当成正常返回。

阅读导航:上一节:6.2 熔断状态机与恢复策略 · 下一节:7.1 超时预算如何在调用链中分配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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