☰
CQRS不是读写分离:拆解命令查询职责分离的本质与实践
2026/10/13 7:51:31 网站建设 项目流程

很多团队在讨论CQRS时,其实讨论的完全不是同一件事。有人把它当成“读写分离”的另一种叫法,有人以为它就是命令总线上挂几个Handler,还有人把CQRS和事件溯源死死绑在一起,觉得用了CQRS就必须上Event Sourcing。这种鸡同鸭讲造成的直接后果,是CQRS被大量用在根本不需要它的系统里,然后被骂“过度设计”。这篇文章想做的事情很朴素——把CQRS这个词汇背后的核心问题拆开,讨论它真正解决了什么矛盾、什么场景值得用、什么场景千万别碰,以及一次真实改造过程中那些文档里不太会写的细节。适合正在评估架构方案、或者已经被“团队里要不要引入CQRS”争论困扰的开发者。

1. 先搞清楚:当我们说CQRS时,我们其实在说两件不同的事

1.1 命令与查询:两种截然不同的操作性格

一个系统只要活上两年,读写两端的“性格差异”一定会暴露出来。写操作关心的是业务规则:这笔订单能不能取消、库存够不够扣、余额是否允许透支;读操作关心的是消费效率:列表页要一次返回一百条记录,还要带出用户昵称、商品名、优惠券状态这些跨领域信息。把这两类性格完全不同的操作揉在同一个领域模型里,结果往往是两边都在迁就:领域模型为了承接复杂的查询需求,被迫塞进一堆查询专用方法;查询为了躲开领域模型的延迟加载和规则校验,又大量使用裸SQL绕过模型。最后领域模型名存实亡,事务边界也一团糟。

我拿图书馆来打个比方。读者借书服务是一个语义很重的写操作:要校验会员资格、要检查是否超期未还、要锁定某本书的唯一副本。书架查询则完全不同——按书名搜、按分类筛选、按出版年份排序,它关心的是“怎么让读者最快找到书”。如果图书馆系统强制用同一个“图书领域模型”来同时支撑这两件事,这个模型要么为查询做大量妥协,要么写操作繁琐到没人愿意用。CQRS想做的事情,本质上就是把这两套语义分开:命令侧专心保证业务规则和一致性,查询侧专心构造“界面想要的东西”。

1.2 同一个模型同时干两件事的矛盾在哪

传统的三层架构里,Service层既是命令入口也是查询入口,ORM实体既是数据载体也是视图模型。短期看不出问题,可一旦业务复杂到一定程度,妥协就开始积累。比如订单模型里有一个Status属性,UI上要显示“订单状态”的下拉筛选,同时业务规则又规定Status只能由领域方法变更。为了让查询好写,有人会把Status改成可公开set;为了让写操作严谨,又得在Service层补一堆校验。这么来回一折腾,模型就不是模型了,只是一个没有规则约束的“数据袋子”。

另一个被忽视的矛盾是事务边界和查询性能。命令操作喜欢小事务:锁定订单、修改状态、记录日志,事务粒度越小越安全。而报表类查询希望一条SQL能把几百万行数据join出来,这种情况下让它跟写操作共享同一个事务边界、同一套索引策略,几乎总是互相伤害。写操作要的索引往往拖累插入性能,查询要的索引又会拖慢更新。很多时候系统变成“写优化一点,查询就卡一点;查询优化一点,写入就等锁”,就是这么被来回拉扯出来的。

1.3 CQRS的正确姿势:不是“读写分离”,是“职责分离”

很多人把CQRS理解成“主从复制下的读写分离”,这其实差了十万八千里。读写分离解决的是“物理IO资源的分配”,也就是把读压力从主库剥离到从库;CQRS解决的是“逻辑模型的设计”,它允许命令侧和查询侧设计出两个完全不同的模型,这两个模型可以共享同一个数据库,也可以各用各的存储,完全取决于你的场景。

