事件溯源中的流版本

Stream Version in Event Sourcing

Event Sourcing 中,您存储了一个 Aggregate 发生的所有单独的 Domain Events ] 实例,称为 Event Stream。除了 Event Stream,您还存储了一个 Stream Version.

版本 应该与每个 域事件 相关,还是应该与事务更改(又名命令)相关?


示例:

我们目前的活动商店状态是:

aggregate_id | version | event
-------------|---------|------
1            | 1       | E1
1            | 2       | E2

聚合1中执行了一条新命令。该命令产生了两个新事件E3和E4。

方法一:

aggregate_id | version | event
-------------|---------|------
1            | 1       | E1
1            | 2       | E2
1            | 3       | E3
1            | 4       | E4

使用这种方法,可以通过使用唯一索引的存储机制来实现乐观并发,但在版本 3 之前重放事件可能会使 aggregate/system 处于不一致状态。

方法二:

aggregate_id | version | event
-------------|---------|-----
1            | 1       | E1
1            | 2       | E2
1            | 3       | E3
1            | 3       | E4

重播事件直到版本 3 使 aggregate/system 处于一致状态。

谢谢!

方法 1 是我使用过并看到其他人使用的方法 - 只是事件的递增数字,通常称为 EventNumber

乐观并发部分只是为了让您在加载聚合时知道最新事件是什么。然后您处理命令并保存任何结果事件 - 如果您看到任何超出您加载的数字的内容,则表示您已经过时并可以采取相应行动,否则您可以保存事件。

简答:#1。

事件 E3 和 E4 的写入应该是同一事务的一部分。

请注意,对于您所关心的情况,这两种方法并没有真正的区别。如果您在第一种情况下的阅读可能错过 E4,那么您在第二种情况下的阅读也可能错过。在您加载聚合以进行写入的用例中;加载前三个事件会告诉你下一个版本应该是#4。

在方法#1 的情况下,尝试编写版本 4 会产生唯一约束冲突;命令处理程序将无法判断问题是数据加载不当,还是简单的乐观并发失败,但无论哪种情况,结果都是没有写入,并且记录簿仍处于一致状态。

在方法 #2 的情况下,尝试编写版本 4 不会与任何内容发生冲突。写入成功,现在您有与 E4 不一致的 E5。死气沉沉

有关事件存储模式的参考,您可以考虑查看:

我的首选架构(假设您被迫推出自己的架构)将流与事件分开。

stream_id    | sequence | event_id
-------------|----------|------
1            | 1        | E1
1            | 2        | E2

流为您提供了一个过滤器(流 ID)来识别您想要的事件,以及一个顺序(序列)以确保您读取的事件与您写入的事件的顺序相同。但除此之外,它是一种人为的东西,是我们碰巧选择聚合边界的方式的副作用。所以它的作用应该是相当有限的。

存在于其他地方的实际事件数据。

event_id | data | meta_data | ...
--------------------------------------
E1       | ...  | ... | ...
E2       | ...  | ... | ...

如果您需要能够识别与特定命令关联的事件,那是事件元数据的一部分,而不是流历史的一部分(参见:correlationId、causationId)。

没有什么能阻止您引入 commit_sequenceversion

例如,在 NEventStore 中,您可以看到提交有一个 StreamRevision(版本 - 每个事件都增加)和一个 CommitSequence.

在具有事件源的领域驱动设计中,聚合表示一致性边界,并且其不变量必须在每个命令的开头和结尾为真(或函数调用)。您可以在成员函数的中间违反不变量,只要在它的末尾不违反即可。

您在 post 中指出的内容非常有见地。也就是说,如果聚合上的单个命令(或调用成员函数)产生多个事件,那么当另一个进程从磁盘重新加载聚合时,仅存储其中一些事件可能会导致违反您的不变量。使用 SQL 数据库作为事件存储时,有许多相关场景会产生此问题。

避免这种情况的第一个(也是最简单的)方法是将所有事件 INSERT 语句包装到一个事务中,这样要么所有事件都被持久化,要么其中 none 个(例如,由于并发性) ).这样,您对不变量的 "on disk" 表示就会得到维护。您还必须确保您的事务隔离级别不是 "READ UNCOMMITTED"(这样其他进程就看不到您提交的一半)。您还必须确保数据库不会 "interleave" 进程之间的事件序列号。例如,数据库为进程 A 中的事件分配序列号“1”,为进程 B 中的事件分配序列号“2”,然后为进程 A 中的第二个事件再次分配序列号“3”。所有事件都可以提交到数据库,因为在并发约束(聚合 ID + 事件序列号)上没有冲突,但是事件序列是由两个进程写入的,所以你的不变量仍然可能被违反。

第二个选项是将所有事件包装到一个数组中,该数组通过单个 INSERT 语句保存。这实质上使您每次提交都有一个版本号,而不是每个事件都有一个版本号。对我来说,这更合乎逻辑,但它要求您在将事件数组发送到各种事件处理程序和流程管理器之前有一个 "unflatten" 事件数组的过程。我个人在一个以原始二进制格式在磁盘上存储事件的项目中使用了第二种机制。事件本身仅包含聚合更改状态所需的最少信息量——事件不包含聚合标识符。另一方面,commit 确实包含聚合标识符、提交序列号和各种其他元数据。这实质上将 聚合作为未提交事件的处理程序 已提交事件的事件处理程序 之间的功能分开。这种区别也是有道理的,因为如果事件是 "fact" - 发生了某些事情 - 那么聚合所做的事情与聚合所做的事情是否实际保存到磁盘之间存在差异。

从理论上讲,您的问题的一个很好的例子是链表 - 仅考虑内存中的表示:磁盘上没有持久性。在向量或数组上使用链表的原因之一是它允许高效地插入节点(好吧,比数组更高效)。插入操作通常需要将当前节点的"next"指针设置为新节点的内存地址,并将新节点的"next"指针设置为当前节点的前一个"next"指针。如果另一个进程在第一个操作完成后但第二个操作完成之前正在读取内存中的同一个链表,则它不会看到链表中的所有节点。如果每个 "operation" 都像一个 "event",那么只看到第一个事件会导致 reader 看到一个损坏的链表。