想象一座老式电影院:导演、演员、观众全部挤在同一间房里。导演刚喊了一句“换机位!”,观众立刻追问“为什么换?”,导演不得不回头解释半天;观众想回看刚才一帧画面,导演就得暂停整个拍摄。拍摄要按剧本严谨推进,观看要随心所欲随时回放——两种需求挤在一起,互相拖累,现场一片混乱。 现实中的电影工业早就解决了这个问题:导演在片场下达命令,演员执行,摄像机把一切记录成胶片;胶片被保存进资料库,之后由放映机把胶片投影到银幕上,供观众观看。拍电影和放电影被分成了两座建筑,各自按自己的节奏优化。CQRS(Command Query Responsibility Segregation,命令查询职责分离)正是把这种思想带进软件架构:写操作走命令通道,读操作走投影视图。而这套体系的核心枢纽,就是本文的主角——事件投影(Event Projection)。
对 AI 从业者而言,CQRS 与事件投影是构建高并发读多写少系统的重要架构武器。理解它,你才能设计出查询与写入互不拖累、可扩展、可回放的现代分布式系统。
目录
可视化演示
将命令与查询分离到独立模型,经事件投影构建读模型,实现读写独立扩展与最终一致。
本节配有交互动效(进入视口后自动加载,约 3KB)。
读写分离 命令 查询 模型 CMD+QRY 读写独立优化
起源与定义:从混沌单间到双子影城
老式电影院的困境,正是传统软件架构的日常。一个订单模块里,下单、改状态、校验库存这些“写操作”,和查列表、看详情、跑报表这些“读操作”共用同一个实体模型、同一张表、同一把锁。写流量大时,慢查询把数据库拖垮;读流量大时,事务提交被阻塞。就像一个剧组既要拍戏又要现场售票,两边都做不好。
传统 CRUD 系统使用同一个领域模型同时承担读写职责。但读与写有着截然不同的诉求:写关注业务规则和不变量,读关注查询速度和形态。Greg Young 在 2010 年前后将 CQRS 系统化:把系统拆成两条通道——写侧用命令(Command)表达“希望发生的意图”,读侧用查询(Query)读取投影后的视图,两者使用不同的模型、不同的存储、不同的优化策略。
导演从此专心在片场拍戏,观众在放映厅舒服观影。两条通道各自演进、各自扩容,这就是 CQRS 的起点。
- 写侧定义写侧接收命令,执行业务规则后产生状态变化。
- 状态增量ΔS 表示系统状态的变化量,而不是完整的新状态。
- 读侧定义读侧只执行查询,不产生任何副作用。
命令端设计:导演的拍摄指挥部
导演身边有一位尽职的场记助理。助理收到“演员就位”的指令后,先核对剧本(校验规则),再喊“开拍”,演员按剧本执行,摄像机记录下真实发生的一切。这里,助理就是命令处理器,演员就是聚合对象,摄像机记录下来的素材就是领域事件。
命令端是 CQRS 的写半边。命令对象是不可变的意图描述,比如 PlaceOrderCommand(userId, items)。命令处理器(Command Handler)负责从仓库加载聚合、调用业务方法、校验业务不变量,最终让聚合产生一个领域事件。这个事件被追加到事件存储中,代表“已经发生的事实”——不是命令的希望,而是岁月的见证。
现场拍完的镜头被完整归档,场记在台本上写下“本镜已拍”。没有人需要立刻跑到放映厅去手动改银幕——那是投影器的工作。
- 命令进入一条命令从客户端进入命令总线,等待处理器接管。
- 处理器编排处理器负责加载聚合、调用方法、捕获结果,不自己写业务逻辑。
- 聚合校验聚合在自己的一致性边界内校验规则,保证不变量不被破坏。
- 事件发布事件被持久化并发布到消息总线,供投影器订阅。
读模型构建:观众看到的银幕
观众不会去片场看摄像机,他们看的是售票台、场次屏、座位图——每一个都是为特定查询精心准备的信息形态。同一部电影可以投影成海报、预告片、影评榜,它们来自同一份原片,却以各自适合的样子呈现在不同入口。
读模型是专门为查询而设计的模型,通常以反规范化(denormalized)的形式存在。查询“我的订单”页面需要用户名、订单号、商品列表、状态、金额,传统架构要 JOIN 三张表才能查全;而读模型在事件投影时就把这些字段物化成一张宽表。CQRS 允许甚至鼓励读模型与写模型使用完全不同的存储:写库用 MySQL 保证事务,读库用 Elasticsearch 或 Redis 追求性能。
影院的每个入口都挂着不同的信息牌:给观众看排片表,给财务看票房日报。读模型的“多形态”,正是 CQRS 应对复杂查询需求的优雅姿势。
- 投影运算π 表示按需要的列做投影,只保留查询关心的字段。
- 连接运算⋈ 表示把订单表和用户表按关联键连接起来。
- 物化结果把查询结果提前算好存入专用存储,查询时不再现场计算。
事件投影机制:放映机的魔法
真正的魔法藏在放映室。放映员把母带装进放映机,一帧帧投到银幕上。如果银幕布旧了要换新的,放映员只需要把母带从头再放一遍——投影可以无限重放,不必重新拍电影。CQRS 的读模型也是从事件流里“投影”出来的,那个负责投影的放映员,叫投影器(Projector)。
投影器订阅事件流,每收到一个事件(比如 OrderPlaced),就更新一次读模型。它需要维护一个进度位置(position/checkpoint),记录自己已经处理到第几个事件。当读模型需要重建时,投影器从位置 0 开始重放全部历史事件。为了提升效率,系统每隔一段时间打一个快照——相当于把胶片某几卷的“剧情摘要”存档,重建时从最近的快照开始,而不必从头逐帧放映。
放映机不会为了换幕布重拍电影,它只需要重放胶片。投影器同理——事件流就是那卷永不失真的母带。
- 读模型状态R_t 表示投影完成后读模型在时刻 t 的快照。
- 顺序叠加⊕ 表示按事件发生顺序把中间状态一个个应用、累积。
- 投影函数proj(e_i) 把单个事件 e_i 转化为对读模型的一次更新。
最终一致性:胶片需要冲洗时间
导演喊完“卡”,观众不可能瞬间看到这个镜头——胶片还要冲洗、剪辑、拷贝、配送。观众看到的画面总是“几分钟前的事实”,但故事不会因此出错。CQRS 中的读模型也有类似的“冲洗时间”,这就是最终一致性。
命令提交成功后,事件进入日志,但投影器可能还没处理到那条事件,读模型短暂滞后。如果用户刚下单就立刻刷新“我的订单”,可能会看不到新订单——这就是“读己之写”问题。解决思路包括:写后重定向到详情页并等待、对关键路径采用同步投影、客户端做短暂重试或轮询。更重要的保障是幂等(idempotent):同一事件被重复应用时不能重复加余额,投影函数必须以事件 ID 或版本号做去重。
观众永远不会看到“穿帮的瞬间”——导演喊停后几分钟,银幕上才出现刚才那个镜头。银幕反映的是真实发生过的事,只是略微迟到,不是编造的事。
- 距离函数dist 度量读模型 R_t 与写模型 S_t 当前状态的差距。
- 时间极限t 趋向无穷时,只要事件不丢失,差距的极限为零。
- 收敛含义系统保证读模型最终追平写模型,但不保证某一瞬间追平。
同步与重放:影院调度与断点续播
影院会根据影片热度安排上映策略:首映夜也许用卫星“实时直播”(同步投影),日常则靠实体拷贝配送(异步投影)。放映员还要在每本胶片上做标记,一旦停电,换好灯泡后从标记处继续放——这就是检查点(checkpoint)的妙处。
投影器的同步策略分为两类:同步投影在命令事务内同时更新读模型,读侧立即可见,但写延迟增加,适合极小规模的低延迟场景;异步投影让事件发布后由投影器在后台消费,读写彻底解耦,是主流选择。异步模式下,消息中间件的持久化订阅(Persistent Subscription)会为每个投影器记录消费位置。投影器崩溃重启后,从断点位置继续消费,而不是从头再来。
放映员在换片时会记住“放到第几本盒”。哪怕放映机烧了灯泡,换好之后接着那盒继续放——不会傻到把整卷胶片从头再放一遍。
- 最大序号pos 取所有“已应用”事件中序号最大的那个。
- 已应用条件e_i applied 表示第 i 个事件已经被投影器成功处理。
- 断点续传从 pos + 1 继续处理,配合快照可避免全量重放。
工程落地实践:从单厅影院到院线集团
从单厅影院到院线集团,并不是每个厅都需要上 IMAX。爆米花柜台用普通 POS 机就好,旗舰厅才值得上 CQRS——落地要看场景。
CQRS 适合:读多写少、查询形态复杂、报表分析拖垮交易库、需要审计回放的系统。不适合:简单 CRUD、对强一致要求苛刻且无法接受异步延迟的系统。它与 Event Sourcing(事件溯源,简称 ES)是正交概念——CQRS 可以不依赖 ES,直接命令后更新读库;ES 也可以不配 CQRS。两者搭配最顺:ES 提供不可变事件流,CQRS 把事件流投影成多样读模型。此外,监控的核心指标是投影延迟(Lag):最新事件序号减去投影器已处理位置,这个差值就是运维大屏上最该跳动的数字。
院线集团的中控室墙上挂着所有影厅的放映状态大屏,投影延迟就是那块跳动的数字——它告诉你哪块银幕掉队了,以及整个放映体系是否健康。
- 最新事件E_max 是事件流中最新事件的全局序号。
- 投影位置pos 是投影器已处理到的位置。
- 延迟告警Lag 超过阈值时,说明读模型掉队,需要排查投影器。
AI 应用场景
电商订单系统:前台下单与后厨查询分离
阶段:写模型设计 + 事件投影
解决的问题:订单查询拖垮交易库。用户下单后,订单列表、详情、运营报表都直接查 MySQL,慢查询和事务锁互相抢占资源。CQRS 把下单命令与查询视图分离,查询侧走专有读模型,事务库负载大幅下降。
具体案例:某电商平台高峰期订单写入约每秒 1000 笔,但查询 QPS 是写入的 20 倍。改造后,下单命令写入订单库并发布 OrderPlaced 事件,投影器把订单数据同步到 Elasticsearch 索引;用户订单页只查 ES,事务库查询压力下降 70% 以上。
银行账户流水与余额视图
阶段:事件存储 + 读模型重放
解决的问题:传统银行系统把余额直接存成“最终值”,一旦出错难以追溯。CQRS + ES 将所有存取款作为事件追加,余额只是一个投影出来的读模型,任何时刻都可以重放全部流水重建余额。
具体案例:银行核心系统的交易明细表记录每笔 Deposited / Withdrawn 事件,余额视图和月度账单视图分别由两个投影器订阅。某次数据事故中,运维从事件流重放重建了所有账户余额,找回了被误更新的数据——就像放映员用母带重新投映了一遍。
在线学习平台的进度报表
阶段:行为事件投影到分析视图
解决的问题:学习行为是典型的写多读杂场景,用户观看视频产生海量行为事件,而“课程完成度”“学习时长排行榜”等报表需要聚合计算。CQRS 让行为命令流式写入事件总线,投影器实时聚合出统计视图。
具体案例:某在线教育平台把 PlayStarted、VideoCompleted 等事件写入 Kafka,投影器消费事件并更新 Redis 中的课程进度和 MySQL 中的学习报表。运营大屏看到的实时数据,全部来自事件投影而非直接查业务库。
Python 演示
# -*- coding: utf-8 -*-"""CQRS 读写分离与事件投影的最小演示""" class BankAccount: """写模型:聚合根""" def __init__(self, account_id): self.account_id = account_id self.balance = 0 self.handled = 0 def apply(self, event): if event["type"] == "deposited": self.balance += event["amount"] elif event["type"] == "withdrawn": self.balance -= event["amount"] self.handled += 1 def handle(self, command, event_store): if command["op"] == "deposit" and command["amount"] > 0: event = {"type": "deposited", "account": self.account_id, "amount": command["amount"]} event_store.append(event) self.apply(event) elif command["op"] == "withdraw" and command["amount"] <= self.balance: event = {"type": "withdrawn", "account": self.account_id, "amount": command["amount"]} event_store.append(event) self.apply(event) else: raise ValueError("invalid command") class AccountProjection: """投影器:把事件流变成读模型""" def __init__(self): self._position = 0 self.views = {} def _load(self, account_id): if account_id not in self.views: self.views[account_id] = {"balance": 0, "count": 0} return self.views[account_id] def project(self, event): view = self._load(event["account"]) if event["type"] == "deposited": view["balance"] += event["amount"] elif event["type"] == "withdrawn": view["balance"] -= event["amount"] view["count"] += 1 self._position += 1 def replay(self, event_store): self.views.clear() self._position = 0 for event in event_store: self.project(event) @property def position(self): return self._position def main(): event_store = [] account = BankAccount("acc-001") account.handle({"op": "deposit", "amount": 1000}, event_store) account.handle({"op": "withdraw", "amount": 300}, event_store) projection = AccountProjection() print("命令已提交但尚未投影", projection.views) projection.replay(event_store) print("投影完成后的读模型", projection.views["acc-001"]) print("投影器位置", projection.position) if __name__ == "__main__": main()
event_store.append(event)把事件追加到事件存储,而不是直接覆盖余额字段,这就是“记录事实”。self.apply(event)聚合通过 apply 事件恢复自身状态,这是 Event Sourcing 的“从事件重建状态”。projection.replay(event_store)投影器全量重放事件流,重建读模型视图。view["balance"] += event["amount"]投影函数把单个事件转成对读模型的一次更新。self._position += 1投影器记录已处理位置,支撑断点续传与延迟监控。
常见误区
误区“我看到的教程都是 CQRS 和 Event Sourcing 一起讲的,那我不用 ES 是不是就不算 CQRS?”
正解CQRS 与 Event Sourcing 是两个正交概念。CQRS 只规定读写两侧模型分离;ES 规定用事件流作为持久化方式。你完全可以命令直接更新业务表、然后同步更新读模型,这也是 CQRS。就像电影院可以拍完胶片再投影,也可以“现场直播”——两者不强制捆绑。
为何常见:Greg Young 当年常把二者放在一起讲,社区教程也习惯捆绑演示,导致“CQRS 离不开 ES”的错觉。
误区“数据库主从分离不就是读写分离吗?我配一个主库写、从库读,就是 CQRS 了吧?”
正解主从复制是把同一份数据复制到多台数据库,读的依然是同一个逻辑模型。CQRS 在应用层把命令模型和查询模型拆成不同的类、不同的接口、不同的存储形态。读模型是对事件流的投影物化结果,而不是主库的物理副本。
为何常见:两者都叫“读写分离”,名字相近但层级完全不同,初学者极易混淆。
误区“反正系统是最终一致的,那读模型查到旧值也没关系,过一会儿它就自己好了。”
正解最终一致承诺的是“延迟窗口内读到旧数据,而不是错误数据”。银幕上晚几分钟出现的是“刚才真实发生的事”,不是篡改的剧情。投影仍然需要幂等、去重、对账;对支付结果等关键业务,还要额外做读己之写补偿。
为何常见:把“异步”与“不可靠”画了等号,忽略了 CQRS 背后严格的容错设计。
误区“聚合只是一个类而已,我可以在一个命令里同时改订单聚合和用户聚合,反正都是一次事务。”
正解聚合的意义正是“一致性边界”——一次命令事务只允许更新一个聚合,保证聚合内部的不变量。跨聚合的状态协调应该通过事件驱动的最终一致完成,而不是靠一个大事务强行耦合。否则聚合边界失去意义,CQRS 的扩展性也荡然无存。
为何常见:写惯了传统事务型代码的人,习惯把多个实体塞进一个大事务里,忽略了领域建模的边界约束。
面试题
Q1. CQRS 全称中 C 与 Q 分别是什么词?
- A.Cache 与 Queue
- B.Command 与 Query← 正确答案
- C.Create 与 Quality
- D.Connect 与 Quantity
答案:B。CQRS 是 Command Query Responsibility Segregation 的缩写。C 代表命令(写意图),Q 代表查询(读数据)。Cache 与 Queue 张冠李戴,Create 和 Connect 都不属于该缩写。
Q2. CQS 与 CQRS 的本质差异是什么?
- A.CQS 在方法级别规范读写← 正确答案
- B.CQS 在架构级别强制分离
- C.CQRS 禁止任何查询操作
- D.CQRS 只适用于单体应用
答案:A。CQS 由 Bertrand Meyer 提出,要求一个方法要么是命令(改状态)要么是查询(读状态),不能两者兼得。CQRS 把这一思想从方法级扩展到架构级,让读写使用完全不同的模型。CQRS 并不禁止查询,也不限于单体。
Q3. 写命令成功的事务标志是什么?
- A.读模型立即更新完毕
- B.业务校验通过且事件持久化← 正确答案
- C.所有查询全部返回新值
- D.界面提示用户操作成功
答案:B。命令端的成功标志是业务规则通过校验、事件被持久化写入事件存储。读模型是否立即更新不属于命令端的职责范围,界面提示也只是表象。CQRS 中命令与查询的时延被有意解耦。
Q4. 下列哪些属于事件投影器的职责?
- A.订阅事件流并更新读模型← 正确答案
- B.维护已投影的进度位置← 正确答案
- C.校验命令的业务规则
- D.支持从快照重放重建视图← 正确答案
答案:ABD。投影器负责消费事件、更新读模型、记录位置、重放重建。业务规则校验属于命令端和聚合的职责,投影器不应该也不需要有业务决策能力——它只是忠实的放映员。
Q5. 读模型采用反规范化的主要目的是什么?
- A.减少查询时的 JOIN 提高性能← 正确答案
- B.降低数据库的存储成本
- C.增强数据的保密性
- D.消除所有冗余数据
答案:A。反规范化是刻意把关联数据冗余到一张宽表或一个文档里,让查询不必现场 JOIN 多张表,从而显著降低读延迟。它不省存储、不加密,也恰恰是“保留冗余”而非消除冗余。
Q6. 关于 CQRS 的最终一致性,哪种说法正确?
- A.读模型将永远落后于写模型
- B.延迟窗口内读到的是旧数据而非错误数据← 正确答案
- C.读写两侧数据完全互不相关
- D.只要最终一致就不需要幂等
答案:B。最终一致性承诺读模型会收敛到写模型状态。延迟窗口内读到的是“刚刚发生过的事实”(旧数据),不是编造的错误数据。投影依然需要幂等保证,避免重放重复计数。
Q7. 投影器重启后需要具备哪些能力才能保证恢复?
- A.从 checkpoint 位置继续投影← 正确答案
- B.对重复事件做幂等去重← 正确答案
- C.每次重放都重新定义业务规则
- D.必要时从快照重放历史事件← 正确答案
答案:ABD。投影器崩溃恢复依赖三件事:持久化的检查点位置、事件的幂等处理、快照加速重建。业务规则属于命令端,重放事件是“重新投影”而非“重新定义规则”。
Q8. 关于聚合边界,下列理解正确的是?
- A.一个命令事务可以同时更新多个聚合
- B.聚合是保证业务不变量的一致性边界← 正确答案
- C.聚合内部不允许存在任何状态
- D.读模型必须与聚合结构完全一致
答案:B。聚合是领域驱动设计中的一致性边界,一次事务只更新一个聚合,保证边界内的不变量。跨聚合的协作应通过事件实现最终一致。读模型是投影视图,不必与聚合结构一致。
Q9. CQRS 与 Event Sourcing(事件溯源,ES)的关系是?
- A.CQRS 必须依赖 ES 才能使用
- B.两者正交,可以独立或组合使用← 正确答案
- C.ES 必须搭配 CQRS 才有效果
- D.CQRS 和 ES 是同义词
答案:B。CQRS 关心读写模型分离,ES 关心如何持久化状态变化。两者可以独立使用,也可以组合——组合时 ES 为 CQRS 提供优质事件流,CQRS 为 ES 提供投影出口,但互相不强制。
Q10. 以下哪些场景不适合直接引入 CQRS?
- A.简单 CRUD 业务系统← 正确答案
- B.强一致要求拒绝异步延迟← 正确答案
- C.读多写少且查询形态复杂
- D.团队缺乏事件建模经验← 正确答案
答案:ABD。简单 CRUD 引入 CQRS 是过度设计;强一致场景无法接受读侧延迟;团队对事件建模不熟时冒然引入会陷入复杂度泥潭。读多写少、查询复杂恰恰是 CQRS 的典型适用场景,不应排除。