质量属性不是形容词
“高可用”“低延迟”“可扩展”单独出现时没有信息量,因为它们不告诉你:谁在什么条件下、对哪个部件、施加什么刺激、期望看到什么响应、用什么度量判定通过。质量属性只有变成场景才能被设计,也只有变成断言才能被守住。
一个合格的质量属性场景包含六个要素:刺激源、刺激、制品、环境、响应、度量。缺一个,讨论就会滑回口号。比如“支付要快”不是场景;“支付网关在正常负载下响应变慢时,订单服务应在总预算内失败并释放库存,超时请求占比可统计”才是。
订单系统的六个场景
下表是 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 选型矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。