CQRS + ES:您可以并行处理多个命令吗?

时间:2017-03-30 05:57:03

标签: cqrs event-sourcing

我可以看到如何使用CQRS + ES模式在严格的序列中执行命令。让多个命令并行执行将显着提高系统的吞吐量,并利用我在服务器上拥有的所有核心。但这肯定会影响并发性问题吗?

举一个简单的例子,两个用户更新客户名称并同时提交命令。并行地,两个进程重放先前的事件以使聚合进入最新状态,然后两者都确定一切有效。然后两者都保存一个新的更新事件。无论哪一个最后保存似乎都是赢家,因为任何未来的重播都会导致最后一次更新是客户名称。

如果命令的结果是向客户发送电子邮件,该怎么办?现在我们发送两封电子邮件而不是预期的电子邮件。如果该命令导致创建使用客户属性附加到客户的新聚合,我们最终创建了两个新聚合但是一个是僵尸,因为它从未被客户引用,因为另一个聚合已保存为而是参考。

感觉您需要实现自己的事务锁定功能,以确保永远不会被覆盖,这是一个我宁愿避免的复杂解决方案!

更新

我的例子有点太简单了。想象一下,我们有一个发票条目进入发票。在发票中添加/更改/删除后,我们需要找到新的总计并将其设置为 Invoice.Total 属性字段。

如果并行两个用户都添加了一个新的,那么总数就会出错,没有明确的锁定机制。两者都为发票重新创建发票汇总和商品子汇总的现有列表。同时创建一个新的,然后将新总计相加并将其设置为 Invoice.Total 属性。

然后两者都保存这些操作的事件。但是现在我们有一个错误,因为最后完成的任何一个命令都有一个不包括首先完成的实例中的新项目的总数。这与悲观/乐观并发无关,但与事务锁定无关。

4 个答案:

答案 0 :(得分:4)

  

我可以看到命令如何一次严格执行   具有CQRS + ES模式的序列。有多个命令   并行执行会显着增加吞吐量   系统并使用我在服务器上的所有核心。但   这肯定会影响并发性问题吗?

CQRS+ES的角度来看,没有并发问题。任何好的Event store实现都有一个Aggregate版本约束,可以防止同一Aggregate上的并发事件添加,例如使用乐观锁定。

更新:CQRS+ES喜欢并欢迎并发命令。

  

如果命令的结果是向客户发送电子邮件,该怎么办?   现在我们发送两封电子邮件而不是预期的电子邮件。

预计由谁?同样,这不是CQRS+ES问题。这是业务设计问题。在这种特殊情况下,您应该设计另一个有界上下文Aggregate来处理向客户端发送电子邮件,具有业务不变性,确保将有关某个主题的多封电子邮件(即用户名更改)分组为一个,最后一个一个具有更高优先级。

  

如果该命令导致创建获得的新聚合,该怎么办?   使用客户财产附加到客户,我们最终得到两个   创建了新的聚合但是一个是僵尸,因为它永远不会   由客户引用,因为已保存其他聚合   作为参考。

同样,这取决于您的业务。从CQRS + ES的角度来看,没有问题。事件存储非常适合处理大量数据(这是因为事件存储只是追加持久性)。僵尸聚合是无害的。它只占用事件存储中的一些事件。在投影(阅读模型)中没有痛苦,因为它不存在。

  

感觉您需要实现自己的事务锁定   能够确保你永远不会被覆盖,这是一个复杂的   解决方案我宁愿避免!

无论使用CQRS + ES,这都是一个问题。如果这是业务要求,则无论如何都必须这样做。

如果两个用户同时更改了用户名,您可以进行一些额外的检查,并通知失去的用户其他用户已修改用户名,甚至可能显示用户名和让决定下一步做什么:重试或取消。

更新后:

  

我的例子有点太简单了。想象一下,我们有一个发票和   进入发票的项目条目。添加/更改/删除后   我们需要找到新的总数并将其设置为发票中的项目   Invoice.Total属性字段。

     

如果并行两个用户都添加一个新项目,那么总数将是   错误没有明确的锁定机制。两者都重新创建发票   聚合以及项目子聚合的现有列表   发票。并行创建一个新项目,然后添加新项目   total并将其设置为Invoice.Total属性。

     

然后两者都保存这些操作的事件。但现在我们有一个错误   因为最后完成的任何命令都会有总数   不包括首先完成的实例中的新项目。这个   与悲观/乐观并发无关,但与   交易锁定

我认为您必须了解Event sourcing的工作原理: 我们假设两位用户都在他们面前Invoice(实际上他们看到的是Cart,而不是Invoice; Invoice是放置Order后生成,但为了简单起见,我们假设)。让我们假设他们看到2个项目,并且每个项目都想要添加一个新项目。他们都发起了一个命令:AddAnItemToInvoiceCommand。这就是:

  • 命令调度程序/处理程序同时接收命令

  • 对于每个命令,它从存储库加载聚合,存储聚合版本,假设它是10

  • 对于每个命令,处理程序在聚合上调用一个添加发票的方法,然后它接收ItemWasAddedToInvoiceEvent两次,每个命令处理程序执行一次。

  • 对于每个命令,处理程序尝试将事件持久保存到Event store

  • 对于第一个事件持久化操作(始终它是纳秒级别的第一个),事件将保持不变并且聚合版本会增加;现在是11;

  • 对于由第二个命令处理程序生成的第二个相同的事件,该事件不会保留,因为找不到预期的版本10,因为它现在是{{ 1}}。因此,存储库会重试commnand执行(这一点非常重要)。也就是说,再次调用 all 命令执行:从存储库再次加载聚合,现在版本为11,然后在其上应用命令,然后将事件持久化到活动商店。

