CQRS + Event Sourcing:读写分离与审计

FreeGuideOnline 最新 2026-06-12

什么是 CQRS 与事件溯源

CQRS(命令查询职责分离)与事件溯源(Event Sourcing)是两种经常搭配使用的架构模式。它们共同解决复杂业务系统中的读写性能、扩展性与审计追溯问题。简单地说:

  • CQRS 将“写操作”(命令)与“读操作”(查询)拆分成不同的模型与数据存储,做到读写分离
  • 事件溯源 不直接存储当前状态,而是以不可变事件序列的形式记录所有状态变更。通过重放事件可以重建任意时刻的状态。

当两者结合时,命令端产生领域事件,这些事件被持久化到事件存储;查询端消费这些事件并构建优化的读模型,极大提升查询效率与可审计性。

为什么需要读写分离

传统 CRUD 应用使用同一个数据模型处理读写,这在业务简单时没有问题。但随着需求复杂化,读写压力、数据形状差异会带来冲突:

  • 写操作需要业务规则校验、完整性保证,通常涉及聚合根、事务边界。
  • 读操作往往需要跨实体聚合、展示扁平化或报表化的数据,频繁 JOIN 或复杂视图。

如果不分离,要么读模型为了性能被迫妥协业务完整性,要么写模型被读需求拖累,导致架构僵化。CQRS 允许对读模型进行专门优化(如使用 NoSQL、物化视图、缓存),而写模型只关注核心业务逻辑。

CQRS 的核心概念

命令与查询

  • 命令(Command):意图改变系统状态,通常以祈使语气命名,如 PlaceOrderCommandCancelReservationCommand。命令是单向请求,可被拒绝或失败,不应返回业务数据。
  • 查询(Query):只读请求,不产生副作用,如 GetOrderHistoryQuery。可以自由返回任意形状的数据。

分离的模型与数据存储

命令模型和查询模型不仅代码分离,底层数据存储也可不同:

模型 职责 典型存储
写模型 验证命令、执行业务规则、产生事件 事件存储(追加日志)、关系库
读模型 响应查询、提供高性能读取 文档数据库、内存缓存、全文搜索引擎

在前端/API 层,命令和查询走不同的接口,甚至可以部署成独立的微服务。

事件溯源如何支撑 CQRS

事件溯源的核心思想是:记录“发生了什么”而非“当前是什么”。订单系统不会直接保存订单的当前状态,而是保存诸如 OrderPlacedItemAddedShippingAddressUpdated 等事件流。

每个事件是一个不可变的事实,包含事件类型、业务数据、时间戳、用户等信息。要得到当前状态,按事件发生的顺序依次应用即可。

事件溯源为 CQRS 带来以下关键能力:

  • 完整审计日志:所有更改都有迹可循,天然满足合规审计。
  • 自由构建读模型:当业务需要新的查询视图时,只需从事件流中重新构建,无需修改写端。
  • 时间旅行与调试:可重建任意历史点的状态,方便调试、分析历史数据。
  • 灵活演化:新读模型可以并行构建,零停机上线。

实现模式:命令处理器、聚合与事件

一个典型的实现流程如下:

  1. 命令处理器接收到命令(如 ProcessPayment)。
  2. 从事件存储加载该聚合的所有历史事件,重建当前状态。
  3. 执行聚合内的业务方法,验证是否可接受该命令。
  4. 聚合返回新的领域事件(如 PaymentProcessed)。
  5. 将这些事件追加写入事件存储——这是原子的提交。
  6. 事件总线/消息代理将这些事件发布给读模型订阅方
  7. 读模型按需更新自己的数据记录,例如更新订单状态、增加积分记录等。

需要特别注意:事件存储是事实的唯一来源,读模型数据是可丢弃重建的。

搭建 CQRS + 事件溯源的步骤

步骤1:设计领域事件

从业务行为中提取事件,确保事件名反映过去发生的事情:UserRegisteredProductAddedToCartOrderShipped。事件应包含所有已发生的事实数据,避免过度膨胀。

步骤2:实现命令模型

建立聚合根,接收命令并产生事件。保证每个聚合的事件流边界清晰(如一个订单聚合)。编写严格的验证逻辑,确保只有有效命令才能产生事件。

步骤3:构建事件存储

