降级链的排序原则
降级不是“出错就返回空”,而是按“保住契约、保住信息量、保住可写性”的顺序逐级退让。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 超时预算如何在调用链中分配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。