《后端架构设计·架构基础与边界》6.3 订单读模型:CQRS 的引入判据与退出条件

不重复 CQRS 定义,只回答订单列表与详情读模型何时值得单独建投影:给出可量化的引入判据、必须同时满足的条件、投影幂等与重建实现,以及读模型不再划算时的五条退出条件与删除步骤;并用 Go 测试验证重复投递、旧版本晚到与列表过滤,强调退出是监控数据推翻假设后的正常回收。

先问读侧到底慢在哪

6.2 把订单状态和 outbox 消息放进了同一个本地事务,事件流已经可靠。现在读侧可以消费这些事件,但“能消费”不等于“应该建读模型”。站内 CQRS 与事件溯源 已经讲过读写分离与事件存储的完整形态,本节不重复定义,只回答 arch-lab 的两个决策:订单列表/详情什么时候需要独立投影,以及什么时候应该把它删掉。

先排除伪问题。如果列表慢只是因为缺索引,加一条 (customer_id, created_at DESC) 就能把 P95 从 800ms 降到 20ms,那不需要读模型。如果详情慢是因为 N+1 查询,改成一次 join 或批量读取即可。读模型解决的是“写模型的结构与读侧访问模式长期冲突”,不是“今天这条 SQL 没写好”。

冲突通常有三个信号:列表要跨多个聚合拼装、筛选维度无法用现有索引覆盖、读流量与写事务争抢同一批行。三个信号都出现,才进入下面的量化判据。

列表视图与详情视图分开

订单列表和订单详情不是同一张表。列表要窄、要能按客户和状态过滤、要能分页;详情要宽、要包含行项目快照、要能承受单条大对象读取。arch-lab 把它们定义成两个投影:

视图主键关键字段更新频率主要消费者
order_list_vieworder_id客户、状态、总价、行数、版本、更新时间每次状态迁移用户订单列表、客服后台
order_detail_vieworder_id列表字段 + 行项目 JSON + 支付摘要创建、行项目变更、支付订单详情页、售后

列表视图只存标量,单行小,适合 (customer_id, updated_at DESC) 与 (status, updated_at DESC) 两条索引。详情视图把行项目序列化成 JSONB,读的时候一次取回,不再回查写库。两者都由同一组事件驱动,但可以独立重建、独立扩容。

不要用一张“大而全”的读模型同时服务列表和详情。列表查询会因为大字段拖慢扫描,详情读取又会把列表不需要的列带进缓存。拆开的成本只是多一张表和一个投影循环。

投影代码

投影的输入是已经持久化的事件,输出是视图。Apply 用聚合版本做幂等:重复投递同版本、旧版本晚到,都直接忽略。Rebuild 先按聚合、再按版本排序,保证乱序历史也能得到确定结果。下面的代码实现列表投影;详情投影复用同一个 Apply 骨架,只在创建事件里额外写入行项目快照,重建测试结构相同:

// file: internal/readmodel/projection.go
// tier: L1
// verify: go test ./internal/readmodel/
package readmodel

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"time"
)

var (
	ErrNotCreated   = errors.New("first event must be OrderCreated")
	ErrUnknownEvent = errors.New("unknown event type")
)

type EventType string

const (
	EventCreated       EventType = "OrderCreated"
	EventStatusChanged EventType = "OrderStatusChanged"
)

type OrderEvent struct {
	EventID    string
	OrderID    string
	Version    int
	Type       EventType
	CustomerID string
	Status     string
	TotalCents int64
	ItemCount  int
	OccurredAt time.Time
}

type OrderView struct {
	OrderID    string
	CustomerID string
	Status     string
	TotalCents int64
	ItemCount  int
	Version    int
	UpdatedAt  time.Time
}

type Query struct {
	CustomerID string
	Status     string
	Limit      int
}

type Store interface {
	Get(ctx context.Context, orderID string) (OrderView, bool, error)
	Upsert(ctx context.Context, v OrderView) error
	List(ctx context.Context, q Query) ([]OrderView, error)
	Reset(ctx context.Context) error
}