在CQRS里,命令侧接收到“提交订单”这个意图时,它会执行一整套复杂的业务规则——校验库存、计算价格、拉起支付单——最终落库的可能是传统的范式化表结构。而查询侧收到“查询我的订单列表”时,它不会去碰这套范式化模型,而是直接从一个为这个界面定制的读模型里取数据。这个读模型可能是一张冗余的宽表,也可能是一个搜索引擎索引,甚至可能是内存缓存。关键不在于存储是否分离,而在于模型是否分离——这一点我反复跟团队强调过,但实际动手时最容易忘。

2. CQRS不是银弹:什么时候该上,什么时候不该上

2.1 典型适用场景:读模型和写模型的形态严重不对称

我参与过的项目里,最适合CQRS的往往是那些“写很简单但读很复杂”或者“读写都复杂但关注点完全不同”的系统。举三个真实的例子。第一个是订单管理后台:下单链路涉及库存、优惠、支付、物流多个领域,规则密集;但后台列表页需要一条数据里同时包含商品名、店铺名、优惠明细、物流状态,这些跨域信息混在一起,如果用订单聚合根一层层导航出来,性能惨不忍睹。第二个是协同编辑类的文档系统:写端关心的是版本、并发冲突、操作合并,读端关心的是“某个用户当前看到什么”以及“最近的变更记录”,这两个模型几乎无法共用。第三个是任何报表统计类模块,写模型永远在更新实时状态,读模型永远在跑聚合查询。

这几个场景的共性是:如果你强行在同一个模型上同时表达命令语义和查询语义,最后一定有一方在做“扭曲式妥协”。这时候把两者拆开,反而让两边都更干净。注意,这里的“复杂”不只是代码行数问题,而是指读操作和写操作关心的维度已经明显分叉,继续共用一套模型会让双方的设计都变得别扭。

2.2 千万不要CQRS的场景

和很多人想象的不同,CQRS最不适合的就是“简单CRUD”。有一个项目组找我聊过,说他们想把用户管理、角色管理、配置管理全部改成CQRS架构,理由是“以后系统会越来越复杂”。我当时的建议就一句话:如果现在每天只有几百个请求,表结构清晰、查询模式固定、写操作没有复杂规则,CQRS只会让你的代码量翻倍,让一次本应五分钟的字段修改变成改四个文件。后来他们反馈果然如此——为了把“新增一个用户状态”做出来,团队改了DTO、Command、CommandHandler、Validator、QueryService、QueryRepository整整六个类,而普通CRUD只需要一个实体加一个接口就能搞定。

还有一个常见误判:把“性能瓶颈”当成引入CQRS的理由。查询慢的根源往往是SQL写得烂、缺索引、缓存策略不对,先优化这些,成本低得多,效果也快得多。CQRS是一个结构性调整,它可能改善性能,但那是结果而不是目标。在你还没把慢查询本身调查清楚之前,别用架构调整掩盖实现层问题。

2.3 成本清单:上CQRS之前必须想清楚的三笔账

我把自己做CQRS的经验整理成一张检查表,每次有人问“我要不要上CQRS”,我都让他拿这个表对照:

评估维度适合引入CQRS的信号不建议引入CQRS的信号
读模型复杂度列表/详情查询需要跨多个聚合、多张表拼装,存在明显的“查询形态与领域模型不一致”查询就是简单单表查询,返回原始字段即可
写模型复杂度写操作有复杂业务规则、状态流转、与外部系统交互写操作基本是直接增删改,规则简单
团队规模有独立的读写需求方,能承受双模型维护成本团队只有一两个人,需求变化频繁但简单
性能瓶颈由模型结构导致的查询性能问题,优化SQL无法根治典型的数据库索引/SQL低效问题
一致性要求读写之间存在可协商的延迟窗口业务要求强一致,读写更新必须同步可见

