CQRS 与事件溯源图:命令、事件和读模型

用一张图讲解 CQRS 与事件溯源:命令端、只追加的事件存储、投影器、读模型、重放、版本管理,以及什么时候不该用。

· 创始人兼开发者

发布于

CQRS 与事件溯源图:命令、事件和读模型

大多数应用通过同一个模型读写同一批表。这在两边的数据形态开始分化之前都没问题:写入需要对单个订单执行严格的业务规则,而读取需要跨越成千上万个订单的列表、搜索和汇总。CQRS 把两者分开,而事件溯源则从根本上改变了你存储的东西。

它们常被放在一起介绍,但其实是两个独立的决定。本文分别解释两者、它们如何配合、组合系统的架构图是什么样子,以及让许多团队后悔过早采用它们的那些成本。

一段话讲清 CQRS

命令查询职责分离(Command Query Responsibility Segregation)指用一个模型修改数据,用另一个模型读取数据。命令(PlaceOrder、CancelOrder)发往执行规则的写端。查询发往按页面需求设计的读端。Martin Fowler 的 CQRS 文章 把这个模式归功于 Greg Young,文中还有一句值得反复强调的警告:对大多数系统来说,CQRS 会带来有风险的复杂性。只在读写真正分化的地方使用它,而不是用在整个应用上。

CQRS 并不需要两个数据库。一个写模型,加上一组基于同一批表的 SQL 视图,就已经是 CQRS 了。等读负载或读取形态真正需要时,再引入独立的存储。

一段话讲清事件溯源

事件溯源存储的是变化,而不是当前状态。一个订单不是一行带状态列的数据,而是一串事件:OrderPlaced、PaymentAuthorized、OrderShipped。当前状态就是重放这些事件得到的结果。Fowler 的 事件溯源文章 详细介绍了这个思路。由于什么都没有被覆盖,你免费获得了完整的审计历史,而且随时可以为过去的数据构建新的视图。

两者如何配合

事件溯源几乎迫使你采用 CQRS。在修改一个订单之前通过重放事件来加载它,是个不错的方法;但要回答“列出所有金额超过 100 美元的未完成订单”,这就是个糟糕的方法。所以写端处理事件流,读端维护独立的读模型,并根据这些事件进行更新。

流程如下:

  1. 命令到达命令端。
  2. 处理器通过重放事件流加载聚合(订单),检查业务规则,并决定要产生哪些新事件。
  3. 它带着事件流的 期望版本 把事件追加到事件存储。如果在此期间有其他命令追加了事件,这次追加就会失败,命令会重试。这就是乐观并发控制,它取代了行锁。
  4. 投影器订阅事件流并更新读模型:一张用于订单列表的表、一个搜索索引、一组用于仪表盘的计数器。
  5. 查询只从读模型中读取。

事件存储

事件存储需要的功能不多,但每一项都必须准确:

  • 只追加的事件流, 每个聚合一个,每个事件带一个版本号。
  • 基于期望版本的条件追加, 并且是原子的。
  • 读取事件流, 从头开始或从某个版本开始。
  • 订阅, 按顺序接收所有新事件,并带有一个位置,投影器可以保存它并从那里继续。

专用的事件存储提供这些功能;一张在流 ID 和版本上建有唯一约束的关系表也可以做到,再配合 outbox 或变更流来实现订阅。

读模型与最终一致性

投影在事件存储之后才运行,所以读模型会落后几毫秒,积压时还会落后更久。设计时要考虑到这一点:

  • 命令执行后返回新版本号,让客户端等到读模型追上,或者直接根据命令的响应显示结果。
  • 给每个投影器各自的位置,这样慢的搜索索引不会拖慢订单列表。
  • 让投影保持幂等。投影器会重启并重新读取事件;OrderShipped 被应用两次时,不能算成两次发货。

回报是读模型可以随时丢弃。新页面需要新的数据形态:写一个投影器,把历史重放进去。投影有 bug:修好它,删掉读模型,再重建。

实例:一张新报表

