与 DDD + CRQS + ES 的并发

Rod*_*eal 5 architecture domain-driven-design cqrs event-sourcing dddd

我研究 DDD 一段时间了,并偶然发现了 CQRS 和事件溯源 (ES) 等设计模式。这些模式可用于帮助以更少的努力实现 DDD 的某些概念。

\n\n

然后我开始开发一个简单的软件来实现所有这些概念。并开始想象可能的失败路径。

\n\n

为了阐明我的架构,下图描述了来自前端并到达后端控制器的一个请求(为简单起见,我忽略了所有过滤器、绑定器)。

\n\n

时序图

\n\n
    \n
  1. 演员发送一张表格,其中包含他想从一个账户提取的金额。
  2. \n
  3. 控制器将视图模型传递到应用层,在应用层将其转换为一个命令
  4. \n
  5. 应用层打开一个工作单元(UOW)将VM映射到命令并将命令发送到调度程序。
  6. \n
  7. 调度程序找到知道如何处理命令(帐户)的相应聚合类,并向工厂请求帐户的特定实例。
  8. \n
  9. 工厂创建一个新的帐户实例并从事件存储中请求所有事件。
  10. \n
  11. 事件存储返回帐户的所有事件。
  12. \n
  13. 工厂将所有事件发送到聚合,以便其内部状态正确。并返回帐户的实例。
  14. \n
  15. 调度程序将命令发送到帐户,以便可以对其进行处理。
  16. \n
  17. 帐户检查是否有足够的资金进行提款。如果有,它会发送一个新事件“MoneyWithdrawnEvent”。
  18. \n
  19. 此事件由更改其内部状态的聚合(帐户)处理。
  20. \n
  21. 应用程序层关闭 UOW,当关闭时,UOW 检查所有加载的聚合,以检查它们是否有新事件要保存到事件存储中。如果有,它将事件发送到存储库。
  22. \n
  23. 存储库将事件保存到事件存储中。
  24. \n
\n\n

可以添加很多层,例如:聚合缓存、事件缓存、快照等。

\n\n

有时ES可以与关系数据库并行使用。这样,当 UOW 保存已发生的新事件时,它还会将聚合持久保存到关系数据库中。

\n\n

ES 的好处之一是它拥有一个中心事实来源,即事件存储。因此,即使内存中甚至关系数据库中的模型被损坏,我们也可以从事件中重建模型。

\n\n

有了这个事实来源,我们就可以构建其他系统,这些系统可以以不同的方式使用事件来形成不同的模型。

\n\n

然而,要实现这一点,我们需要真相来源干净且未被损坏。否则所有这些好处都不会存在。

\n\n

也就是说,如果我们考虑图中描述的架构中的并发性,可能会出现一些问题:

\n\n
    \n
  • 如果actor在一个排序周期内向后端发送两次表单,并且后端启动两个线程(每个请求一个),那么它们将调用两次应用层,并启动两个UOW,依此类推。这可能会导致两个事件存储在事件存储中。
  • \n
\n\n

这个问题可以在很多不同的地方处理:

\n\n
    \n
  1. 前端可以控制哪个用户/演员可以执行什么操作以及执行多少次。

  2. \n
  3. 调度程序可以拥有正在处理的所有命令的一个缓存,如果存在引用同一聚合(帐户)的命令,则会抛出异常。

  4. \n
  5. 存储库可以创建聚合的新实例,并在保存之前运行事件存储中的所有事件,以检查版本是否仍与步骤 7 中获取的版本相同。

  6. \n
\n\n

每个解决方案的问题:

\n\n
    \n
  1. 前端

    \n\n
      \n
    • 用户可以通过编辑一些 javascript 来绕过此限制。
    • \n
    • 如果打开了多个会话(例如不同的浏览器),则需要一些静态字段来保存对所有打开的会话的引用。并且需要锁定某个静态变量才能访问该字段。
    • \n
    • 如果有多个服务器用于执行特定操作(水平缩放),则此静态字段将不起作用,因为有必要在所有服务器之间共享此字段。因此,某些层是必要的(例如Redis)。
    • \n
  2. \n
  3. 命令缓存

    \n\n
      \n
    • 为了使该解决方案发挥作用,有必要在读取和写入命令缓存时锁定命令缓存的某些静态变量。

    • \n
    • 如果有多个服务器用于正在执行的应用程序层的特定用例(水平扩展),则此静态缓存将不起作用,因为有必要在所有服务器之间共享此缓存。因此,某些层是必要的(例如Redis)。

    • \n
  4. \n
  5. 存储库版本检查

    \n\n
      \n
    • 为了使此解决方案发挥作用,有必要在进行检查(数据库版本等于步骤 7 中获取的版本)并保存之前锁定某些静态变量。

    • \n
    • 如果系统是分布式的(水平规模),则有必要锁定事件存储。因为,否则,两个进程都可以通过检查(数据库版本等于步骤 7 中获取的版本),然后一个保存,然后另一个保存。并且根据技术的不同,不可能锁定事件存储。因此,将有另一层来序列化对事件存储的每次访问,并添加锁定存储的可能性。

    • \n
  6. \n
\n\n

这种锁定静态变量的解决方案有点好,因为它们是局部变量并且速度非常快。然而,依赖于 Redis 之类的东西会增加一些很大的延迟。如果我们谈论锁定对数据库(事件存储)的访问,甚至更多。甚至更多,如果这必须通过另一项服务来完成。

\n\n

我想知道是否有任何其他可能的解决方案来处理这个问题,因为这是一个主要问题(事件存储上的损坏),如果没有办法解决它,整个概念似乎是有缺陷的。

\n\n

我对架构的任何改变持开放态度。例如,如果一种解决方案是添加一个事件总线,以便所有内容都通过它汇集,那很好,但我看不到这可以解决问题。

\n\n

另外一点我不熟悉的是Kafka。我不知道 Kafka 是否为这个问题提供了一些解决方案。

\n

Con*_*enu 6

尽管您提供的所有解决方案都可以在某些特定场景中工作,但我认为最后一个解决方案(3.2)适用于更一般的用例。我在我的开源框架中使用它并且效果非常好。

\n\n

因此,事件存储负责确保聚合不会同时被两个命令改变。

\n\n

一种方法是使用乐观锁定。当从事件存储加载聚合时,您会记住它的version. 当您保留事件时,您尝试将它们附加到version + 1. 每个 必须有一个唯一索引AggregateType-AggregateId-version。如果追加失败,您应该重试整个过程(加载+处理+追加)。

\n\n

我认为这是最具可扩展性的解决方案,因为当分片键是 AggregateId 的子集时,它甚至可以与分片一起使用。

\n\n

您可以轻松地将 MongoDB 用作 EventStore。在 MongoDB <= 3.6 中,您可以通过插入单个文档以及包含事件数组的嵌套文档来自动追加所有事件。

\n\n

另一种解决方案是使用悲观锁定。在加载聚合、附加事件、增加其版本并提交之前启动事务。您需要使用 2 个表/集合,一张用于聚合元数据+版本,另一张用于实际事件。MongoDB >= 4.0 有事务。

\n\n

在这两种解决方案中,事件存储都不会损坏

\n\n
\n

另外一点我不熟悉的是Kafka。我不知道 Kafka 是否为这个问题提供了一些解决方案。

\n
\n\n

您可以将 Kafka 与事件源结合使用,但需要更改架构。看这个答案。

\n