为什么 CQRS 架构很重要却难以实现?

12 阅读12分钟

在软件架构的讨论中,CQRS(Command Query Responsibility Segregation,命令查询职责分离)几乎是一个绕不开的话题。很多人都听说过它,也大致理解它的思想,但真正将它完整落地到生产系统中的团队却并不多。

原因其实很简单:CQRS非常重要,但也非常难实现

要理解这一点,我们需要先看看它到底解决了什么问题。

CQRS解决的问题

众所周知,后端开发的主要困难往往集中在数据访问上。随着业务规模的扩大,大多数后端系统都会逐渐遇到一些非常典型的瓶颈。

很多后端开发者都经历过类似的困境:

1. 性能方面的瓶颈:读和写的负载不均衡

在绝大多数业务系统中,读请求的数量远远高于写请求。 典型的互联网系统往往是 90% 甚至 99% 的请求都是读取操作

然而,在传统的架构中,读写通常共享同一个数据模型、同一个数据库。这会带来两个问题:

  • 写模型往往为了保证事务一致性而设计得比较规范化(normalized)
  • 读操作却往往需要复杂的 join、聚合或冗余结构

结果就是:

  • 写模型不适合读
  • 读模型也不适合写

数据库在两者之间被迫做妥协,最终两头都不理想。

2. 读取模式的多样性

现代系统中的查询需求是极其多样化的,例如:

  • 低延迟的随机读取(KV查询)
  • 范围查询(时间序列或分页)
  • 全文搜索
  • 聚合分析
  • 向量搜索(AI/Embedding)

这些需求通常需要完全不同的数据结构和存储引擎

查询需求适合的存储
OLTP事务关系数据库
高速KVRedis
全文搜索Elasticsearch
数据分析OLAP数据库
向量检索Vector DB

因此,一个单一数据库往往难以同时满足所有需求。

3. 模式演进的困难

业务系统是不断变化的。

随着业务发展:

  • 实体属性会增加或调整
  • 查询需求会不断出现新的组合
  • 原有的查询接口会被新的需求替代

如果所有读写都依赖同一个数据库模式,那么每一次变更都会带来巨大的成本:

  • schema迁移
  • 复杂SQL修改
  • 历史数据兼容

很多系统在几年之后会变成一个高度耦合的数据库模式怪兽

4. 历史状态与当前状态一样重要

大多数系统的数据建模都是围绕 OLTP关系数据库进行的。

这种模式有一个天然倾向: 只关注当前状态(current state)

例如:

订单表
order_id
status
amount
update_time

在这种模型中:

  • 我们只知道订单现在是什么状态
  • 却不知道它曾经经历过哪些状态

但在真实业务中:

  • 审计
  • 风控
  • 数据分析
  • 故障排查

都需要完整的历史状态变更记录

换句话说:

状态的变化过程,和最终状态一样重要。

所以,总的来看,传统的“读写共享模型”的架构在规模扩大后往往会遇到四个问题:

  1. 读写负载不均衡
  2. 查询模式高度多样化
  3. 模式演进困难
  4. 历史状态难以追溯

而 CQRS 的提出,正是为了解决这些问题。

CQRS的工作过程

CQRS 的核心思想非常简单:

将系统的读操作和写操作分离到不同的模型中。

也就是说:

  • 写模型(Command Model) 负责处理业务逻辑和状态变更
  • 读模型(Query Model) 专门用于查询,可以根据查询需求自由设计

这种分离使得两者可以各自独立优化

但在实际实现中,CQRS通常还会结合另一个重要概念:事件溯源(Event Sourcing)

一个典型的CQRS系统通常具备以下几个要素。

1. 基于事件溯源

在传统系统中,我们存储的是状态

而在事件溯源系统中,我们存储的是事件

例如订单系统:

传统模式:

Order {
  status = "SHIPPED"
}

事件溯源模式:

OrderCreated
OrderPaid
OrderPacked
OrderShipped

当前状态可以通过重放这些事件得到。

2. 领域事件的不可变性

在CQRS系统中,领域事件是不可变的(immutable)

一旦事件被记录,就永远不会被修改。

