☰
CQRS 架构实战:从读写分离到命令查询职责分离的落地指南
2026/10/7 4:24:30 网站建设 项目流程

当我们在讨论CQRS时,我们在讨论些神马?

我刚入行那会儿,第一次听到CQRS这个缩写是在一次架构评审会上。当时两个老哥争得面红耳赤,一个说“这不就是读写分离吗”,另一个拍桌子说“读写分离是数据库的事,CQRS是应用架构的事”。我在旁边一头雾水:命令和查询分开?数据库主从不早就这么干了吗?后来自己啃了几年代码、翻了N篇英文博客、在真实项目里踩了无数次坑,才慢慢想明白——CQRS这几个字母背后,讨论的压根不是某个具体技术,而是一整套看待“业务状态变化”和“业务状态展示”的思维方式。

文章适合的人群,我理解有三类:一是正在做中后台系统、被复杂查询和事务逻辑搅得头大的后端开发;二是刚接触领域驱动设计、想搞清楚CQRS和那些“高深词汇”到底是什么关系的架构新人;三是面试前临时抱佛脚,想用几分钟把CQRS讲出个所以然的朋友。这篇内容不会给你堆一堆抽象概念,我会结合一个真实的订单系统改造过程,把CQRS的来龙去脉、设计思路、落地步骤和坑全部分享一遍。

1. 先把概念说人话:CQRS到底在聊什么

1.1 为什么“读写分离”听着简单,落地却让你头大?

CQRS的全称是Command Query Responsibility Segregation,中文一般翻译成“命令查询职责分离”。拆开看就是两件事:写数据用命令(Command),读数据用查询(Query),两边各管各的,不混在一起。

但这句话说起来容易,做起来难。绝大多数业务系统在早期都是“单模型”写法:一个实体类,既承载增删改的领域逻辑,又被查询接口直接返回。比如你定义一个Order类,里面既有placeOrder()这种改状态的方法,又被getOrderDetail()直接序列化成JSON返回给前端。这种做法在业务简单时毫无问题,但系统一旦复杂起来,问题就开始露头。

我自己遇到的最典型情况是:一个订单列表接口,页面要展示订单号、用户昵称、商品名、商品图、金额、优惠明细、状态、支付时间、发货时间……这个接口的SQL要join七张表,每行数据要跑几十毫秒。而订单写入那边呢,又要校验库存、校验优惠券、锁定价格、更新状态机,逻辑重得不得了。两边挤在同一个模型和同一套存储上,结果就是:读接口为了“方便展示”把所有关联关系都预加载出来,写接口又为了“保护业务规则”不得不在每次加载时带上这些重量级导航属性。互相迁就,两边都不讨好。

CQRS的思路就是:别让它们互相迁就了。你要改数据,就走命令通道,用领域模型、用事务、用状态机去保证业务规则;你要查数据,就走查询通道,直接查一个为了展示而准备的读模型,甚至是宽表、物化视图、缓存,怎么快怎么来。两边可以共用数据源,也可以分开存储,唯一的要求是:不要试图用一个模型同时满足两头。

1.2 从CQS到CQRS:一个原则的“升维”

很多人第一次接触CQRS都会问:这和Bertrand Meyer提出的CQS(Command Query Separation)有什么关系?CQS是面向对象设计里的一条原则:一个方法要么是命令(改变系统状态、不返回数据),要么是查询(返回数据、不改变状态),不要搞出“改了状态又给你返回一堆数据”的幺蛾子方法。

CQRS可以理解成把CQS这条“方法级原则”放大到了“架构层”:一个对象负责命令,另一个对象负责查询;一个模型处理写入,另一个模型处理读取。方法级的CQS往往在代码规范里靠约定维持,架构级的CQRS则直接靠代码结构、模块边界甚至独立部署来强制保证。

举个例子你就明白了。老写法里你可能有个OrderService.updateOrderStatus(orderId, newStatus),它更新完状态还顺手把最新的订单对象返回给你。这时候调用方就会依赖“更新完立刻能拿到对象”这个隐含契约——哪怕没有契约,代码也容易写成“先改后用”。而CQRS思路下,命令要么不返回业务数据,要么只返回个ID或结果,查询则走另一个专门的OrderQueryService。调用方在写操作之后如果想看数据,会明确知道“我要再查一次”,而不是想当然地依赖旧内存对象。