type Projection struct{ store Store }

func New(store Store) *Projection { return &Projection{store: store} }

// Apply 以聚合版本做幂等:重复投递同版本、或旧版本晚到时直接忽略。
func (p *Projection) Apply(ctx context.Context, e OrderEvent) error {
	current, found, err := p.store.Get(ctx, e.OrderID)
	if err != nil {
		return err
	}
	if found && e.Version <= current.Version {
		return nil
	}
	v := current
	if !found {
		if e.Type != EventCreated {
			return fmt.Errorf("%w: got %s at version %d", ErrNotCreated, e.Type, e.Version)
		}
		v = OrderView{OrderID: e.OrderID}
	}
	switch e.Type {
	case EventCreated:
		v.CustomerID = e.CustomerID
		v.TotalCents = e.TotalCents
		v.ItemCount = e.ItemCount
		v.Status = e.Status
	case EventStatusChanged:
		v.Status = e.Status
	default:
		return fmt.Errorf("%w: %s", ErrUnknownEvent, e.Type)
	}
	v.Version = e.Version
	v.UpdatedAt = e.OccurredAt
	return p.store.Upsert(ctx, v)
}

// Rebuild 先按聚合、再按版本排序,使乱序历史事件也能得到确定结果。
func (p *Projection) Rebuild(ctx context.Context, events []OrderEvent) error {
	if err := p.store.Reset(ctx); err != nil {
		return err
	}
	ordered := append([]OrderEvent(nil), events...)
	sort.SliceStable(ordered, func(i, j int) bool {
		if ordered[i].OrderID != ordered[j].OrderID {
			return ordered[i].OrderID < ordered[j].OrderID
		}
		return ordered[i].Version < ordered[j].Version
	})
	for _, e := range ordered {
		if err := p.Apply(ctx, e); err != nil {
			return err
		}
	}
	return nil
}

版本判断放在投影内部,而不是依赖消息顺序。Kafka 同一分区能保证顺序,但重建、补数和多实例消费都会打破“严格按版本到达”的假设。读者可以对照 CQRS 与事件溯源 里的重放模型,这里只保留 arch-lab 需要的最小字段。

幂等、乱序与重建

投影必须能处理三种异常:同一事件重复投递、旧事件晚到、首次事件不是创建。前两种用版本号直接忽略,第三种返回错误而不是凭空造一个订单视图。把错误暴露出来,重建时才能发现事件日志缺口。

重建不是“删表重灌”这么简单。先在一个影子表或影子索引上重放,校验总数、抽样哈希和关键状态分布,再原子切换。重建期间新事件仍要写入,通常做法是双写旧表和新表,切换后再停旧表;或者记录重建起点,结束后补放起点之后的事件。

测试覆盖四条断言:增量投影与重建结果一致、重复与旧版本被忽略、状态事件不能先于创建、列表过滤与 limit 生效。后两条是列表视图的契约,不是投影算法本身。

// file: internal/readmodel/projection_test.go
// tier: L1
// verify: go test ./internal/readmodel/
package readmodel

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

type memStore struct {
	views map[string]OrderView
}

func newMemStore() *memStore { return &memStore{views: make(map[string]OrderView)} }

func (s *memStore) Get(ctx context.Context, orderID string) (OrderView, bool, error) {
	v, ok := s.views[orderID]
	return v, ok, nil
}

func (s *memStore) Upsert(ctx context.Context, v OrderView) error {
	s.views[v.OrderID] = v
	return nil
}

func (s *memStore) Reset(ctx context.Context) error {
	s.views = make(map[string]OrderView)
	return nil
}

func (s *memStore) List(ctx context.Context, q Query) ([]OrderView, error) {
	out := make([]OrderView, 0, len(s.views))
	for _, v := range s.views {
		if q.CustomerID != "" && v.CustomerID != q.CustomerID {
			continue
		}
		if q.Status != "" && v.Status != q.Status {
			continue
		}
		out = append(out, v)
	}
	if q.Limit > 0 && len(out) > q.Limit {
		out = out[:q.Limit]
	}
	return out, nil
}