上线一个月后,财务要求按商品类别和日期统计收入,而且要包含历史数据。在 CRUD 系统里,你得写一个迁移和一个回填脚本,而且变更之前的历史可能已经没了。有了事件溯源,你只需写一个监听 OrderPlaced 和 OrderRefunded 的投影器,把它指向事件日志的开头,让它重放。等它追上之后,报表就覆盖了上线以来的每一个订单,并且此后会自动保持最新。

架构图

用户向命令端发送 PlaceOrder 命令,命令端把事件追加到事件存储。事件经由事件流到达投影器,投影器更新三个读模型:订单列表、搜索索引和每日汇总。查询发往只读取这些模型的查询端。

10 个组件 · 11 个连接

在 Archboard 中打开
图表内容
  • User
  • Command side
  • Event store
  • Event stream
  • Projectors
  • Read model: order list
  • Read model: search index
  • Read model: daily totals
  • Query side
  • PlaceOrder
  • append
  • subscription
  • GET /orders
结合事件溯源的 CQRS。命令先针对聚合进行校验,再作为事件写入只追加的事件存储;投影器把事件流转换成多个读模型,查询只读取这些模型。

这张图的读法如下:

  • 上面一行,写路径: 用户的 PlaceOrder 命令发往命令端,命令端把事件追加到事件存储。一个订阅把事件送入事件流。
  • 右侧: 投影器消费事件流,让三个读模型保持最新,每个读模型都放在最适合其查询的存储中。
  • 左下,读路径: 查询发往查询端,查询端只读取读模型,不包含任何业务规则。
  • Markdown 块 展示了一个带版本号的事件流,便于解释期望版本检查。

成本

  • 模式演进永无止境。 事件会被永久保存,所以旧版本的事件必须一直可读。从第一天起就规划好向上转换(upcasting,即在读取时把旧事件转换成当前结构)。
  • 删除数据很难。 要求删除数据的法规与不可变日志相冲突;不要把个人数据放进事件,或者按人加密,需要删除时销毁密钥。
  • 长事件流加载变慢。 每隔几百个事件给聚合状态做一次快照,就能解决。
  • 调试的位置变了。 bug 表现为读模型出错,而修复方式是重放,这需要相应的工具。
  • 团队技能。 用事件建模是另一种思维方式。只有一个核心领域采用事件溯源就足够了,系统的其余部分可以继续使用 CRUD。

图上应该画什么

  • 命令和查询两条明确分开的路径。
  • 事件存储作为唯一可信来源, 读模型画成派生数据。
  • 每个投影器及其读模型, 让读者看出哪些视图可能滞后。
  • 一个示例事件流, 带上版本号,作为讨论的落脚点。

事件和消费者的机制在 事件驱动架构文章 中有介绍,事务性发件箱模式 则解释了在不使用专用事件存储时如何可靠地发布事件。Kafka 架构图 介绍了事件流本身。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加 C4 模型节点包中的一个 人员,以及表示命令端、投影器和查询端的 容器 节点。
  3. 添加一个 数据库 作为事件存储,再添加几个 数据库 节点作为读模型,并从数据节点包中添加 Kafka 和 Redis,分别表示事件流和计数器。
  4. 把写路径画在上方,读路径画在下方,投影器从右侧为读模型供数。
  5. 添加一个基础节点包中的 Markdown 块,写一个示例事件流。

这个示例共用 10 个图表节点,在 Free 套餐的限制之内。

常见问题

CQRS 和事件溯源必须一起使用吗? 不必。CQRS 可以配合普通数据库使用,事件溯源也可以只为一个读模型供数。不过,完全不用某种形式的 CQRS 的事件溯源很少见,因为在重放的事件流上执行查询很慢。

什么时候不该使用 CQRS? 当读和写使用相同形态的数据时,大多数 CRUD 页面都是这种情况。只在系统中读写分化的部分使用它。

读模型如何与事件存储保持一致? 最终一致。投影器按顺序处理事件并记录自己的位置;故障之后从该位置继续。客户端必须能容忍短暂的延迟。

以后要修改事件结构怎么办? 给事件加上版本,在读取时转换旧版本(向上转换)。永远不要原地修改已存储的事件。

事件溯源中的快照是什么? 聚合在某个版本时状态的一份保存副本。有了它,加载聚合时只需读取快照及其之后的事件,而不必读取整个事件流。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。