《后端架构设计·架构基础与边界》6.1 订单数据该放什么存储:所有权与存储选型 ADR

先确定 orders 与 order_items 的唯一写 owner,再按点查、客户列表、运营筛选与状态迁移四类读写模式筛选存储;给出选择 PostgreSQL 的 ADR、MySQL 作为等价备选以及 DynamoDB、文档库被否决的具体理由,并用仓储契约与内存实现测试锁定版本冲突、过滤与隔离拷贝语义,最后说明触发重审的量化条件。

先定写入口,再谈数据库

上一节把错误模型和版本兼容讲完了,这一节换一个更底层的问题:订单数据到底归谁写、放在哪里。很多团队一上来就比性能参数,最后选出一个“最快”的库,却在半年后发现三个服务都能改 orders.status,没人说得清哪次状态迁移是合法的。存储选型的第一约束不是 QPS,而是所有权。

站内 分布式数据一致性 讨论的是跨服务一致性协议;本节不重复 CAP、Saga 的推导,只把 arch-lab 的范围缩到一张订单表:谁持有写连接,哪些读路径必须走它,什么情况下才允许引入第二个存储。

先给结论:orders 和 order_items 由订单服务 orderd 独占写入,其他上下文只能通过订单 API 或领域事件读取。这个约束先于数据库选型成立,选型只是在满足约束的候选里挑一个运维成本最低的。

所有权清单

arch-lab 卷一有五组状态,每组只有一个写 owner。下面这张表是评审时要签字的那张,不是示意图:

数据唯一写 owner其他上下文如何读事实源
ordersorderd订单 API、order.* 事件PostgreSQL
order_itemsorderd随订单聚合返回PostgreSQL
inventory_reservationsinventoryinventory.* 事件PostgreSQL
paymentspaymentpayment.* 事件PostgreSQL
outbox_messages写入方自己投递器按 topic 消费PostgreSQL

orders 与 order_items 必须同 owner,因为总价、行项目和状态迁移在同一个聚合里。把 order_items 拆给“商品服务”写,等于让两个服务同时维护一张订单的完整性,任何跨服务事务都补不回来。

读侧不受这条限制:报表、搜索、客服后台都可以有自己的副本,但副本是派生数据,不能反向写回。读副本延迟、投影失败、缓存穿透,都是读侧要自己承担的问题,不是把写权限借出去的理由。

读写模式决定索引,而不是反过来

先写清楚访问路径,再谈存储能力。arch-lab 的订单端点只有四类访问:

访问频率假设条件返回
详情点查最高id单订单 + 全部行项目
客户列表高customer_id + 可选 status按创建时间倒序分页
运营列表中status + 时间窗按创建时间倒序分页
状态迁移低id + 当前版本单行更新

这里的频率是设计假设,不是压测结果。点查走主键;客户列表需要 (customer_id, created_at DESC, id) 复合索引;运营列表需要 (status, created_at DESC, id)。行项目按 (order_id, sku) 聚簇读取,一次订单详情只发一条范围查询。

写入模式更关键:创建订单是一个多行事务,插入 orders 一行、order_items N 行;状态迁移是单行更新,但必须带版本号。一个订单的生命周期通常只有三到五次写,读却可能几十次。读多写少、单聚合多行事务、按二级索引分页,这三个特征直接淘汰了一批“高写入吞吐”的存储。

存储选型 ADR

ADR-006:orders 与 order_items 以 PostgreSQL 为唯一事实源

  • 状态:接受
  • 决策日期:2026-10-08
  • 决策者:订单域 owner、平台组
  • 约束:单聚合多行事务、乐观锁版本、按客户/状态分页、outbox 与状态同事务提交
