跳到内容

15.3 CQRS 与 Event Sourcing:分离模型和保存事实

订单系统既要快速查询当前状态,又要解释状态为何变成今天这样,团队因此提出拆分读写模型并保留事件历史。

CQRS 与 Event Sourcing 经常一起出现,但它们解决不同问题:CQRS 分离写入模型和读取模型;Event Sourcing 把领域事件序列作为状态的权威记录。可以只用其中一个,也可以都不用。

CQRS:读写使用不同模型

写模型保护不变量,通常按聚合和命令组织;读模型为具体查询预先组合数据,通常按页面、报表或 API 响应组织。

text
Command → Write Model → Database

                         └─ events / CDC → Projection → Read Store → Query

CQRS 不要求两个数据库。最轻量的实现可以在同一个数据库中使用不同的对象模型或视图;只有负载、权限、部署或存储特性确有差异时,才分离存储。

分离后要明确读延迟。命令成功并不保证查询投影已更新,客户端可能需要:

  • 接受短暂旧数据并显示更新时间;
  • 根据写模型返回关键结果;
  • 使用版本 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。重建大投影时应:

  1. 在新表或新索引中从头构建;
  2. 持续追赶新增事件;
  3. 验证计数和业务不变量;
  4. 原子切换读流量;
  5. 保留可回退窗口。

不要把审计日志误当 Event Sourcing

记录“谁在何时改了哪一列”有审计价值,但不一定包含重建业务状态所需的完整语义。Event Sourcing 要求事件是状态权威来源、顺序可验证、演进可管理,并有投影与恢复工具链。

采用前的判断

适合考虑 Event Sourcing 的信号:

  • 历史事实本身有核心业务价值;
  • 需要回溯某时刻状态或重新计算新投影;
  • 领域天然由命令和事件表达;
  • 团队能运营版本、重放、快照和修复流程。

不适合的信号:

  • 只是普通 CRUD;
  • 团队希望借它“自动解决审计”;
  • 无法定义稳定事件语义;
  • 没有能力测试重放和灾难恢复。

参考资料

Built with VitePress | Software Systems Atlas