8年双时态事件溯源经验


双时态事件源将数据存储为一系列事件,这些事件告诉数据发生了什么,并且数据有两个关联的时间点,一个是数据进入系统的时间,另一个是数据生效的时间。

这篇文章讲述了我们 8 年多的双时态事件溯源经验,以及展示如何实现这一目标的代码示例。

TimeRocket 是我们的时间跟踪应用程序,其中使用双时态事件源。时间跟踪域知道很多双时态数据:
我现在预订提前 2 小时开始工作,我今天预订下个月的假期等等。

我们选择事件溯源是因为它允许我们保留数据的历史记录及其更改的原因。这有助于解释为什么数字是这样显示的,并纠正我们用户所犯的操作错误——系统永远不会丢失数据。

使用事件源时,数据存储为事件序列。图中的示例显示了地址的数据。首先,显示地址 - 例如,在创建帐户时。然后,地址会因为人员移动而发生变化。然后,用户更正地址中的拼写错误。这些事件描述了发生的事情。

与简单的 CRUD(创建、读取、更新、删除)相比,我们只能拥有地址的最新状态,我们知道当前地址是如何产生的。我们还可以区分移动和修复拼写错误的人,这可以实现原本不可能实现的业务案例。

单个地址的事件形成所谓的事件流。为了获取当前地址,我们通过按顺序应用事件来投影事件流的事件。我们将结果称为投影。

双时态数据
双时态数据有两个与之关联的时间点。在我们的系统中,数据进入系统时通常有一个时间点。我们将此称为应用时间。并且有一个时间点,数据会产生影响。我们将此称为有效时间。就像支付账单时一样:我现在在账单支付应用程序(应用程序)中输入数据,账单应该在月底支付(生效)。

当我输入关于同一事物(我不喜欢实体这个术语)的多个数据且应用时间和有效时间不同时,可能会发生一个有趣的情况。

  1. 首先,我在1 (申请时间线早期和生效时间线早期)输入数据,
  2. 然后输入2 (申请时间线较晚且生效时间线较晚),
  3. 最后输入3 的数据(比 2 晚应用,但比 2 早生效)。

这意味着 3 在系统中输入的时间较晚,但比 2 中指定的值早生效。

让我们看一个如何处理这种潜在冲突的示例。

在上面的例子中,我在我想去度假的时候输入。

  1. 首先,我告诉系统我想在10月20日休假7天。
  2. 后来我下定决心,我要提前一周去,但是两周。在这种情况下,很明显我希望新数据推翻旧数据。旧数据应被忽略。

我们系统中的许多东西都可以使用规则进行配置。例如,规定一个人何时应该工作或不允许工作,或者最多工作多长时间的规则。

上例中形式化:

  1. 我首先告诉系统使用规则 A 和 B。
  2. 随后、,我告诉系统在稍后的时间点,应该应用规则 B 和 C。

因此,我希望系统从 A 和 B 切换到 B 和 C。


我们的代码库使用 Timeline 类型来表示值随时间的变化情况。

我们区分了两种不同的时间线。

  1. 生命周期时间线代表一个可以创建、更新和删除的值。例如,一个地址。
  2. 基于集合的时间线表示只能设置或删除的值。例如,一组应适用的规则。

这两种时间线在预测事件时有一些微妙的区别。

在本博文中,我们坚持使用生命周期时间线。

事件
在我们的系统中,我们可以定义所谓的组织形式。举例来说,您公司的组织结构图显示了部门、团队和老板。

在我们的系统中,一个典型的事件有一个事件 ID、它所属事物的 ID(这里是组织表单)、一个包含事件详细信息的数据字段和一个应用日期时间。

组织表单可以创建、删除或重命名。组织表单中的单位可以添加、移动、重命名或删除。大多数情况下都有相关记录,其中包含详细信息。下面是两个例子。表格删除事件只需要有效日期时间。

创建的组织表单事件包含标签、子单元和生效日期--工作日,这是一个特殊的抽象概念,代表没有日历的日子*。表单通常在公司重组时创建或更改,并在某个日期生效。现在我们有了申请日期时间和生效日期,因此它是双时间的。

* 由于涉及时区,处理时间问题很困难。

组织表单重命名事件只需要表单的新标签。这是一个单时事件,因为它缺少一个生效日期。在我们的领域,如果我们显示历史数据的旧标签,会让人们感到困惑。因此,我们在单个事件流中混合了单时和双时数据--这很有趣!

在与用户交流和实施实际用例的过程中,我们很快发现,在我们的领域中,事件还有更多的可能性。

