CQRS与DDD结合
一个电商订单的查询灾难
2021年,我们电商平台上线了新功能——"买家中心"订单详情页。
页面包含:订单基本信息、商品明细、物流信息、优惠明细、发票信息、商家信息。这些数据来自 8 个不同的微服务。
最直接的方案是前端聚合——前端调用 8 个接口自己拼。但这样前端性能差,每个用户的设备性能参差不齐,有些低端机光等这些接口就卡了 5 秒。
我们改成了 BFF(Backend For Frontend)聚合模式,在网关层统一调 8 个服务。但新的问题来了:随着业务增长,这个聚合查询的响应时间从 200ms 飙升到 3 秒。业务方抱怨用户体验差,转化率下降了 15%。
排查发现:订单服务为了支撑这个聚合查询,在数据库里搞了 3 个大 JOIN,每次查询都是一次全表扫描。读写混合在一起,读的流量把写的性能也拖累了。
最后我们把订单域拆成了读模型和写模型——CQRS 上线后,聚合查询响应时间从 3 秒降到了 50ms。
问题定义
DDD(领域驱动设计)解决的是"如何建模复杂业务",CQRS(命令查询职责分离)解决的是"如何高性能地读写分离"。两者天然互补:
- DDD 提供战术设计(聚合根、领域事件、限界上下文),帮我们把业务建模清楚
- CQRS 提供读写分离架构,让读和写各自优化,不互相影响
但两者结合不是简单的"DDD 项目里用 CQRS"。如果结合不好,会引入大量复杂度,反而不如不用。
【架构权衡】
CQRS 和 DDD 结合的核心价值:
- 写模型专注业务正确性:聚合根维护业务规则和不变量,领域事件捕获所有状态变更
- 读模型专注查询性能:反范式化设计、预计算、缓存,支撑各种复杂查询场景
- 天然支持事件驱动:领域事件直接驱动读模型的更新,无需额外的同步机制
方案演进
方案A:单模型 + 读写混合(传统做法)
问题:
- 读写互相影响:复杂查询占满数据库连接,写操作被阻塞
- 查询模型受限于写模型:为了支持聚合查询,不得不破坏范式
- 聚合根被污染:聚合根上多了很多"为查询而生"的字段
方案B:CQRS + DDD 分离
核心设计:限界上下文边界
CQRS + DDD 结合时,最关键的设计决策是限界上下文的划分。划分不合理,CQRS 会变成灾难。
【架构权衡】
跨限界上下文的读模型是 CQRS + DDD 中最容易翻车的地方:
- 跨上下文的事件顺序:OrderPaid 事件和 OrderShipped 事件到达投影的顺序是不确定的(都是异步的)。如果买家中心要展示"支付后发货"的物流信息,需要额外的处理。
- 跨上下文的事件 Schema:不同上下文的事件 Schema 是独立演进的,买家上下文投影需要适配各方的 Schema 变更。
- 最终一致性延迟:写操作完成后,读模型可能需要几十毫秒到几秒才能更新完成。用户可能在"下单成功"后立刻刷新页面,看到的还是空白。
最终一致性延迟处理
生产避坑
坑1:写模型泄露到读服务
CQRS 的核心原则是读服务和写模型完全隔离。但实践中很容易出现"为了方便"而绕过这个边界。
坑2:事件丢失导致读模型不一致
如果事件总线(Event Bus)丢了事件,读模型就会出现数据不一致,而且很难发现。
解决方案:
- 事件溯源双写:写模型保存事件到 EventStore,Event Bus 基于 EventStore 投递而不是内存事件
- 幂等投影:投影处理器支持幂等,可以重复处理同一个事件而不产生重复数据
- 定期校验:定期比对写模型和读模型的数据,发现不一致立即告警
坑3:读模型重建时间过长
系统上线或故障恢复时,需要从事件重放重建读模型。如果历史事件有几千万条,重建可能需要数小时甚至数天。
解决方案:
- 快照 + 增量重放:定期打快照,重建时从最新快照 + 增量事件
- 分片并行重放:按聚合根 ID 分片,多机器并行重放
- 蓝绿投影:新旧投影同时运行,切换时无停机
工程代价评估
【架构权衡】
CQRS + DDD 不是万能药。适合使用的场景:
- 复杂业务域,业务规则经常变化,需要清晰的领域模型
- 读写压力差异大,写少读多或写多读少
- 需要支撑多种不同维度的查询,查询模型经常变化
- 对数据一致性要求不是绝对实时(可以接受最终一致)
不太适合的场景:
- 简单 CRUD 业务,领域模型不复杂
- 需要强一致性(每次写入后立即读取,必须看到自己的写入)
- 团队对 DDD 和 CQRS 没有经验
- 项目时间紧张,需要快速交付
落地 Checklist
- 限界上下文划分确定(哪些上下文需要 CQRS,哪些不需要)
- 写模型:聚合根、领域事件、仓储接口设计完成
- 读模型:反范式化视图设计完成(哪些字段要冗余存储)
- 事件总线选型(Kafka / RabbitMQ / Redis Stream)
- 投影处理器幂等性设计(防止事件重复投递导致数据重复)
- 读模型重建方案(快照策略、分片并行)
- 最终一致性延迟监控(写成功到读可见的平均延迟)
- 降级方案(读模型不可用时的 fallback)
- 单元测试:聚合根业务规则、投影转换逻辑
- 集成测试:端到端命令 → 事件 → 投影 → 查询