《后端架构设计·架构基础与边界》1.2 质量属性与无法回避的取舍

把“高可用、低延迟”还原成六条可测的质量属性场景,逐条落到 arch-lab 的模块与断言;给出状态机不变式和幂等重放的可执行 Go 测试,并用 ADR-lite 记录一次一致性取舍的备选方案、否决理由与重新评估条件,最后列出常见误用边界。

质量属性不是形容词

“高可用”“低延迟”“可扩展”单独出现时没有信息量,因为它们不告诉你:谁在什么条件下、对哪个部件、施加什么刺激、期望看到什么响应、用什么度量判定通过。质量属性只有变成场景才能被设计,也只有变成断言才能被守住。

一个合格的质量属性场景包含六个要素:刺激源、刺激、制品、环境、响应、度量。缺一个,讨论就会滑回口号。比如“支付要快”不是场景;“支付网关在正常负载下响应变慢时,订单服务应在总预算内失败并释放库存,超时请求占比可统计”才是。

订单系统的六个场景

下表是 arch-lab 第一版必须正面回答的六个场景。度量一列写的是目标值或判定条件,不是本机测量值;只有带具体测试名的落点才是本节真正执行过的断言。

编号刺激源刺激制品(落点)环境响应度量
Q1支付网关重复或迟到的成功回调internal/order 状态机正常拒绝非法迁移,返回 INVALID_STATE非法迁移 100% 被拒且聚合零修改(TestStateMachineInvariants)
Q2客户端同 Idempotency-Key 重试创建internal/platform/idempotency正常与并发返回首次结果,不重复下单同键同指纹 handler 执行次数 = 1;同键异指纹 100% 冲突(TestIdempotencyReplayAndConflict)
Q3支付网关响应时间超过阶段预算internal/platform/timeout + 支付端口过载在总预算内失败,取消下游并释放库存请求总耗时不超过预算;超时后无悬挂调用(L2 假时钟测试)
Q4两个运营同时对同一订单取消与支付internal/order + 仓储条件更新并发一个成功,另一个 VERSION_CONFLICT丢失更新 = 0(PostgreSQL 集成测试)
Q5库存服务连接拒绝或熔断打开internal/ports + 库存适配器故障快速失败,订单不进入 INVENTORY_RESERVED失败在 200ms 目标值内返回,连接池不堆积(L2 假实现)
Q6外部用户构造他人的 order_id 查询internal/platform/httpserver 授权中间件正常返回 404,不泄露资源存在性跨客户读取 = 0(接口授权测试)

这张表有两个用途。第一,它逼你把“非功能需求”写成可观测的响应,而不是形容词。第二,它暴露冲突:Q1 的强一致拒绝与 Q3 的快速失败可能打架;Q2 的“返回首次结果”要求存储,Q3 的预算又不允许无限等待。取舍不是表格的副产物,而是表格的目的。

场景必须落到模块或断言

一个场景如果找不到落点,通常有两种情况:要么它其实是另一个场景的重复,要么当前架构根本没有承载它的位置。两种都值得记录,但不应该留在“质量属性清单”里假装已经覆盖。

以 Q4 为例,Version 字段在 1.1 就已经存在,但“丢失更新 = 0”不是靠字段本身证明的。它需要仓储层把 UPDATE ... WHERE id = ? AND version = ? 的影响行数映射成 VERSION_CONFLICT,再用两个并发事务去验证。没有这一步,Version 只是一个装饰字段。

反过来,Q6 的“返回 404 而不是 403”也不是安全策略的全部。它只保证不泄露资源存在性;真正的授权判断仍然要在中间件里完成。场景写清楚了,测试才知道该断言什么。

两个可执行断言

先看状态机。下面的实现把合法边写成数据,测试则独立再写一遍期望的边,并穷举 6×6 个状态对。这样“规格”和“实现”互为校验,不会一起漂移。

// file: internal/order/order.go
// tier: L1
// verify: go test ./internal/quality/ -run TestStateMachineInvariants
package order

import "fmt"

// 本节只保留断言需要的最小字段;完整聚合见 1.1。
type Status string

const (
	StatusCreated           Status = "CREATED"
	StatusInventoryReserved Status = "INVENTORY_RESERVED"
	StatusPaid              Status = "PAID"
	StatusFulfilled         Status = "FULFILLED"
	StatusCancelled         Status = "CANCELLED"
	StatusRefunded          Status = "REFUNDED"
)

type Order struct {
	ID      string
	Status  Status
	Version int64
}

type TransitionError struct {
	From Status
	To   Status
}

func (e *TransitionError) Error() string {
	return fmt.Sprintf("invalid transition: %s -> %s", e.From, e.To)
}