这一步“升维”带来的最大好处是认知负担下降:你读命令代码时,只需要关注状态是怎么变的;读查询代码时,只需要关注数据是怎么拼的。两边不会因为共享一套模型而互相污染。

2. 什么项目才值得上CQRS:别拿大炮打蚊子

2.1 CQRS真正解决的四个痛点场景

CQRS不是银弹,但如果你的系统出现了下面这几种情况,说明你已经站在CQRS的适用边界上了。

第一个场景,查询复杂度和写入复杂度都很高,且两者明显不对等。例如电商后台的订单管理:查询接口需要聚合商品、用户、优惠、物流等大量信息,同时订单创建又涉及库存锁定、优惠校验、状态机流转。这种场景下,查询用专门的投影表和宽表,写入用领域模型,能同时提升两侧的清晰度和性能。

第二个场景,同一个数据在不同上下文里有不同的“展示形态”。用户端订单列表是一个形态(关注商品图、价格、物流进度),运营后台订单报表是另一个形态(关注毛利、来源渠道、退款状态),财务对账单又是第三种形态。非要让一个订单表满足所有查询需求,SQL会越来越臃肿,索引会越来越膨胀。CQRS允许针对每种查询建一个读模型,各查各的,彼此不干扰。

第三个场景,需要给外部系统或微服务提供事件通知,而不仅仅是被动查询。CQRS经常和事件驱动配合使用:写侧产生领域事件,读侧订阅事件并异步刷新自己的视图。这样下游系统不需要反向依赖你的主库,数据流也清晰可追踪。

第四个场景,团队规模和代码规模到了需要明确职责边界的时候。一个订单模块里如果读写逻辑混在一个Service类里,几百上千行代码,那新同学接手时几乎无法判断哪些代码是核心业务规则、哪些代码只是为了组装返回DTO。CQRS把链路切成“命令处理链路”和“查询处理链路”,新成员看代码时能顺着通道走,效率高不少。

2.2 明确不适合CQRS的信号

说实话,我见过最多的CQRS误用就是:项目就一个后台管理界面,增删改查逻辑都很简单,非要强行搞命令总线、查询服务、事件表、读模型同步……结果代码量翻倍,上线时间翻倍,收益为零。

这里有一个很现实的判断标准:你的查询压力是不是真的超过了单模型处理的舒适区?你的业务规则是不是真的复杂到写模型和读模型互相拖累?如果答案都是否定的,老老实实用一个模型走天下更划算。CQRS引入的额外设施——消息队列、事件表、读模型同步、数据一致性检查——都是成本,规模不够大时这些成本会直接吃光收益。

还有一个误信号值得警惕:不少人以为CQRS能帮忙解决“数据库读写性能瓶颈”,于是把主从复制和CQRS混为一谈。集群层面做主从复制、读写分离,那是数据库基础设施的事;CQRS是应用层把读模型和写模型拆开设计。如果只是单表查询慢,先优化索引和SQL,别急着上架构改造。

3. 落地前必须想清楚的四个细节

3.1 读模型到底长什么样:投影、快照与宽表

CQRS落地时,写模型往往还能沿用原有领域模型,读模型却是个完全新造的玩意。读模型我一般分三种形态,按复杂程度递增排:

第一种是轻量投影:还是放在同一张业务表上,但查询时不再走ORM的完整实体映射,而是单独写一个查询DTO,只select需要的列,用轻量数据访问工具直接映射。这种方法改动最小,适合读压力不那么大、字段也不复杂的系统。

第二种是宽表快照:单独建一张读模型表,比如order_summary,每个字段都是页面上要展示的字段——用户昵称、商品快照、金额快照、状态文本等。这些数据在写操作发生时专门去刷新,查询端无脑查这张表就够了,不需要join。宽表是读模型里最实用也最常见的形式。

第三种是独立投影存储:把读模型放到Redis、Elasticsearch或者独立的只读库里。这类存储天然擅长某种查询形态,比如搜索场景用ES、热点查询用Redis、复杂分析用分析型数据库。注意它们都是一层“可重建的投影”,源数据仍然在写侧主库里。

在设计读模型表时,我有一条很笨但有效的经验:直接对着高保真页面原型建表。页面上展示什么字段,表里就有什么字段;页面上有分页和筛选,表里就建对应的索引。读模型不是业务实体的镜像,而是UI视图的投影,这个视角很重要。

3.2 命令侧别把“领域逻辑”写成“更新四件套”