var base = time.Date(2026, 10, 8, 16, 0, 0, 0, time.UTC)

func created(orderID, customer string) OrderEvent {
	return OrderEvent{
		EventID: "evt-created-" + orderID, OrderID: orderID, Version: 1,
		Type: EventCreated, CustomerID: customer, Status: "CREATED",
		TotalCents: 12800, ItemCount: 3, OccurredAt: base,
	}
}

func changed(orderID string, version int, status string, at time.Time) OrderEvent {
	return OrderEvent{
		EventID: "evt-" + orderID + "-" + status, OrderID: orderID,
		Version: version, Type: EventStatusChanged, Status: status, OccurredAt: at,
	}
}

func TestIncrementalMatchesRebuild(t *testing.T) {
	ctx := context.Background()
	events := []OrderEvent{
		created("o-1", "c-1"),
		changed("o-1", 2, "PAID", base.Add(time.Minute)),
		changed("o-1", 3, "FULFILLED", base.Add(2*time.Minute)),
	}
	incremental := New(newMemStore())
	for _, e := range events {
		if err := incremental.Apply(ctx, e); err != nil {
			t.Fatal(err)
		}
	}
	rebuilt := New(newMemStore())
	if err := rebuilt.Rebuild(ctx, events); err != nil {
		t.Fatal(err)
	}
	a, _, _ := incremental.store.Get(ctx, "o-1")
	b, _, _ := rebuilt.store.Get(ctx, "o-1")
	if a != b || a.Status != "FULFILLED" || a.Version != 3 {
		t.Fatalf("incremental=%+v rebuilt=%+v", a, b)
	}
}

func TestIgnoresDuplicateAndStaleVersions(t *testing.T) {
	ctx := context.Background()
	p := New(newMemStore())
	for _, e := range []OrderEvent{
		created("o-1", "c-1"),
		created("o-1", "c-1"),
		changed("o-1", 2, "PAID", base.Add(time.Minute)),
		changed("o-1", 1, "CANCELLED", base.Add(2*time.Minute)),
	} {
		if err := p.Apply(ctx, e); err != nil {
			t.Fatal(err)
		}
	}
	v, _, _ := p.store.Get(ctx, "o-1")
	if v.Version != 2 || v.Status != "PAID" {
		t.Fatalf("stale event won: %+v", v)
	}
}

func TestRejectsStatusBeforeCreated(t *testing.T) {
	err := New(newMemStore()).Apply(context.Background(), changed("o-1", 2, "PAID", base))
	if !errors.Is(err, ErrNotCreated) {
		t.Fatalf("err = %v, want ErrNotCreated", err)
	}
}

func TestListFiltersAndLimit(t *testing.T) {
	ctx := context.Background()
	p := New(newMemStore())
	events := []OrderEvent{
		created("o-1", "c-1"),
		created("o-2", "c-1"),
		created("o-3", "c-2"),
		changed("o-1", 2, "PAID", base.Add(time.Minute)),
	}
	if err := p.Rebuild(ctx, events); err != nil {
		t.Fatal(err)
	}
	got, err := p.store.List(ctx, Query{CustomerID: "c-1"})
	if err != nil || len(got) != 2 {
		t.Fatalf("customer filter: %+v err=%v", got, err)
	}
	got, err = p.store.List(ctx, Query{Status: "PAID", Limit: 1})
	if err != nil || len(got) != 1 || got[0].OrderID != "o-1" {
		t.Fatalf("status filter: %+v err=%v", got, err)
	}
}

引入判据

只有下面五条同时成立,才值得为订单列表/详情单独建投影。任何一条不成立,先改写模型或加索引:

判据阈值为什么需要
读写比读 QPS / 写 QPS ≥ 10读侧优化收益能覆盖投影运维成本
读延迟加完索引后列表/详情 P95 仍 > 200ms证明瓶颈不是缺索引
列表占比列表读取 ≥ 全部订单读取的 30%投影主要服务列表,占比太低不划算
形状冲突至少两种消费者需要不同字段组合单一宽表无法同时满足
运维能力能在 SLO 内处理投影延迟与重建读模型是新的值班面

