CQRS(命令-查询职责分离)将写入(Command)与读取(Query)分开,用独立模型和存储优化各自职责,从而提升吞吐、降低耦合并便于扩展。在HelloWorld示例中,我会展示如何设计命令处理器、查询模型、事件传播与最终一致性,以及必要的同步策略与测试方法,让初学者能快速上手并理解常见陷阱哦


先放点背景:CQRS 是什么,为什么要用它
CQRS(Command Query Responsibility Segregation,命令查询职责分离)是一种架构思想:把负责改变系统状态的“写”逻辑(Command)和负责返回数据的“读”逻辑(Query)拆成两个不同的模型。用一句接地气的话来比喻,就是厨房和前台分开——厨师只做菜(写),服务员只上菜(读),各自专注,效率更高。
核心概念一览
- Command(命令):表示意图改变系统状态的请求,比如“创建用户”“增加库存”。
- Command Handler(命令处理器):接收命令并执行业务逻辑,通常会产生事件或直接写入写数据库。
- Event(事件):写操作完成后产生的事实记录,如“UserCreated”。事件常用于通知系统其他部分更新读模型或触发异步流程。
- Query(查询):只读操作,针对为读取优化的模型或视图,不修改系统状态。
- Projection / Read Model(投影/读模型):由事件或写操作构建、优化后的查询数据结构,通常与写数据库不同。
为什么用 CQRS?优缺点速览
- 优点:提高性能(读写可独立扩展)、降低耦合(读模型变化不影响写模型)、更容易为读操作做优化(缓存、索引、去范式化)。
- 缺点:实现复杂度上升、需要处理读写之间的最终一致性、测试与运维难度增加。
| 方面 | 写模型(Command) | 读模型(Query) |
| 主要职责 | 执行业务规则、更新状态 | 提供快速查询、为UI优化 |
| 数据形态 | 范式化、完整一致性 | 去范式化、冗余以提高查询速度 |
| 一致性 | 强一致(通常) | 最终一致(常见) |
HelloWorld 示例:从零到可运行的 CQRS 思路
我们用一个简单的“问候(Greeting)”示例来讲清楚整个流程:用户可以创建问候、查询最新问候列表。这个例子很小,但能把CQRS的关键点都展示出来。
1. 明确业务边界与用例
- 命令:CreateGreeting(text)、UpdateGreeting(id, text)
- 查询:GetGreeting(id)、ListRecentGreetings(limit)
注意先把业务用例写清楚,越简单越好。很多问题其实是因为一开始用例没想明白。
2. 设计写模型(Command 侧)
写模型聚焦于业务规则和数据完整性。一个典型的流程:
- 接收命令(例如 CreateGreeting)
- 在事务中执行验证与写入(例如检查文本长度、写入Greeting表)
- 产生事件(例如 GreetingCreated)并持久化事件或写入事件总线
伪代码思路(不用逐字复制):
Command: CreateGreeting { text } → CommandHandler 验证 → 写入写库(Greeting表)→ 产生事件 GreetingCreated(id, text, timestamp)
3. 事件与投影:把写侧的结果传播到读侧
写库产生的事件被派发到投影系统,投影会根据事件更新读模型。投影可以是单独的进程、消费队列里的消费者或数据流处理组件。
- 事件总线(Kafka、RabbitMQ、AWS SNS/SQS 等)用于异步传递事件。
- 投影消费者接收 GreetingCreated,更新 ReadGreeting 聚合,例如维护一个按时间倒序的问候列表。
4. 读模型(Query 侧)设计
读模型为了快速响应查询经常采用去范式化结构,例如:
- GreetingView{id, text, createdAt}
- RecentGreetingsView{dateBucket, listOfGreetingIds}(为了高效分页)
读侧常常使用 NoSQL 或内存缓存来加速响应,比如 ElasticSearch、Redis 或单纯的关系型数据库加索引。
5. 一致性如何保证?最终一致性 vs 强一致性
在CQRS里,读模型通常是“最终一致”的:写完成后,读侧在短时间内会收到事件并更新。如果需要即时读到刚写入的数据,有几种策略:
- 在命令完成后,同步更新读模型(牺牲写性能,仍可在事务外同步触发投影)
- 读请求命中写库而不是读库(按需降级)
- 在客户端显示“加载中”或等待短暂重试,直到读模型更新(用户体验妥协)
选择取决于业务对一致性的要求:例如银行转账通常需要强一致性,而微博时间线可以接受最终一致性。
实现步骤:一步步来(HelloWorld 实操思路)
准备
- 选好通信/事件基础设施(内存队列用于demo,生产环境用Kafka/RabbitMQ等)
- 分别准备写库与读库(可同一数据库的不同表,也可完全不同系统)
- 确定命令、事件和查询接口契约(JSON Schema 或 Protobuf)
实现命令处理器
命令处理器关注业务规则。示例流程:
- 接收 CreateGreeting(text)
- validate(text),返回错误或继续
- 在写库写入 Greeting 表,拿到 id 与 timestamp
- 发布事件 GreetingCreated(id, text, timestamp) 到事件总线
实现投影消费者
投影消费者订阅事件总线,处理事件并更新读库:
- 接到 GreetingCreated → 向 ReadGreeting 表插入视图记录
- 若发生重复事件(幂等问题),消费者需保证幂等性(通过事件ID去重或乐观检查)
实现查询接口
查询接口只从读库读取数据,通常非常轻量,支持分页、过滤、排序等。
测试与部署:不要漏掉这些现实细节
CQRS 系统要多关注集成测试与端到端测试,单元测试不足以覆盖异步传播与最终一致性场景。
- 模拟事件总线,验证命令发布事件并且投影最终更新读库。
- 测试幂等性:重复发送同一事件不会导致读库数据错乱。
- 压力测试:写侧与读侧独立扩展,在高并发场景下分别观察吞吐与延迟。
常见陷阱与优化建议(说实话,这些坑很真实)
- 最终一致性导致用户困惑:未告知用户“已提交,正在更新”的状态会导致重复操作。用 UI 提示或短暂锁定可以减少问题。
- 事件丢失或顺序错乱:选择可靠的消息系统并确保投影消费者有重试和幂等策略。
- 读模型膨胀:大量去范式化视图会占用更多存储,要设计清晰的归档与清理策略。
- 跨聚合事务:CQRS 不适合强一致性要求的跨聚合事务,通常需要 Saga 或补偿流程。
Saga(补偿事务)与流程管理
当一个业务事务需要跨多个写操作或外部服务时,可以用 Saga 来协调:每一步有正向操作与补偿操作。示例:创建订单需要扣库存和扣款,任一步失败要回滚前一步的效果。
实践小贴士:让 HelloWorld 更接近真实场景
- 先在单机环境把读写拆分出来做成两个微服务,再引入消息中间件;这样便于逐步演进。
- 给读模型加版本号或事件编号,便于回溯和重建投影。
- 投影恢复机制:当投影逻辑变更或数据损坏时,可以从事件流重新构建读数据库。
- 监控关键指标:事件延迟、未处理事件数、读写延迟、幂等冲突次数等。
小结(不正经的小结——像边想边写那样)
好吧,我其实是想把复杂的CQRS拆成能让人一眼理解的几块:写侧负责规则与事实,读侧负责快速响应,事件连接两者。HelloWorld 的价值在于把这些环节都亲自跑一遍,尤其是投影、幂等与一致性测试——这些东西看起来枯燥,但一旦理解了,后面扩展就顺很多。我自己当初也踩过好几次坑,比如忘了处理重复事件、把读表设计得太规范化导致查询很慢,这些经验就留在这里,大家别学我当初那样折腾。
参考材料(可查阅的书名/概念)
- Greg Young 的 CQRS 文章与演讲
- Event Sourcing 概念资料
- Saga 模式与分布式事务相关论文
如果你准备实践这个 HelloWorld,可以告诉我你用的语言与消息中间件,我可以帮你把上面伪代码变成具体的实现步骤和注意事项,顺便把测试用例也列出来,省得你走弯路。