15.3 CQRS 与 Event Sourcing:分离模型和保存事实
订单系统既要快速查询当前状态,又要解释状态为何变成今天这样,团队因此提出拆分读写模型并保留事件历史。
CQRS 与 Event Sourcing 经常一起出现,但它们解决不同问题:CQRS 分离写入模型和读取模型;Event Sourcing 把领域事件序列作为状态的权威记录。可以只用其中一个,也可以都不用。
CQRS:读写使用不同模型
写模型保护不变量,通常按聚合和命令组织;读模型为具体查询预先组合数据,通常按页面、报表或 API 响应组织。
text
Command → Write Model → Database
│
└─ events / CDC → Projection → Read Store → QueryCQRS 不要求两个数据库。最轻量的实现可以在同一个数据库中使用不同的对象模型或视图;只有负载、权限、部署或存储特性确有差异时,才分离存储。
分离后要明确读延迟。命令成功并不保证查询投影已更新,客户端可能需要:
- 接受短暂旧数据并显示更新时间;
- 根据写模型返回关键结果;
- 使用版本 token 等待投影追上;
- 对“读己之写”提供专门路径。
Event Sourcing:状态来自事件折叠
传统持久化保存当前状态;Event Sourcing 保存导致当前状态的领域事实:
text
EntryRequested(v1)
SeatReserved(v2)
PaymentAuthorized(v3)
RegistrationConfirmed(v4)加载聚合时按顺序应用事件:
java
Registration rehydrate(List<RegistrationEvent> events) {
Registration state = Registration.empty();
for (RegistrationEvent event : events) {
state = state.apply(event);
}
return state;
}追加事件必须带期望版本,防止两个命令基于同一旧状态同时提交:
text
append(streamId, expectedVersion=4, newEvents)若当前版本已不是 4,写入失败,调用者重新读取后决定重试或报告冲突。
事件是长期契约
事件一旦写入历史,就可能在多年后重放。事件演进策略包括:
- 读取时 upcast 旧版本到当前内存表示;
- 新消费者同时理解多个 schema 版本;
- 修复事实时追加纠正事件,不篡改已经发生的历史;
- 禁止在事件中依赖会消失的外部对象引用。
事件名称应表达已发生的业务事实,如 PaymentAuthorized,而不是含糊的 RegistrationUpdated。
快照与投影重建
快照用于缩短长事件流的聚合加载时间,但不是权威事实。快照应记录所覆盖的事件版本,损坏时能够丢弃并从事件重建。
投影处理器也需要幂等和 checkpoint。重建大投影时应:
- 在新表或新索引中从头构建;
- 持续追赶新增事件;
- 验证计数和业务不变量;
- 原子切换读流量;
- 保留可回退窗口。
不要把审计日志误当 Event Sourcing
记录“谁在何时改了哪一列”有审计价值,但不一定包含重建业务状态所需的完整语义。Event Sourcing 要求事件是状态权威来源、顺序可验证、演进可管理,并有投影与恢复工具链。
采用前的判断
适合考虑 Event Sourcing 的信号:
- 历史事实本身有核心业务价值;
- 需要回溯某时刻状态或重新计算新投影;
- 领域天然由命令和事件表达;
- 团队能运营版本、重放、快照和修复流程。
不适合的信号:
- 只是普通 CRUD;
- 团队希望借它“自动解决审计”;
- 无法定义稳定事件语义;
- 没有能力测试重放和灾难恢复。
参考资料
- Martin Fowler, CQRS
- Martin Fowler, Event Sourcing
- Microsoft, CQRS pattern