读写比是设计假设时也要写清楚,不要拿“感觉读很多”当依据。arch-lab 的示例值是 20:1、P95 450ms、列表占 45%、客服与用户两种形状、已有 outbox 与回放工具;这些是假设,不是线上测量。真实决策必须用监控数据替换。

还有一个常被忽略的条件:写侧必须已经能稳定产出事件。如果 outbox 还在丢消息,投影只会把问题放大成“读侧看起来不一致”。先让 6.2 的投递器与消费去重上线,再谈读模型。

判据要用趋势而不是单点。大促当天的读写比会失真,至少看连续四个自然周的中位数;P95 要区分列表与详情,不要用全站混合值掩盖某一类慢查询。

退出条件

读模型不是永久资产。下面任意两条持续成立,就应该评估退出:

退出信号观察窗口动作
读写比 < 3连续 4 周合并回写库直读,删除投影
加索引后 P95 ≤ 80ms连续 2 周读模型收益不足以覆盖成本
列表读取占比 < 10%连续 4 周投影只服务长尾,改为按需查询
投影延迟 P99 > 5s连续 2 周且业务不可接受修复同步链路,否则退出
消费者少于 2 个一个发布周期单一消费者可退回直读

退出不是失败,而是架构假设被数据推翻。删除步骤要按顺序:先切读流量回写库、观察一个发布周期、确认没有消费者、停止投影、保留事件日志至少一个审计周期、最后删表。反过来先删表再切流量,会把回滚窗口一起删掉。

退出的前提是读模型不是唯一事实源。删除之前必须确认没有下游把它当写入口、没有报表直接连它的库;否则先解耦消费者,再谈删表。

真实运行结果

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

$ gofmt -l .
$ go vet ./...
$ go test ./... -count=1 -v
=== RUN   TestIncrementalMatchesRebuild
--- PASS: TestIncrementalMatchesRebuild (0.00s)
=== RUN   TestIgnoresDuplicateAndStaleVersions
--- PASS: TestIgnoresDuplicateAndStaleVersions (0.00s)
=== RUN   TestRejectsStatusBeforeCreated
--- PASS: TestRejectsStatusBeforeCreated (0.00s)
=== RUN   TestListFiltersAndLimit
--- PASS: TestListFiltersAndLimit (0.00s)
PASS
ok  	archlab/internal/readmodel	0.462s

gofmt -l . 没有输出,go vet ./... 没有告警。本节的 P95、读写比和延迟数字都是待替换的设计假设,不是实测;真实判据必须来自订单服务的监控面板。

常见错误与边界

第一,把 CQRS 当成默认架构。读模型引入的是一张新表、一条同步链路和一个新的值班面;没有量化判据就引入,通常只是把复杂度换了个位置。

第二,投影内部不做版本幂等,依赖消息顺序。一旦重建、补数或多实例消费打破顺序,旧事件会覆盖新状态。

第三,列表视图和详情视图共用一张宽表。列表扫描被大字段拖慢,详情缓存又被列表流量污染;拆表比调优便宜。

第四,重建时直接删旧表重灌。重建期间读流量会看到空窗,失败后没有回滚点;影子表加原子切换才是可运维的做法。

第五,只写引入判据,不写退出条件。读模型上线后没人敢删,最后变成第二套需要维护的事实源。

小结

订单读模型的价值不在“读写分离”这四个字,而在它能否用可量化的收益覆盖新增的投影、延迟和值班成本。arch-lab 用列表/详情两张视图承接不同访问形状,用聚合版本保证幂等与重建确定性;只有读写比、延迟、列表占比、形状冲突和运维能力五条同时成立才引入,任意两条退出信号持续出现就按顺序删除。下一章转向同步调用链,看看服务之间的直接依赖如何把这种边界重新粘回去。

阅读导航:上一节:6.2 事务边界:本地事务、出站表与最终一致 · 下一节:7.1 同步调用链的隐性耦合 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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