很多团队以为 "CQRS 就是 Command Controller + Query Controller",
但真正的 CQRS 涉及:
- 读写分离的理念
- 读模型冗余、去聚合化
- 最终一致性
- 事件驱动构建 Read Model
- 性能 / 架构演进路径
第7课:CQRS 与查询模型
一、什么是 CQRS?
CQRS 的本质:
Command 与 Query 分离,不共享模型,不共享数据结构。
Command(写模型)
- 修改系统状态
- 使用领域模型(聚合)
- 严格遵守领域规则
- 大部分复杂性在这里
Query(读模型)
- 不修改系统状态
- 不使用聚合(因为聚合太重)
- 只需要高性能查询
- 可以冗余字段、去规范化
为什么需要 CQRS?
1. 读写需求的本质不同
读:
- 高频
- 多字段查询
- 排序、过滤、分页
- 不需要业务规则
写:
- 有状态流转
- 有一致性与业务规则
- 修改聚合
使用同一个模型处理两者会导致:
- 聚合变得巨大(反模式:Big Aggregate)
- 查询性能低
- 不必要地加载大量对象
- 索引、表结构难以兼顾
2. 聚合不是为查询设计的
聚合是:
维持业务不变量的结构,不是为查询优化存在的。
例如,为了查询订单列表,你不应该加载:
java
List<Order> orders = orderRepository.findAll();
因为:
- 聚合包含状态机
- 包含行为
- 包含内部实体(OrderItem)
- 包含不变量逻辑
读查询不需要这些逻辑。
3. 查询可以"去领域化"
Query Model 可以:
- 包含冗余字段
- 直接以数据库结构映射
- 多表 join
- 不需要 Money、Value Object
- 没有业务规则
查询模型的目标只有一个:
高效返回前端需要的数据。
CQRS 的核心:读写模型分离
下面是 DDD 中典型的 CQRS 架构:

设计我们的 Query Model
写模型(聚合):
Order
id
status
totalAmount
items (OrderItem)
read model(查询模型):
OrderSummaryView
orderId
status
totalAmount
createdTime
OrderDetailView
orderId
status
items: List<OrderItemView>
totalAmount
restaurantName ← 冗余字段
deliveryAddress ← 冗余字段
为什么 Query Model 可以有冗余?
因为查询模型:
- 不参与业务逻辑
- 不参与事务
- 不保不变量
- 可以为查询而设计
冗余是允许的甚至鼓励的:
- 你可以把订单、餐厅、用户地址 JOIN 在一起存成一条记录
- 也可以做统计字段(monthlySales)
- 也可以使用 Elasticsearch / Redis / 专用读库
这不是坏事:
冗余换性能,是 CQRS 的核心价值之一。
读写一致性问题
当写模型更新后,读模型可能不会马上同步。
例如:
- 用户刚下单
- 写侧 OrderRepository.save(order)
- 读侧 QueryRepository.findOrderSummary()
- 读侧返回旧数据(正常)
这叫:
最终一致性(Eventual Consistency)
而不是:
强一致性(Strong Consistency)
为什么不要求强一致?
- 能提高性能
- 降低锁争用
- 降低数据库压力
- 支持横向扩展(分库分表)
- 支持事件驱动体系
因此:
在 CQRS 中,读模型延迟是被允许的,也必须接受的。
如何构建读模型?
推荐的方式:
写模型产生领域事件 → Listener → 更新 Read Model
例如:
OrderCreatedEvent
OrderPaidEvent
OrderCompletedEvent
监听器:
java
@EventListener
public void handle(OrderPaidEvent event) {
queryRepository.updateStatus(event.getOrderId(), PAID);
}
这就是真正的 CQRS,不是简单的 Controller 分离。
事件驱动读模型更新流程图
流程:
- Command 修改聚合
- 聚合触发 Domain Event
- Event Publisher 发布事件
- Listener 接收并更新 Read Model
- Query API 查询到更新后的 Read Model
CQRS 实现的三个级别
Level 1:Controller 分离
- CommandController
- QueryController
- 写模型 / 读模型分别服务
适用于单体架构。
Level 2:读模型独立 Repository
- QueryRepository
- QueryService
- DTO 优化查询
Level 3:事件驱动读库
- 独立读库
- 事件驱动更新
- 支持 Redis / ES / 分库分表
这是企业级 CQRS 的核心。
本课目标
这节要真正做到:
-
命令(写)和查询(读)逻辑在代码结构上分离
- 写:继续用现在的 DDD 聚合(Order、OrderLifecycleService 等)
- 读:使用专门的"查询模型"和"查询服务"
-
为查询构建专用的 View Model(查询 DTO)
- 不把领域对象直接返回给前端
- 查询模型可以根据前端需求优化(冗余字段、拼接文案等)
-
为查询建立单独的 Repository(只读)
- 读侧可以直接使用 JPA/SQL 投影,不必经过聚合
-
Controller 层清晰区分 Command API 和 Query API
一、CQRS 概念快速对齐
- C ommand:命令(写操作)→ 改变系统状态
- 下单、支付、确认、取消等
- 必须通过聚合保证业务规则(Order、Domain Service)
- Q uery:查询(读操作)→ 只读,不改变状态
- 查询订单详情、列表、统计等
- 可以直接从数据库/缓存中读,绕过聚合(因为不改状态)
我们现在的系统已经是一个典型的 Command Side(写侧) :
OrderLifecycleService + OrderRepository + Order 聚合。
这一课我们要加上 Query Side(读侧)。
二、查询模型设计
查询 DTO:OrderView / OrderItemView
这些类只用于"返回给前端或 API 客户端",
不参与业务计算,可以自由根据 UI 需求调整结构。
位置建议 :application.order.query 包
java
package com.example.ordersystem.application.order.query;
import java.util.List;
/**
* 查询模型:订单视图(返回给前端)
*/
public class OrderView {
private String orderId;
private String status;
private double totalAmount;
private List<OrderItemView> items;
public OrderView(String orderId, String status, double totalAmount, List<OrderItemView> items) {
this.orderId = orderId;
this.status = status;
this.totalAmount = totalAmount;
this.items = items;
}
public String getOrderId() { return orderId; }
public String getStatus() { return status; }
public double getTotalAmount() { return totalAmount; }
public List<OrderItemView> getItems() { return items; }
}
java
package com.example.ordersystem.application.order.query;
/**
* 查询模型:订单项视图
*/
public class OrderItemView {
private String name;
private int quantity;
private double price;
private double subtotal;
public OrderItemView(String name, int quantity, double price, double subtotal) {
this.name = name;
this.quantity = quantity;
this.price = price;
this.subtotal = subtotal;
}
public String getName() { return name; }
public int getQuantity() { return quantity; }
public double getPrice() { return price; }
public double getSubtotal() { return subtotal; }
}
注意:
subtotal 字段在数据库里没有直接存,我们可以在查询服务中计算并填入(这就是读侧可以"为展示优化结构"的能力)。
查询侧 Repository
读侧可以直接使用 JPA 实体,不需要走领域模型(因为只是读,不改)。
我们之前已经有:
OrderEntityOrderItemEntityJpaOrderRepository(命令用的 / 也可复用)
这里我们可以 复用 JpaOrderRepository 做查询 ,
也可以单独定义一个 OrderQueryRepository 接口,调用的其实还是 JpaOrderRepository。
为了结构清晰,这里增加一个 应用层用的 Query Repository 封装。
位置 :application.order.query
java
package com.example.ordersystem.application.order.query;
import com.example.ordersystem.infrastructure.persistence.entity.OrderEntity;
import com.example.ordersystem.infrastructure.persistence.jpa.JpaOrderRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.stream.Collectors;
/**
* 查询侧仓储:专门用于构建查询模型(View)
*/
@Repository
public class OrderQueryRepository {
private final JpaOrderRepository jpaOrderRepository;
public OrderQueryRepository(JpaOrderRepository jpaOrderRepository) {
this.jpaOrderRepository = jpaOrderRepository;
}
public OrderView findOrderView(String orderId) {
OrderEntity entity = jpaOrderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("订单不存在: " + orderId));
List<OrderItemView> itemViews = entity.getItems().stream()
.map(item -> new OrderItemView(
item.getName(),
item.getQuantity(),
item.getPrice(),
item.getPrice() * item.getQuantity()
))
.collect(Collectors.toList());
return new OrderView(
entity.getId(),
entity.getStatus(),
entity.getTotalAmount(),
itemViews
);
}
/**
* 查询全部订单的简单列表(示例)
* 实际中可以做分页
*/
public List<OrderSummaryView> findAllOrderSummaries() {
return jpaOrderRepository.findAll().stream()
.map(entity -> new OrderSummaryView(
entity.getId(),
entity.getStatus(),
entity.getTotalAmount()
))
.collect(Collectors.toList());
}
}
再加一个简化列表 DTO:OrderSummaryView:
java
package com.example.ordersystem.application.order.query;
/**
* 查询模型:订单列表视图(简要信息)
*/
public class OrderSummaryView {
private String orderId;
private String status;
private double totalAmount;
public OrderSummaryView(String orderId, String status, double totalAmount) {
this.orderId = orderId;
this.status = status;
this.totalAmount = totalAmount;
}
public String getOrderId() { return orderId; }
public String getStatus() { return status; }
public double getTotalAmount() { return totalAmount; }
}
这一步已经实现了:
Query Repository 专门面向查询模型,而不是返回领域对象。
查询服务(Query Service)
为了保持应用层风格统一,我们也给读侧加一个 Query Application Service:
位置 :application.order.query
java
package com.example.ordersystem.application.order.query;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 应用服务(读侧):只负责查询,不修改状态
*/
@Service
public class OrderQueryService {
private final OrderQueryRepository orderQueryRepository;
public OrderQueryService(OrderQueryRepository orderQueryRepository) {
this.orderQueryRepository = orderQueryRepository;
}
/**
* 查询订单详情(用于前端详情页)
*/
public OrderView getOrderDetail(String orderId) {
return orderQueryRepository.findOrderView(orderId);
}
/**
* 查询订单列表(用于前端列表页)
*/
public List<OrderSummaryView> listOrders() {
return orderQueryRepository.findAllOrderSummaries();
}
}
区分 Command API 与 Query API
现在我们让 Controller 显式地区分:
- 命令控制器 :
OrderCommandController(用于 create/pay/confirm/complete) - 查询控制器 :
OrderQueryController(用于 get/list)
你现在的 OrderController 可以按下面方式拆成两个(或者保留一个也行,这里为了教学更清晰,就拆开写)。
命令控制器:OrderCommandController
java
package com.example.ordersystem.interfaces.api;
import com.example.ordersystem.application.order.OrderLifecycleService;
import com.example.ordersystem.domain.order.model.Order;
import org.springframework.web.bind.annotation.*;
/**
* 命令侧 API:创建、支付、确认、完成等
*/
@RestController
@RequestMapping("/orders")
public class OrderCommandController {
private final OrderLifecycleService lifecycleService;
public OrderCommandController(OrderLifecycleService lifecycleService) {
this.lifecycleService = lifecycleService;
}
@PostMapping("/create")
public Order createOrder() {
return lifecycleService.createOrder();
}
@PostMapping("/{id}/pay")
public Order pay(@PathVariable String id) {
return lifecycleService.payOrder(id);
}
@PostMapping("/{id}/confirm")
public Order confirm(@PathVariable String id) {
return lifecycleService.confirmOrder(id);
}
@PostMapping("/{id}/complete")
public Order complete(@PathVariable String id) {
return lifecycleService.completeOrder(id);
}
}
如果你希望"命令侧也只返回 View",完全可以改成返回
OrderView,
查询控制器:OrderQueryController(核心)
java
package com.example.ordersystem.interfaces.api;
import com.example.ordersystem.application.order.query.OrderQueryService;
import com.example.ordersystem.application.order.query.OrderSummaryView;
import com.example.ordersystem.application.order.query.OrderView;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 查询侧 API:专门用于读取订单数据
*/
@RestController
@RequestMapping("/orders")
public class OrderQueryController {
private final OrderQueryService queryService;
public OrderQueryController(OrderQueryService queryService) {
this.queryService = queryService;
}
/**
* 查询订单详情
* GET /orders/{id}
*/
@GetMapping("/{id}")
public OrderView getOrder(@PathVariable String id) {
return queryService.getOrderDetail(id);
}
/**
* 查询订单列表
* GET /orders
*/
@GetMapping
public List<OrderSummaryView> listOrders() {
return queryService.listOrders();
}
}
运行验证:Command / Query 完整流
-
创建订单(命令)
httpPOST /orders/create返回:
json{ "id": {"value": "123e..."}, "items": [...], "totalAmount": {"amount": 18.5}, "status": "CREATED" } -
支付订单(命令 + 触发领域事件)
httpPOST /orders/123e.../pay -
用查询模型获取订单详情(查询)
httpGET /orders/123e...返回类似:
json{ "orderId": "123e...", "status": "PAID", "totalAmount": 18.5, "items": [ { "name": "Fried Rice", "quantity": 1, "price": 12.5, "subtotal": 12.5 }, { "name": "Coke", "quantity": 2, "price": 3.0, "subtotal": 6.0 } ] } -
查询订单列表(查询)
httpGET /orders返回:
json[ { "orderId": "123e...", "status": "PAID", "totalAmount": 18.5 }, { "orderId": "456f...", "status": "CREATED", "totalAmount": 45.0 } ]
第7课总结
| 目标 | 是否达成 | 实现位置 |
|---|---|---|
| 1. 命令 & 查询逻辑分离 | ✅ | OrderLifecycleService vs OrderQueryService |
| 2. 查询 ViewModel 独立 | ✅ | OrderView / OrderItemView / OrderSummaryView |
| 3. 查询仓储独立 | ✅ | OrderQueryRepository,使用 OrderEntity 直接构建 View |
| 4. API 层命令/查询分离 | ✅ | OrderCommandController(POST) vs OrderQueryController(GET) |