CQRS 命令查询职责分离

FreeGuideOnline 最新 2026-07-09

什么是 CQRS?

CQRS(Command Query Responsibility Segregation,命令查询职责分离)是一种软件架构模式,它将应用程序的读操作(查询)写操作(命令) 分离到不同的模型、数据库甚至服务中。在传统的 CRUD 架构中,我们通常对同一个数据实体进行读取和修改,而 CQRS 明确划出两条独立的路径:命令端负责处理创建、更新和删除,查询端仅负责读取数据

这种分离意味着你可以为读和写选择不同的持久化机制、不同的数据视图以及不同的扩展策略。例如,写模型可以使用关系型数据库以保证事务一致性,而读模型可以独立优化为文档数据库,甚至预聚合成一个搜索索引或缓存。


为什么要使用 CQRS?

复杂业务系统往往会遇到以下几类挑战,而 CQRS 恰是应对这些挑战的有效手段:

  • 读写负载不对称:读写操作的频率和性能要求差异巨大,分离后可以独立水平扩展。
  • 读模型与写模型结构冲突:写操作需要保证业务规则和不变性约束,倾向于规范化的领域模型;而读操作常常需要从多个实体聚合数据,形成扁平化的视图。单一模型难以同时兼顾。
  • 复杂的查询需求:报表、全文搜索、实时统计等查询通常不适合在写模型上直接执行,分离后可以在读模型上自由优化。
  • 事件溯源(Event Sourcing)的自然搭档:当采用事件溯源时,写端只产生事件,读端通过事件构建投影,CQRS 成为必然选择。
  • 团队协作边界清晰:由不同团队分别负责读端和写端的开发与维护,降低相互干扰。

CQRS 的核心概念

命令(Command)

命令是意图改变系统状态的请求,通常是命令式的、有特定目的地发出的。命令应当被命名为动词形式,如 CreateOrderUpdateCustomerAddressCancelSubscription。命令具有以下特点:

  • 一个命令只由一个处理程序执行。
  • 命令执行可能失败,可能会抛出异常或返回错误。
  • 命令不返回业务数据(但可以返回操作结果,如新生成的 ID)。
  • 命令应该是不可变的,包含完成操作所需的所有数据。

查询(Query)

查询只请求数据,不产生任何副作用。查询应当被命名为以 GetFind 开头的形式,如 GetOrderByIdFindActiveCustomers。查询的特点包括:

  • 查询可以任意执行多次,结果一致(幂等且安全)。
  • 查询可以针对高度优化的读模型,直接返回 DTO 或视图对象。
  • 查询不应修改任何状态。

命令模型(写模型)

写模型体现了业务领域的核心逻辑,包含业务规则、校验和不变量。通常采用领域驱动设计(DDD) 中的聚合、实体和值对象来表达。写模型:

  • 持有权威的数据版本,是唯一能够改变状态的途径。
  • 通过命令对聚合执行操作,生成领域事件。
  • 持久化到写数据库(通常是关系数据库或事件存储)。

查询模型(读模型)

读模型是专门为查询场景定制的数据结构,可以完全脱离写模型独立设计。例如:

  • 将多个聚合的数据组合成一个专用的查询对象。
  • 直接保存为 JSON 文档、搜索引擎索引(Elasticsearch)或内存缓存。
  • 通过投影(Projection)机制从领域事件异步更新,最终保持一致。

CQRS 的典型架构流程

  1. 客户端发送命令到命令处理程序。
  2. 命令处理程序调用领域模型执行操作,生成领域事件。
  3. 领域事件被保存到事件存储或写数据库。
  4. 事件监听器捕获事件,异步更新一个或多个读模型。
  5. 客户端使用查询服务直接从读模型获取数据。

该流程自然形成了最终一致性:读模型可能不是实时反映刚刚写入的变化,而是经过一个微小延迟后更新完成。这对于大多数业务场景是可接受的,并且带来了巨大的性能和灵活性收益。


CQRS 的几种实现级别