候选能满足的部分否决或保留的理由
PostgreSQL 18多行 ACID、唯一约束、部分索引、JSONB、逻辑复制选用;团队已有备份、升级、慢查询治理经验
MySQL 8 InnoDB多行 ACID、成熟复制等价备选;否决点是部分索引与 JSONB 运维经验不足,不是能力缺失
DynamoDB单表高吞吐、按 key 扩展跨行事务上限与运营侧 ad-hoc 查询成本高;订单列表需要多个二级索引
MongoDB文档聚合读写自然跨文档事务与唯一约束的组合不如关系库直观;报表仍需第二套管道
事件存储作为唯一事实源审计完整、天然投影卷一还不需要重放全部历史;点查与运营查询要额外读模型,团队尚无运维经验
Redis低延迟点查持久化与事务边界不适合作为事实源;只能做缓存

后果也要写清楚:正面是订单状态与 outbox 可以在同一个本地事务里提交,版本冲突由 UPDATE ... WHERE version = ? 天然串行化;负面是所有订单读流量都压在同一个主库族上,列表分页在数据量过亿后需要归档或读模型。重审条件:单表超过 5 亿行且列表 P95 连续两周超过 300ms,或者订单写入需要跨区域多活,或者出现必须跨聚合的强一致事务;触发任意一条就重新开 ADR,而不是在代码里悄悄加第二个库。

订单聚合与仓储契约

订单聚合本身不依赖任何端口。Order 与 OrderItem 定在 internal/order,行项目切片在跨层传递时统一走 Clone:

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

import "time"

type Status string

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

type Item struct {
	SKU            string
	Quantity       int
	UnitPriceCents int64
}

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

// Clone 隔离调用方与仓储内部状态;契约测试会验证切片没有被共享。
func (o Order) Clone() Order {
	o.Items = append([]Item(nil), o.Items...)
	return o
}

所有权要落到代码,下一步是把“唯一写入口”变成接口。端口只暴露订单服务需要的能力,不暴露 SQL、事务或连接池:

// file: internal/ports/order_repository.go
// tier: L1
// verify: go test ./internal/contracttest/
package ports

import (
	"context"
	"errors"

	"archlab/internal/order"
)

var (
	ErrNotFound = errors.New("order not found")
	ErrConflict = errors.New("order version conflict")
)

type OrderQuery struct {
	CustomerID string
	Status     order.Status
	Limit      int
}

// OrderRepository 是 orders/order_items 的唯一写入口。
// 其他上下文只能通过消息或查询接口读取,不能持有写连接。
type OrderRepository interface {
	Get(ctx context.Context, id string) (order.Order, error)
	List(ctx context.Context, q OrderQuery) ([]order.Order, error)
	Save(ctx context.Context, o order.Order, expectedVersion int) (order.Order, error)
}

expectedVersion 为 0 表示创建,非 0 表示“只有当前版本等于它才允许更新”。这个参数不是可有可无的乐观锁装饰:没有它,两个并发的取消与支付请求会互相覆盖,最后的状态取决于谁后提交。错误分类也只有两类对外可见:对象不存在、版本冲突;数据库连接失败属于基础设施错误,由适配器包装后交给上层重试策略。

List 的 Limit 必须有上限。arch-lab 把它封在 200,超过就截断而不是报错;分页游标留给后续章节,本节只验证过滤与排序的确定性。

内存实现

内存适配器不是“测试替身”这么简单,它是契约的第一个可执行定义。它必须处理并发、值拷贝和版本推进:

// file: internal/adapters/memory/order_repo.go
// tier: L1
// verify: go test ./internal/contracttest/
package memory

import (
	"context"
	"fmt"
	"sort"
	"sync"

	"archlab/internal/order"
	"archlab/internal/ports"
)

type OrderRepo struct {
	mu     sync.RWMutex
	orders map[string]order.Order
}

func NewOrderRepo() *OrderRepo {
	return &OrderRepo{orders: make(map[string]order.Order)}
}

func (r *OrderRepo) Get(ctx context.Context, id string) (order.Order, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	o, ok := r.orders[id]
	if !ok {
		return order.Order{}, fmt.Errorf("%w: %s", ports.ErrNotFound, id)
	}
	return o.Clone(), nil
}

