事件溯源模式入门(译)

事件溯源 (Event sourcing) 和 Command Query Responsibility Segregation (CQRS) 通常会一起被提到。虽然两者之间没有直接的关系,但我们会发现它们是相辅相成的。
本章节介绍了事件溯源中的一些关键概念,并介绍了一些与 CQRS 模式相关的知识点。本章节仅仅是一个简介,在第4章节中会有关于事件溯源和 CQRS 之间关系的深入介绍。
为了帮助我们理解 事件溯源,首先我们需要理解 事件 的基本特征:

  • 事件是发生在过去的。举个例子,’预约了演讲者’、’预定了座位’,’发放了现金’,他们都是是用过去式描述这些事件的。
  • 事件是不可变的。应为事件是发生在过去的,所以他不能被修改或者撤回。但是,后续事件可能会更改或抵消早期事件的影响。例如,’预约已取消’ 该事件更改了先前预约事件的影响结果。
  • 事件是单向消息。事件只有一个源头(发布者)发布事件,一个或多个接收者(订阅者)接收事件。
  • 通常,事件包括事件相关的其他信息。例如,’E23座位是由爱丽丝预订的’。
  • 在事件溯源的上下文中,事件应描述业务意图。例如,’E23座位是由爱丽丝预订的’描述了业务发生了什么,这比’在预订表中,ID为E23的行的名称字段更新为爱丽丝’表达了更多信息。

我们还将本章讨论事件与聚合的关系。有关 DDD 术语,聚合、聚合根和实体的说明,请参见 《CQRS in Context》。其中有两个特性与事件和事件溯源有关:

  • 聚合定义了相关实体组的一致性边界。因此,我们可以聚合相关事件,来通知相关方更新该实体(更新的一致的性)
  • 每个聚合都有唯一的 ID,因此,我们可以使用该 ID 来记录该聚合有哪些事件。

在本章的其余部分,我们将使用 聚合 一词来指代一组关联的对象,这些对象被视为一个单元,以进行数据修改。这并不意味着事件溯源与 DDD 方法直接相关;我们只是使用 DDD 中的术语来尝试保持本指南中我们语言的一致性。

什么是事件溯源

事件溯源是我们持久化应用状态的一种方式,他通过存储历史事件来记录应用当前的状态。例如,会议室管理系统需要跟踪已经被预定会议室的座位情况,目的为了检查当有座位预定时,是否有空余的座位。系统可以通过两种方式存储会议室以被预订座位的总数:

  • 系统可以直接存储会议室座位被预订的总数,并在当有人预订或取消时修改此数字。我们可以将预订数视为一个整数值,该整数值存储在表的特定列中,该表在系统中每个会议都有一条记录。
  • 系统存储每个会议室座位被预定或取消的事件,并且通过重放该会议室相关的事件来计算当前被预定座位的总数。

关系数据库 vs 事件溯源

关系数据库

上图的处理步骤:

  1. 流程管理器或 UI 发出命令,为 ID 为 157 的会议室保留两个席位。该命令由 SeatsAvailability 聚合处理程序处理。
  2. 如有必要,对象关系映射(ORM)层将数据填充到聚合实体中。 ORM 从数据存储的表,来查询会议室被预定座位的现有数量。
  3. 命令处理程序在聚合实体上调用业务方法进行保留座位。
  4. SeatsAvailability 聚合执行其领域逻辑,计算会议室被预订座位新的总数。
  5. ORM 将聚合实体中的信息更新持久化到数据库中

有关流程管理器的定义,请参见第6章,《关于 Sagas 的传奇》

上图提供了该过程的简化视图。实际上,由 ORM 层执行的映射逻辑是更复杂的。我们还需要考虑何时执行加载和保存操作,以平衡一致性、可靠性、可伸缩性和性能的需求。

事件溯源

使用事件溯源代替 ORM 层和关系数据库(RDBMS),执行步骤:
  1. 流程管理器或 UI 发出命令,为 ID 为 157 的会议室保留两个席位。该命令由 SeatsAvailability 聚合处理程序处理
  2. 通过查询 SeatsAvailability 聚合 ID 为 157 的所有事件来生成聚合实例。
  3. 命令处理程序在聚合实例上调用业务方法进行保留座位。
  4. SeatsAvailability 聚合执行其领域逻辑,计算会议室被预订座位新的总数。SeatsAvailability 将创建一个事件,用于记录被预定的两个座位。
  5. 系统将’预定了两个座位的’的事件追加到与事件存储中。

