在软件架构的讨论中,CQRS(Command Query Responsibility Segregation,命令查询职责分离)几乎是一个绕不开的话题。很多人都听说过它,也大致理解它的思想,但真正将它完整落地到生产系统中的团队却并不多。
原因其实很简单:CQRS非常重要,但也非常难实现。
要理解这一点,我们需要先看看它到底解决了什么问题。
CQRS解决的问题
众所周知,后端开发的主要困难往往集中在数据访问上。随着业务规模的扩大,大多数后端系统都会逐渐遇到一些非常典型的瓶颈。
很多后端开发者都经历过类似的困境:
1. 性能方面的瓶颈:读和写的负载不均衡
在绝大多数业务系统中,读请求的数量远远高于写请求。 典型的互联网系统往往是 90% 甚至 99% 的请求都是读取操作。
然而,在传统的架构中,读写通常共享同一个数据模型、同一个数据库。这会带来两个问题:
- 写模型往往为了保证事务一致性而设计得比较规范化(normalized)
- 读操作却往往需要复杂的 join、聚合或冗余结构
结果就是:
- 写模型不适合读
- 读模型也不适合写
数据库在两者之间被迫做妥协,最终两头都不理想。
2. 读取模式的多样性
现代系统中的查询需求是极其多样化的,例如:
- 低延迟的随机读取(KV查询)
- 范围查询(时间序列或分页)
- 全文搜索
- 聚合分析
- 向量搜索(AI/Embedding)
这些需求通常需要完全不同的数据结构和存储引擎:
| 查询需求 | 适合的存储 |
|---|---|
| OLTP事务 | 关系数据库 |
| 高速KV | Redis |
| 全文搜索 | Elasticsearch |
| 数据分析 | OLAP数据库 |
| 向量检索 | Vector DB |
因此,一个单一数据库往往难以同时满足所有需求。
3. 模式演进的困难
业务系统是不断变化的。
随着业务发展:
- 实体属性会增加或调整
- 查询需求会不断出现新的组合
- 原有的查询接口会被新的需求替代
如果所有读写都依赖同一个数据库模式,那么每一次变更都会带来巨大的成本:
- schema迁移
- 复杂SQL修改
- 历史数据兼容
很多系统在几年之后会变成一个高度耦合的数据库模式怪兽。
4. 历史状态与当前状态一样重要
大多数系统的数据建模都是围绕 OLTP关系数据库进行的。
这种模式有一个天然倾向: 只关注当前状态(current state) 。
例如:
订单表
order_id
status
amount
update_time
在这种模型中:
- 我们只知道订单现在是什么状态
- 却不知道它曾经经历过哪些状态
但在真实业务中:
- 审计
- 风控
- 数据分析
- 故障排查
都需要完整的历史状态变更记录。
换句话说:
状态的变化过程,和最终状态一样重要。
所以,总的来看,传统的“读写共享模型”的架构在规模扩大后往往会遇到四个问题:
- 读写负载不均衡
- 查询模式高度多样化
- 模式演进困难
- 历史状态难以追溯
而 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)
系统需要同时写入:
- 多个强一致读模型
- 事件发布系统
而这些系统往往是不同组件。
常见解决方案:Transactional Outbox
一种常见的解决方案是:
Transactional Outbox 模式
基本思路是:
- 使用支持事务的数据库
- 在同一事务中写入:
事件表
读模型表
Outbox表
- 由后台任务或CDC系统读取Outbox表
- 再将事件发布到消息系统
这样可以保证:
- 事件与数据库状态强一致
- 消息最终一定会被发布
注意事件重放的粒度与延迟
在CQRS讨论中,经常会出现一个误解:
CQRS的事件溯源 ≠ Kappa架构的事件重放
两者虽然看起来相似,但关注点不同。
CQRS需要实体级事件溯源
CQRS中的事件溯源通常需要精确到:
单个业务实体
例如:
Order-123 的所有事件
当系统需要恢复该实体状态时,可以只重放这些事件。
同时又需要全量事件重放
另一方面,在数据分析或机器学习场景中,系统可能需要:
重放整个系统的所有事件
例如:
- 重建统计模型
- 训练推荐算法
- 构建新的分析视图
这与Kappa架构非常类似。
查询延迟的现实约束
如果查询模型依赖于:
实时重放实体事件
那么必须考虑一个现实问题:
单个实体的事件数量是否会导致查询延迟过高
例如:
账户事件数量 = 50000
每次查询都重放这些事件显然不可接受。
因此很多系统会采用:
- 快照(snapshot)
- 周期性状态持久化
来减少重放成本。
中间件选择的现实问题
理论上,一个理想的系统需要同时支持:
- 单实体事件溯源
- 全量事件流重放
- 发布订阅模式
能同时很好满足这三点的系统并不多。
例如:
- 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的架构形态。