命令侧是CQRS里最容易写歪的地方。很多人以为命令处理就是“接受Request,校验一下,然后update数据库”,其实那是过肥的Controller,不是命令模型。

命令(Command)语义上是一个意图,比如“提交订单”“修改收货地址”,它应该由领域层消化,而不是直接映射成一次数据库更新。举例说明,订单状态机的流转逻辑绝不能散落在Controller或Repository里。命令处理器的标准动作是:加载聚合根、执行领域方法(内部做各种校验和状态变更)、把变更持久化、发布领域事件。

我常跟同事强调一句话:命令不返回业务数据。如果命令处理器返回了一个完整DTO,那说明你又把查询逻辑塞回命令链路里了。命令结果最多就是个“处理成功/失败、失败原因、新资源ID”之类的轻量结果。至于提交订单之后页面要展示订单详情,那属于查询侧的工作,走查询服务去查即可。

3.3 异步一致性是默认选择,同步是例外

读写模型分开之后,一个绕不开的问题来了:写侧刚把订单状态改成“已支付”,读侧的order_summary表还是“待支付”,用户刷新页面结果对不上,怎么办?

这里需要明确一件事:CQRS本身不承诺强一致。读写模型之间如果走异步刷新,那一瞬间的不一致是设计内的一部分。大多数业务场景都能容忍秒级甚至分钟级的最终一致性,比如订单列表、通知中心、报表汇总。但有些场景不能容忍,比如支付结果查询、库存扣减结果确认,这时候要单独设计“实时通道”:要么查询直接查主库,要么写侧同步更新读模型,要么在读模型里带一个“数据置信时间”让调用方知道数据新鲜度。

我的默认选型是:在同一个本地事务里更新写模型和读模型。如果读写模型在同一数据库,可以做到同步刷新、强一致,实现成本最低。如果不在同一个库,优先用“事务性发件箱”(Transactional Outbox)模式——在写业务数据的同一个事务里,往outbox_event表插一条事件记录,后台任务再把这个事件投递到消息队列,消费端去刷新读模型。这个模式巧妙地避开了“双写分布式事务”的坑,是我在实际项目里最喜欢用的方案。

3.4 CQRS和事件溯源是两件事,别混着说

几乎每一篇CQRS文章都会提到Event Sourcing(事件溯源),导致很多人以为CQRS必须搭配事件溯源,或者干脆认为它们是一回事。这里我说清楚:它们是两个独立维度。

事件溯源是把聚合的状态变化全部记录成事件流,当前状态由事件流重放得到;CQRS是读写模型的职责分离。它们确实经常搭档出现——事件溯源天然会产生领域事件,这些事件又恰好可以用来异步刷新CQRS的读模型——但两者完全解耦。你可以只做CQRS不搞事件溯源,读模型的刷新由命令处理器在写库后同步或异步触发;你也可以只做事件溯源不拆读模型,查询照样走原模型。

我的建议是,如果你只是为了解决查询性能问题或模型混乱问题,那就只做CQRS,不要顺手把事件溯源也背上。事件溯源的引入会彻底改变你的持久化方案、事件版本管理、快照策略,复杂度按指数增长,收益如果不清楚,不要碰它。

4. 一个订单系统的CQRS改造实录

4.1 改造前的痛点和目标

去年我们有一款电商产品,订单模块的读接口越来越慢。用户端的“我的订单”列表接口,一次查询要关联订单表、订单明细表、商品表、商品类目表、用户表、店铺表、优惠明细表,光是join就是一大串。高峰期这个接口的P95延迟到了850ms,数据库CPU经常飙红。

与此同时,订单写入的链路也不轻松:创建订单时要锁定库存、校验优惠券、生成订单号、扣减库存积分,事务里还塞了大量为后续展示服务的字段组装逻辑。两条链路在同一个订单聚合上互相踩踏,再加上团队的代码规范宽松,读逻辑和写逻辑在Service层里缠成了一大团。

改造目标定得很克制:第一,读接口性能必须降下来,P95目标降到200ms以下;第二,写链路代码逻辑重新梳理,去掉查询相关的字段组装;第三,保持系统稳定,不引入分布式事务。基于这三点,我们决定做一轮CQRS改造,先拆读模型,再整理写模型,事件异步方案作为第二阶段再上。

4.2 写侧改造:只保留“下订单”这条命令的独立链路

