4. 读写模型边界划分原则

在 CQRS 架构中,读写模型的边界划分是决定系统复杂度与灵活性的关键决策。错误的边界会导致命令与查询相互耦合、数据不一致或过度工程化。本章从三个核心维度展开:为什么需要分离、粒度如何控制、Bounded Context 与 CQRS 的关系。

4.1 为什么需要分离读写模型

传统 CRUD 架构将同一实体同时用于命令(写)和查询(读),导致以下问题:

问题 说明 典型后果
职责混杂 一个结构体既要承载业务规则,又要满足查询展示 字段膨胀、验证逻辑与投影逻辑耦合
性能冲突 写操作需要事务一致性,读操作需要快速投影 索引设计妥协、缓存策略复杂
安全边界模糊 查询可能暴露不应被命令修改的字段 数据泄露或误修改风险
演化困难 修改读模型可能影响写模型的领域逻辑 回归测试成本高、发布周期长
核心原则:写模型关注行为与不变量,读模型关注投影与性能。两者在代码层面应完全独立,仅在事件层面通过异步消息同步。

分离后的收益:

  • 独立优化:写模型可使用规范化存储(如 PostgreSQL),读模型可使用反规范化视图(如 Elasticsearch、Redis)。
  • 独立安全:写模型校验权限与业务规则,读模型只做数据投影,减少攻击面。
  • 独立演化:读模型可随时新增投影字段而不影响写模型,反之亦然。

4.2 边界划分的粒度

读写模型的边界并非“一刀切”全部分离,而是根据业务场景选择合适粒度。以下是三种常见粒度模式:

粒度 适用场景 示例 复杂度
粗粒度(整个聚合) 聚合内字段少、读写频率接近 用户基本信息:读/写都使用 User 聚合 低
中粒度(聚合内拆分) 聚合较大,读写关注不同字段集 订单写模型含状态机,读模型含商品快照 中
细粒度(跨聚合投影) 读模型需要多个聚合的数据组合 订单列表页需要用户昵称 + 商品标题 + 物流状态 高
实践建议:从中粒度开始,当出现以下信号时再拆分为细粒度:
① 读模型频繁出现 N+1 查询;
② 写模型因读需求被迫添加冗余字段;
③ 读模型需要跨聚合 JOIN 且延迟不可接受。

在 Go 代码中,中粒度分离的典型结构:

// 写模型 —— 领域聚合
type Order struct {
    ID        OrderID
    Items     []OrderItem
    Status    OrderStatus
    Version   int
}

func (o *Order) Submit() error {
    // 校验业务规则
    if o.Status != OrderStatusDraft {
        return ErrInvalidStatus
    }
    o.Status = OrderStatusSubmitted
    return nil
}

// 读模型 —— 查询投影
type OrderListView struct {
    OrderID     string
    CustomerName string
    ItemCount   int
    TotalAmount float64
    Status      string
    CreatedAt   time.Time
}

4.3 上下文边界(Bounded Context)与 CQRS 的关系

Bounded Context 是 DDD 中划分领域边界的核心模式,CQRS 是每个 Context 内部实现读写分离的架构模式。两者关系如下:

  • 每个 Bounded Context 可独立决定是否使用 CQRS:核心域(如订单、支付)适合 CQRS,支撑子域(如通知、日志)可能不需要。
  • CQRS 的读写边界不应跨越 Bounded Context:读模型只能投影当前 Context 内的事件,跨 Context 的数据应通过上下文映射(Context Map)或事件总线同步。
  • Bounded Context 是 CQRS 的天然部署单元:每个 Context 可拥有独立的读库、写库和事件存储。
常见误区:将 CQRS 的读模型直接查询另一个 Bounded Context 的写库。这会导致上下文耦合,破坏领域边界。正确做法是通过领域事件或 API 网关同步数据。

下图展示了一个电商系统中 Bounded Context 与 CQRS 的典型关系:

Bounded Context 与 CQRS 关系图 订单上下文 写模型:Order Aggregate 读模型:OrderListView 事件:OrderSubmitted OrderShipped 支付上下文 写模型:Payment Aggregate 读模型:PaymentRecord 事件:PaymentCompleted PaymentFailed 物流上下文 写模型:Shipment Aggregate 读模型:TrackingView 事件:ShipmentDelivered ShipmentException 领域事件总线(Event Bus) 跨上下文读模型:OrderTrackingView 投影自:OrderSubmitted + PaymentCompleted + ShipmentDelivered

4.4 边界划分决策矩阵

在实际项目中,可通过以下矩阵快速判断是否需要在某个 Bounded Context 内分离读写模型:

判断维度 不分离(CRUD) 分离(CQRS)
读写比例 接近 1:1 读远大于写(如 10:1 以上)
查询复杂度 单表简单查询 多表聚合、全文检索、实时报表
业务规则复杂度 简单状态机或无状态 复杂工作流、多步骤验证
数据一致性要求 强一致性 最终一致性可接受
团队规模 小团队(1-3人) 中大型团队(5人以上)
决策口诀:
读多写少、查询复杂、规则厚重、最终一致 → 分离 CQRS
读写均衡、查询简单、规则轻量、强一致 → 保持 CRUD

4.5 边界划分的常见陷阱

  1. 过度分离:每个聚合都拆成读写模型,导致代码量翻倍、维护成本激增。建议只对核心域和支撑域中查询复杂的部分使用 CQRS。
  2. 边界泄漏:读模型直接引用写模型的内部结构(如私有字段),导致两者耦合。应通过事件或 DTO 解耦。
  3. 同步陷阱:在同一个事务中同时更新写库和读库,违背 CQRS 的最终一致性原则。应使用事件驱动异步同步。
  4. 忽略上下文映射:跨 Bounded Context 的读模型未通过事件总线同步,而是直接查询源 Context 的数据库,导致部署耦合。
Go 实现建议:在项目初期使用 internal/readmodel 和 internal/writemodel 包分离代码,即使当前只使用 CRUD,也为未来拆分预留结构。当读模型出现超过 3 个不同投影时,再引入独立读库。