// allowed 用边列表表达,新增一条边只需加一行。
var allowed = [][2]Status{
	{StatusCreated, StatusInventoryReserved},
	{StatusCreated, StatusCancelled},
	{StatusInventoryReserved, StatusPaid},
	{StatusInventoryReserved, StatusCancelled},
	{StatusPaid, StatusFulfilled},
	{StatusPaid, StatusRefunded},
}

func CanTransition(from, to Status) bool {
	for _, edge := range allowed {
		if edge[0] == from && edge[1] == to {
			return true
		}
	}
	return false
}

func (o *Order) TransitionTo(next Status) error {
	if !CanTransition(o.Status, next) {
		return &TransitionError{From: o.Status, To: next}
	}
	o.Status = next
	o.Version++
	return nil
}

第二个断言针对幂等。注意它缓存的是“成功结果”,失败不落库:业务失败通常意味着请求本身需要修改,允许客户端换参数重试;而网络超时导致的失败,应该由调用方用同一个键重试,而不是让服务端把一次失败永久钉死。

// file: internal/platform/idempotency/idempotency.go
// tier: L1
// verify: go test ./internal/quality/ -run TestIdempotencyReplayAndConflict
package idempotency

import (
	"errors"
	"sync"
)

var (
	ErrEmptyKey = errors.New("idempotency: key and fingerprint are required")
	ErrConflict = errors.New("idempotency: key reused with a different request")
)

type record struct {
	fingerprint string
	body        string
}

// Store 把“同 key + 同请求指纹”折叠成一次执行;失败不落库,允许重试。
type Store struct {
	mu   sync.Mutex
	seen map[string]record
}

func NewStore() *Store {
	return &Store{seen: make(map[string]record)}
}

