《后端架构设计·架构基础与边界》1.1 后端架构到底在解决什么问题

从 arch-lab 订单系统一个把所有逻辑塞进 HTTP handler 的朴素实现出发,逐条推演它在状态、并发、故障、变更、观测与安全上付出的代价,再给出第一版 Go 领域模型与显式状态机,说明架构为何是一组可验证、可回退的约束与决策边界。

先看一版能跑的订单实现

arch-lab 是本书贯穿始终的订单交易系统:创建订单、预留库存、支付、履约、取消和退款。第一版不追求架构,只追求“今天能演示”。整个服务不到 200 行,一个 map 存订单,一个 sync.Mutex 防并发,HTTP handler 里直接改状态。

下面是最核心的两个 handler,省略了路由注册和启动代码:

// file: internal/adapters/httpapi/naive_handler.go
// tier: L1
// verify: go vet ./internal/adapters/httpapi/
package httpapi

import (
	"encoding/json"
	"fmt"
	"net/http"
	"sync"
	"time"
)

// naiveOrder 把状态存成裸字符串,金额按 SKU 个数推算。
type naiveOrder struct {
	ID         string    `json:"id"`
	CustomerID string    `json:"customer_id"`
	Status     string    `json:"status"`
	SKUs       []string  `json:"skus"`
	TotalCents int64     `json:"total_cents"`
	CreatedAt  time.Time `json:"created_at"`
}

type NaiveStore struct {
	mu     sync.Mutex
	orders map[string]*naiveOrder
	seq    int
}

func NewNaiveStore() *NaiveStore {
	return &NaiveStore{orders: make(map[string]*naiveOrder)}
}