事件存储需要支持:

  • 按聚合ID顺序追加事件。
  • 并发控制(通常用乐观锁,通过版本号/事件编号)。
  • 高效重放:为每个聚合缓存最新事件版本号,或使用快照减少重放开销。

常用实现:关系数据库的事件表,或专用事件存储(EventStoreDB、Apache Kafka + 表)。

步骤4:实现读模型投影

定义投影逻辑:订阅某种事件类型,更新对应的读数据表。例如 OrderReadModel 监听 OrderPlacedOrderItemChanged 等事件,维护一张宽表 order_summary。读模型可以随时重新从事件流生成,或通过定期全量重建修正不一致。

步骤5:搭建查询服务

查询服务直接读取读模型存储,提供快速、定制化的 API。可以为不同查询场景建立多个读模型:用户订单列表、管理面板统计、全文搜索等。

审计能力详解

事件溯源天然实现了不可篡改的审计:

  • 完整历史:所有操作记录为事件,永不删除。
  • 事件溯源时间轴:可回放任何时间点的状态,回答“这个订单当时是什么情况”。
  • 合规性:金融、医疗等领域需要证明谁在何时做了什么,事件源中的用户ID、时间戳满足要求。
  • 异常检测:分析事件流可以发现业务漏洞或违规模式。

配合 CQRS 后,读模型可以专门构建用于审计的视图,如“操作日志报表”,而不影响业务主流程。

常见误区与挑战

误区1:所有系统都需要事件溯源

事件溯源适合业务逻辑复杂、需要完整审计、多读模型的场景。简单 CRUD 系统引入事件源反而增加复杂度。

误区2:事件可以随意修改

事件是事实记录,不应删除或修改。业务修正通过追加补偿事件(如 OrderRefunded)来实现,而不是修改历史。

挑战1:最终一致性

读模型通过事件异步更新,存在读取到旧数据的窗口。需要在前端策略上处理,如采用“等待确认”或“轮询最新”。

挑战2:事件版本演进

事件结构会随业务变化。需定义事件版本的向上兼容策略(比如可选字段、Upcaster 转换旧事件到新版)。

挑战3:查询性能与重建时间

大量事件重放可能缓慢。采用事件快照机制定期保存聚合状态,结合快照和增量事件快速重建。

何时使用 CQRS + 事件溯源

符合以下特征时该模式值得采用:

  • 高读写分离需求:读请求远多于写,且读写数据形态差异大。
  • 严格审计要求:需保留完整操作历史并可追溯。
  • 复杂业务规则:领域模型复杂,需用事件促使解耦。
  • 多版本查询:需要为不同类型的消费者提供不同读视图。
  • 微服务协作:事件作为服务间松散耦合的契约。

避免在简单、事务性要求强即时一致、没有审计需求的小型应用上使用,否则只增加开销。

常用技术栈与范例

  • 事件存储:EventStoreDB、PostgreSQL(自建事件表)、微软 Cosmos DB 变更源。
  • 消息代理:RabbitMQ、Apache Kafka、Azure Event Hubs。
  • 读模型存储:MongoDB、Redis、Elasticsearch。
  • 框架支持:.NET 的 MediatR + EventFlow、Java 的 Axon Framework、Node.js 的 NestJS CQRS 模块。

一个简单的代码概念(伪代码):

// 命令端
commandHandler.handle(PayOrder command) {
    events = eventStore.loadEvents(command.orderId)
    order = Order.rehydrate(events)
    newEvent = order.pay(command.amount)
    eventStore.appendEvents(command.orderId, [newEvent])
    eventBus.publish(newEvent)
}

// 读模型投影
projection.on(PaymentProcessed e) {
    db.orderReadModel.updateOne(
        { orderId: e.orderId },
        { $set: { status: "paid", paidAt: e.timestamp } }
    )
}

总结

CQRS 与事件溯源通过分离读写模型以事件为中心存储,为复杂系统带来了高扩展性、灵活的查询能力和无与伦比的审计追溯能力。虽然实施需要一定的额外基础设施和设计成本,但在需要高可靠性、强追溯性和复杂业务解耦的场景中,它们是不可替代的组合模式。

通过逐步实施事件驱动思想,从简单的 CQRS 开始,再逐步引入事件溯源,团队可以平滑过渡到这一先进架构,构建出长期可演化的业务系统。