// Do 返回 (结果, 是否命中重放, 错误)。并发同 key 时先落库者胜出。
func (s *Store) Do(key, fingerprint string, fn func() (string, error)) (string, bool, error) {
	if key == "" || fingerprint == "" {
		return "", false, ErrEmptyKey
	}
	s.mu.Lock()
	existing, found := s.seen[key]
	s.mu.Unlock()
	if found {
		if existing.fingerprint != fingerprint {
			return "", false, ErrConflict
		}
		return existing.body, true, nil
	}

	body, err := fn()
	if err != nil {
		return "", false, err
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	if existing, found := s.seen[key]; found {
		if existing.fingerprint != fingerprint {
			return "", false, ErrConflict
		}
		return existing.body, true, nil
	}
	s.seen[key] = record{fingerprint: fingerprint, body: body}
	return body, false, nil
}

断言本身放在 internal/quality,因为它同时依赖订单与幂等两个包,放在任一实现包里都会制造反向依赖:

// file: internal/quality/quality_test.go
// tier: L1
// verify: go test ./internal/quality/ -v
package quality_test

import (
	"errors"
	"testing"

	"archlab/internal/order"
	"archlab/internal/platform/idempotency"
)

// 场景 1:状态机边与规格一致,终态无出边,非法流转不改聚合。
func TestStateMachineInvariants(t *testing.T) {
	legal := map[[2]order.Status]bool{
		{order.StatusCreated, order.StatusInventoryReserved}:   true,
		{order.StatusCreated, order.StatusCancelled}:           true,
		{order.StatusInventoryReserved, order.StatusPaid}:      true,
		{order.StatusInventoryReserved, order.StatusCancelled}: true,
		{order.StatusPaid, order.StatusFulfilled}:              true,
		{order.StatusPaid, order.StatusRefunded}:               true,
	}
	statuses := []order.Status{
		order.StatusCreated, order.StatusInventoryReserved, order.StatusPaid,
		order.StatusFulfilled, order.StatusCancelled, order.StatusRefunded,
	}
	terminal := map[order.Status]bool{
		order.StatusFulfilled: true, order.StatusCancelled: true, order.StatusRefunded: true,
	}

	for _, from := range statuses {
		exits := 0
		for _, to := range statuses {
			if got, want := order.CanTransition(from, to), legal[[2]order.Status{from, to}]; got != want {
				t.Errorf("CanTransition(%s, %s) = %v, want %v", from, to, got, want)
			}
			if order.CanTransition(from, to) {
				exits++
			}
		}
		if got, want := exits == 0, terminal[from]; got != want {
			t.Errorf("terminal(%s) = %v, want %v", from, got, want)
		}
	}

	o := &order.Order{ID: "ord-1", Status: order.StatusFulfilled, Version: 7}
	var transitionErr *order.TransitionError
	if !errors.As(o.TransitionTo(order.StatusPaid), &transitionErr) {
		t.Fatal("illegal transition did not return TransitionError")
	}
	if o.Status != order.StatusFulfilled || o.Version != 7 {
		t.Fatalf("illegal transition mutated aggregate: %s/%d", o.Status, o.Version)
	}
}

// 场景 2:同键同指纹只执行一次;同键不同指纹必须报冲突。
func TestIdempotencyReplayAndConflict(t *testing.T) {
	store := idempotency.NewStore()
	calls := 0
	create := func() (string, error) { calls++; return `{"id":"ord-1"}`, nil }

	first, replay, err := store.Do("key-1", "sha256:aaa", create)
	if err != nil || replay || calls != 1 {
		t.Fatalf("first = (%q, %v, %v), calls=%d", first, replay, err, calls)
	}
	second, replay, err := store.Do("key-1", "sha256:aaa", create)
	if err != nil || !replay || calls != 1 || second != first {
		t.Fatalf("replay = (%q, %v, %v), calls=%d", second, replay, err, calls)
	}
	if _, _, err := store.Do("key-1", "sha256:bbb", create); !errors.Is(err, idempotency.ErrConflict) {
		t.Fatalf("conflict = %v, want ErrConflict", err)
	}
	if _, _, err := store.Do("", "sha256:aaa", create); !errors.Is(err, idempotency.ErrEmptyKey) {
		t.Fatalf("empty key = %v, want ErrEmptyKey", err)
	}
}

两个测试都只依赖标准库和项目内包,命令是 go test ./internal/quality/ -v。它们不能证明系统“高质量”,只能证明两条具体约束在被破坏时会立刻报警。质量属性的工程价值,恰恰来自这种局部的、可重复的报警。

ADR-lite:把取舍写下来

取舍如果不落文档,三个月后只剩“当时就是这么定的”。ADR-lite 不追求长篇,只要求写清五件事:

ADR-<编号>:<一句话决策>
状态:提议 / 已接受 / 已废弃(被哪条取代)
背景:什么约束逼出了这个决策(2–3 句)
决策:选了什么,边界在哪
备选:A 方案与否决理由;B 方案与否决理由
后果:正面、负面、重新评估的触发条件

下面用“一致性 vs 可用性”填一个真实取舍。arch-lab 的下单路径必须在“超卖”和“下单失败”之间选一个默认答案。

ADR-002:下单采用同步库存预留,而不是下单后异步补偿
状态:已接受(卷一)
背景:订单创建后用户立即进入支付;超卖的赔付与客诉成本高于短暂下单失败。
      当前没有成熟的对账任务,也没有跨服务的补偿事务框架。
决策:在创建订单的请求内同步调用库存预留;预留成功才把订单置为
      INVENTORY_RESERVED;预留超时 90 秒未支付则自动释放(示例参数)。
备选 A:事件驱动最终一致——订单先创建成功,异步预留,失败再取消订单。
      否决理由:用户可能已完成支付才发现无货,退款链路与客诉成本更高;
      异步补偿需要幂等消费与对账,卷一还不具备。
备选 B:预扣库存表 + 定时对账——先写本地预扣记录,后台对齐库存服务。
      否决理由:复杂度接近同步预留,却把不一致窗口留给了用户可见路径。
后果:正面——超卖在同步路径被挡住,状态与库存同成败;
      负面——库存服务故障时下单不可用,下单延迟受库存服务 P99 影响;
      重新评估触发条件——库存服务达到约定 SLA,且补偿对账连续运行一个季度无差异。

这段 ADR 没有给出“最优解”,只给出“在当前约束下的默认解”。如果库存服务是跨机房调用,或者大促期间允许少量超卖换取下单成功率,结论可能反过来。架构决策的质量不在于选对一次,而在于选错时知道从哪里改回来。

常见错误与边界

第一,把质量属性写成“系统要稳定”。没有刺激源和度量的“稳定”无法验收,也无法在两种方案之间比较。

第二,只写响应不写度量。“拒绝非法迁移”是响应,“100% 拒绝且聚合零修改”才是可判定的度量。

第三,把目标值当实测值。表中的 200ms、90 秒都是决策参数或 SLO 目标,不是本机跑出来的数字;真正测量必须附命令、机器和原始输出。

第四,ADR 只写结论不写备选。没有否决理由,后人无法判断当时的约束是否仍然成立。

第五,用集成测试替代单元断言。状态机不变式、幂等键冲突这类逻辑,应该在毫秒级单测里穷举;集成测试留给版本冲突和真实事务。

第六,忽略取舍的连锁反应。同步预留保住了库存一致,却把库存服务变成了下单路径的可用性单点;这个代价必须写进 ADR 的“负面”一栏,而不是等故障时才发现。

小结

质量属性只有写成“刺激源—刺激—制品—环境—响应—度量”才算需求,只有落到模块或断言才算设计。本节把订单系统的六个场景钉在 arch-lab 的具体包上,并用状态机不变式与幂等重放两个测试证明:约束可以被执行,也可以被破坏时报警。至于这些场景背后的风格选择——单体、模块化单体还是微服务——下一节用一张固定维度的矩阵来比较。

阅读导航:上一节:1.1 后端架构到底在解决什么问题 · 下一节:1.3 架构风格谱系与 arch-lab 选型矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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