先问读侧到底慢在哪
6.2 把订单状态和 outbox 消息放进了同一个本地事务,事件流已经可靠。现在读侧可以消费这些事件,但“能消费”不等于“应该建读模型”。站内 CQRS 与事件溯源 已经讲过读写分离与事件存储的完整形态,本节不重复定义,只回答 arch-lab 的两个决策:订单列表/详情什么时候需要独立投影,以及什么时候应该把它删掉。
先排除伪问题。如果列表慢只是因为缺索引,加一条 (customer_id, created_at DESC) 就能把 P95 从 800ms 降到 20ms,那不需要读模型。如果详情慢是因为 N+1 查询,改成一次 join 或批量读取即可。读模型解决的是“写模型的结构与读侧访问模式长期冲突”,不是“今天这条 SQL 没写好”。
冲突通常有三个信号:列表要跨多个聚合拼装、筛选维度无法用现有索引覆盖、读流量与写事务争抢同一批行。三个信号都出现,才进入下面的量化判据。
列表视图与详情视图分开
订单列表和订单详情不是同一张表。列表要窄、要能按客户和状态过滤、要能分页;详情要宽、要包含行项目快照、要能承受单条大对象读取。arch-lab 把它们定义成两个投影:
| 视图 | 主键 | 关键字段 | 更新频率 | 主要消费者 |
|---|---|---|---|---|
order_list_view | order_id | 客户、状态、总价、行数、版本、更新时间 | 每次状态迁移 | 用户订单列表、客服后台 |
order_detail_view | order_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 同步调用链的隐性耦合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。