因此,事件存储中添加了两个事件,因此Invoice具有正确的状态:现在有4个项目的总价格正确为4的总和。

答案 1 :(得分:2)

这根本不是问题;它非常特定于事件存储,以及特定的事件存储策略。

  

举一个简单的例子,两个用户更新客户名称并同时提交命令。并行地,两个进程重放先前的事件以使聚合进入最新状态,然后两者都确定一切有效。然后两者都保存一个新的更新事件。无论哪一个最后保存似乎都是赢家,因为任何未来的重播都会导致最后一次更新是客户名称。

这里有一些注意事项 - 一个是如果这些命令按顺序运行则存在同样的问题;第二个解除第一个的影响。在某些情况下,这正是您想要的行为;修复拼写错误的示例。

其次,请注意,因为您只是追加,所以两个编辑都保留;这两个变化都存在于流中;它是视图(折叠),而不是流,决定了两个编辑中的哪一个获胜。

这意味着,在某种程度上,你还没有完成 - 你仍然需要正确地选择策略来选择"胜利者"。

如果编辑可能会发生冲突,那么您需要采取预防措施,禁止允许写入不会确认所有先前接受的写入。在这种情况下,您不想附加到流,而是比较和交换。

Digression

  

使用传统数据库,从记录中写入和读取的操作会自动为您处理锁定,这是数据库所做的。在CQRS + ES中执行此操作是在数据库之外,因此我需要手动执行此操作。

我认为,部分困惑在于您将追加事件更新历史记录混为一谈。关于这一点,文献尚不清楚,因此审查维持历史的替代方法可能是有价值的。

想象一下,您将状态保留为事件的,而不是事件的文档。在快乐路径中,您加载文档,修改本地副本,将以前的副本替换为您的修订版。

在并发写入方案中,我们有两个编写器加载文档,进行不同的编辑,然后每个都尝试进行替换。怎么了?如果我们不做任何事情来减轻并发写入,我们最终可能会以最后一位作家赢得策略 - 通过删除前一位作者所做的编辑来破坏我们的历史。

为确保我们不会丢失写入内容,我们需要文档存储区的帮助。具体来说,我们想要一些条件put的模拟(例如,mongodb:findAndMofidy)。第二个作者应该得到一些ConcurrentModification响应,它可以缓解(失败,重试,合并)。

当我们在说话时我们正在做事件采购时,这一点没有任何改变。

改变是我们" PUT&#34 ;;因为我们接受事件的表示是不可变的规则,并且事件的集合只是追加,我们将文档的有效编辑限制为允许我们优化每个传输整个文档的集合时间。

如果您愿意,可以想象我们向活动商店发送我们想要对文档进行更改的说明。因此,商店会加载文档的副本,对其应用我们的更改,然后存储更新的版本。

但域模型并未验证更改是否适用于历史文档的任意版本,而是适用于其特定版本。因此,我们发送以描述我们的更改的消息需要包含对我们开始的文档版本的引用。

你可以想象"事件存储"是内存中的链表。 "追加"类似于更新文档而不确保它与您验证的内容不变。如果我们需要确保我们的假设仍然有效(即乐观并发),那么我们需要比较和交换尾指针;如果一场比赛允许其他作家自我们阅读后更新尾部,那么CAS操作应该失败,并允许我们恢复。

远程事件存储需要提供类似的界面:请参阅expectedVersion in GetEventStore的使用。

您可能会发现在collaborative domains上查看Udi Dahan的着作很有价值。

  

感觉您需要实现自己的事务锁定功能,以确保永远不会被覆盖,这是一个我宁愿避免的复杂解决方案!

您应该记住,当您决定使用并行编写器时,您已经签署了某种锁定功能!进行控制如何维护数据完整性的工作是您签署的权衡的一部分。

好消息是,不需要实施交易锁定策略;您需要选择一个提供事务锁定策略的数据存储。

比较和交换真的不是那么复杂,它是just complicated

  

然后两者都保存这些操作的事件。但是现在我们有一个错误,因为最后完成的任何一个命令都有一个不包括首先完成的实例中的新项目的总数。这与悲观/乐观并发无关,但与事务锁定无关。

是的 - 你不能盲目追加到你期望保持不变的流。您必须确保附加到已检查的位置。

您可能会不时看到另一种设计,它将历史视为图形而不是流。因此,每次写入都可能会分析历史记录,并在必要时稍后合并。您可以了解审核Kleppmann's work on CRDT

的方式

我的阅读告诉我,事件图仍然牢牢地存在于复杂象限中。

答案 2 :(得分:1)

如果在聚合的不同实例上执行了两个命令 - 答案是微不足道的,当然可以。每个聚合都是它自己的事务边界。

如果在同一个实例上执行了两个命令,则有三个选项可供选择 处理并发:

  1. 乐观并发 - 每条命令消息都包含聚合的预期版本(显示给用户的版本)。如果预期版本与实际版本不同,则会引发异常并要求用户刷新视图。这是最简单的解决方案。

  2. 类固醇上的乐观并发 - 只有在预期版本之后附加到聚合的至少一个事件实际上与提交的命令冲突时,事件源才能够引发并发错误。用户。从用户界面的角度来看,这是最佳解决方案。

  3. 悲观并发 - 在这里你可以锁定聚合的实例,只允许一个用户在其上执行命令。在绝大多数情况下 - 去那里。这是最容易出错的解决方案,通常需要Pessimistic Concurrency由业务域决定。

答案 3 :(得分:0)

另一个解决方案可能是运行事件的实体是单线程的,因此如果您有多个航班,那么每个航班都可以有一个这样的单线程。

航班A - 一个线程处理事件,单个事件将更新两个聚合,因此一个更新不会与另一个更新重叠。