CQRS 与 Event Sourcing 设计
约 1417 字大约 5 分钟
distributedcqrsevent-sourcing
2026-03-14
概述
CQRS(Command Query Responsibility Segregation)强调把“写模型”和“读模型”分开,而 Event Sourcing 强调“状态不是直接保存出来的,而是由事件序列重放得到的”。这两个模式经常一起出现,但它们不是一回事。
- CQRS 解决的是读写模型不同步、一个模型难以兼顾两类负载的问题。
- Event Sourcing 解决的是审计、时间旅行、状态重建和跨系统事件传播的问题。
为什么会需要它
传统 CRUD 模型常见问题:
- 写入逻辑有复杂业务约束,读接口却只想要扁平聚合结果。
- 同一张表既要支撑事务一致性,又要支撑全文搜索、排行榜、报表查询。
- 审计要求高,需要回答“某个实体为什么变成现在这样”。
当这些问题越来越明显时,CQRS 和 Event Sourcing 才值得进入设计备选项。
基本架构
这个结构里:
- Command API 只负责校验、执行业务命令。
- Aggregate 保证单个聚合内的业务不变量。
- Event Store 记录事实,而不是直接存最终状态。
- Projector 异步把事件投影成适合查询的读模型。
命令、事件、读模型的分工
命令
命令表示“意图”,例如:
CreateOrderPayOrderCancelOrder
命令可能失败,因为它要检查业务规则。
事件
事件表示“已经发生的事实”,例如:
OrderCreatedOrderPaidOrderCancelled
事件一旦写入就不应该被修改,只能追加新的补偿事件。
读模型
读模型只为查询优化服务,例如:
- 订单详情视图
- 用户订单列表
- 财务统计报表
它可以是 MySQL、Elasticsearch、Redis,甚至一份预计算缓存。
一个订单聚合示例
public final class OrderAggregate {
private OrderStatus status;
public List<DomainEvent> handle(PayOrder command) {
if (status == OrderStatus.CANCELLED) {
throw new IllegalStateException("cancelled order cannot be paid");
}
if (status == OrderStatus.PAID) {
return List.of();
}
return List.of(new OrderPaid(command.orderId(), command.paidAt()));
}
public void apply(DomainEvent event) {
if (event instanceof OrderCreated) {
status = OrderStatus.CREATED;
} else if (event instanceof OrderPaid) {
status = OrderStatus.PAID;
} else if (event instanceof OrderCancelled) {
status = OrderStatus.CANCELLED;
}
}
}重点不是代码形式,而是边界:
handle决定是否允许某个命令发生。apply只负责把事件反映为当前状态。- 聚合状态可以通过历史事件重放得到。
Event Store 需要保证什么
最少要保证以下几点:
- 事件追加有序。
- 同一聚合写入时有版本控制,防止并发覆盖。
- 事件不可原地修改。
- 事件具备可追踪元数据,例如时间、操作者、请求链路 ID。
一个常见的数据结构:
CREATE TABLE order_events (
aggregate_id VARCHAR(64) NOT NULL,
version BIGINT NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (aggregate_id, version)
);读写一致性问题
CQRS 体系里最容易让业务方不适应的是“最终一致性”。
命令成功后通常表示:
- 写模型已经接受并提交了事件。
- 但读模型未必已经投影完成。
因此你要提前定义清楚:
- 提交成功后前端是否立刻跳详情页。
- 详情页是读读模型,还是短时间内直接走写模型回查。
- 是否需要“处理中”状态、轮询或服务端推送。
与 Outbox 模式的关系
如果事件既要落本地库,又要发到消息系统,通常要配合 Outbox:
- 在本地事务里同时写入业务事件和 outbox 表。
- 由后台投递器异步把 outbox 事件发到 Kafka / RocketMQ。
- 下游消费后更新读模型或触发其他流程。
这样可以避免“数据库已提交但消息没发出去”的问题。和 Saga模式、Kafka架构原理 可以串起来看。
什么时候值得上
适合:
- 核心领域规则复杂,状态变化需要完整审计。
- 一个写模型要喂给多个完全不同的查询视图。
- 系统本身就是事件驱动架构。
不适合:
- 业务模型很简单,普通 CRUD 就能表达清楚。
- 团队对领域建模、事件演化、异步一致性没有经验。
- 只是想“看起来更先进”。
实施中的常见坑
把事件当 DTO 日志
事件应该表达业务事实,而不是数据库字段快照。否则事件会迅速失去语义,后续演化也会变难。
聚合边界过大
一个命令只应该在单聚合内做强一致校验。把跨上下文的东西都硬塞进一个聚合,最终会把并发能力和可维护性一起拖垮。
读模型更新链路不透明
如果没有对投影延迟、失败重试、死信队列做观测,读模型迟早会悄悄脏掉。
设计建议
- 先在一个高价值领域试点,例如订单、库存、账务。
- 明确聚合边界,先做少量事件类型,不要一次建很大的事件宇宙。
- 先解决幂等、重放、版本升级和事件兼容,再谈平台化。
- 给读模型投影链路补齐监控指标:积压、失败数、投影延迟、重放耗时。
总结
CQRS 和 Event Sourcing 的价值,在于把复杂领域的读写关注点拆开,并把“状态变化过程”变成一等公民。它们适合复杂业务,不适合所有业务;真正的难点从来不是代码结构,而是边界、一致性和演化策略。
贡献者
更新日志
9f6c2-feat: organize wiki content and refresh site setup于