上一篇我们用一整篇文章把 Saga 模式讲透了——跨服务的分布式事务靠 Saga 搞定,编排式和协同式各有千秋。但聊完"写"的问题之后,你有没有想过一个同样棘手的问题:在微服务架构下,跨服务的"读"操作该怎么搞?
比如你在外卖 App 上打开"我的订单"页面,想看到:订单编号和菜品明细(来自订单服务)、配送状态和骑手位置(来自配送服务)、支付状态和优惠信息(来自支付服务)、商家名称和评分(来自商家服务)。
在单体架构时代,这不过是一条 SQL JOIN 的事。但到了微服务架构,四个服务各有各的数据库,你不可能跨库 JOIN。怎么办?
今天我们就来聊聊微服务架构中处理跨服务查询的两种主流方案,重点讲其中最优雅的一个——CQRS。
先看"笨"办法:API 组合模式
面对跨服务查询,最直觉的方案叫做 API 组合模式(API Composition)。思路很简单:搞一个"组合器",它分别调用各个服务的 API,拿到数据后在内存中做拼接。
还是以"我的订单"为例:
第一步:API 组合器(通常是 API 网关或者 BFF 层)收到前端的请求。第二步:它并行调用订单服务、配送服务、支付服务、商家服务,分别获取各自的数据。第三步:把四份数据在内存中拼装成前端需要的格式,返回给客户端。
这个方案简单直接,很多场景下确实够用。但它有几个明显的硬伤:
性能瓶颈。 每次查询都要调多个服务,响应时间取决于最慢的那个。如果某个服务慢了或者挂了,整个查询就废了。
内存压力。 如果要查的数据量大(比如"查询过去一年所有符合条件的订单"),从多个服务拉回来再做内存 JOIN,内存和 CPU 开销可能非常可观。
查询能力受限。 有些复杂的聚合查询(比如"按城市统计每月订单金额排行"),很难通过简单的 API 拼接实现。每个服务暴露的 API 粒度有限,你没法在内存里高效地做排序、分组、分页。
API 组合模式是"查询时聚合"——每次请求都现场拼装。当查询场景简单、数据量不大的时候,它是首选。但当查询复杂度上升,我们需要一种从根本上不同的思路。
CQRS 登场:命令与查询,各走各的路
CQRS,全称 Command Query Responsibility Segregation,翻译过来就是"命令查询职责分离"。这个名字听起来很学术,但核心思想其实很朴素:
写操作(Command)和读操作(Query)的需求天生不同,那就不要强迫它们共享同一个数据模型和数据库。给它们各自独立的模型和存储,各干各的。
在传统架构中,我们习惯用同一张表、同一个数据库既处理写入又处理查询。但仔细想想,写和读的需求其实差异很大:
写操作关心的是:数据完整性、业务规则校验、事务一致性。它需要的是严格的关系模型、外键约束、范式化设计。
读操作关心的是:查询速度、灵活的聚合维度、方便的数据格式。它希望数据是反范式化的、预先聚合好的、按查询需求定制的。
一个要求严谨,一个追求灵活。用同一个模型同时满足两者,必然会互相妥协。CQRS 说:那就别妥协了,分开。
CQRS 具体怎么做?
命令端(Command Side)
命令端负责所有的写操作——创建、更新、删除。每个微服务在自己的数据库中处理业务逻辑,执行本地事务。这一端的数据模型严格遵循领域模型的设计,保证数据完整性和业务规则。
比如订单服务在命令端维护的是标准的关系型数据:orders 表、order_items 表、各种状态字段和外键关系。每次创建或修改订单,走的是完整的业务逻辑校验。
查询端(Query Side)
查询端是一个独立的、只读的数据库(microservices.io 称之为 View Database)。这个数据库的 schema 完全是为查询场景量身定制的。
关键点在于:查询端的数据是通过订阅领域事件来维护的。 当命令端发生数据变更时(比如订单创建、状态更新),它会发布领域事件。查询端的事件处理器接收到这些事件后,更新视图数据库中的数据。
还是用"我的订单"举例:
![数据库读写分离([微服务架构11]CQRS 模式:读写分离的优雅之道)](https://www.skillup.host/uploadfile/202601/9cb3790686a63d3.jpg)
第一步:用户下了一笔订单。订单服务(命令端)创建订单并提交本地事务,然后发布一个"OrderCreated"事件。第二步:配送服务分配了骑手,发布"DeliveryAssigned"事件。支付服务完成扣款,发布"PaymentCompleted"事件。第三步:订单历史服务(查询端)订阅了所有这些事件。每当收到一个事件,它就更新自己的视图数据库——把订单信息、配送信息、支付信息全部聚合到一张宽表或一个文档里。第四步:当用户打开"我的订单"页面时,直接查询这个视图数据库。一次查询,所有数据都有了。不需要调多个服务,不需要内存 JOIN。
视图数据库选型
查询端的数据库选型非常灵活,完全取决于你的查询需求:
需要全文搜索? 用 Elasticsearch。
需要复杂聚合报表? 用 ClickHouse 或者 MongoDB。
需要简单的 key-value 查询? Redis 可能就够了。
需要灵活的文档结构? MongoDB 是好选择。
这就是 CQRS 的魅力之一——读和写用不同的数据库技术,各自发挥最大优势。你甚至可以为同一份源数据创建多个不同的视图数据库,分别服务不同的查询场景。
一个完整的例子:外卖平台的订单查询
让我们把 CQRS 应用到一个完整的业务场景中。
命令端的服务们各司其职:
订单服务:负责创建和管理订单,数据库里维护 orders 和 order_items 表。每次订单变化,发布 OrderCreated、OrderUpdated、OrderCancelled 等事件。配送服务:负责骑手分配和配送跟踪。发布 DeliveryAssigned、DeliveryPickedUp、DeliveryCompleted 事件。支付服务:负责支付处理。发布 PaymentProcessed、RefundIssued 事件。商家服务:负责商家信息管理。发布 RestaurantInfoUpdated 事件。
查询端的"订单历史服务":
这个服务维护一个 MongoDB 数据库。每个文档长这样:
{ "orderId": "ORD-20250301-0042", "userId": "U10086", "restaurant": { "name": "老王麻辣烫", "rating": 4.8 }, "items": [ {"name": "麻辣烫大份", "price": 28.00}, {"name": "加蛋", "price": 2.00} ], "totalAmount": 30.00, "payment": { "method": "微信支付", "status": "已支付", "paidAt": "2025-03-01T12:05:00Z" }, "delivery": { "rider": "张师傅", "status": "配送中", "estimatedArrival": "2025-03-01T12:35:00Z" }, "status": "配送中", "createdAt": "2025-03-01T12:00:00Z" }
所有信息聚合在一个文档中。前端查询"我的订单"时,一次读取就拿到了全部需要的数据。查询快,逻辑简单,完全不需要跨服务调用。
CQRS 的优势
查询性能优异。 视图数据库的 schema 完全为查询场景优化。不需要运行时跨服务调用和内存 JOIN,响应速度极快。可以创建多个针对不同查询场景的视图,每个都是最优的。
读写独立扩展。 很多系统的读写比例是 10:1 甚至 100:1。使用 CQRS,你可以独立扩展查询端(加更多只读副本),而不影响命令端。
关注点分离。 命令端专注于业务逻辑和数据一致性,查询端专注于查询性能和数据展示。代码更清晰,职责更明确。
技术多样性。 命令端可以用 PostgreSQL 保证事务一致性,查询端可以用 Elasticsearch 做全文搜索、用 Redis 做热点缓存、用 MongoDB 做复杂文档查询。真正做到用合适的工具干合适的事。
与事件溯源天然搭配。 如果你使用了事件溯源(Event Sourcing),CQRS 几乎是必须的——因为事件存储不适合直接做复杂查询,你需要一个独立的查询视图。
CQRS 的代价
天下没有免费的午餐,CQRS 带来的好处是有代价的。
架构复杂度上升。 你需要维护两套数据模型、两套数据存储、事件发布和订阅机制、事件处理器。对于简单的 CRUD 应用来说,这是杀鸡用牛刀。
最终一致性。 这是 CQRS 最需要你理解和接受的一点。因为查询端的数据是通过异步事件更新的,所以存在延迟。用户刚下完单,可能在"我的订单"里暂时还看不到——事件还没处理完。这个延迟通常在毫秒到秒级,但你的前端和业务逻辑必须能处理这种"最终一致"的情况。
事件处理器的可靠性。 事件处理器如果挂了或者处理失败了,查询端的数据就会和命令端不一致。你需要考虑幂等性、重试机制、事件顺序保证等问题。
代码重复。 命令端和查询端可能有一些类似的数据映射逻辑,维护两套模型意味着一定程度的代码重复。
什么时候该用 CQRS?
推荐使用的场景:
你的查询需要聚合多个微服务的数据,而且查询频率高、性能要求严格。这正是 CQRS 最擅长的场景。
你使用了事件溯源。事件存储天然不适合复杂查询,CQRS 是标配。
读写比例悬殊。如果系统 90% 以上是读操作,CQRS 让你可以针对读进行极致优化。
不建议使用的场景:
你的系统是简单的 CRUD 应用,查询不跨服务、不复杂。用 API 组合模式甚至直接查数据库就够了。
团队对事件驱动架构不熟悉。CQRS 的运维和调试复杂度不低,团队需要有相应的技术储备。
业务对实时一致性要求极高。如果"用户刚修改的数据必须立刻在所有页面看到最新状态"这个需求是硬性的,CQRS 的最终一致性可能会带来麻烦。
CQRS vs API 组合:该选哪个?
查询简单、涉及服务少(2-3个)、数据量不大 → API 组合模式。简单高效,别过度设计。
查询复杂、涉及服务多、需要聚合排序分页 → CQRS。前期投入大,但后期收益明显。
高并发读场景 → CQRS。查询端可以独立扩展和优化,API 组合模式的每次查询都要调用多个服务,扩展性有天花板。
实际项目中,两种模式经常共存。简单的查询用 API 组合,复杂的查询用 CQRS。不必教条主义。
CQRS 的核心思想就一句话:读写分家,各过各的日子。写操作走严格的领域模型保证数据正确,读操作走定制的视图数据库追求查询极致。用事件把两端连接起来,代价是接受最终一致性和额外的架构复杂度。
明天我们聊一个和 CQRS 天然搭配的模式——事件溯源(Event Sourcing):用事件来记录系统的每一次状态变化。Saga 负责跨服务事务,CQRS 负责跨服务查询,而事件溯源则是这一切的底层基座。敬请期待。
本文为"微服务架构 30 天通关"系列第 11 篇
每天一个核心模式,用通俗的语言讲透微服务的设计哲学与落地实践
#微服务架构 #CQRS #读写分离 #后端开发 #系统设计