使用DDD + CRQS + ES进行并发

时间:2018-03-26 20:42:34

标签: architecture domain-driven-design cqrs event-sourcing dddd

我一直在研究DDD,并偶然发现了CQRS和事件采购(ES)等设计模式。这些模式可用于帮助实现DDD的一些概念,而不需要花费太多精力。

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

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

Sequence Diagram

  1. 演员发送一张表格,上面写着他想从一个账户中提款的金额。
  2. 控制器将视图模型传递到应用程序层,在那里它将转换为一个命令
  3. 应用程序层打开一个工作单元(UOW)将VM映射到命令并将命令发送给调度程序。
  4. 调度程序找到知道如何处理命令(帐户)的相应聚合类,并询问工厂的特定帐户实例。
  5. 工厂创建一个新的帐户实例并请求事件存储中的所有事件。
  6. 事件存储返回帐户的所有事件。
  7. 工厂将所有事件发送到聚合,以使其内部状态正确。并返回帐户的实例。
  8. 调度程序将命令发送到帐户,以便进行处理。
  9. 帐户检查是否有足够的资金来提取。如果有,它会发送一个新事件" MoneyWithdrawnEvent"。
  10. 此事件由更改其内部状态的聚合(帐户)处理。
  11. 应用程序层关闭UOW,当它执行时,UOW会检查所有已加载的聚合,以检查它们是否有要保存到事件存储的新事件。如果有,它将事件发送到存储库。
  12. 存储库将事件持久保存到eventstore。
  13. 可以添加许多层,例如:聚合缓存,事件缓存,快照等。

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

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

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

    然而,为了实现这一目标,我们需要真理的来源清洁而不是腐败。否则所有这些好处都不会存在。

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

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

    可以在许多不同的地方处理此问题:

    1. 前端可以控制哪个用户/演员可以做什么动作和多少次。

    2. Dispatcher可以拥有一个正在处理的所有命令的缓存,如果有一个命令引用同一个聚合(帐户),它会抛出异常。

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

    4. 每个解决方案的问题:

      1. 前端

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

        • 要使此解决方案正常工作,在读取和写入命令时,必须锁定命令缓存的某些静态变量。

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

      3. 存储库版本检查

        • 要使此解决方案正常工作,必须在执行检查之前锁定一些静态变量(数据库版本等于在步骤7中获取的版本)并保存。

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

      4. 这种锁定静态变量的解决方案有点可以,因为它们是局部变量而且非常快。但是,根据像Redis这样的东西会增加一些大的延迟。如果我们谈论锁定对数据库的访问(事件存储),甚至更多。甚至更多,如果必须通过另一项服务来完成。

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

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

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

2 个答案:

答案 0 :(得分:3)

虽然您提供的所有解决方案都可以在某些特定情况下使用,但我认为最后一个解决方案(3.2)适用于更一般的用例。我在我的开源框架中使用它并且它运行良好。

因此,事件存储是负责通过两个命令确保聚合不会同时突变的人。

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

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

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

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

在这两个解决方案中,事件存储都没有损坏

  

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

您可以将Kafka与事件采购结合使用,但您需要更改架构。请参阅this回答。

答案 1 :(得分:2)

简短回答:原子交易仍然是一件事。

更长的答案:要正确处理并发写入,您需要 lock ,或者您需要条件写入(也就是比较和交换)。

使用日志:我们需要在步骤6之前获取锁定,并在步骤12之后释放锁定。

使用条件写入:在步骤6,存储库将捕获并发谓词(可能是隐式的 - 例如,读取的事件数)。在步骤12执行写入时,将检查并发谓词以确保没有并发修改。

例如,HTTP API for Event Store使用ES-ExpectedVersion;客户端负责计算(从它已经获取的事件)它期望写入发生的位置。

Gabriel Schenker在他的2015年论文Event Sourcing applied -- the Repository中描述了一个RDBMS存储库和一个事件存储库。

当然,随着条件写入的引入,您应该考虑在写入失败时您希望模型执行的操作。您可能会引入重试策略(转到步骤6),或尝试合并策略,或者只是失败并返回发件人。

  

在您的条件写入示例中,我假设在步骤11中需要添加一个Lock(以便它锁定事件存储以获取并发谓词)。仅在将新事件写入事件存储库后释放锁定。否则,两个并发进程可以通过并发谓词检查并保存事件。

不一定。

如果您的持久性存储提供锁定而不是条件写入,那么您就有了正确的想法:在步骤12中,存储库将获取锁定,检查前置条件,提交新事件并释放锁定。

但是,了解条件写入的持久性设备可以为您实现该检查。使用事件存储,存储库不需要获取锁。它将带有关于预期状态的元数据的事件发送到商店。事件存储本身使用该信息来执行条件写入。

没有什么魔力 - 某人需要做的工作是为了确保并发写入不会互相破坏。但它不一定要在你的代码中。

请注意,我正在使用Eric Evans在蓝皮书中描述的“Repository” - 它是如何从系统的其余部分存储事件的hides your choice的抽象;换句话说,它是适配器,使您的事件存储看起来像事件的内存集合 - 它不是事件存储本身。