在 DDD 和 CQRS 中,正确设计同步读取模型与多个聚合更新
In DDD & CQRS, proper design on syncing read model with multiple aggregate updates
假设我有 2 个聚合 Staff
和 Shop
,我有一个读取模型 StaffModel
,它在非规范化视图中包含商店信息(商店 ID、名称、地址等)。
一个业务规则是在单个请求中创建一个 Staff
和一个 Shop
,所以我有一个 CreateStaffService
创建一个 Staff 并触发 StaffCreatedEvent
,然后 CreateShopService
StaffCreatedEvent
的侦听器,创建 Shop
,然后触发 ShopCreatedEvent
在读取模型方面,我有 4 种方法来设计同步器服务:
订阅StaffCreatedEvent
,创建StaffModel
的记录。然后订阅 ShopCreatedEvent
,使用 staffId.
更新 StaffModel
上的商店信息
ShopCreatedEvent
包含staff信息,synchronizer服务订阅事件,一次性插入完整的读模型。但是人员信息与Shop
聚合无关,是否可以将其包含在事件中?
分别建模StaffModel
和ShopModel
,更新模型以响应相应的聚合事件。
将 CreateStaffService
和 CreateShopService
包装在单个事务中,触发 StaffAndShopCreatedEvent
我个人更喜欢选项 2 和 4,因为选项 1 很难确保 StaffCreatedEvent
总是在 ShopCreatedEvent
之前到达。
请分享您对此主题的想法和经验。
谢谢
更新:
为了避免使用序列号乱序使用事件,假设我正在使用数据库序列生成序列号,每次递增 1,然后假设我的订阅者使用了事件 1,所以最后处理的事件序列为1,然后producer依次发布event 2、event 3、event 4,只有当前事务成功才会发送event。所以如果事务2创建了序号2,但是事务失败回滚了,事件2没有发送,但是事件3和事件4发送成功了。
在消费者端,事件3和事件4都比最后处理的事件1更新,事件2永远不会到来。所以在这种情况下检查 lastProcessed + 1 == currentVersion
是错误的,除非事件序号(版本)是严格连续的,这也很难保证。
您已经定义了两个不同的聚合,它们也可以在它们自己的限界上下文中。对于事件,每个事件都应该描述一个聚合。您将看到的大多数文档和示例都显示一个单一的聚合标识符。请记住,虽然您现在正在查看事件的创建方面,但它仍然只是一个增量 - 在这种情况下,从 "nothing" 到 "something"。
许多框架处理您最关心的问题,即处理乱序事件。这是一个真正的问题,尤其是对于分布式系统。为了缓解这种情况,通常在写入端为事件提供一个顺序标识符,然后反规范化器将按顺序提供事件。因此,如果事件 4 到达,并且只处理了事件 2,它将保留事件 4,直到处理完事件 3。
以下是围绕同一问题的一般性讨论:
这听起来像是在推出自己的框架,这可能会让人望而生畏。我正在做同样的事情,但它更多的是为了扩展知识而不是计划在 real-world 设置中使用。然而,我可以为您提出的是考虑如何让事件以更可预测的方式到达。如果您现在不考虑横向扩展,那么您可以通过确保将事件推入 FIFO 队列来减轻很多顾虑。然后,您的同步器服务可以轮询队列,而不是订阅事件。当您重建聚合时,将此与对事件进行排序以进行重放相结合,您就有了一个很好的起点。这样做,您实际上不必担心乱序事件,除非您有多个进程轮询您的队列。
为了确保您实际上是按顺序生成事件,您所描述的内容听起来像是域服务的一个很好的用例。您正在协调两个聚合的操作。从域服务中引发这些事件可帮助您确保对两个聚合的操作均已完成。
更新:
我将展开一些内容以反映附加问题。让我们先退后一步。在写入方面,您永远不会持久化聚合。您正在持久化反映状态更改的事件,其中包括创建。您的目标是您将发出一个命令,该命令将被提供给聚合,聚合将从该命令创建一个事件。创建事件后,聚合会将其应用于自身,然后使命令处理程序可以从聚合中检索它。由于该事件是聚合更改状态所需知道的全部信息,因此我们必须将其保存到写入端。当我们再次需要该聚合时,我们只需加载与给定聚合标识符相关的所有事件,并针对聚合 class 重播它们。重播所有事件后,您已将聚合水合。
当您的命令处理程序将事件传递给您的事件存储时,它只关心它是一个事件。在持久性方面,您可能会在特定字段中序列化事件,以及包含事件周围元数据的其他字段;例如聚合标识符和聚合类型(当您需要重放时,这使查询更容易)。此外,该事件将具有顺序标识符。这是放置自动递增标识符的好地方。而且,非常重要的是,你在那里有连续性。
对于您正在做的大部分工作,您将最终一次只插入一个事件。很可能,它将构成您的大部分业务。这样做的好处是几乎没有失败的机会。由于事件只是增量流,因此不存在参照完整性。您可能会遇到更复杂的场景,主要是通过使用 sagas 或流程管理器,您可以在其中潜在地维护多个聚合的状态,然后再将它们的事件发布到您的事件存储。如何做到这一点可以从简单到复杂。不过,这里的主要困难在于确保聚合没有被流程范围之外发生的其他事件改变 manager/saga。不过,这几乎超出了您所要求的范围。最后,我将提供一个很好的资源供您阅读。
回到事件流。由于您只是将事件推送到写入端,并且我们可以假设它们是按顺序创建的,因此当您进行预测时,可能会出现乱序事件。最常见的情况是,这会发生在横向扩展部署中,您有多个服务器从队列中挑选命令并分发它们。在您的事件存储将您的事件推送到您的写入端以进行持久化后,它会将其推送到事件总线,您的订阅者将在其中采取行动。这是排序变得重要的地方,因为这是您的阅读面将被更新的地方。
根据您的情况,假设事件 1 正在创建员工,事件 2 正在创建商店。如果他们以相反的顺序到达事件总线,您将需要知道这一点。事件总线的目标是将事件传送到某个地方,这通常是一个队列。一个简单的 FIFO 队列此时实际上会出现问题,因为您处于模仿收到的订单的情况。您的排队机制应该能够让您检查所有排队的项目,并且您需要跟踪最后处理的事件。在原始系统上,对于您的两个请求,无论什么正在监视队列,都知道最后处理的事件是 0。它看到 2 到达但什么都不做。然后 1 到达,它可以将事件 1 推送给所有订阅者,将跟踪最后处理的事件的数值更新为 1,调度事件 2,并将内部跟踪器更新为 2。本质上,您正在查看某种服务它监视事件总线发布到的队列,然后为每个事件显式分派给订阅者。
我提到了一个资源,它涵盖了很多这方面的内容,那就是 Microsoft's CQRS Journey,他们的模式与实践系列的一部分。即使您不在 Microsoft 堆栈中,它也包含很多重要信息。最好的部分是它确实是 "journey"。您不仅可以获得代码示例以及为什么要这样做,而且还可以在准 real-world 应用程序中看到项目的演变。整本书都可以免费下载,PDF 格式。如果您愿意,也可以获得纸质书。 (我这样做了,因为科技书籍的触感以及我发现自己翻阅它们的方式不利于 e-books)。
假设我有 2 个聚合 Staff
和 Shop
,我有一个读取模型 StaffModel
,它在非规范化视图中包含商店信息(商店 ID、名称、地址等)。
一个业务规则是在单个请求中创建一个 Staff
和一个 Shop
,所以我有一个 CreateStaffService
创建一个 Staff 并触发 StaffCreatedEvent
,然后 CreateShopService
StaffCreatedEvent
的侦听器,创建 Shop
,然后触发 ShopCreatedEvent
在读取模型方面,我有 4 种方法来设计同步器服务:
订阅
StaffCreatedEvent
,创建StaffModel
的记录。然后订阅ShopCreatedEvent
,使用 staffId. 更新 ShopCreatedEvent
包含staff信息,synchronizer服务订阅事件,一次性插入完整的读模型。但是人员信息与Shop
聚合无关,是否可以将其包含在事件中?分别建模
StaffModel
和ShopModel
,更新模型以响应相应的聚合事件。将
CreateStaffService
和CreateShopService
包装在单个事务中,触发StaffAndShopCreatedEvent
StaffModel
上的商店信息
我个人更喜欢选项 2 和 4,因为选项 1 很难确保 StaffCreatedEvent
总是在 ShopCreatedEvent
之前到达。
请分享您对此主题的想法和经验。 谢谢
更新:
为了避免使用序列号乱序使用事件,假设我正在使用数据库序列生成序列号,每次递增 1,然后假设我的订阅者使用了事件 1,所以最后处理的事件序列为1,然后producer依次发布event 2、event 3、event 4,只有当前事务成功才会发送event。所以如果事务2创建了序号2,但是事务失败回滚了,事件2没有发送,但是事件3和事件4发送成功了。
在消费者端,事件3和事件4都比最后处理的事件1更新,事件2永远不会到来。所以在这种情况下检查 lastProcessed + 1 == currentVersion
是错误的,除非事件序号(版本)是严格连续的,这也很难保证。
您已经定义了两个不同的聚合,它们也可以在它们自己的限界上下文中。对于事件,每个事件都应该描述一个聚合。您将看到的大多数文档和示例都显示一个单一的聚合标识符。请记住,虽然您现在正在查看事件的创建方面,但它仍然只是一个增量 - 在这种情况下,从 "nothing" 到 "something"。
许多框架处理您最关心的问题,即处理乱序事件。这是一个真正的问题,尤其是对于分布式系统。为了缓解这种情况,通常在写入端为事件提供一个顺序标识符,然后反规范化器将按顺序提供事件。因此,如果事件 4 到达,并且只处理了事件 2,它将保留事件 4,直到处理完事件 3。
以下是围绕同一问题的一般性讨论:
这听起来像是在推出自己的框架,这可能会让人望而生畏。我正在做同样的事情,但它更多的是为了扩展知识而不是计划在 real-world 设置中使用。然而,我可以为您提出的是考虑如何让事件以更可预测的方式到达。如果您现在不考虑横向扩展,那么您可以通过确保将事件推入 FIFO 队列来减轻很多顾虑。然后,您的同步器服务可以轮询队列,而不是订阅事件。当您重建聚合时,将此与对事件进行排序以进行重放相结合,您就有了一个很好的起点。这样做,您实际上不必担心乱序事件,除非您有多个进程轮询您的队列。
为了确保您实际上是按顺序生成事件,您所描述的内容听起来像是域服务的一个很好的用例。您正在协调两个聚合的操作。从域服务中引发这些事件可帮助您确保对两个聚合的操作均已完成。
更新:
我将展开一些内容以反映附加问题。让我们先退后一步。在写入方面,您永远不会持久化聚合。您正在持久化反映状态更改的事件,其中包括创建。您的目标是您将发出一个命令,该命令将被提供给聚合,聚合将从该命令创建一个事件。创建事件后,聚合会将其应用于自身,然后使命令处理程序可以从聚合中检索它。由于该事件是聚合更改状态所需知道的全部信息,因此我们必须将其保存到写入端。当我们再次需要该聚合时,我们只需加载与给定聚合标识符相关的所有事件,并针对聚合 class 重播它们。重播所有事件后,您已将聚合水合。
当您的命令处理程序将事件传递给您的事件存储时,它只关心它是一个事件。在持久性方面,您可能会在特定字段中序列化事件,以及包含事件周围元数据的其他字段;例如聚合标识符和聚合类型(当您需要重放时,这使查询更容易)。此外,该事件将具有顺序标识符。这是放置自动递增标识符的好地方。而且,非常重要的是,你在那里有连续性。
对于您正在做的大部分工作,您将最终一次只插入一个事件。很可能,它将构成您的大部分业务。这样做的好处是几乎没有失败的机会。由于事件只是增量流,因此不存在参照完整性。您可能会遇到更复杂的场景,主要是通过使用 sagas 或流程管理器,您可以在其中潜在地维护多个聚合的状态,然后再将它们的事件发布到您的事件存储。如何做到这一点可以从简单到复杂。不过,这里的主要困难在于确保聚合没有被流程范围之外发生的其他事件改变 manager/saga。不过,这几乎超出了您所要求的范围。最后,我将提供一个很好的资源供您阅读。
回到事件流。由于您只是将事件推送到写入端,并且我们可以假设它们是按顺序创建的,因此当您进行预测时,可能会出现乱序事件。最常见的情况是,这会发生在横向扩展部署中,您有多个服务器从队列中挑选命令并分发它们。在您的事件存储将您的事件推送到您的写入端以进行持久化后,它会将其推送到事件总线,您的订阅者将在其中采取行动。这是排序变得重要的地方,因为这是您的阅读面将被更新的地方。
根据您的情况,假设事件 1 正在创建员工,事件 2 正在创建商店。如果他们以相反的顺序到达事件总线,您将需要知道这一点。事件总线的目标是将事件传送到某个地方,这通常是一个队列。一个简单的 FIFO 队列此时实际上会出现问题,因为您处于模仿收到的订单的情况。您的排队机制应该能够让您检查所有排队的项目,并且您需要跟踪最后处理的事件。在原始系统上,对于您的两个请求,无论什么正在监视队列,都知道最后处理的事件是 0。它看到 2 到达但什么都不做。然后 1 到达,它可以将事件 1 推送给所有订阅者,将跟踪最后处理的事件的数值更新为 1,调度事件 2,并将内部跟踪器更新为 2。本质上,您正在查看某种服务它监视事件总线发布到的队列,然后为每个事件显式分派给订阅者。
我提到了一个资源,它涵盖了很多这方面的内容,那就是 Microsoft's CQRS Journey,他们的模式与实践系列的一部分。即使您不在 Microsoft 堆栈中,它也包含很多重要信息。最好的部分是它确实是 "journey"。您不仅可以获得代码示例以及为什么要这样做,而且还可以在准 real-world 应用程序中看到项目的演变。整本书都可以免费下载,PDF 格式。如果您愿意,也可以获得纸质书。 (我这样做了,因为科技书籍的触感以及我发现自己翻阅它们的方式不利于 e-books)。