DDD学习第7课CQRS与查询模型

很多团队以为 "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 分离。


事件驱动读模型更新流程图

流程:

  1. Command 修改聚合
  2. 聚合触发 Domain Event
  3. Event Publisher 发布事件
  4. Listener 接收并更新 Read Model
  5. Query API 查询到更新后的 Read Model

CQRS 实现的三个级别

Level 1:Controller 分离

  • CommandController
  • QueryController
  • 写模型 / 读模型分别服务

适用于单体架构。


Level 2:读模型独立 Repository

  • QueryRepository
  • QueryService
  • DTO 优化查询

Level 3:事件驱动读库

  • 独立读库
  • 事件驱动更新
  • 支持 Redis / ES / 分库分表

这是企业级 CQRS 的核心。


本课目标

这节要真正做到

  1. 命令(写)和查询(读)逻辑在代码结构上分离

    • 写:继续用现在的 DDD 聚合(Order、OrderLifecycleService 等)
    • 读:使用专门的"查询模型"和"查询服务"
  2. 为查询构建专用的 View Model(查询 DTO)

    • 不把领域对象直接返回给前端
    • 查询模型可以根据前端需求优化(冗余字段、拼接文案等)
  3. 为查询建立单独的 Repository(只读)

    • 读侧可以直接使用 JPA/SQL 投影,不必经过聚合
  4. 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 实体,不需要走领域模型(因为只是读,不改)。

我们之前已经有:

  • OrderEntity
  • OrderItemEntity
  • JpaOrderRepository(命令用的 / 也可复用)

这里我们可以 复用 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 完整流

  1. 创建订单(命令)

    http 复制代码
    POST /orders/create

    返回:

    json 复制代码
    {
      "id": {"value": "123e..."},
      "items": [...],
      "totalAmount": {"amount": 18.5},
      "status": "CREATED"
    }
  2. 支付订单(命令 + 触发领域事件)

    http 复制代码
    POST /orders/123e.../pay
  3. 用查询模型获取订单详情(查询)

    http 复制代码
    GET /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
        }
      ]
    }
  4. 查询订单列表(查询)

    http 复制代码
    GET /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)

相关推荐
职业码农NO.114 天前
系统架构设计中的 15 个关键取舍
设计模式·架构·系统架构·ddd·架构师·设计规范·领域驱动
mask哥5 个月前
详解flink java基础(一)
java·大数据·微服务·flink·实时计算·领域驱动
liulilittle5 个月前
DDD领域驱动中瘦模型与富态模型的核心区别
开发语言·c++·算法·ddd·领域驱动·思想
有泥土的路8 个月前
领域驱动设计实战:聚合根设计与领域模型实现
领域模型·ddd·领域驱动·领域建模·聚合根
有泥土的路8 个月前
大模型应用开发进阶篇:Spring-AI 结合领域驱动开发设计思想
ai·大模型·领域驱动
有泥土的路8 个月前
领域驱动的事实与谬误 一 DDD 与 MVC
ddd·领域驱动
蜗牛沐雨1 年前
在 Go 中实现事件溯源:构建高效且可扩展的系统
服务器·golang·ddd·事件驱动·领域驱动
衣舞晨风2 年前
京东云开发者:DDD 学习与感悟 —— 向屎山冲锋
数据库·学习·ddd·京东云·领域驱动
上善若水-学者至上2 年前
【领域驱动设计】模式--通用语言(Ubiquitous language)
java·领域驱动