CQRS + Event Sourcing:读写分离与审计
什么是 CQRS 与事件溯源
CQRS(命令查询职责分离)与事件溯源(Event Sourcing)是两种经常搭配使用的架构模式。它们共同解决复杂业务系统中的读写性能、扩展性与审计追溯问题。简单地说:
- CQRS 将“写操作”(命令)与“读操作”(查询)拆分成不同的模型与数据存储,做到读写分离。
- 事件溯源 不直接存储当前状态,而是以不可变事件序列的形式记录所有状态变更。通过重放事件可以重建任意时刻的状态。
当两者结合时,命令端产生领域事件,这些事件被持久化到事件存储;查询端消费这些事件并构建优化的读模型,极大提升查询效率与可审计性。
为什么需要读写分离
传统 CRUD 应用使用同一个数据模型处理读写,这在业务简单时没有问题。但随着需求复杂化,读写压力、数据形状差异会带来冲突:
- 写操作需要业务规则校验、完整性保证,通常涉及聚合根、事务边界。
- 读操作往往需要跨实体聚合、展示扁平化或报表化的数据,频繁 JOIN 或复杂视图。
如果不分离,要么读模型为了性能被迫妥协业务完整性,要么写模型被读需求拖累,导致架构僵化。CQRS 允许对读模型进行专门优化(如使用 NoSQL、物化视图、缓存),而写模型只关注核心业务逻辑。
CQRS 的核心概念
命令与查询
- 命令(Command):意图改变系统状态,通常以祈使语气命名,如
PlaceOrderCommand、CancelReservationCommand。命令是单向请求,可被拒绝或失败,不应返回业务数据。 - 查询(Query):只读请求,不产生副作用,如
GetOrderHistoryQuery。可以自由返回任意形状的数据。
分离的模型与数据存储
命令模型和查询模型不仅代码分离,底层数据存储也可不同:
| 模型 | 职责 | 典型存储 |
|---|---|---|
| 写模型 | 验证命令、执行业务规则、产生事件 | 事件存储(追加日志)、关系库 |
| 读模型 | 响应查询、提供高性能读取 | 文档数据库、内存缓存、全文搜索引擎 |
在前端/API 层,命令和查询走不同的接口,甚至可以部署成独立的微服务。
事件溯源如何支撑 CQRS
事件溯源的核心思想是:记录“发生了什么”而非“当前是什么”。订单系统不会直接保存订单的当前状态,而是保存诸如 OrderPlaced、ItemAdded、ShippingAddressUpdated 等事件流。
每个事件是一个不可变的事实,包含事件类型、业务数据、时间戳、用户等信息。要得到当前状态,按事件发生的顺序依次应用即可。
事件溯源为 CQRS 带来以下关键能力:
- 完整审计日志:所有更改都有迹可循,天然满足合规审计。
- 自由构建读模型:当业务需要新的查询视图时,只需从事件流中重新构建,无需修改写端。
- 时间旅行与调试:可重建任意历史点的状态,方便调试、分析历史数据。
- 灵活演化:新读模型可以并行构建,零停机上线。
实现模式:命令处理器、聚合与事件
一个典型的实现流程如下:
- 命令处理器接收到命令(如
ProcessPayment)。 - 从事件存储加载该聚合的所有历史事件,重建当前状态。
- 执行聚合内的业务方法,验证是否可接受该命令。
- 聚合返回新的领域事件(如
PaymentProcessed)。 - 将这些事件追加写入事件存储——这是原子的提交。
- 事件总线/消息代理将这些事件发布给读模型订阅方。
- 读模型按需更新自己的数据记录,例如更新订单状态、增加积分记录等。
需要特别注意:事件存储是事实的唯一来源,读模型数据是可丢弃重建的。
搭建 CQRS + 事件溯源的步骤
步骤1:设计领域事件
从业务行为中提取事件,确保事件名反映过去发生的事情:UserRegistered、ProductAddedToCart、OrderShipped。事件应包含所有已发生的事实数据,避免过度膨胀。
步骤2:实现命令模型
建立聚合根,接收命令并产生事件。保证每个聚合的事件流边界清晰(如一个订单聚合)。编写严格的验证逻辑,确保只有有效命令才能产生事件。
步骤3:构建事件存储
事件存储需要支持:
- 按聚合ID顺序追加事件。
- 并发控制(通常用乐观锁,通过版本号/事件编号)。
- 高效重放:为每个聚合缓存最新事件版本号,或使用快照减少重放开销。
常用实现:关系数据库的事件表,或专用事件存储(EventStoreDB、Apache Kafka + 表)。
步骤4:实现读模型投影
定义投影逻辑:订阅某种事件类型,更新对应的读数据表。例如 OrderReadModel 监听 OrderPlaced、OrderItemChanged 等事件,维护一张宽表 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 开始,再逐步引入事件溯源,团队可以平滑过渡到这一先进架构,构建出长期可演化的业务系统。