func (s *NaiveStore) Create(w http.ResponseWriter, r *http.Request) {
	var req struct {
		CustomerID string   `json:"customer_id"`
		SKUs       []string `json:"skus"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.seq++
	o := &naiveOrder{
		ID:         fmt.Sprintf("ord-%d", s.seq),
		CustomerID: req.CustomerID,
		Status:     "CREATED",
		SKUs:       req.SKUs,
		TotalCents: int64(len(req.SKUs)) * 100,
		CreatedAt:  time.Now(),
	}
	s.orders[o.ID] = o
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(o)
}

func (s *NaiveStore) Pay(w http.ResponseWriter, r *http.Request) {
	s.mu.Lock()
	defer s.mu.Unlock()
	o, ok := s.orders[r.PathValue("id")]
	if !ok {
		http.NotFound(w, r)
		return
	}
	o.Status = "PAID" // 不校验当前状态,重复支付也会成功
	_ = json.NewEncoder(w).Encode(o)
}

这段代码有三个典型特征:状态是字符串、规则写在 handler 里、存储与协议共用同一个结构体。它没有“错”,只是把六类问题延后到了第一次真实流量。

六处代价

代价朴素实现里的表现第一次真实流量时的后果架构回应
状态Status 是裸字符串,没有合法边已取消订单可再次支付状态机 + 显式错误码
并发锁保护 map,不保护业务不变量两人同时取消与支付,后写覆盖先写版本号 + 条件更新
故障没有超时、重试、降级边界一次慢调用占满连接与内存超时预算 + 端口隔离
变更HTTP 请求体就是领域模型加一个字段就改三处领域模型与 DTO 分离
观测日志只有状态码无法回答“这单卡在哪一步”结构化事件 + 订单时间线
安全按 id 直接查,不校验归属改一个 URL 就能看别人的订单授权边界 + 不可枚举 ID

状态:字符串没有边

o.Status = "PAID" 这一行可以在任何时刻执行。已取消的订单能支付,已退款的订单能再次履约,因为代码里根本没有“哪些迁移合法”的概念。数据库里也存不下这个知识:它只看到一个字符串,不知道 CREATED 的下一个合法值是 INVENTORY_RESERVED 或 CANCELLED。

并发:锁保护的是 map,不是不变量

sync.Mutex 只保证 orders 这个 map 不被并发写坏。它不保证“库存预留成功”和“订单状态变为已预留”这两件事一起发生,也不保证支付回调和取消请求不会交错。两个请求各自读到 CREATED,各自通过自己那段 if 判断,最后谁后写谁生效,中间那次状态迁移凭空消失。

故障:没有超时,就没有故障边界

handler 里直接调用库存和支付时,一个慢下游会让 goroutine 一直挂着。HTTP 服务器不会替你设业务超时;连接数、内存和上游重试会一起放大问题。更糟的是,调用方无法区分“支付没发生”和“支付发生了但响应丢了”,于是重试变成重复扣款。

变更、观测与安全

变更的代价在第二周就会显现:前端要加 currency,你要同时改 JSON 结构、内存结构和响应;运营要加“已履约”筛选,只能在 handler 里再补一层 if。观测的代价更隐蔽:出问题时你只有访问日志,没有“订单从创建到支付经历了哪些状态、每步耗时多少”的时间线。安全的代价最直接:GET /orders/{id} 只按 ID 查,没有校验 CustomerID 是否属于当前调用者。

这六处代价不是六个独立缺陷,它们共同指向一件事:代码里没有一处地方能回答“这个系统允许发生什么”。

第一版领域模型

第一版重构不拆服务、不引入框架,只做一件事:把订单的状态和迁移规则收进 internal/order,让 HTTP 层只能通过方法改状态。

// file: internal/order/order.go
// tier: L1
// verify: go test ./internal/order/
package order

import (
	"fmt"
	"time"
)

// Status 用字符串而不是 int:日志、数据库、API 三处看到同一个值。
type Status string

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

type Code string

const (
	CodeInvalidState        Code = "INVALID_STATE"
	CodeOutOfStock          Code = "OUT_OF_STOCK"
	CodePaymentFailed       Code = "PAYMENT_FAILED"
	CodeVersionConflict     Code = "VERSION_CONFLICT"
	CodeIdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
)

// Error 带上被拒绝的迁移边,日志不需要再拼业务上下文。
type Error struct {
	Code Code
	Op   string
	From Status
	To   Status
}

func (e *Error) Error() string {
	if e.From == "" {
		return fmt.Sprintf("%s: %s", e.Code, e.Op)
	}
	return fmt.Sprintf("%s: %s (%s -> %s)", e.Code, e.Op, e.From, e.To)
}

type OrderItem struct {
	SKU            string
	Quantity       int
	UnitPriceCents int64
}

type Order struct {
	ID         string
	CustomerID string
	Status     Status
	Items      []OrderItem
	TotalCents int64
	Currency   string
	Version    int64
	CreatedAt  time.Time
	UpdatedAt  time.Time
}

// New 只负责“创建即 CREATED”,不碰库存和支付。
func New(id, customerID, currency string, items []OrderItem, now time.Time) (*Order, error) {
	if id == "" || customerID == "" || currency == "" || len(items) == 0 {
		return nil, &Error{Code: CodeInvalidState, Op: "order.New: missing required field"}
	}
	var total int64
	for _, item := range items {
		if item.SKU == "" || item.Quantity <= 0 || item.UnitPriceCents < 0 {
			return nil, &Error{Code: CodeInvalidState, Op: "order.New: invalid item " + item.SKU}
		}
		total += int64(item.Quantity) * item.UnitPriceCents
	}
	return &Order{
		ID: id, CustomerID: customerID, Status: StatusCreated,
		Items: append([]OrderItem(nil), items...), TotalCents: total,
		Currency: currency, Version: 1, CreatedAt: now, UpdatedAt: now,
	}, nil
}

// allowed 是状态机的唯一事实来源。
var allowed = map[Status][]Status{
	StatusCreated:           {StatusInventoryReserved, StatusCancelled},
	StatusInventoryReserved: {StatusPaid, StatusCancelled},
	StatusPaid:              {StatusFulfilled, StatusRefunded},
	StatusFulfilled:         {},
	StatusCancelled:         {},
	StatusRefunded:          {},
}

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

// TransitionTo 是唯一的写状态入口:判合法、改状态、升版本一次完成。
func (o *Order) TransitionTo(next Status, now time.Time) error {
	if !CanTransition(o.Status, next) {
		return &Error{Code: CodeInvalidState, Op: "order.TransitionTo", From: o.Status, To: next}
	}
	o.Status = next
	o.Version++
	o.UpdatedAt = now
	return nil
}

Version 从第一版就存在,因为并发问题不能等到接数据库再补。Error.Code 也不是装饰:HTTP 层要把 INVALID_STATE 映射成 409,把 OUT_OF_STOCK 映射成 409 或 422,把 VERSION_CONFLICT 映射成 409 并允许重试。调用方按码分支,不按错误文本分支。

状态机:把“允许”写成数据

状态机不是一张画给评审看的图。它必须是一张表,能被代码读取、被测试穷举。上面 allowed 就是这张表;TransitionTo 是唯一的写入口;测试则独立再写一遍期望的边,防止实现和测试一起漂移。

// file: internal/order/order_test.go
// tier: L1
// verify: go test ./internal/order/ -run TestTransitionTo -v
package order

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

// 期望的边独立于实现再写一遍;实现被误改时测试不会跟着漂移。
func TestTransitionTo(t *testing.T) {
	legal := map[[2]Status]bool{
		{StatusCreated, StatusInventoryReserved}:   true,
		{StatusCreated, StatusCancelled}:           true,
		{StatusInventoryReserved, StatusPaid}:      true,
		{StatusInventoryReserved, StatusCancelled}: true,
		{StatusPaid, StatusFulfilled}:              true,
		{StatusPaid, StatusRefunded}:               true,
	}
	statuses := []Status{
		StatusCreated, StatusInventoryReserved, StatusPaid,
		StatusFulfilled, StatusCancelled, StatusRefunded,
	}
	now := time.Unix(1_700_000_000, 0)

	for _, from := range statuses {
		for _, to := range statuses {
			order := &Order{ID: "ord-1", Status: from, Version: 3}
			err := order.TransitionTo(to, now)
			want := legal[[2]Status{from, to}]

			if got := err == nil; got != want {
				t.Fatalf("%s -> %s: error = %v, want legal = %v", from, to, err, want)
			}
			if want {
				if order.Status != to || order.Version != 4 || !order.UpdatedAt.Equal(now) {
					t.Fatalf("%s -> %s: state not committed: %+v", from, to, order)
				}
				continue
			}
			var domainErr *Error
			if !errors.As(err, &domainErr) || domainErr.Code != CodeInvalidState {
				t.Fatalf("%s -> %s: error = %v, want INVALID_STATE", from, to, err)
			}
			if order.Status != from || order.Version != 3 {
				t.Fatalf("%s -> %s: illegal transition mutated order", from, to)
			}
		}
	}
}

func TestNewComputesTotal(t *testing.T) {
	o, err := New("ord-1", "cus-1", "CNY", []OrderItem{
		{SKU: "sku-a", Quantity: 2, UnitPriceCents: 1999},
		{SKU: "sku-b", Quantity: 1, UnitPriceCents: 500},
	}, time.Unix(1_700_000_000, 0))
	if err != nil {
		t.Fatal(err)
	}
	if o.TotalCents != 4498 || o.Status != StatusCreated || o.Version != 1 {
		t.Fatalf("order = %+v", o)
	}
	if _, err := New("ord-2", "cus-1", "CNY", []OrderItem{{SKU: "sku-a", Quantity: 0}}, time.Now()); err == nil {
		t.Fatal("zero quantity accepted")
	}
}

这个测试穷举 6×6 个状态对,既验证合法边能提交,也验证非法边不修改 Status 和 Version。它的价值不在覆盖率,而在把“状态图”从口头约定变成可执行断言:以后有人加了 PAID -> CANCELLED,测试会立刻指出这与规格不符。

架构不是图,是一组可验证的约束

走到这里可以回答标题的问题了。后端架构要解决的不是“怎么画分层图”,而是在业务、流量和团队同时变化时,让系统仍然能做出可解释的决策。把这个目标翻译成 arch-lab 的第一组约束:

  • C1:订单状态只能通过 TransitionTo 修改,非法迁移返回 INVALID_STATE。
  • C2:每次状态变更必须递增 Version,持久化层用版本做条件更新。
  • C3:HTTP 层不直接操作 Order 字段,只调用领域方法并映射错误码。
  • C4:所有外部依赖调用必须带截止时间,超时预算在入口处分配。
  • C5:任何一条约束都要有对应的测试或检查命令;没有检查的约束只是愿望。

这五条约束有三个共同点:可验证、可回退、有明确的失效信号。可验证意味着能用 go test 或静态检查证明;可回退意味着决策记录了备选方案和重新评估的触发条件;失效信号意味着约束被打破时有人会知道,而不是等事故复盘。

架构决策也不是“一旦选定就永远正确”。第一版选择内存存储、同步调用和模块化单体,是因为当前只有一个团队、一个部署单元、还没有跨服务的强一致需求。等到库存服务必须独立扩缩容,或者支付团队要求独立发布,这些约束会被重新评估,而不是被当成信仰。

与站内文章的分工

站内的 架构设计基础原则 已经把 SOLID、高内聚低耦合、CAP/BASE 这些概念讲清楚了,本节不再重复定义。它回答“有哪些原则”,本节回答“这些原则在 arch-lab 的哪一行代码、哪一条断言上生效”。

架构模式与设计模式 区分了架构模式与设计模式的层次,并盘点了分层、管道-过滤器、微内核、事件驱动等模式。本节不重复那张清单,只把“模式”还原成选型维度:团队规模、独立部署、数据一致性、故障域、演进成本和运维复杂度,具体矩阵放在 1.3。

常见错误与边界

第一,把状态机写成 switch。switch 能判合法,但不能被测试穷举、不能被文档生成、不能告诉调用方“哪条边被拒绝”。表驱动才有这些能力。

第二,只改 Status 不改 Version。没有版本号,乐观锁无从谈起;两个并发请求都“成功”,实际是一次覆盖。

第三,让 HTTP DTO 直接复用领域结构体。JSON 字段一旦和聚合字段绑定,改 API 就会改领域,改领域就会破 API。

第四,把错误码当日志文案。Code 是契约,文本不是;调用方按文本匹配,翻译一次就全坏。

第五,以为领域模型能解决分布式一致性。它只能保证单进程内的不变量;跨库存、支付、消息的一致性要靠 1.2 的取舍和第 7 章之后的事务边界。

第六,在 New 里做库存校验。构造只回答“这个订单对象是否合法”,库存是另一个聚合的事实,失败应返回 OUT_OF_STOCK,而不是让订单创建失败得含糊其辞。

小结

后端架构要解决的是“变化时仍能做出可解释决策”的问题。朴素 handler 的六处代价——状态、并发、故障、变更、观测、安全——本质上是同一件事:规则没有落点。第一版 arch-lab 用显式 Status、表驱动 allowed、唯一写入口 TransitionTo 和独立期望表的测试,把状态规则变成了可执行约束。下一节把这些约束扩展成可测的质量属性场景,并正面处理它们之间的冲突。

阅读导航:上一节:目录 · 下一节:1.2 质量属性与无法回避的取舍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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