第一步是梳理命令。我们把订单模块里的操作全部动词化:CreateOrderCommand(创建订单)、CancelOrderCommand(取消订单)、PayOrderCommand(支付回调)、ConfirmOrderCommand(确认收货)。每个命令对应一个Handler,Handler只做一件事:加载订单聚合,执行领域行为,持久化,发布事件。

以CreateOrderCommand为例,写侧核心代码简化后大概是这个形态:

public class CreateOrderCommand { private String userId; private List<OrderItemRequest> items; private Address address; // 构造方法、getter省略 } @Component public class CreateOrderCommandHandler implements CommandHandler<CreateOrderCommand, CreateOrderResult> { @Autowired private OrderRepository orderRepository; @Autowired private OrderSnapshotPublisher snapshotPublisher; @Transactional @Override public CreateOrderResult handle(CreateOrderCommand command) { // 1. 加载用户、商品信息,校验本次下单是否合法 // 2. 执行领域模型创建订单,包括库存扣减、优惠计算、状态初始化 Order order = Order.create(...); // 3. 持久化订单以及订单明细 orderRepository.save(order); // 4. 发布事件:订单已创建(当前先同步刷新读模型,后续再异步化) snapshotPublisher.onOrderCreated(order); return new CreateOrderResult(order.getOrderId()); } }

注意几个细节:

  • Order.create()是领域模型上的静态工厂方法,里面完成了所有的业务规则校验,Controller和Handler里没有任何计算公式。
  • 命令处理器几乎不含查询逻辑,它只做“改状态”这件事。原来那些“查优惠价、拼商品快照”的展示字段逻辑全部挪去读模型刷新那边。
  • 命令返回结果只有订单ID,没有整个订单DTO。

这样做的直接收益是:写侧代码看得懂、边界清晰,任何新来的同事都能在五分钟内找到“下订单到底改了哪些状态”。代码可维护性肉眼可见地提升了。

4.3 读侧改造:查询入口全部换成读服务

读侧我们建了一张宽表order_summary,字段就是订单列表页和详情页要展示的所有列:订单号、下单用户昵称、商品标题、商品封面图、商品单价、数量、订单总额、优惠金额、实付金额、订单状态文本、最近物流节点、支付时间、发货时间、收货地址快照。

对应的读服务代码如下:

@RestController public class OrderQueryController { @Autowired private OrderQueryService orderQueryService; @GetMapping("/api/orders") public PageResult<OrderSummaryDTO> queryMyOrders(String userId, int page, int size) { return orderQueryService.queryOrdersByUser(userId, page, size); } } @Service public class OrderQueryService { @Autowired private JdbcTemplate jdbcTemplate; public PageResult<OrderSummaryDTO> queryOrdersByUser(String userId, int page, int size) { List<OrderSummaryDTO> data = jdbcTemplate.query(""" SELECT order_id, user_name, sku_title, sku_cover, quantity, total_amount, discount_amount, pay_amount, status_text, latest_logistics, pay_time, ship_time FROM order_summary WHERE user_id = ? ORDER BY create_time DESC LIMIT ? OFFSET ? """, rowMapper, userId, size, page * size); // 再query一次count返回分页信息 return PageResult.of(data, totalCount); } }

这段代码的价值不在代码本身,而在于它透出的设计意图:查询端只对order_summary这张投影表负责,它压根不知道订单领域模型长什么样,也完全join不到业务主表。查询SQL的复杂度被锁死在有限的列和索引里,不会再随着业务野蛮增长。

整个改造完成后,这个列表接口的P95从850ms降到了65ms左右。原因是原来七表join变成了单表单条件有序查询,锁竞争和数据准备量都大幅下降。这里也提醒一下,CQRS不是性能银弹,我们的库存扣减接口并发瓶颈是另外靠其他手段优化的,读接口变快只是“读模型独立”带来的自然结果。

4.4 数据最终一致的保障与回放机制

读模型刷新我们一开始选择了“同步刷新”策略,好处是上线期内基本没有一致性风险。snapshotPublisher.onOrderCreated(order)在同一个本地事务里调用读模型更新逻辑,直接写order_summary表。同库同事务,强一致无延迟,适用于改造初期。

后来业务量上来,订单创建链路的TPS明显变高,同步刷新读模型这一步拖慢了写事务。我们把同步刷新改成事务性发件箱模式:在业务库中新增一张outbox_event表,CreateOrderCommandHandler在同一个事务里插入订单和事件记录,后台线程定期扫增量事件投递到消息队列,独立消费端负责刷新order_summary。核心流程如下:

  1. 事务内:插入order_tab、order_item_tab,同时插入一条OrderCreatedEvent到outbox_event。
  2. 定时任务:每500毫秒扫一次outbox_event中未发送的事件,投递到MQ的order.events主题。
  3. 消费者:收到OrderCreatedEvent后,组装读模型的快照字段,写入order_summary。

改造后的最终一致性延迟在1秒以内,对用户几乎无感。而且因为事件表跟业务数据在同一个本地事务里,不存在“业务成功但事件丢失”的极端情况,精度有保证。

回放机制这块我们其实也搭了:写了一个管理后台工具,可以把一段时间内的订单事件重新消费一遍,用来修复读模型脏数据。比如某次线上误操作导致一部分order_summary状态没刷新,后台一键重放那段时间的事件就能自动修正,不用手工改库。这对运维是极大的安慰。

5. 踩过的坑与排查速查表

5.1 高频坑的定位思路

CQRS落地过程中,有些坑几乎每个人都会踩到,我列几个典型的,附上定位思路。

坑一:读模型刷新失败,用户看到“消失的订单”或“过期状态”。定位顺序是:先去outbox_event表看事件有没有投递成功;再去MQ消费日志看消费端有没有报错;最后看读模型更新SQL有没有幂等。有一个经验是,读模型表里一定要有order_id这种业务唯一键,更新时用insert ... on duplicate key update或先删后插的方式保证幂等。否则消息重投一次就会产生两条脏数据。

坑二:命令返回了业务数据,查询逻辑又被塞回写链路。这种问题代码层面非常隐蔽,因为它不报错,只是让架构逐渐腐化。我的检查方法很简单:给命令处理器的返回值列一个清单,凡是返回类型里带有多个字段的“DTO”,就要反问一句——这个数据是不是为了接口展示准备的?如果是,请挪到查询侧。

坑三:读模型字段被“顺手”修改。读模型是投影,理论上应该只被写侧事件刷新,不能被业务查询接口反向修改。但总有人为了“图省事”,在某个查询接口里顺手update了读模型字段。结果就是数据一致性凭空多出一处维护点。建议在读模型的存储层上不要暴露通用写接口,只有领域事件刷新专用入口可以操作。

坑四:过度设计,命令、事件、总线、读模型全套上马,实际业务却很简单。这个我在前面说过,判断标准就是查询性能是否真的造成了问题、业务规则是否真的复杂到需要武装到牙齿。如果系统只是简单CRUD,那单模型的Controller-Service-Repository三层结构比CQRS舒服一百倍。

5.2 常见问题排查速查表
症状优先怀疑排查手段
读模型一直查不到新数据outbox投递失败或消费端异常查outbox表投递状态,查消费日志,查RabbitMQ/Kafka堆积量
同一订单读模型出现两条数据更新逻辑未做幂等检查读模型表唯一键,改on duplicate key update
订单状态展示比实际慢几分钟事件消费被阻塞或批处理延迟优化消费者并发度,增加延迟监控
查询接口变慢了读模型索引缺失或查询走了回表explain读模型SQL,建联合索引
写命令越来越慢同步刷新读模型拖慢了写事务改事务性发件箱异步刷新
命令执行成功但页面展示缺失某字段读模型刷新逻辑没覆盖某个事件检查事件类型和刷新订阅关系,补全投影

排查工具方面,我们用了最简单的组合:查询系统日志、跟踪MQ消费断点、管理后台的重放工具。只要outbox表和事件消费日志完整,基本都能在十分钟内定位问题。

这套改造做完之后,我对CQRS的认知有了一个明显转变。CQRS不是为了追新或者炫技,它本质上是在回答一个问题:同一个业务数据,在“被修改”和“被展示”这两种语境下,是不是应该被当成同一种东西?我的答案也越来越坚定:当复杂度上来之后,把它们当成两种东西来设计,反而让整体更简单。最后分享一个个人体会:CQRS改造千万不要追求一步到位。先拆读模型,再理写命令,最后再上异步事件,每一步都能独立交付、独立验证。带着同步刷新稳跑一两个月,确认数据一致性没问题了,再去动异步化,这个节奏比一次大重构稳太多了。

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

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

立即咨询