第二种方法更简单,因为它省去了 ORM 层,并用更简单的方法代替了数据库中的复杂关系模型。数据库仅需要支持通过聚合对象 ID 查询历史事件、附加新事件的功能。我们仍然需要考虑读写事件的性能和可伸缩性,可以通过对聚合对象进行快照达到性能优化。因为这样我们无需查询和重放全部的事件,只需要从快照之后获取事件并重放即可获取聚合对象的当前状态,并且无需在内存中维护聚合对象的缓存副本。
我们还必须确保有一种机制,能够可以通过查询历史事件来重建聚合对象状态。
通过第二种方法,我们可以获取到会议预订和取消的完整历史记录。因此,事件流是我们唯一事实的来源。我们无须直接保存聚合对象,因为我们可以通过重放事件,将系统恢复到任何时间点的聚合状态。
在某些领域,例如账务领域,事件溯源是一种自然的、公认的方法。账务系统存储每个交易的事件,系统始终可以恢复到系统的当前状态。事件溯源也可以在其他领域带来类似的好处。

为什么我们需要使用事件溯源

到目前为止,我们使用事件溯源的唯一原因,是因为它存储了领域中聚合相关的全部历史事件。在某些领域(例如账务),这是至关重要的功能,在该领域中,我们需要账务交易的完整记录跟踪,并且事件必须是不可变的。交易一旦发生,就不能删除或更改,尽管可以根据需要创建新的事件进行修改或撤消交易。

使用事件溯源的主要好处是自带的审核机制,它可以确保事务数据和审核数据的一致性,因为它们是相同的数据。通过事件重放,允许我们随时重建到对象的任何状态。—Paweł Wilkosz(客户咨询委员会)