这样做的好处是:

  • 所有历史都可以追溯
  • 可以重新构建任意时间点的状态
  • 可以重放事件生成新的读模型

这也是很多系统实现审计能力和可追溯性的重要基础。

3. 所有查询都是投影(Projection)

在CQRS中,查询通常不会直接访问写模型。

取而代之的是投影(Projection)

投影的本质是:

根据事件流构建一个适合查询的数据视图。

例如:

事件流:

OrderCreated
OrderPaid
OrderShipped

可以投影出不同的读模型:

  • 用户订单列表
  • 物流查询视图
  • 销售统计报表

这些读模型可以:

  • 使用不同的数据库
  • 使用不同的数据结构
  • 独立扩展

并且如果投影逻辑发生变化,也可以通过重放事件重新构建

4. 查询模型是只读的

在CQRS架构中:

  • 写模型只负责产生事件
  • 读模型只负责提供查询

读模型通常具有以下特点:

  • 数据冗余
  • 结构为查询优化
  • 可以使用不同数据库
  • 可以独立水平扩展

这使得系统在面对复杂查询需求时具有非常大的灵活性。

核心问题:读写的一致性保障

到这里,一个关键问题就出现了:

读模型是如何更新的?

通常的流程是:

写模型 -> 产生事件 -> 发布事件 -> 更新读模型

而事件的传播往往是通过消息系统完成的。

例如:

  • Kafka
  • RabbitMQ
  • Pulsar

这意味着一个事实:

读模型的更新通常是异步的。

因此,CQRS天然更适合:

最终一致性(Eventual Consistency)

但在真实系统中,并不是所有查询都能接受最终一致性。

因此,一个成熟的CQRS系统必须对查询进行分类。

强一致读 vs 最终一致读

通常需要明确区分两种查询:

1. 强一致读

例如:

  • 用户提交订单后立刻查看订单状态
  • 交易系统中的余额查询

这些查询必须看到最新状态

2. 最终一致读

例如:

  • 报表统计
  • 搜索结果
  • 推荐系统

这些查询可以接受短暂的不一致。

强一致读带来的问题:多写一致性

如果某些读模型需要强一致性,那么它们的更新就必须:

与事件写入处于同一个事务中

否则就可能出现:

  • 事件已经产生
  • 但读模型尚未更新

从而导致读到旧数据。

这就引出了一个新的问题:

多写一致性(Multiple Writes Consistency)

系统需要同时写入:

  1. 多个强一致读模型
  2. 事件发布系统

而这些系统往往是不同组件。

常见解决方案:Transactional Outbox

一种常见的解决方案是:

Transactional Outbox 模式

基本思路是:

  1. 使用支持事务的数据库
  2. 在同一事务中写入:
事件表
读模型表
Outbox表
  1. 由后台任务或CDC系统读取Outbox表
  2. 再将事件发布到消息系统

这样可以保证:

  • 事件与数据库状态强一致
  • 消息最终一定会被发布

注意事件重放的粒度与延迟

在CQRS讨论中,经常会出现一个误解:

CQRS的事件溯源 ≠ Kappa架构的事件重放

两者虽然看起来相似,但关注点不同。

CQRS需要实体级事件溯源

CQRS中的事件溯源通常需要精确到:

单个业务实体

例如:

Order-123 的所有事件

当系统需要恢复该实体状态时,可以只重放这些事件。

同时又需要全量事件重放

另一方面,在数据分析或机器学习场景中,系统可能需要:

重放整个系统的所有事件

例如:

  • 重建统计模型
  • 训练推荐算法
  • 构建新的分析视图

这与Kappa架构非常类似。

查询延迟的现实约束

如果查询模型依赖于:

实时重放实体事件

那么必须考虑一个现实问题:

单个实体的事件数量是否会导致查询延迟过高

例如:

账户事件数量 = 50000

每次查询都重放这些事件显然不可接受。

因此很多系统会采用:

  • 快照(snapshot)
  • 周期性状态持久化

来减少重放成本。

中间件选择的现实问题

理论上,一个理想的系统需要同时支持:

  1. 单实体事件溯源
  2. 全量事件流重放
  3. 发布订阅模式

能同时很好满足这三点的系统并不多。