有些事件是显而易见的。这些事件包括创建、更新和删除。但我们还发现了更多事件。

  • 我们的系统支持工作流。例如,当我想休假时,我会向我的老板发出请求。然后,他可以接受或拒绝我的休假。如果拒绝,我们就需要删除我日历中的休假条目。但这不是简单的删除。我们称之为 "创建被拒绝"。区分这两种情况可以让我们在业务逻辑中做出更好的决策。
  • 还有一种情况是删除了但应该重新创建。这就是重新创建事件--一种撤销删除的事件。

前面我们已经看到,组织形式的标签没有与之相关的生效日期。为了能够混合单时和双时事件,我们必须修改永久事件。这就意味着,在组织表单的整个生命周期内,标签都要进行更改。

最后,我们有时会以不同的方式投射单个事件流,以实现不同的用例。有时,并非所有事件都与特定投影相关。因此,我们可以跳过事件。


  • 投影projection:事件投影的结果类型。
  • 粒度granularity:时间轴的度量*,通常是工作日或 UTC 点位。

可以看出,指定为 RejectCreation、ModifyPerpetually 和 Skip 的事件是单时事件,而其他事件是双时事件。

投射
回到我们的组织形式事件数据。下面显示了两个数据,以及在对单个组织表单的事件进行投射时得出的投射组织表单的类型。

时间线修订
当要求提供某件事情的时间表时,时间表可以是以下三种情况之一:

  • 如果有可用的数据,我们就会得到一条存在的时间线。
  • 如果找不到数据,我们就会得到一条不存在的时间线。
  • 如果创建被拒绝,我们就会得到一条被拒绝的时间线。被拒绝的时间线可以为我们提供被拒绝的值。例如,我们可以用它来显示哪个假期申请被拒绝了。

存在的时间线由一系列阶段组成。一个阶段可以是有数值的阶段,也可以是没有数值的阶段。这两种阶段都有一个起点,但只有带值的阶段才有相关的值。因此,每当一件事情发生变化,就会增加一个新的阶段。


投影算法

  • 投影算法首先循环遍历我们传递给它的所有事件,并使用一些元数据来增强事件。元数据包含描述我们应该对事件执行的操作的关联操作(创建、更新、删除等)。
  • 第二步是将事件划分到两个存储桶中。第一个桶包含双时间事件 – 具有有效日期(时间)的事件。第二个桶包含单时态事件(Skip、ModifyPerpetually、RejectCreation)。
  • 然后对双时态事件进行排序,首先按其有效性,然后按应用程序,如果两者相同,则另外按其类型排序。例如,创建先于删除。
  • 单时态事件按其应用程序排序。
  • 然后,我们循环双时态事件并根据相关动作修改当前投影。我们将一个新阶段添加到阶段列表中。我们还在这一步中应用所有单时间事件。

8年以上(双时态)事件溯源经验
我们发现使用双时态事件源和“正常”单时态事件源有许多优点。数据更具表现力,因为我们知道数据发生变化的原因。这使得对历史数据的操作变得更加容易和强大。我们还拥有所有更改的历史记录并且永远不会丢失数据。当您的客户致电支持人员不小心“删除”了某些数据时,这非常有用。拥有所有可用事件可以更轻松地调试问题,因为我们可以看到数据如何随时间变化以及何时变化(应用程序)。因此,更容易修复系统或用户所犯的错误。

但我们也发现了许多挑战。使用事件对业务领域进行建模很困难。特别是撤消事件,“哎呀,我不小心更改了错误的值”等。这些事件不是真正的领域事件,但反映了无论如何都可能发生的事情。现有的业务流程通常也不简单,并且它们知道许多特殊情况和例外。我们无法改变数百个客户的业务流程,因此我们需要保持灵活性。

此外,迁移也可能具有挑战性。有时,我们迁移事件本身——我们更改历史数据。从长远来看,这有时比处理不同版本的事件更简单。或者有技术原因。我们从 Newtonsoft JSON 切换到 System.Text.Json,因为它更快。但我们必须迁移所有事件数据(以 JSON 形式存储),因为 JSON 看起来并不完全相同(可区分联合的表示)。

当我们迁移数据时,我们可以在部署系统时进行迁移。这是更简单的方法,但会导致停机。因此这仅适用于非常快速的迁移。对于长时间运行的迁移,我们进行多阶段发布:

  1. 向系统添加新功能,但仍支持旧事件。事件始终以新格式写入,但可以以旧格式和新格式读取。
  2. 借助我们可以在本地运行的控制台应用程序,将旧事件迁移为新格式。该应用程序一次转换几个事件,以防止数据库过载。
  3. 删除对旧版本活动的支持。这使我们的系统简单且可维护。

结论
处理双时态数据很困难。将其与事件源相结合就更加棘手。然而,采用双时态事件溯源的决定对我们来说是有好处的。一旦我们有了处理双时态事件的基础设施代码,我们就能够非常快速地实现困难的用例,而这对于 CRUD 来说是不可能的(或者对于 CRUD-with-history 来说是乏味的)。