先定写入口,再谈数据库
上一节把错误模型和版本兼容讲完了,这一节换一个更底层的问题:订单数据到底归谁写、放在哪里。很多团队一上来就比性能参数,最后选出一个“最快”的库,却在半年后发现三个服务都能改 orders.status,没人说得清哪次状态迁移是合法的。存储选型的第一约束不是 QPS,而是所有权。
站内 分布式数据一致性 讨论的是跨服务一致性协议;本节不重复 CAP、Saga 的推导,只把 arch-lab 的范围缩到一张订单表:谁持有写连接,哪些读路径必须走它,什么情况下才允许引入第二个存储。
先给结论:orders 和 order_items 由订单服务 orderd 独占写入,其他上下文只能通过订单 API 或领域事件读取。这个约束先于数据库选型成立,选型只是在满足约束的候选里挑一个运维成本最低的。
所有权清单
arch-lab 卷一有五组状态,每组只有一个写 owner。下面这张表是评审时要签字的那张,不是示意图:
| 数据 | 唯一写 owner | 其他上下文如何读 | 事实源 |
|---|---|---|---|
orders | orderd | 订单 API、order.* 事件 | PostgreSQL |
order_items | orderd | 随订单聚合返回 | PostgreSQL |
inventory_reservations | inventory | inventory.* 事件 | PostgreSQL |
payments | payment | payment.* 事件 | 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 事务边界:本地事务、出站表与最终一致 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。