func (r *OrderRepo) List(ctx context.Context, q ports.OrderQuery) ([]order.Order, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	limit := q.Limit
	if limit <= 0 {
		limit = 50
	}
	if limit > 200 {
		limit = 200
	}
	out := make([]order.Order, 0, limit)
	for _, o := range r.orders {
		if q.CustomerID != "" && o.CustomerID != q.CustomerID {
			continue
		}
		if q.Status != "" && o.Status != q.Status {
			continue
		}
		out = append(out, o.Clone())
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].CreatedAt.Equal(out[j].CreatedAt) {
			return out[i].ID < out[j].ID
		}
		return out[i].CreatedAt.After(out[j].CreatedAt)
	})
	if len(out) > limit {
		out = out[:limit]
	}
	return out, nil
}

func (r *OrderRepo) Save(ctx context.Context, o order.Order, expectedVersion int) (order.Order, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	current, exists := r.orders[o.ID]
	if !exists {
		if expectedVersion != 0 {
			return order.Order{}, fmt.Errorf("%w: %s missing", ports.ErrNotFound, o.ID)
		}
		stored := o.Clone()
		stored.Version = 1
		r.orders[o.ID] = stored
		return stored.Clone(), nil
	}
	if current.Version != expectedVersion {
		return order.Order{}, fmt.Errorf("%w: %s at version %d, expected %d",
			ports.ErrConflict, o.ID, current.Version, expectedVersion)
	}
	stored := o.Clone()
	stored.Version = current.Version + 1
	r.orders[o.ID] = stored
	return stored.Clone(), nil
}

Clone 在 Get、List、Save 三处都要调用。只要有一处把 map 里的切片直接交出去,调用方改一下 Items[0].Quantity 就绕过了 Save,所有权形同虚设。排序键必须是稳定的:CreatedAt 相同时按 ID 升序,否则分页会在同一秒创建的多笔订单上抖动。

契约测试

契约测试不关心存储是内存还是 PostgreSQL,只断言端口语义。同一套断言将来要跑在真实适配器上:

// file: internal/contracttest/order_repo_test.go
// tier: L1
// verify: go test ./internal/contracttest/
package contracttest

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

	"archlab/internal/adapters/memory"
	"archlab/internal/order"
	"archlab/internal/ports"
)

func newOrder(id, customer string, createdAt time.Time) order.Order {
	return order.Order{
		ID: id, CustomerID: customer, Status: order.StatusCreated,
		Items: []order.Item{
			{SKU: "sku-1", Quantity: 2, UnitPriceCents: 500},
		},
		TotalCents: 1000, Currency: "CNY", CreatedAt: createdAt,
	}
}