无需一步到位引入完全异构的 CQRS,可以根据业务复杂度渐进式应用:

同数据库同模型

在同一个数据库上,将代码逻辑分离为命令服务和查询服务,但共用同一套实体和 ORM。适用于逻辑简单、读写不冲突的场景。

同数据库不同模型

使用同一个数据库,但为查询定义专门的 DTO 或视图,并通过仓库模式隔离读写。命令端依旧操作领域实体,查询端直接映射为更利于 UI 的扁平对象。

不同数据库(完全 CQRS)

命令端和查询端分别使用独立的数据库。写库面向规范化和事务性,读库面向去规范化、高性能查询。这是最典型的 CQRS 形式,也是和事件溯源结合最紧密的方式。


何时使用 CQRS

CQRS 并非银弹,在以下场景中能发挥最大价值:

  • 复杂业务领域:业务规则繁多,需要 DDD 聚合保证一致性,读写模型明显不同。
  • 读写性能差异巨大:读请求远多于写请求,或者读需要复杂的聚合和低延迟响应。
  • 需要事件溯源:通过事件还原状态,CQRS 是自然的补充。
  • 多客户端差异化查询:不同的前端(Web、移动端、IoT)需要各自定制的查询视图。
  • 团队规模较大:可以将读写端分配给不同小组并行开发。

不适合 CQRS 的场景:

  • 简单 CRUD 应用,业务逻辑极少。
  • 数据模型与查询需求基本一致。
  • 团队缺乏设计分布式系统的经验,维护成本会超过收益。

CQRS 的挑战与注意事项

  • 最终一致性:必须向业务方明确解释数据延迟问题,并在 UI 上做恰当处理(如确认后刷新)。
  • 复杂度增加:引入消息队列、事件处理、投影构建等,增加了开发和运维负担。
  • 数据一致性边界:命令端必须保证自身强一致性,而读端投影处理要具备幂等性和补偿机制。
  • 事件版本演进:领域事件格式变更时,需要兼容的历史事件投影或升级策略。
  • 调试和监控难度:异步链路使得端到端追踪更复杂,需引入分布式追踪和集中日志。

与其他模式的结合

CQRS + Event Sourcing

写模型将所有状态变更记录为不可变的事件序列,查询模型通过重播事件构建。这种组合提供了完整的审计日志、时间旅行和灵活的重构能力,但需要额外设计事件存储和快照机制。

CQRS + Sagas

在长事务或跨聚合协同场景中,命令端通过 Saga 编排或编排者模式保证最终一致性,并结合 CQRS 读模型查询流程状态。

CQRS + DDD

命令端使用聚合、领域服务、领域事件等战术设计,查询端可以完全忽略领域模型,仅关注数据传输效率。


实施 CQRS 的代码示例

以下用 C# 伪代码展示一个简单的 CQRS 实现思路,仅用于理解核心结构:

// 命令和处理器
public record CreateOrderCommand(string CustomerId, List<OrderLine> Lines) : IRequest<Guid>;

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _repo;
    public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.CustomerId, cmd.Lines);
        await _repo.Save(order);
        return order.Id;
    }
}

// 查询和处理器(可完全独立)
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto>;

public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
    private readonly IQueryDatabase _db;
    public async Task<OrderDto> Handle(GetOrderQuery query, CancellationToken ct)
    {
        return await _db.OrderProjections.FindAsync(query.OrderId);
    }
}

如果使用事件溯源,CreateOrderHandler 将保存事件,后台投影则负责构建 OrderProjection 表。


总结

CQRS 通过将读和写的职责彻底分离,让你能用不同的模型、数据库和优化策略分别应对查询和命令的挑战。它尤其适合复杂业务系统、事件驱动架构和需要独立规模化扩展的场景。但引入 CQRS 必然伴随分布式复杂度与最终一致性成本,因此需要根据实际业务需求谨慎权衡。当你发现单一模型已经难以同时服务好写端的事务完整性和读端的性能需求时,CQRS 便是应该认真考虑的演进方向。