开奶茶店最怕什么?高峰期收银台前挤成一锅粥:顾客既要问“现在有什么新品”,又要在同一张纸上登记“一杯珍珠奶茶,去冰三分糖”。前者是读,后者是写,全都挤在同一本账本上。传统软件也一样,读请求和写请求共用一套数据库、一套实体模型,并发一高,读等写、写堵读。CQRS(Command Query Responsibility Segregation,命令查询职责分离)要做的,就是把“菜单大屏”和“厨房订单小票”彻底分开:凡是要改状态的操作用户,统称命令(Command),走一条专用通道;凡是只读取、不修改状态的操作用户,统称查询(Query),走另一条专用通道。查询侧还可以用“甜品展示柜”——也就是物化视图——提前把数据准备好,让用户一秒钟得到答案,而不会干扰后厨做奶茶。这篇文章就用奶茶店的故事,把 CQRS 的读写分离、命令侧、查询侧、事件同步、物化视图以及 AI 高并发实践讲清楚。
学会 CQRS,你就能把高并发 AI 系统的“读”与“写”拆成两条独立车道,让查询响应更快、训练与写入更稳,是架构师规划 AI 服务的关键能力。
可视化演示
动态演示CQRS命令查询分离的核心机制,通过总览/传输/转换/输出四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
CQRS命令查询分离:CQRS CQRS模式的核心原理与读写分 命令侧的领域模型与业务规则验证 核心机制可视化 CQRS命令查询分离整体架构与关键组件
读写分离思想
开奶茶店最怕什么?高峰期收银台前挤成一锅粥:顾客既要问“现在有什么新品”,又要在同一张纸上登记“一杯珍珠奶茶,去冰三分糖”。前者是读,后者是写,都挤在同一本账本上。排队的顾客越多,收银员越容易算错,后厨也越难跟上节奏。
CQRS(Command Query Responsibility Segregation,命令查询职责分离)说的正是把这两件事拆开:凡是会改变业务状态的操作用户——下单、改库存、发消息——都归为命令(Command);凡是只读取、不改变状态的操作用户——查菜单、看订单、浏览商品——都归为查询(Query)。奶茶店的解决方案是:门口挂一块只读的菜单大屏,后厨只收写好的订单小票,两边不再共用同一张纸。
形式化地看,查询集合与命令集合互不重叠:
菜单大屏永远不修改后厨配方,后厨小票也不会被顾客拿来当菜单比划。这样,高峰期的点单、出餐、补货都能各自优化,互不拖后腿。
- Q 查询集合$Q$ 代表所有不会改变状态的只读操作,例如查看商品详情。
- C 命令集合$C$ 代表所有会改变业务状态的写操作,例如下单、扣库存。
- 交集为空$Q$ 与 $C$ 之间没有共享的职责,交集为空,保证读写互不干扰。
命令侧设计
后厨最怕顾客隔着窗口喊话:“我要一杯草莓波波,等一下,改成芒果的!”乱。聪明的店设计了一张标准订单小票:每个字都写清楚,后厨只处理小票。命令侧就是这张小票,它把“想做什么”固化成一件可以传递、可以执行的事。
在代码里,一个命令是一个对象,包含这次写操作的全部信息,例如 CreateOrderCommand 里有商品 ID、数量、用户 ID。命令处理器(Handler)负责校验、执行状态变更并把结果落地。命令侧强调“动作”,而不是“查询结果”,所以它不会返回一堆用于展示的领域数据,最多返回一个订单号或成功标志。
命令执行可以看作一次状态转移:
小票一旦写好,后厨只需要按步骤执行,不会有人中途问“现在锅里的奶茶是什么状态”。命令侧把写操作做成稳定、可重放、可追溯的流水线。
- S 当前状态$S$ 表示处理前的订单或库存状态。
- C 命令输入$C$ 是封装好的命令对象,包含动作和数据。
- f 处理逻辑$f$ 是命令处理器,负责校验、执行业务规则并改变状态。
- S′ 新状态$S'$ 是命令执行后的新状态,需要持久化。
查询侧设计
顾客站到菜单大屏前,只需要看图片、价格和甜度选项,不用知道珍珠是几点煮的、加糖顺序是什么。大屏就是后厨的“只读投影”。查询侧专门为读而设计,目标是让用户一眼得到答案。
查询侧的查询模型可以是独立的 DTO 或只读仓储,字段按展示需求组织,比如“商品卡片 = 名称 + 价格 + 今日销量”。它不调用命令链,不修改任何状态,还可以加缓存、加索引。这样,即使几百人同时盯着大屏,点一下也立刻出结果,不会让后厨手忙脚乱。
查询响应可以抽象为一个函数:
大屏背后的数据不是临场去后厨现问的,而是由同步机制预先摆好的只读展示数据。再多人盯着大屏看,也不会让后厨停下来答话。
- Q 查询参数$Q$ 是只读请求携带的条件,例如分类、页码、排序。
- V 只读模型$V$ 是预先组织好的物化视图或查询模型。
- g 查询映射$g$ 把查询参数映射到 $V$ 上并生成响应。
事件同步机制
奶茶做好后,出餐员不会跑到大屏前亲口告诉顾客“珍珠没了”,而是对着叫号器喊一声“188号好了”。这个广播就是事件。命令侧完成写操作后,不必等着查询侧回应,只要把事实广播出去,让关心这件事的人自己去更新。
命令侧发布领域事件(Domain Event),比如 OrderCreated、StockChanged;查询侧订阅这些事件并更新自己的只读模型。两侧不直接共用数据库连接,而是通过事件流同步。由于事件传播需要时间,系统通常是最终一致,而不是强一致——就像大屏晚半秒钟显示“珍珠已售罄”,顾客并不会觉得天塌了。
事件流可以表示为若干按时间排列的事件:
叫号广播之后,即使厨房和菜单大屏中间有短暂延迟,系统依然能正常运转。事件同步让奶茶店不用把后厨和菜单绑在同一把锁上。
- e_i 单个事件每个事件代表一次已经发生的状态变更。
- E 事件流事件按发生顺序排列,形成可回放的事件流。
- 投影更新查询侧每收到一个新事件,就增量更新自己的视图。
物化视图
展示柜里的甜品样品,是提前做好放上去的复制品。顾客看到“今天有芋泥”,但芋泥桶还在后厨;样品不会因为客人询问而变少。物化视图(Materialized View)就是这个展示柜,它是查询侧提前“做好”的数据快照。
物化视图把事件流折叠成适合查询的表结构,比如把多个订单事件聚合成“今日销量”。它是有意冗余的数据,不需要查询时再扫描原始命令数据,所以读起来特别快。由于它是从事件推导出来的,可以定期重建,也可以在事件到达时增量更新。
物化视图是事件流经过投影函数后的结果:
样品柜放久了可能不新鲜,所以要有刷新策略;物化视图也可能短暂落后,所以要有重建和补偿机制。展示柜让顾客一秒钟得到答案,这正是它的价值。
Project 把事件历史折叠成一张可查询的表;每当新事件到达,就增量更新 $V$,而不需要全量重算。- E 事件历史事件流保存了所有已经发生的变更。
- Project 投影投影函数把事件折叠成面向查询的结构。
- V 物化结果$V$ 是按查询模型组织的冗余快照。
传统CRUD对比
老式奶茶店,收银台上一本厚账,顾客问一句、点一杯,收银员都翻同一本账。单量少时方便,单量一大就排队。传统 CRUD 也一样,把同一个实体模型同时用来读写:Order 既要被 create/update 修改,又要在列表页被 read 查询。
读写逻辑揉在一个类里,查询要防写锁,写要防读堵,系统越往后越卡。CQRS 则不共用模型:一个写模型负责事务,一个读模型负责展示,互不牵制。当然,代价是多了一套模型和一套同步机制,所以不是所有系统都适合。
用读写比判断是否值得分离:
小奶茶店没必要上两套系统,一本账就够了;但到了写字楼楼下,每天几千人看菜单,菜单大屏和厨房小票就必须分开。CQRS 不是银弹,而是取舍:用多一套模型,换独立扩展和互不阻塞。
- R 读操作数$R$ 表示单位时间内的查询次数。
- W 写操作数$W$ 表示单位时间内的写入次数。
- D 读写比$D=R/W$ 可以衡量“读多写少”的程度。
AI系统实践
分店开张后,白天客流全挤在菜单大屏前,晚上研发才在后厨试新品。大屏和后厨的节奏完全不同——这正是 AI 系统的日常。在 AI 系统中,模型训练、特征更新、数据回填是命令侧,它们重、慢、要资源;在线推理、推荐查询、用户画像读取是查询侧,它们轻、快、要低延迟。
CQRS 的思路是把两者拆开:训练集群专心写模型版本,推理服务从物化好的特征视图和模型索引中读。特征工程的结果可以做成物化视图,用户每一次点击行为作为事件发布,查询侧只读“特征视图 + 模型快照”,避免在推理链路里现场做复杂清洗。
查询侧的扩展能力可以简单表示为:
就像分店只扩建菜单大屏和展示柜,后厨保持原来的节奏。AI 平台上,模型训练慢慢跑,线上推理随时顶住流量洪峰,这就是 CQRS 带来的架构韧性。
- μ_node 单节点能力一个推理服务节点每秒能处理的查询数。
- N_q 节点数量查询侧可以根据流量水平扩展的节点个数。
- μ_total 总吞吐总吞吐等于单节点能力乘以节点数量。
AI 应用场景
电商订单与库存查询
阶段:系统架构设计 / 后端服务
解决的问题:传统 CRUD 在秒杀时,库存写入和商品查询互相竞争数据库锁,页面卡死。CQRS 将下单命令和商品浏览查询分离,查询走物化视图,命令异步处理订单。
具体案例:电商大促时,商品详情页 QPS 达到 10 万,下单 QPS 只有 2000。查询侧用物化视图展示库存与价格,命令侧独立处理订单,互不阻塞。
AI推荐系统的训练与推理分离
阶段:模型训练与在线推理
解决的问题:在线推理请求如果直接依赖实时特征拼接,延迟会很高。CQRS 中,特征工程、模型更新是命令,推荐请求是查询,特征和模型索引被物化成视图。
具体案例:某推荐平台把用户点击行为做成事件流,离线训练更新模型,在线推理只读取物化特征向量,最终 p99 延迟下降,推荐命中率提升。
社交 Feed 流的事件投影
阶段:数据同步 / 事件驱动
解决的问题:发帖是命令,关注者刷 Feed 是查询。如果每次刷都实时聚合所有关注的人,成本极高。CQRS 让发帖事件广播,每个用户的 Feed 视图由事件投影维护。
具体案例:社交 App 用户发帖后,事件总线把消息推给关注者的 Feed 物化视图,查询侧直接读取该用户的 Feed 列表。
Python 演示
# 简化 CQRS: 命令侧写库存, 查询侧维护物化视图import time class EventStore: def __init__(self): self.events = [] def append(self, event): self.events.append(event) def since(self, seq): return self.events[seq:] class ProductCommandService: def __init__(self, event_store): self._event_store = event_store self._db = {"apple": 10, "milk": 5} def restock(self, sku, qty): if sku not in self._db: self._db[sku] = 0 self._db[sku] += qty event = {"type": "restock", "sku": sku, "qty": qty, "ts": time.time()} self._event_store.append(event) def get_state(self): return dict(self._db) class ProductQueryService: def __init__(self, event_store): self._view = {} self._seq = 0 def sync(self): for event in self._event_store.since(self._seq): sku = event["sku"] self._view[sku] = self._view.get(sku, 0) + event["qty"] self._seq += 1 def get_stock(self, sku): self.sync() return self._view.get(sku, 0) if __name__ == "__main__": store = EventStore() cmd = ProductCommandService(store) query = ProductQueryService(store) cmd.restock("apple", 20) cmd.restock("milk", 10) print("查询视图:", query.get_stock("apple"), query.get_stock("milk")) print("命令侧状态:", cmd.get_state())
event = {"type": "restock", "sku": sku, "qty": qty, "ts": time.time()}命令完成后发布一个事件,记录“发生了什么”。self._event_store.append(event)事件写入事件流,命令侧不需要关心查询侧怎么读。ProductQueryService查询侧只依赖事件流,不直接访问命令侧的_db。for event in self._event_store.since(self._seq)按序号拉取增量事件,避免重复处理。self._view[sku] = self._view.get(sku, 0) + event["qty"]把事件投影成物化视图。cmd.restock("apple", 20)模拟一次写操作,走命令侧。query.get_stock("apple")模拟一次读操作,走查询侧物化视图。
常见误区
误区CQRS 不就是搞一个主库写、从库读嘛,MySQL 主从复制就够了。
正解主从复制只是物理层面复制同一份数据模型,从库的表结构和主库一模一样;CQRS 是逻辑层面拆分命令模型和查询模型,可以连表结构都不一样,甚至使用不同的存储。主从复制像把同一个菜单复印几份,CQRS 则像把菜单大屏和后厨小票做成完全不同的两种东西。
为何常见:因为“读和写分开”听起来很像主从读写分离,容易把物理复制和逻辑建模混为一谈。
误区命令一执行完,查询侧马上要读到最新数据,所以必须强一致。
正解CQRS 使用事件同步时,数据从命令侧传播到查询侧需要时间,通常是最终一致。菜单、库存、Feed 等场景对短暂延迟并不敏感。如果业务强一致要求极高,比如转账余额,就要很谨慎地使用 CQRS。
为何常见:习惯了传统 CRUD 的同步事务,会下意识要求每次写后读都看到最新数据。
误区CQRS 规定命令侧不能返回值,所以连订单 ID 都不能返回。
正解命令侧完全可以返回订单 ID、成功状态、错误码等“操作结果”,只是不返回用于页面展示的领域查询数据。后厨做完奶茶也会叫号告诉你“做好了”,关键是它不会把整个制作过程清单塞给顾客。
为何常见:把“不返回查询数据”误解成“不返回任何值”。
误区不引入 Event Sourcing(事件溯源),CQRS 就不完整。
正解事件同步是 CQRS 的常见搭档,但不是必需品。你可以只做模型分离:命令侧直接写状态,再同步更新查询模型。Event Sourcing 是另一个独立的架构决策,两者捆绑出现只是因为教程常把它们放一起讲。
为何常见:很多示例代码都同时展示 CQRS 和 Event Sourcing,容易让人以为它们是强绑定的。
误区只要把读写分开,系统就高枕无忧,所有并发问题都消失了。
正解CQRS 解决了读写模型互相阻塞和独立扩展的问题,但重复事件、消息乱序、最终一致窗口、查询侧数据延迟仍然需要设计。它像是把厨房和前厅分开,但如果后厨自己混乱,生意照样会出问题。
为何常见:架构宣传往往夸大模式的效果,忽略了分布式系统本身的复杂性。
面试题
Q1. 下列哪项是 CQRS 中最核心的拆分对象?
- A.读操作与写操作← 正确答案
- B.数据库与缓存
- C.前端与后端
- D.同步与异步
答案:A。CQRS 全称是 Command Query Responsibility Segregation,核心是把命令(写)和查询(读)分离,就像菜单大屏和厨房小票互不混用。数据库与缓存、前端与后端、同步与异步是其他维度的架构拆分。
Q2. 命令侧通常不会执行下列哪项操作?
- A.创建新订单
- B.更新用户余额
- C.聚合报表查询← 正确答案
- D.扣减库存商品
答案:C。聚合报表查询是只读操作,属于查询侧职责。创建订单、更新余额、扣减库存都会改变状态,必须走命令侧。
Q3. CQRS 中的查询模型通常来自于哪里?
- A.直接读主库原始表
- B.基于物化视图读取← 正确答案
- C.通过命令日志回溯
- D.从内存中随机生成
答案:B。查询侧通常从物化视图或专门只读模型中读取数据,避免在主库上做复杂查询。命令日志回溯不是常规查询路径,随机生成更不可能。
Q4. 查询侧为了提升性能,最不建议做的是?
- A.增加只读副本
- B.为视图建立索引
- C.在查询接口中修改状态← 正确答案
- D.使用缓存加速
答案:C。查询侧的铁律是“只读不写”。在查询接口中修改状态等于把命令混进查询链路,违背了 CQRS 的核心约束。只读副本、索引、缓存都是合法的查询侧优化手段。
Q5. 事件同步机制通常带来的数据一致性是?
- A.强一致性
- B.最终一致性← 正确答案
- C.无一致性
- D.串行一致性
答案:B。事件从命令侧传播到查询侧需要时间,因此查询视图会短暂滞后,最终会追上命令侧的状态,这叫最终一致性。强一致性需要同步等待,通常不适用于事件驱动的 CQRS。
Q6. 命令侧设计通常包含哪些要素?
- A.命令对象← 正确答案
- B.命令处理器← 正确答案
- C.复杂报表查询
- D.事件发布← 正确答案
答案:ABD。命令侧由命令对象表达意图,命令处理器执行业务逻辑,完成后发布事件通知查询侧。复杂报表查询属于查询侧,不应该出现在命令链中。
Q7. 与传统 CRUD 相比,CQRS 的典型特征有哪些?
- A.读写使用不同模型← 正确答案
- B.两侧可独立扩展← 正确答案
- C.查询返回持久化实体
- D.通过事件同步视图← 正确答案
答案:ABD。CQRS 让命令模型和查询模型分离,两侧可以独立扩容,并通过事件同步视图。传统 CRUD 才倾向于把持久化实体直接返回给前端,CQRS 的查询侧返回的是视图 DTO。
Q8. 当读写比 D=R/W 远大于 1 时,CQRS 的收益主要来自?
- A.减少写请求数量
- B.查询侧摆脱写锁← 正确答案
- C.取消命令侧模块
- D.强制实时数据同步
答案:B。读多写少时,查询侧压力远大于写侧。CQRS 让查询侧不再和写事务抢锁,可以单独加缓存、加索引、加节点,所以收益最明显。
Q9. 在 AI 高并发推理场景中,按 CQRS 思想可以怎么做?
- A.训练作为命令侧← 正确答案
- B.推理作为查询侧← 正确答案
- C.特征和模型物化← 正确答案
- D.查询请求直接改模型权重
答案:ABC。训练是重资源写操作,推理是高频只读操作,特征和模型做成物化视图可以加速推理。查询请求直接改模型权重等于在查询侧做写操作,违背 CQRS 原则,也会破坏模型稳定性。
Q10. 命令处理完成后,最不适合返回给调用方的是?
- A.新订单 ID
- B.完整领域对象聚合← 正确答案
- C.成功状态码
- D.错误码提示
答案:B。命令侧可以返回订单 ID、状态码、错误码等操作结果,但不适合返回完整领域对象聚合,因为那是查询侧的责任。就像后厨会告诉你“做好了”,但不会把整锅原料和配方都端给顾客。