CQRS读取方,多个事件流主题,并发/竞争条件

时间:2017-11-25 05:28:38

标签: concurrency race-condition cqrs event-sourcing eventual-consistency

我遇到了(重新)在阅读/查询方面以正确顺序应用多个主题的事件的问题。

示例:

在写/命令端,我们有2个具有n:m关系的聚合:

  • 联系

这些聚合在 2个单独的事件流主题上产生以下事件(因为最佳实践说:每个聚合一个主题。我完全同意):

  • 联系主题:

    1. ContactCreated (contactId: "123", name: "Peter")
    2. ContactAddedToGroup (contactId: "123", groupId: "456")
  • 小组主题:

    1. GroupCreated (groupId: "456", name: "Customers")

在读取/查询方面(例如Elasticsearch)我想执行此查询:

  • 查找属于名称以Custo...
  • 开头的任何组的所有联系人
  • 查找名称以Custo... 开头的所有群组(这应该不是问题)

为实现这一目标,有2种阅读模型。示例数据:

  • {contactId: "123", name: "Peter", groups: [{id: "456", name: "Customers"}]}
  • {groupId: "456", name: "Customers"}

问题:

事件的顺序只能保证单个事件主题(就像在Apache Kafka中一样)。虽然读取/查询方可以多种方式使用3个事件:1,2,31,3,23,1,2

如何处理1,2,3数据库伪语句示例:

  1. INSERT Contact (contactId: "123", name: "Peter")
    • FIND Group WHERE (groupId: "456")(不起作用,因为尚未插入群组)
    • UPDATE Contact WHERE (contactId: "123") ADD Group (groupId: "456", name: "???") (这是问题所在)
  2. INSERT Group (groupId: "456", name: "Customers")
  3. 观(S):

    • 我可以扩展算法并附加一个语句。这将查找已添加到组中的所有联系人,并将组名添加到这些联系人(以使搜索查询起作用):

      1. UPDATE Contact WHERE (groupId: "456") REPLACE Group (groupId: "456", name: "Customers")
    • 另一个想法(我不喜欢)可能只使用单个事件流主题。那么事件的顺序总是正确的。 但是会出现这种情况不容易发生的情况。 (最佳实践也告诉我们,每个聚合应该使用一个主题)

    • 忽略此问题,因为它不太可能发生,因为用户将在创建组添加联系人组之间提供必要的延迟。 当涉及事件重播时,没有延迟,并且可以并行/'随机'顺序使用事件主题。

    问题(S):

    这种情况应该相当普遍。但不幸的是,网上很少有现实世界的 CQRS示例。而且他们中的大多数人都不会解释小/隐藏的陷阱。

    你如何解决这些问题?

2 个答案:

答案 0 :(得分:1)

在您的示例中,您可以保证在ContactAddedToGroup(2)之前添加了GroupCreated事件(3),因为显然用户无法在创建组之前向组添加联系人。因此,即使您碰巧首先阅读ContactAddedToGroup事件,也可以读取GroupCreated事件。

坚持使用2个独立的流(这是绝对正确的,因为群组和联系人是单独的聚合),这是一种方法:

  • 联系人可以维护自己的组名表(在您的示例中只需要id和name列)。或者如果您乐意结合群组和联系人(它们听起来像是相同的有界上下文),您可以让单个事件处理程序处理群组和联系人投影。
  • 投影处理程序订阅Group和Contact事件(来自单个进程和线程)。
  • 如果它读取了组名称表中没有的联系人添加的消息,它会立即执行组事件的追赶(或者至少赶上它确实得到的事件该组)然后再次处理联系事件。

此方法在重播期间以及实时处理期间都有效。在重放期间,您甚至可以选择仅在完全使用父流(例如,组)之前开始使用投影的主流(在这种情况下是联系人),尽管您仍然需要准备好再次使用必要时分组流,因为在追赶期间可能会有新事件进入。

如果你有GroupRenamed事件,单个线程也可以确保没有竞争条件 - 你可以确定你将在所有联系人中重命名该列,而对于多个线程,你可能在插入具有旧组名的联系人之间有竞争以及更新使用该组的联系人中的所有组名的查询。如果你需要疯狂的比例,你必须对联系人进行分片,并让每个分片都保留自己的组名表,以避免竞争条件。

另一种方法是决定允许组名为空,只是在阅读事件时(第一个想法)更新联系人。因此,您将以相同的方式处理新组和组重命名(如果允许),但您的客户将需要处理联系人中的临时空组名称,这可能是一个不受欢迎的复杂情况。

答案 1 :(得分:0)

  

你如何解决这些问题?

补救措施是避免尝试从事件历史记录的不稳定表示重建图像。当您将状态加载到 write 模型中时,通常会通过按照编写顺序查询具有聚合历史记录的“文档”来执行此操作。

在阅读模型中采用相同的方法,您可以阅读每个主题的稳定事件历史记录,避免因主题事件无序到达而可能遇到的问题。

请参阅Greg Young关于polyglot data的演讲。

在从多个主题构建读取模型时,您可以采用相同的方法,这为您提供了每个主题的一致历史记录......但不一定是同步整体。

因此,要使用您的特定示例,您可能拥有ContactCreated (contactId: "123", name: "Peter") ContactAddedToGroup (contactId: "123", groupId: "456"),但没有属于“中间”的事件。那么现在呢?

一个可能的答案是使用未对齐的历史记录构建视图 - 您有00:15的联系信息,以及00:00的组信息,并且您将读取模型的时间差异部分作为一部分。这可能包括使用NullObject模式的变体来表示尚不存在的对象。

另一种可能性是使用Lamport Clock之类的东西来跟踪不同主题中事件之间的依赖关系。这可能看起来像ContactAddedToGroup中的元数据,让消费者知道该事件是GroupCreated的结果。然后,消费者可以决定是否忽略缺少先例的事件。