下面描述了使用事件溯源可以带来的一些其他好处:

  • 性能。由于事件是不可变的,因此在保存事件时仅有追加操作。事件也是简单的独立对象,与使用复杂的关系存储模型的方法相比,这两个点都可以为系统带来更好的性能和可伸缩性。
  • 简单。事件是简单的对象,它们描述系统中发生的事情。通过保存事件,可以避免将复杂领对象保存到关系存储所带来的复杂性。
  • 审计跟踪。事件是不可变的,并且还保存了所有事件的历史记录。这样,他们可以根据历史记录进行审计跟踪。
  • 与其他子系统的集成。事件提供了与其他子系统通信的方式。我们可以将事件通知给其他关心此事件的子系统。
  • 从历史事件中获取额外的业务价值。通过存储事件,我们可以通过查询与该时间点之前与领域对象关联的事件来还原任何时间点的系统状态,这样我们能够获取到系统所有的历史信息。此外,我们无法预测未来需要从系统中提取哪些新的信息。如果我们保存了事件,则不会丢弃将来可能是被认为有价值的信息。
  • 生产故障排除。我们可以通过复制生产事件存储并在测试环境中进行重放,来对生产系统中的问题进行故障排除。如果我们知道生产系统中发生问题的时间,那么我们可以轻松地重放事件流直至该点,即可准确分析产生的问题。
  • 修正错误。我们可能会发现代码的 bug,导致系统计算出错误的数值。我们可以修改代码的 bug 后,并重放事件流,达到系统根据正确的代码计算出正确的值。而不是修改代码后,并对存储的数据执行危险的手动调整。
  • 测试。聚合中的所有状态更改都记录为事件。因此,我们可以通过检查事件来判定结果是否符合预期。
  • 灵活性。事件序列可以转化为任何所需的结构存储。

    只要有事件流,就可以将其转化为任何形式存储,甚至是常规的 SQL 数据库。例如,我最喜欢将事件流存储在云存储中的 JSON 文档中。— Rinat Abdullin(Why Event Sourcing?

在第4章 《CQRS 和 ES 的深入探究》 将详细讨论这些好处。

事件溯源需要关注的问题

在上节中描述了使用事件溯源模式的一些好处。但是,我们可能面临一些问题需要解决:

  • 性能。虽然事件溯源确实提高了更新操作的性能,我们需要考虑,查询所有相关事件并重放所花费的时间。使用快照可以限制我们加载事件的数量,因为我们可以获取最新快照,然后从该点开始重放事件。可以查阅《CQRS 和 ES 的深入探究》获取更多信息。
  • 版本控制。我们可能在将来需要更改事件消息的结构。我们必须考虑系统如何处理修改事件结构导致的多版本问题。
  • 查询。虽然很容易通过重放事件的方式加载领域对象当前的状态,但是它对于执行条件查询来说是困难的。例如,查询价格超过 $250 的订单。如果我们实现了 CQRS 模式,我们应该记住,此类查询通常将在读取端执行,我们需要构建专门数据投影来执行此类查询。

CQRS/ES

CQRS 模式和事件溯源经常结合使用,他们互为补充。

第2章 《CQRS 介绍》 建议将事件从写入侧到读取侧进行推送同步。读取侧的数据存储通常包含非规范化数据,这些数据针对数据条件查询进行了优化。例如,在应用程序的 UI 中显示查询结果。

ES 是一种很好的模式,可用于实现写入和查询之间的联系。ES不是唯一的方法,但是一种合理的方法,还原事实的关键,来源于事件日志是临时的还是永久的。CQRS 模式本身要求在写入和读取之间进行区分,因此和 ES 完全是互补的。 - Clemens Vasters(CQRS顾问邮件列表)

事件溯源中领域模型的状态是事件流的持久化,而不是单个快照持久化,也不是关于如何让命令侧和查询侧如何保持数据同步的方法(通常使用基于发布/订阅消息的方法)。 - Udi Dahan(CQRS顾问邮件列表)

我们可以将写入端接收到的事件同步转发到读取端,读取端处理事件保存到物化试图中(View DB),来提供条件查询。

请注意,写入端将事件持久化到事件存储后再发布事件,这样可以避免使用两阶段提交。如果聚合负责将事件保存到事件存储中并将事件发布,则需要使用两阶段提交。
通常,这些事件使您可以实时地实时更新读取数据。事件传输机制可能会导致一些延迟,在第4章 《CQRS 和 ES 的深入探究》 讨论了这种延迟的可能带来的问题。
我们可以随时通过重放事件来重建数据。如果读取侧数据存储由于某种原因不同步,或者因为您需要修改读取侧数据存储的结构以支持新查询,则可能需要执行此操作。
如果其他领域的有界上下文也订阅了相同的事件,则需要小心地重放事件。因为在重放事件之前,清空读取侧存储的数据很容易,但是确保另一个领域的有界上下文的一致性可能不是那么容易。

事件存储

我们使用事件溯源,我们需要用一种机制方法,能够保存我们的事件,并且能够查询返回出事件流,用于通过重放事件流重新创建聚合实例的状态。这个存储机制通常称为事件存储。
我们可以实现自己的事件存储,或者使用第三方事件存储。例如,Jonathan Oliver 的 EventStore。 虽然我们实现一个小型事件存储相对容易,但是具备可靠性、可伸缩的将带来挑战性。
第8章,《总结:经验教训》 总结了我们团队实现自己的事件存储的经验。

基本要求

通常,当我们实现 CQRS 模式时,聚合会创建事件,将信息发给其他相关方。使用事件溯源时,将这些相同事件保留到事件存储中,让我们能够通过重放与该聚合关联的事件流来恢复聚合的状态。实际上,并非系统中的所有事件都必须具有订阅者。我们可以创建某些事件,仅是为了保留聚合的某些属性。

底层存储

事件不是复杂的数据结构。通常,会包含一些基础数据,如与之关联的聚合实例的 ID 、事件版本号,以及事件本身的详细信息。我们不需要使用关系数据库来存储事件,我们可以使用 NoSQL、文档数据库或文件系统存储。

性能、扩展性、持久化

存储的事件应该是不可变的,并且始终能够按其保存的顺序进行读取。因此保存事件应该是在底层存储上,执行简单、快速的追加操作。
加载持久化的事件时,必须按照它们最初保存的顺序来加载它们。如果使用关系数据库,则应使用聚合 ID 和定义的事件顺序的字段来加载。
如果聚合实例具有大量事件,这可能会影响重放所有事件来重新恢复聚合状态所花费的时间。在这种情况下,需要考虑的使用快照机制。除了事件存储中的完整事件流之外,我们还可以在最近的某个时间点存储聚合状态的快照。当要重新加载聚合的状态时,首先要加载最新的快照,然后重放快照之后的所有事件。我们可以在写入事件的过程中生成快照,例如,每处理 100 个事件创建一个聚合的快照。
作为替代方案,我们可以在内存中缓存使用率很高的聚合实例,避免反复重放事件流。
当事件存储保留事件时,它还必须发布该事件,即对事件消息的先保存后处理。为了保持系统的一致性,两个操作必须同时成功或失败。我们可以使用分布式两阶段提交事务,该事务将存储数据和消息发布包装在一起。但是实际上,我们会发现在许多数据存储和消息中间件,对两阶段提交事务提交的支持是有限制的。使用两阶段提交可能会限制系统的性能和可伸缩性。
如果选择使用自己实现的事件存储,则必须解决的关键问题之一就是如何实现一致性。
如果计划使用跨多个存储节点的分布式事件存储。在这种情况下,我们必须保证在分布式下,写数据的完全一致性,而不是最终一致性。
有关 CAP 定理和在分布式系统中保持一致性的更多信息,请参见下一章《CQRS 和 ES 的深入探究》

补充阅读

《事件溯源模式》

参考原文

《Reference 3: Introducing Event Sourcing》