如果对照下来有五条以上都偏向左列,CQRS值得认真考虑;如果大多是右列,我建议先把CRUD做好,把精力留给真正复杂的那部分业务。别因为“别人都在聊CQRS”就觉得自己不上不行,架构选型最怕的就是跟着流行词走。

3. 落地实录:一个订单查询服务的CQRS改造全过程

3.1 原始系统的痛点:为什么原有的订单查询越写越痛苦

我之前做过一个订单系统的维护工作,经典场景:订单表、订单明细表、支付表、物流表、优惠券使用记录表,列表接口却需要一次返回订单号、下单人、商品名、数量、实付金额、优惠信息、物流单号、当前状态。最初的实现是从订单聚合根出发,遍历引用,一层层关联加载,写了一个两百多行的查询方法,再加一堆if判断处理空值。上线初期数据量小感觉还行,可订单量过百万以后,这个接口的响应时间从50毫秒涨到800多毫秒,而且每晚跑批时还频繁出现死锁。

当时第一反应是优化SQL和索引,也确实见效了一阵子。但没过多久,产品又提了一个需求:列表页要按“促销活动维度”筛选订单,还要显示每个订单在活动里的补贴金额。这意味着又要join一张新表,又要给这条本来就复杂的查询链再加一环。我意识到这不是SQL优化能解决的,是“领域模型和查询模型共用一套结构”这个设计决策出了问题——领域模型为了保持一致性必须保证某种形态,查询却需要完全不同的形态。

3.2 命令侧:保留领域模型,把写操作收敛成明确的Command

改造的第一步不是急着做读模型,而是先把写操作的边界夯实。我把原来Service层里散落的订单操作整理成一组明确的Command:提交订单、取消订单、确认收货、申请退款。每个Command对应一个Handler,Handler里封装完整的业务规则和事务边界。举个例子:

