ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CQRS 架构实战:从读写分离到命令查询职责分离的落地指南

2026/10/7 4:24:32 拓冰建站 浏览量
CQRS 架构实战:从读写分离到命令查询职责分离的落地指南 当我们在讨论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提出的CQSCommand 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确认收货。每个命令对应一个HandlerHandler只做一件事加载订单聚合执行领域行为持久化发布事件。以CreateOrderCommand为例写侧核心代码简化后大概是这个形态public class CreateOrderCommand { private String userId; private ListOrderItemRequest items; private Address address; // 构造方法、getter省略 } Component public class CreateOrderCommandHandler implements CommandHandlerCreateOrderCommand, 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 PageResultOrderSummaryDTO queryMyOrders(String userId, int page, int size) { return orderQueryService.queryOrdersByUser(userId, page, size); } } Service public class OrderQueryService { Autowired private JdbcTemplate jdbcTemplate; public PageResultOrderSummaryDTO queryOrdersByUser(String userId, int page, int size) { ListOrderSummaryDTO 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。核心流程如下事务内插入order_tab、order_item_tab同时插入一条OrderCreatedEvent到outbox_event。定时任务每500毫秒扫一次outbox_event中未发送的事件投递到MQ的order.events主题。消费者收到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改造千万不要追求一步到位。先拆读模型再理写命令最后再上异步事件每一步都能独立交付、独立验证。带着同步刷新稳跑一两个月确认数据一致性没问题了再去动异步化这个节奏比一次大重构稳太多了。