CQRS 与读写分离的架构视角
CQRS(Command Query Responsibility Segregation,命令查询职责分离)常被当成「读写分离的高级说法」,两者其实不在一个维度:读写分离是部署形态,把流量导向不同副本;CQRS 是模型形态,让写侧的领域模型与读侧的查询模型各自只服务一个目的。单库单表也能做 CQRS,做了读写分离也未必有 CQRS。
判断要不要分离模型,只需回答三个问题:写侧的不变式(invariant)是否复杂到需要完整领域模型?读侧是否被跨聚合的列表、搜索、报表需求拖着走?两端对一致性的要求能不能分开谈?答案是「否、是、能」,分离才可能有收益。
一、三档形态:从视图分离到事件溯源
| 档位 | 写侧 | 读侧 | 典型适用 | 主要代价 |
|---|---|---|---|---|
| 一档:只分离读模型 | 领域模型 + 单库事务 | 同库中的只读视图、DTO、覆盖索引查询 | 写不重,但读形态与写模型差异大(列表页、后台报表) | 两套映射代码,字段改动要同步两侧 |
| 二档:读写库分离 | 写库强一致,事实源唯一 | 独立读库:ES、宽表、KV、物化视图 | 读量与读形态都超出写库承受范围(搜索、Feed、跨聚合查询) | 多一条投影链路:延迟、重建、运维两套存储 |
| 三档:事件溯源(Event Sourcing)配套 | 事件流是唯一事实源,状态由事件重放得出 | 任意多个投影,按需重建 | 需要完整审计、时点回溯、以事件驱动其他系统 | 事件版本治理、快照与重放,团队门槛最高 |
三档是递进关系而非等级关系。多数团队需要的是一档;二档是为读侧扩容;三档是用写侧的高可追溯性换取复杂度。跳过一档直接上三档,通常是技术选型冲动而不是业务需要。事件溯源的形态选择与事件契约如何只加不改,见上一章「事件驱动架构」。
二、职责怎么划
写侧(Command 端):
- 唯一负责业务规则与不变式校验,一个命令对应一次状态变更。
- 一次命令只改一个聚合,跨聚合的一致性交给流程或事件,不塞进一个大事务。
读侧(Query 端):
- 只做查询与拼装,不写业务规则、不做「能不能操作」的判断——读侧出现这类分支,说明规则已经漂移。
- 可以完全按查询形状建模:预计算字段、冗余、宽表、反范式,怎么快怎么来。
两侧之间只有单向数据流:写侧发布事件,读侧消费事件更新投影。读侧反向调用写侧、或两端共享同一套实体类,都是边界塌陷的信号。
三、投影更新:延迟是设计的一部分
命令端 写库 / Outbox 投影器 读库
│ PlaceOrder │ │ │
├─ 校验聚合 ─────────►│ │ │
│ 同一本地事务内 │ │ │
│ 写业务表 + 写 Outbox│ │ │
│◄─ 提交成功 ─────────┤ │ │
├─ 返回 orderId ──────┤ │ │
│ ├─ 事件 OrderPlaced►│ │
│ │ ├─ 幂等 upsert ────►│
│ │ │ │
查询请求 ── 查 orderId ───────────────────────────────────────►│
└─ 若投影尚未更新:读不到刚下的单(读己之写不一致)
投影器必须满足三条:至少一次投递下的幂等(用事件 ID 去重或覆盖式 upsert)、单调(旧事件不能覆盖新状态,按聚合版本号比较)、可重建(原始事件保留,读库可整体重建——这是读库敢用非事务存储的前提)。
「写后立即读」的三种架构级处理:
- 命令响应直接返回写侧结果(下单接口直接返回订单详情),前端不必回查读库。
- 写后短时间内把该用户或该聚合的读请求路由回写侧或主库(路由标记,路由实现细节可参考 system-design 的读写分离章节)。
- 前端带版本号或游标等待投影追平(
wait_until(version)),超过阈值则回退到方案 1。
四、投影存储与重建
| 投影载体 | 适合的查询形态 | 重建成本 | 要接受的限制 |
|---|---|---|---|
| 同库只读表或物化视图 | 条件过滤、聚合统计 | 低:SQL 直接重建 | 与写库争资源,重建要限流 |
| 宽表(列存或大宽表) | 多维筛选、报表 | 中 | 冗余字段多,写放大明显 |
| 搜索引擎(ES 等) | 全文检索、复杂排序 | 中到高 | 近实时而非实时,一致性保证有限 |
| KV 或缓存 | 按主键与已知键查询 | 低 | 不支持复杂查询,只做加速 |
| 图库或关系型读库 | 关系与路径查询 | 高 | 建模与长期维护成本高 |
重建流程必须在故障发生之前被真实验证过一次:
新建影子读库 → 从事件保留期起点重放(或全量快照 + 增量追平)
→ 抽样比对行数与关键字段 → 切换读流量 → 观察投影延迟与错误率
没有演练过的重建脚本,在真实故障当天的表现约等于没有脚本。
五、什么时候不值得用
| 信号 | 说明 |
|---|---|
| 业务就是 CRUD | 写侧没有不变式,读侧就是按主键查表,分离只增加两套代码 |
| 读写模型基本一致 | 读侧直接复用写模型即可,投影纯属重复劳动 |
| 写入本身是瓶颈 | CQRS 不提升写吞吐,它只是把读负载搬走,写侧该分片仍要分片 |
| 一致性不可放宽 | 账务账本等场景要求两侧强一致,投影延迟不可接受 |
| 没有投影运维能力 | 没有监控、重建、回放手段的投影,出故障只能手工补数据 |
六、三种常见误用
- 当性能银弹:以为上了 CQRS 慢查询就好了。真正的瓶颈常在索引、深分页、数据量,而不是模型是否分离。
- 读库当主库:把 ES 或宽表当作事实源,写侧数据一丢就无从恢复。事实源永远只有一个。
- 规则双写:读侧为了「顺手」补一套校验与状态推演,一段时间后两端结论不一致,且没人知道哪边对。
- 投影无监控:只保证写侧成功,不看投影延迟与积压。故障表现是「订单列表少了几单」,而投影延迟必须是一等指标,否则排查永远从最后一环开始。
小结:CQRS 是模型层面的分离,三档形态分别解决视图差异、读侧扩容与可追溯性。写侧独占业务规则,读侧只拼装查询,中间靠事件单向流动,投影延迟要靠「直接返回写侧结果、路由回写侧、版本号等待」三选一兜住。动手前先确认读侧确实需要不同模型、且能接受投影延迟;否则老实的单库加索引,性价比高于一整套投影链路。