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)、单调(旧事件不能覆盖新状态,按聚合版本号比较)、可重建(原始事件保留,读库可整体重建——这是读库敢用非事务存储的前提)。

「写后立即读」的三种架构级处理:

  1. 命令响应直接返回写侧结果(下单接口直接返回订单详情),前端不必回查读库。
  2. 写后短时间内把该用户或该聚合的读请求路由回写侧或主库(路由标记,路由实现细节可参考 system-design 的读写分离章节)。
  3. 前端带版本号或游标等待投影追平(wait_until(version)),超过阈值则回退到方案 1。

四、投影存储与重建

投影载体适合的查询形态重建成本要接受的限制
同库只读表或物化视图条件过滤、聚合统计低:SQL 直接重建与写库争资源,重建要限流
宽表(列存或大宽表)多维筛选、报表中冗余字段多,写放大明显
搜索引擎(ES 等)全文检索、复杂排序中到高近实时而非实时,一致性保证有限
KV 或缓存按主键与已知键查询低不支持复杂查询,只做加速
图库或关系型读库关系与路径查询高建模与长期维护成本高

重建流程必须在故障发生之前被真实验证过一次:

新建影子读库 → 从事件保留期起点重放(或全量快照 + 增量追平)
   → 抽样比对行数与关键字段 → 切换读流量 → 观察投影延迟与错误率

没有演练过的重建脚本,在真实故障当天的表现约等于没有脚本。

五、什么时候不值得用

信号说明
业务就是 CRUD写侧没有不变式,读侧就是按主键查表,分离只增加两套代码
读写模型基本一致读侧直接复用写模型即可,投影纯属重复劳动
写入本身是瓶颈CQRS 不提升写吞吐,它只是把读负载搬走,写侧该分片仍要分片
一致性不可放宽账务账本等场景要求两侧强一致,投影延迟不可接受
没有投影运维能力没有监控、重建、回放手段的投影,出故障只能手工补数据

六、三种常见误用

  • 当性能银弹:以为上了 CQRS 慢查询就好了。真正的瓶颈常在索引、深分页、数据量,而不是模型是否分离。
  • 读库当主库:把 ES 或宽表当作事实源,写侧数据一丢就无从恢复。事实源永远只有一个。
  • 规则双写:读侧为了「顺手」补一套校验与状态推演,一段时间后两端结论不一致,且没人知道哪边对。
  • 投影无监控:只保证写侧成功,不看投影延迟与积压。故障表现是「订单列表少了几单」,而投影延迟必须是一等指标,否则排查永远从最后一环开始。

小结:CQRS 是模型层面的分离,三档形态分别解决视图差异、读侧扩容与可追溯性。写侧独占业务规则,读侧只拼装查询,中间靠事件单向流动,投影延迟要靠「直接返回写侧结果、路由回写侧、版本号等待」三选一兜住。动手前先确认读侧确实需要不同模型、且能接受投影延迟;否则老实的单库加索引,性价比高于一整套投影链路。

笔记加载中…