例如:

  • KurrentDB / EventStoreDB 就是为此设计的事件数据库

但它们相对小众,在很多企业环境中不够通用。

因此,一个更常见的工程化方案是:

关系数据库 + Kafka

职责划分如下:

关系数据库

  • 存储实体级事件
  • 支持事务
  • 支持精确查询

Kafka

  • 全量事件流
  • 发布订阅
  • 流式处理

这种组合虽然没有专用事件数据库优雅,但通用性更好

实现难点

读到这里,你可能已经感受到一件事情:

CQRS架构确实很复杂。

这种复杂性主要体现在三个方面。

1. 运维复杂度

一个完整的CQRS系统往往涉及多个组件:

  • 关系数据库
  • 消息系统
  • 投影服务
  • 事件处理器
  • 读模型存储

这些组件不仅需要部署,还需要你编写代码去小心翼翼地粘合它们

2. 调试困难

在传统系统中,一个请求通常只有:

API -> Service -> DB

而在CQRS系统中,一个写操作可能变成:

API
 -> 写模型
 -> 事件存储
 -> 消息系统
 -> 投影处理
 -> 查询模型

当问题发生时,调试链路会变得非常长。

3. 学习曲线

CQRS系统通常是:

  • 事件驱动
  • 异步优先

团队需要适应:

  • 最终一致性
  • 事件驱动编程
  • 分布式系统问题

对于习惯传统CRUD开发模式的团队来说,这是一条不短的学习曲线。

架构师在实施CQRS过程中容易踩的坑

1. 在简单系统中过早引入CQRS

CQRS本质上是一个为复杂系统设计的架构模式

如果一个系统:

  • 数据规模不大
  • 查询模式简单
  • 团队规模较小

那么传统的CRUD架构往往更加合适。

很多团队在系统早期就引入:

  • 事件溯源
  • Kafka
  • 投影系统
  • 多种读模型

结果就是:

架构复杂度远远超过业务复杂度。

这种情况往往会让开发效率急剧下降。

在早期架构中,最需要关注的是使用OLTP记录当前状态,以及使用OLAP记录所有历史领域事件。 不必引入完整的CQRS,只需要确保事件是被完整记录下来的。

2. 忽略一致性问题

很多CQRS介绍文章都会强调:

CQRS适合最终一致性系统

但现实中,并不是所有查询都能接受最终一致性

如果没有清晰地区分:

  • 强一致读
  • 最终一致读

系统就很容易出现:

  • 用户操作成功
  • 但查询却看不到最新状态

这种问题在订单、交易、库存等系统中尤其严重。

3. 事件设计不合理

在事件溯源系统中,事件本身就是系统的事实记录(source of truth)

如果事件设计得过于随意,例如:

  • 事件粒度不合理
  • 事件语义不清晰
  • 事件字段经常变化

那么随着时间推移,事件流就会变得难以维护。

很多团队在几年后会发现:

历史事件已经无法正确重放系统状态。

4. 忽略运维成本

一个完整的CQRS系统通常包含:

  • 事件存储
  • 消息系统
  • 投影服务
  • 多种读模型数据库

这意味着:

  • 更复杂的部署
  • 更多的监控指标
  • 更困难的故障排查

如果团队没有足够的运维能力,系统稳定性反而可能下降。

总结

CQRS并不是一种简单的架构模式,它更像是一种面向复杂系统的数据架构思想

当系统规模逐渐扩大时,很多问题会不可避免地出现:

  • 查询模式越来越复杂
  • 读写负载严重不均衡
  • 历史数据变得越来越重要
  • 数据分析和机器学习需求不断增加

在这样的背景下,传统的“单一数据库 + CRUD”模式往往会逐渐走到极限。

CQRS提供了一种不同的思路:

将写入的复杂性和查询的复杂性彻底分离。

这种分离使系统在面对未来变化时具有更大的灵活性。

当然,这种灵活性是有代价的。 CQRS在架构设计、工程实现以及运维层面都带来了更高的复杂度。

但对于那些需要长期演进的大型系统来说,这种复杂度往往是值得的。

很多成熟的大型系统,最终都会在某种程度上演化出类似CQRS的架构形态。