写在前面
前三篇把"写"这一侧讲透了——实体、值对象、聚合、领域事件,都是围绕"怎么正确地变更业务状态"。但真实系统里,读和写的负载往往完全不对称:写要严谨一致,读要快、要灵活、要多形态。用同一个模型硬扛两边,两头不讨好。
这就是 CQRS(Command Query Responsibility Segregation,命令查询职责分离) 要解决的。这篇从 DDD 的视角讲 CQRS——它和领域事件、事件溯源是天然搭档。
这篇和微服务系列第五篇是互补关系:那篇从"数据一致性"角度讲 CQRS 解决最终一致;这篇从"领域建模"角度讲 CQRS 怎么把读写模型分开。
一、CQRS 的本质
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 传统:一个模型既读又写(CRUD 一把梭)
Order 实体 + OrderRepository + OrdersController
→ 写要校验、要维护不变量、要走聚合根
→ 读要 JOIN、要排序、要返回 DTO、要支持各种查询
→ 一个模型被两种截然不同的需求拉扯,越来越臃肿
CQRS:把"写"和"读"拆成两套模型
写侧(Command) 读侧(Query)
──────────────── ────────────────
命令处理 查询服务
领域模型(聚合) 读模型(DTO/物化视图)
仓储 只读数据源
强一致、校验、规则 快、灵活、可反规范化
|
1
2
3
4
| 一句话:CQRS 不是技术,是"承认读写不一样,别硬凑一起"的设计观。
来源:Bertrand Meyer 的 CQS 原则(命令查询分离:方法要么改状态,要么返回值,不兼得)
CQRS 把它从"方法级"放大到"架构级"——整个系统的读写分开。
|
二、为什么要分离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 写(Command)的真实诉求:
✓ 严格校验业务规则(不能超卖、余额够不够)
✓ 维护不变量(聚合一致性)
✓ 强一致(一个事务一个聚合)
✓ 领域模型要"对",要可维护
→ 频率通常不高(用户操作)
读(Query)的真实诉求:
✓ 快(缓存、索引、反规范化)
✓ 灵活(各种筛选/排序/聚合查询)
✓ 多形态(列表页/详情页/报表,各有 DTO)
✓ 高并发(一次列表查 N 条,QPS 远高于写)
→ 频率通常很高(浏览、搜索)
一个模型同时满足"严格校验"和"高速查询",本质矛盾。
分开后,各自优化到极致。
|
三、写侧:领域模型
1
2
3
4
5
6
7
8
| 写侧 = DDD 的主战场(前三篇的内容)
入口:命令(PlaceOrderCommand、CancelOrderCommand)
处理:应用服务 → 加载聚合 → 调聚合方法 → 保存 → 发事件
模型:聚合根(带完整不变量校验)
存储:仓储,按聚合根整体存取
写侧不关心"页面要显示什么",只关心"变更是否合法"。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 写侧:命令处理(领域模型驱动)
public sealed record CancelOrderCommand(OrderId OrderId, string Reason);
public class OrderCommandHandler
{
private readonly IOrderRepository _repo;
private readonly IUnitOfWork _uow;
private readonly IDomainEventDispatcher _events;
public async Task Handle(CancelOrderCommand cmd, CancellationToken ct)
{
var order = await _repo.Find(cmd.OrderId, ct) ?? throw new NotFoundException();
order.Cancel(cmd.Reason); // 聚合内校验 + 收集事件
await _repo.Save(order, ct);
await _uow.SaveChangesAsync(ct);
await _events.DispatchAsync(order.DequeueEvents(), ct); // 通知读侧更新
}
}
|
四、读侧:读模型
1
2
3
4
5
6
7
8
9
10
11
12
| 读侧 = 为"查询"优化的模型,不管业务规则
特征:
- 反规范化(denormalized):把需要的字段拍平,免 JOIN
- 直接返回 DTO,不经过领域模型
- 可以用完全不同的存储(关系库宽表 / Redis / Elasticsearch)
- 不校验业务规则(读而已)
一个"订单列表"读模型可能是:
OrderListItemDto { OrderId, CustomerName, Total, Status, PlacedOn }
→ 已经 JOIN 好客户名,列表查询一次搞定
而"订单详情"又是另一个读模型,各取所需。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 读侧:直接查、直接返 DTO,不碰领域模型
public class OrderQueryService
{
private readonly IDbConnection _db; // Dapper 直接查读库
public Task<IReadOnlyList<OrderListItemDto>> ListByCustomer(CustomerId cust, CancellationToken ct)
=> _db.QueryAsync<OrderListItemDto>(@"
SELECT o.id AS OrderId, c.name AS CustomerName,
o.total AS Total, o.status AS Status, o.placed_on AS PlacedOn
FROM order_list_view o JOIN customers c ON c.id = o.customer_id
WHERE o.customer_id = @cust", new { cust });
// 详情、报表……各有各的读模型,互不干扰
}
public sealed record OrderListItemDto(
Guid OrderId, string CustomerName, decimal Total, string Status, DateTime PlacedOn);
|
1
2
3
4
5
| 读侧自由度极高:
- 不走聚合根(读不修改,没必要)
- 不走仓储(仓储是为"整体存取聚合"设计的,读不需要)
- 可以直接 SQL / Dapper / 查 ES / 读 Redis
→ 读侧就是"怎么快怎么来",领域规则是写侧的事。
|
五、数据同步:读模型怎么跟写侧一致
1
2
3
4
5
6
7
8
9
10
11
| 写侧一更新,读侧怎么同步?用领域事件投影。
写侧 Order 状态变了 → 发 OrderCancelled 事件
→ 读侧投影器(Projector)订阅 → 更新对应的读模型表
→ 读侧最终一致
读模型表(投影):
order_list_view ← 订单列表用
order_detail_view ← 订单详情用
customer_orders_view ← 按客户聚合的视图
每个视图由各自的事件投影维护。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 读侧投影器:监听领域事件,更新读模型
public class OrderListProjector
{
private readonly IDbConnection _db;
public async Task On(OrderPlaced e, CancellationToken ct)
{
await _db.ExecuteAsync(@"
INSERT INTO order_list_view (id, customer_id, total, status, placed_on)
VALUES (@OrderId, @CustomerId, @Total, 'Placed', @OccurredOn)", e);
}
public async Task On(OrderCancelled e, CancellationToken ct)
{
await _db.ExecuteAsync(
"UPDATE order_list_view SET status='Cancelled' WHERE id=@OrderId", e);
}
}
|
1
2
3
4
5
| 所以前面四篇是层层铺垫:
实体/值对象/聚合 → 写侧的领域模型
领域事件 → 写侧通知读侧的渠道
CQRS → 读写两侧正式分家,事件是同步纽带
这就是 DDD 的完整闭环。
|
六、CQRS + 事件溯源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| CQRS 和事件溯源是黄金搭档(但可独立使用):
事件溯源(第四篇):写侧存"事件流",状态由重放得到
CQRS(本篇):读写分离
合体:
写侧:命令 → 聚合 → 存事件流(Event Store)
读侧:事件 → 投影 → 读模型(Materialized View)
┌─────────┐ 事件 ┌───────────┐ 投影 ┌──────────┐
│ 写侧 │ ──────→ │ 事件存储 │ ─────→ │ 读模型 │ ← 查询
│ (聚合) │ │ (Event │ │ (宽表/ES)│
└─────────┘ │ Store) │ └──────────┘
└───────────┘
价值:写侧有完整审计(事件流),读侧极致优化(投影)
两者解耦,各自演进。
代价:复杂。只有读多写少 + 强审计需求才值得。
普通 CRUD,单独用 CQRS 都未必需要,更别说加事件溯源。
|
七、什么时候用 CQRS(和什么时候别用)
1
2
3
4
5
6
7
8
9
10
11
12
13
| 值得用 CQRS:
✓ 读写负载严重不对称(读 >> 写)
✓ 查询复杂、多形态(列表/详情/报表差异大)
✓ 需要独立扩容读侧(读加缓存、加副本,写保持单库)
✓ 已用领域事件,顺势做投影(边际成本低)
不要用 CQRS:
✗ 简单 CRUD(读写都很简单,分离只增复杂度)
✗ 读和写模型差异不大
✗ 团队没准备好维护"两套模型 + 同步"
铁律:CQRS 是用"复杂度"换"读写各自优化"。
只有读写矛盾真的痛,才划得来。
|
1
2
3
4
5
6
7
| 渐进式落地:
1. 先把写侧领域模型建好(前三篇)
2. 读侧仍用同一套库,但用独立查询服务 + DTO(轻量 CQRS)
3. 哪个读场景扛不住了,单独给它建投影读模型
4. 读量真的大,再引入独立读存储(Redis/ES)
不要一上来就全套 CQRS + 事件溯源,按痛点逐步推进。
|
八、小结
- CQRS 本质:承认读写需求不同,命令(写)和查询(读)分家
- 写侧:命令 → 领域模型(聚合),严格校验、强一致(前三篇内容)
- 读侧:为查询优化的读模型(反规范化 DTO),不经领域模型,怎么快怎么来
- 同步纽带:领域事件投影——写侧发事件,读侧投影更新读模型(最终一致)
- CQRS + 事件溯源:黄金搭档但复杂,按需组合
- 何时用:读写严重不对称 / 查询复杂多形态;简单 CRUD 别用
- 渐进落地:先领域模型,再独立查询服务,再按痛点加投影,最后才上独立读存储
- 闭环:实体/值对象/聚合(写模型)+ 领域事件(纽带)+ CQRS(读写分家)= DDD 完整架构
最后一篇,把这些落到工程结构——整洁架构与 .NET 项目分层。