func TestOrderRepositoryContract(t *testing.T) {
	ctx := context.Background()
	repo := memory.NewOrderRepo()
	t0 := time.Date(2026, 10, 8, 8, 0, 0, 0, time.UTC)

	t.Run("GetMissingReturnsNotFound", func(t *testing.T) {
		if _, err := repo.Get(ctx, "missing"); !errors.Is(err, ports.ErrNotFound) {
			t.Fatalf("err = %v, want ErrNotFound", err)
		}
	})

	saved, err := repo.Save(ctx, newOrder("o-1", "c-1", t0), 0)
	if err != nil || saved.Version != 1 {
		t.Fatalf("create: saved=%+v err=%v", saved, err)
	}

	t.Run("StaleVersionReturnsConflict", func(t *testing.T) {
		if _, err := repo.Save(ctx, newOrder("o-1", "c-1", t0), 99); !errors.Is(err, ports.ErrConflict) {
			t.Fatalf("err = %v, want ErrConflict", err)
		}
	})

	t.Run("ListFiltersCustomerStatusAndLimit", func(t *testing.T) {
		if _, err := repo.Save(ctx, newOrder("o-2", "c-1", t0.Add(time.Minute)), 0); err != nil {
			t.Fatal(err)
		}
		paid := newOrder("o-3", "c-2", t0.Add(2*time.Minute))
		paid.Status = order.StatusPaid
		if _, err := repo.Save(ctx, paid, 0); err != nil {
			t.Fatal(err)
		}
		got, err := repo.List(ctx, ports.OrderQuery{CustomerID: "c-1"})
		if err != nil {
			t.Fatal(err)
		}
		if len(got) != 2 || got[0].ID != "o-2" {
			t.Fatalf("customer filter: %+v", got)
		}
		got, err = repo.List(ctx, ports.OrderQuery{Status: order.StatusPaid, Limit: 1})
		if err != nil || len(got) != 1 || got[0].ID != "o-3" {
			t.Fatalf("status filter: %+v err=%v", got, err)
		}
	})

	t.Run("GetReturnsIsolatedCopy", func(t *testing.T) {
		got, err := repo.Get(ctx, "o-1")
		if err != nil {
			t.Fatal(err)
		}
		got.Items[0].Quantity = 99
		again, err := repo.Get(ctx, "o-1")
		if err != nil {
			t.Fatal(err)
		}
		if again.Items[0].Quantity != 2 {
			t.Fatalf("stored items mutated: %+v", again.Items)
		}
	})
}

这四个断言分别对应所有权的四种泄漏:不存在的对象被当成空订单、旧版本覆盖新版本、列表过滤器失效、返回值与内部状态共享内存。将来 PostgreSQL 适配器也要跑同一组断言,只是把内存仓库换成一个真实连接。

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),代码抽取到 .local/checks/ch06-be-arch-base-06-1-data-ownership-storage-adr/ 的独立模块执行:

$ gofmt -l .
$ go vet ./...
$ go test ./... -count=1 -v
=== RUN   TestOrderRepositoryContract
=== RUN   TestOrderRepositoryContract/GetMissingReturnsNotFound
=== RUN   TestOrderRepositoryContract/StaleVersionReturnsConflict
=== RUN   TestOrderRepositoryContract/ListFiltersCustomerStatusAndLimit
=== RUN   TestOrderRepositoryContract/GetReturnsIsolatedCopy
--- PASS: TestOrderRepositoryContract (0.00s)
PASS
ok  	archlab/internal/contracttest	0.247s

gofmt -l . 没有输出,go vet ./... 没有告警。这里没有连接 PostgreSQL,因此不能把 ADR 里的索引与部分索引写成“已验证”;它们属于 L3,需要用真实执行计划确认。

常见错误与边界

第一,让多个服务直连同一个 orders 库。只要两个服务都能写,所有权的讨论就失效了;正确做法是把写操作收口到订单 API,其他服务通过事件或查询接口读取。

第二,把读副本当成事实源。副本延迟期间读到旧状态,然后基于旧状态发起写请求,版本冲突会以“用户看到的状态”形式暴露出来。读副本只能读,不能作为补偿依据。

第三,忽略 expectedVersion。没有版本条件的 UPDATE 会让并发的支付与取消互相覆盖,而且问题只在竞争时出现,测试环境很难复现。

小结

存储选型的前置问题是所有权:orders 与 order_items 只能有一个写 owner,读侧可以复制但不能反写。基于单聚合多行事务、乐观锁和按客户/状态分页这三个特征,arch-lab 选择 PostgreSQL,并明确记录了 MySQL 作为等价备选、DynamoDB 与文档库被否决的理由。仓储契约把这些约束变成可执行断言,内存实现先跑通,PostgreSQL 适配器再用同一套契约验收。下一节处理订单状态与 outbox 消息如何落在同一个本地事务里。

阅读导航:上一节:5.3 错误模型、版本演进与兼容性 · 下一节:6.2 事务边界:本地事务、出站表与最终一致 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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