public class SubmitOrderCommand { public string UserId { get; set; } public List<OrderItemInput> Items { get; set; } public string CouponCode { get; set; } } public class SubmitOrderHandler : ICommandHandler<SubmitOrderCommand> { private readonly IOrderRepository _repository; private readonly IStockService _stockService; private readonly IUnitOfWork _unitOfWork; public async Task Handle(SubmitOrderCommand cmd, CancellationToken ct) { var order = Order.Create(cmd.UserId, cmd.Items, cmd.CouponCode); await _repository.AddAsync(order, ct); await _unitOfWork.SaveChangesAsync(ct); // 这里可以发布领域事件,后续可能触发异步流程 } }

这个改造的价值不仅是代码结构更清晰,更关键的是它把“写操作要遵守的规则”显式地暴露出来了。以前新来的同事接手,要看出一个下单操作涉及哪些规则,得顺着Service层的老代码一路翻,现在直接看SubmitOrderHandler就行。写操作收敛以后,我对订单这个聚合根的状态流转更有信心了——因为所有改动都从Command进来,没有谁能绕过规则直接改Status。

3.3 查询侧:专门为界面“定制”的读模型

写侧稳定下来以后,我开始做查询侧的改造。思路非常简单粗暴:不再从聚合根出发做关联查询,而是专门设计一个“订单列表读模型”,这个模型提前把界面需要的所有字段摆好,查询时一条SQL或者一个小查询就能拿到全部数据。

public class OrderListReadModel { public string OrderId { get; set; } public string UserName { get; set; } public string ProductNames { get; set; } public int TotalCount { get; set; } public decimal TotalAmount { get; set; } public string PromotionName { get; set; } public decimal PromotionAmount { get; set; } public string StatusText { get; set; } public string TrackingNumber { get; set; } public DateTime CreatedAt { get; set; } } public interface IOrderQueryService { Task<PagedResult<OrderListReadModel>> SearchAsync(OrderQueryRequest request, CancellationToken ct); }

读模型的数据来源,我当时用了最稳妥的方案:在同一个库里建一张冗余的订单读模型表,通过命令侧写入时同步更新。这张表刻意做成“宽表”,字段都是直接针对界面做的冗余,比如UserName直接存冗余值,ProductNames直接存拼接串。这样做牺牲了存储上的范式化,换来了查询时几乎不需要join的性能提升。实际改造后,两百多行的查询方法变成了二十多行的简单查询,订单列表接口的响应时间从800多毫秒降到了90毫秒左右。

3.4 双写的处理策略:同步落地,还是异步最终一致?

一旦有了两个写入口,第一个跳出来的问题就是:命令模型和读模型之间怎么保持一致?当时我选择了“同步双写”:提交订单这个Command执行成功后,在同一个事务里把读模型一并更新。优点是一致性最强、出问题最少;缺点是写链路变长,而且读模型和命令模型的更新必须在同一事务里。这种方案适合改造初期,业务对“下单后立刻能在列表里看到”有强烈要求,异步方案会有滞后,产品那边不能接受。

后来订单量进一步上升,同步双写开始拖慢主流程,我才把读模型的更新改成异步。具体方式是把订单已提交事件发到消息里,查询侧监听事件后更新读模型。这里必须提一个关键点:异步方案一定要处理“消息投递失败”和“重复消息”,否则会出现读模型和命令模型不一致,事后再排查对账会非常痛苦。我的建议是,如果团队还不太熟练消息机制,别一上来就搞异步,先同步跑通流程,等到确实成为瓶颈了再逐步切换。很多项目死在第一步就想一步到位,结果一致性问题查都查不完。

3.5 这个案例的效果与代价

改造完成后我做过一个粗略对比:

指标改造前改造后
列表查询平均响应时间~800ms~90ms
列表查询方法行数200+~30
写操作规则清晰度散落在Service层集中在CommandHandler
新增一个列表字段的改动范围改聚合根+查询方法改读模型+同步逻辑
团队学习成本低中高(需理解双模型)

性能的红利是立竿见影的,但代价也很实在:团队所有成员都必须明白,系统里有两套模型,写模型和读模型可能对同一个业务概念有不同表达。比如订单状态,写模型里是一个枚举,读模型里是一个文本字段,这种差异如果没人维护好文档,新同学一定会疑惑。后面我会专门讲这个运维层面的坑。

4. CQRS与Event Sourcing:一对总被绑在一起的好基友

4.1 为什么大家总把CQRS和ES一起说

几乎每篇讲CQRS的文章,都会在某个段落里提到Event Sourcing,这两个词就像一对绑定销售的好基友。原因其实有历史必然性:事件溯源本身会天然产生两个视角——事件流是“发生了什么”的写日志,投影(Projection)是“当前状态是什么”的读模型。这种天然的双视角结构和CQRS的命令/查询分离高度契合,很多框架的Demo都是CQRS+ES组合出场,慢慢就给人留下了“CQRS必须配ES”的印象。

但我想强调一个很多人忽略的事实:CQRS和ES是完全独立的两个维度。CQRS解决的是“模型职责分离”,ES解决的是“状态持久化方式”。你可以有CQRS而没有ES,也可以有ES而没有CQRS,当然也可以两者连用。把它们混为一谈,会导致设计讨论陷入无意义的纠缠。

4.2 没有ES的CQRS:完全可行的组合

实际上,我自己做过的最多的是“无ES的CQRS”:命令侧操作关系型数据库的领域模型,查询侧使用一个冗余的读模型表。命令写库后同步或异步更新读模型,整个过程不引入事件流。这种组合的好处是落地成本低,不用处理事件存储、事件版本、投影重建这些高门槛概念,团队成员只要掌握“两套模型”这一个概念就能开工。对绝大多数业务系统来说,这种形式的CQRS已经能解决90%的问题——读模型复杂度、写模型复杂度、性能瓶颈,全都能覆盖到。

我甚至倾向于劝那些刚接触CQRS的团队:先做无ES版本,做熟以后再评估是否值得引入ES。因为ES带来的复杂度是另一个量级的,当你连命令/查询分离都还没玩明白时,同时引入ES几乎等于给自己埋了一颗巨大的定时炸弹。

4.3 有ES的CQRS:什么时候才值得

当然,ES并不是没有价值。它的价值在于把“当前状态”和“历史事实”彻底分开,适用场景非常明确:一是业务需要完整审计日志,比如金融交易、账单系统中的每一笔变更都要可回溯;二是需要支持时间旅行,比如“把系统回滚到上周三的状态”;三是领域事件本身就是重要的业务资产,比如订单整个生命周期产生的各种事件,后续要喂给数据分析或者做流程回顾。在这些场景里,让命令模型将所有业务操作以事件流的方式记录下来,再通过投影生成各种读模型,是非常优雅的架构。

但ES的代价也相当大。事件存储的兼容性是个大坑:你的领域模型演进以后,老事件怎么办?事件格式改了,投影要不要做版本兼容?并发冲突怎么检测?投影重建需要多少时间?这些问题的解决经验非常宝贵,但也非常昂贵。如果一个普通电商系统只是想要一个可追溯的日志,用数据库操作日志就能满足,没必要为这个动用ES重型武器。

4.4 如果只选CQRS不选ES,需要注意什么?

如果你和我一样只做CQRS不做ES,那么最需要注意的就是读模型的更新机制。没有事件流,你仍然需要一种机制把命令侧的变更传播到读模型。我的做法通常是:命令侧在成功写入领域模型之后,向一个内部消息管道发送“数据已变更”事件,查询侧监听后刷新对应读模型。这里的“事件”只是一个局部消息通知,和ES里的领域事件有本质区别——它不需要保留历史、不需要投影重放,只承担“让读模型保持新鲜”的职责。理解了这一点,就不会在讨论中和别人吵起来,因为大家说的“事件”根本不是一回事。

5. 那些年我们踩过的坑:一致性问题、演进成本与团队协作

5.1 读写两侧的一致性:到底能容忍多大延迟?

CQRS落地中最容易翻车的点,就是这个“读模型滞后”。我见过一个很典型的案例:某个系统做了异步读模型更新,业务人员在下单后立刻去列表页刷新,结果订单没出现,三秒后才显示。业务方直接炸了,要求必须立刻回滚。这个事故的本质不是技术方案不对,而是团队没有提前跟业务方对齐一个预期:“读模型是最终一致的,允许的延迟窗口是多少?”如果事先说清楚“最多五秒内可见”,并把异常监控做起来,这个方案完全站得住脚。

所以我的第一个建议是:在引入CQRS之前,先梳理一遍业务对延迟的真实容忍度。“支撑订单状态实时一致的”场景,优先同步双写;“报表/分析/推荐”这种本来就有延迟场景,异步完全没问题。千万别统一用异步、也别统一用同步,按数据域分开处理是最稳的。

5.2 双写不是免费的:事务边界与脏读陷阱

即使你选择了同步双写,也有一些隐藏的坑。比如命令侧写库成功,但读模型更新抛异常了,整个事务回滚还好;最怕的是命令侧已经提交了事务、再执行读模型更新,结果更新失败,两边就出现不一致。这个问题的根本原因是事务边界没有包含读模型的更新。我的做法是把命令模型的落库和读模型的更新放进同一个数据库事务;如果是异步方案,则必须给读模型更新加幂等键,并且建立“对账Job”定期比对两侧数据,发现不一致就自动重放。

这里有一个我踩过的坑值得分享:一开始我以为同步双写就能完全避免不一致,后来发现读模型宽表里有一个字段是通过外部接口查询再写入的,这个外部调用在数据库事务里执行时间太长,导致数据库连接池被占满。后来把外部调用挪到事务外,改为“先落命令模型,事务提交后再调用外部接口,最后更新读模型”。代价是增加了一个中间状态:界面上可能暂时缺这个字段,但业务上可以接受。总之,双写方案的设计必须逐字段评估“这个字段的数据来源是不是足够快、足够可靠”,不能一股脑塞进同一套流程。

5.3 团队协作上的隐形坑:两套模型带来的沟通成本

CQRS最大的隐藏成本,其实是团队心智。以前一次普通的“给订单加个备注”需求,你只需要改一个实体、加一个字段。现在命令侧要加字段、读模型要加字段、映射逻辑要同步、接口文档要更新,牵涉的代码位置很多。如果团队没有建立清晰的“模型字典”,时间一长,命令模型的字段含义和读模型的字段含义就开始漂移:命令侧的UserId是用户主键,读模型里的UserId却是用于展示的脱敏ID,谁敢保证下一个人不会直接把命令侧的值塞到读模型里?

我的经验是,在CQRS系统里必须建立一份专门的“模型映射文档”,上面写清楚命令模型和读模型的每个字段来源、含义、更新时机。每次代码评审看到一个字段从命令侧透传到读模型,都要停下来问一句:这个透传合理吗?是不是应该用读模型自己的字段?这种评审文化比任何工具都重要。

5.4 版本升级时的演进策略

CQRS系统的另一个长期挑战是演进。命令模型修改字段,老接口的调用方式怎么平滑过渡?读模型要新增一个列表字段,是直接改宽表还是新建一张扩展表?如果是ES方案,事件版本怎么兼容?

我的建议是:在改造的初期,就要定义清楚“读模型只增不改”的演进原则。什么意思呢?比如宽表已经上线了,以后要加一个新字段,你不要想着把原来那个废弃字段改个含义复用,而是直接加列,哪怕是空的,也要保留原来的列不动。这样老代码不会因为列含义变化而偷偷产生错误数据。命令模型侧同理,Command参数尽量用“不可变对象”模式,加字段时保留老构造方法作为兼容层,避免一个命令结构变化把所有消费者都炸掉。这套策略听起来没什么技术含量,但在真实的长期项目里,它比任何高明设计都更能保命。

还需要补充一点:读模型的更新逻辑往往会随着业务规则变化而变,一旦出现“新版读模型需要根据原始命令重新推导”的需求,原来的同步双写就不够用了。这时候需要考虑引入轻量级的事件记录——不必是全套ES,只需要把命令侧产生的关键事件记录下来,用于读模型的重建。这个“渐进式ES”策略很实用,我们最后也是用这个方案解决了一次数据迁移问题。

6. 最后的唠叨:如果让我重来一遍,我会怎么做

6.1 分阶段推进的实操路线

如果现在重新启动一个需要CQRS的项目,我不会一上来就全面推广,而是会挑一个痛点最集中的业务域(通常是最热的列表查询)先做读模型改造,命令侧保持原样不动。这个策略的好处是收益立即可见:一个慢查询变快了,团队信心就建立起来了。等大家理解了“读模型可以独立设计”以后,再逐步收敛命令入口、引入CommandHandler。整个过程应该是滚动的、分阶段的,而不是一个周末推倒重来。

在团队协作层面,我还会坚持几个非常朴素的动作:模型映射文档必须存在;代码评审时专门检查“命令侧与查询侧是否相互泄漏”;每次需求排期必须把双模型的改动量一起估进去。这些动作都不复杂,但它们决定了CQRS在你的项目里是变成一个长期可维护的架构,还是变成一个让所有人头疼的“两套代码维护噩梦”。

6.2 日常维护中值得坚持的小习惯

最后分享一个很具体的习惯,算是我自己的小技巧:给命令模型和查询模型的文件名加上明确的命名区分,比如Command后缀和ReadModel后缀。听起来很傻,但很多时候解决问题的不是高超技巧,而是让每个字段、每个类的名字都在告诉后来的人“我在哪一侧、我服务的是谁”。CQRS不是一个需要敬畏的模式,它是一个需要认真对待的工作方式——想清楚你要分离的是什么,你才不会在项目的河流里迷路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询