4. 读写模型边界划分原则
在 CQRS 架构中,读写模型的边界划分是决定系统复杂度与灵活性的关键决策。错误的边界会导致命令与查询相互耦合、数据不一致或过度工程化。本章从三个核心维度展开:为什么需要分离、粒度如何控制、Bounded Context 与 CQRS 的关系。
4.1 为什么需要分离读写模型
传统 CRUD 架构将同一实体同时用于命令(写)和查询(读),导致以下问题:
| 问题 | 说明 | 典型后果 |
|---|---|---|
| 职责混杂 | 一个结构体既要承载业务规则,又要满足查询展示 | 字段膨胀、验证逻辑与投影逻辑耦合 |
| 性能冲突 | 写操作需要事务一致性,读操作需要快速投影 | 索引设计妥协、缓存策略复杂 |
| 安全边界模糊 | 查询可能暴露不应被命令修改的字段 | 数据泄露或误修改风险 |
| 演化困难 | 修改读模型可能影响写模型的领域逻辑 | 回归测试成本高、发布周期长 |
核心原则:写模型关注行为与不变量,读模型关注投影与性能。两者在代码层面应完全独立,仅在事件层面通过异步消息同步。
分离后的收益:
- 独立优化:写模型可使用规范化存储(如 PostgreSQL),读模型可使用反规范化视图(如 Elasticsearch、Redis)。
- 独立安全:写模型校验权限与业务规则,读模型只做数据投影,减少攻击面。
- 独立演化:读模型可随时新增投影字段而不影响写模型,反之亦然。
4.2 边界划分的粒度
读写模型的边界并非“一刀切”全部分离,而是根据业务场景选择合适粒度。以下是三种常见粒度模式:
| 粒度 | 适用场景 | 示例 | 复杂度 |
|---|---|---|---|
| 粗粒度(整个聚合) | 聚合内字段少、读写频率接近 | 用户基本信息:读/写都使用 User 聚合 | 低 |
| 中粒度(聚合内拆分) | 聚合较大,读写关注不同字段集 | 订单写模型含状态机,读模型含商品快照 | 中 |
| 细粒度(跨聚合投影) | 读模型需要多个聚合的数据组合 | 订单列表页需要用户昵称 + 商品标题 + 物流状态 | 高 |
实践建议:从中粒度开始,当出现以下信号时再拆分为细粒度:
① 读模型频繁出现 N+1 查询;
② 写模型因读需求被迫添加冗余字段;
③ 读模型需要跨聚合 JOIN 且延迟不可接受。
① 读模型频繁出现 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 的典型关系:
4.4 边界划分决策矩阵
在实际项目中,可通过以下矩阵快速判断是否需要在某个 Bounded Context 内分离读写模型:
| 判断维度 | 不分离(CRUD) | 分离(CQRS) |
|---|---|---|
| 读写比例 | 接近 1:1 | 读远大于写(如 10:1 以上) |
| 查询复杂度 | 单表简单查询 | 多表聚合、全文检索、实时报表 |
| 业务规则复杂度 | 简单状态机或无状态 | 复杂工作流、多步骤验证 |
| 数据一致性要求 | 强一致性 | 最终一致性可接受 |
| 团队规模 | 小团队(1-3人) | 中大型团队(5人以上) |
决策口诀:
读多写少、查询复杂、规则厚重、最终一致 → 分离 CQRS
读写均衡、查询简单、规则轻量、强一致 → 保持 CRUD
读多写少、查询复杂、规则厚重、最终一致 → 分离 CQRS
读写均衡、查询简单、规则轻量、强一致 → 保持 CRUD
4.5 边界划分的常见陷阱
- 过度分离:每个聚合都拆成读写模型,导致代码量翻倍、维护成本激增。建议只对核心域和支撑域中查询复杂的部分使用 CQRS。
- 边界泄漏:读模型直接引用写模型的内部结构(如私有字段),导致两者耦合。应通过事件或 DTO 解耦。
- 同步陷阱:在同一个事务中同时更新写库和读库,违背 CQRS 的最终一致性原则。应使用事件驱动异步同步。
- 忽略上下文映射:跨 Bounded Context 的读模型未通过事件总线同步,而是直接查询源 Context 的数据库,导致部署耦合。
Go 实现建议:在项目初期使用
internal/readmodel 和 internal/writemodel 包分离代码,即使当前只使用 CRUD,也为未来拆分预留结构。当读模型出现超过 3 个不同投影时,再引入独立读库。