使用Datomic实现没有麻烦的事件溯源


无论使用何种实现技术(EventStore / Kafka /SQL ......),“传统事件溯源”方法会一些常见问题:

设计事件类型和事件处理程序是一项艰苦的工作
比如:你设计一个问答式的网站应用,那么更新问题的正确事件类型应该是什么,你在想是UserUpdatedQuestion?但也许这还不够精细,它应该是更细粒度的UserUpdatedQuestionTitle吗?你应该选择更通用的UserUpdatedFieldOfEntity,但是Log会变得更难理解吗?
6个月后,该系统进入生产环境,大客户经理Tom闯入你的办公室,提了一个新需求:“备受瞩目的专家回答了一个流行的问题,应该可以换取500点声望的特殊礼物;你能否在今晚实现这一目标?” 你想一会儿。没有事件类型可以对声誉进行特殊更改哇, “我很抱歉,”你回答。“现在,如果用户没有来自投票的事件,就不可能改变用户的声誉。我们需要进行具体的开发。”

在“一个数据库总是反映当前状态”方法的旧时代,你所要做的就是为你的状态结构设计合适的表达方式,然后你就能通过查询语言实现在该结构中导航的所有功能。例如,在关系数据库中,你声明一组表和列的结构,然后你就能通过SQL的所有功能来更改您存储的数据。
使用传统的事件溯源时,生活并不那么容易,因为你必须预测状态的每个更改,为其设计事件类型,并为此事件类型实现事件处理程序。
更重要的是,命名,粒度和语义在设计事件类型时很难做到 - 而且你最好在第一时间做到这一点,因为除非你重写事件日志,否则你的事件处理程序必须处理任何事件类型。代码库的整个生命周期(因为重新处理整个日志被认为是一个频繁的操作)。太多的事件类型可能会导致更多的工作来实现事件处理程序; 另一方面,粗粒度事件类型不太可重用。

我认为这里的教训是,应用程序定义的事件类型的枚举是一种描述变化的弱语言。

检测间接变化仍然很难
回到前面将问题评价与用户声誉联系起来的案例:
假设你正在为这个项目的DDD聚合根编写事件处理程序,这样才能跟踪每个用户的信誉得分:事件存储是一个基本的键值存储,它将每个用户user_id与一个数字相关联。 特别是,每次对问题进行投票时,都必须增加问题作者的声誉。 问题是,在其当前形式中,UserVotedOnQuestion事件类型不包含提出问题的作者的user_id,只包含提出问题的ID。 

你该怎么办?

  • 您是否应更改UserVotedOnQuestion事件类型以使其明确包含问题作者的ID?但那会是多余的,然后谁知道在你制作新的聚合时你还想要在事件类型中添加多少新东西呢?
  • 您是否应该更改聚合以便它还跟踪问题 - >用户之间的关系?但这会使它变得更加复杂,并且很可能需要多余地其他聚合的交互......

事件日志为您提供有关两个个时间状态点之间发生变化的精确操作数据; 但这并不意味着数据就很容易操作。要根据事件更新聚合,您需要预测事件是否以及如何影响聚合?在处理关系信息模型时,事件可能是关于某个实体A并间接影响另一个实体B,但A和B之间的关系在事件中并不明显; 在上面的示例中,事件类型UserVotedOnQuestion 会影响用户实体而不直接引用它。 我们需要查询功能来确定事件如何影响下游聚合,但事件日志本身提供的查询能力非常低。

有几种策略可以缓解这个问题,所有这些都有重要的限制使用警告:

  1. 您可以对事件类型进行“非规范化”以向其添加更多数据,从而有效地对聚合进行一些预计算。这意味着生成事件的代码需要预测事件将被消费的所有方式 - 我们试图摆脱对事件源的耦合。
  2. 您可以丰富每个聚合以跟踪所需的关系信息。这使得事件处理程序的实现更加复杂,并且可能是多余的。
  3. 您可以添加“中间”聚合,该聚合仅跟踪关系信息并生成“丰富”事件流。这可能比上面的两个解决方案都要好,但它仍然需要开发工作,并且仍然需要了解所有下游聚合的需求。

事务性很难实现
防止重复提交:你正在调查Q&A网站的一个错误:一些用户创建了一个问题的2个答案,这不应该发生......的确,当用户试图创建答案时,代码通过QuestionsById聚合检查这个用户如果没有创建此问题的答案,就应该没有事件UserCreatedAnswer发出。
然后您意识到这是由事务的竞争条件引起的:在第一个答案添加到日志的时间和它进入QuestionsById聚合的时间之间,第二个答案又被添加,而且会通过了检查......
你自我觉得'很棒'。“我喜欢调试并发问题。”

在这里我们看到事务问题:事务几乎与最终一致的写入兼容,这是在异步处理事件日志时默认获得的。

可以通过使用与事件日志同步更新的聚合来缓解此问题。这意味着添加事件不再像在队列末尾附加数据记录那么简单:您必须自动执行此操作,更新当前状态,以便规则检查时可查询(例如,通过关系数据库查询)。

同样重要的是要意识到事务不仅仅是允许事件进入日志,而且还用于计算它们。例如,当您在线订购演出门票时,票务系统必须查询库存并为您选择一个座位号(即使只是为了将其添加到您的购物车,它必须以事务方式进行)。这导致我们区分命令和事件。

配置命令和事件
在传统的事件源中,解决上述事务性问题的另一种常见方法是添加另一种事件,它们请求更改,但是不提交确认。例如,您可以添加一个UserWantedToCreateAnswer命令,稍后将由命令处理程序处理,命令处理程序将发出UserCreatedAnwser事件或EventCreationWasRejected事件两个结果,并添加到日志中; 这个命令处理程序当然需要维护一个聚合根Aggregate来跟踪问题的创建。

这种方法的优势在于可以让您摆脱某些竞争条件,但却增加了显着的复杂性,处理事件现在是副作用的:不能是幂等的(多重复几次调用的结果是不同的,多添加一个UserWantedToCreateAnswer命令会造成破坏性影响)。由于这些特殊的新事件应该只处理一次,因此在重新处理日志时必须要小心。
最后,这意味着您正在强制对这些事件的生产者进行异步工作流程,你只能回复操作用户:“嘿,感谢您提交此表单,遗憾的是我们不知道您的请求是否以及何时将被处理。请继续关注!”

对我而言,这种复杂性源于这样一个事实,即传统的事件溯源诱使您忘记命令和事件之间的本质区别。关于这些概念的小型复习:

  • 一个命令是一个变更请求。它通常是以命令动词方式中制定的(例如AddItemToCart)。你通常希望它们是短暂的并且只处理一次。
  • 一个事件,正如我们已经提到,描述了聚合根的状态的变化的发生。它通常用过去时表达(例如ItemAddedToCart)。您通常希望它们持久保存,并且可以根据需要进行多次处理。
  • 从这个角度来看,事务引擎是一个将命令转换为事件的过程。

命令和事件起到非常不同的角色,这是毫不奇怪,他们混为一谈导致的复杂性。

Datomic的模型
Datomic将信息建模为事实的集合。每个事实都由Datom表示: Datom是一个5元组[entity-id attribute value transaction-id added?],其中:

  • entity-id 是一个整数,用于标识事实描述的实体(例如用户或问题)(类似于关系数据库中的行号)
  • attribute可能是类似:user_first_name或:question_author(类似于列在关系数据库)
  • value是此实体的属性的“内容”(例如"John")
  • transaction-id识别加入datom的事务(事务本身是一个实体)
  • added?是一个布尔值,确定是否添加了数据(我们现在知道这个事实)或回退(我们不再知道这个事实)

例如,Datom datom [42 :question_title "What is Event Sourcing" 213130 true]可以用英语或中文翻译:“我们从交易或事务编号213130中了解到,实体42是一个用户提出的问题,该问题的标题是'什么是事件溯源''。
Datomic Database Value表示系统在某个时间点的状态,或者更准确地说是系统在某个时间点累积的知识。从逻辑角度来看,Database值只是Datoms的集合。例如,这是我们的问答数据库的摘录:

  [;; ...
   datom [38 :user_id "jane-hacker3444" 896647 true]
   ;; ...
   datom [234 :question_id
"what-is-event-sourcing-3242599" 896770 true]
   datom [234 :question_author 38 896770 true]
   datom [234 :question_title
"What is Event Sourcing" 896770 true]
   datom [234 :question_body
"I've heard a lot about Event Sourcing but not sure what it's for exactly, could someone explain?" 896770 true]
   ;;
   datom [234 :question_title
"What is Event Sourcing" 896773 false]
   datom [234 :question_title
"What is Event Sourcing?" 896773 true]
   ;; ...
   datom [456 :answer_id uuid
"af1722d5-c9bb-4ac2-928e-cf31e77bb7fa" 896789 true]
   datom [456 :answer_question 234 896789 true]
   datom [456 :answer_author 43 896789 true]
   datom [456 :answer_body
"Event Sourcing is about [...]" 896789 true]
   ;; ...
   datom [774 :vote_question 234 896823 true]
   datom [774 :vote_direction :vote_up 896823 true]
   datom [774 :vote_author 41 896823 true]
   ;; ...
   ])

实际上,Datomic Database Value没有实现为基本列表; 它是一个包含多个索引的复杂数据结构,允许使用Datalog(一种关系数据的查询语言)进行表达和快速查询。但从逻辑上讲, Database Value只是一个数据列表。令人惊讶的是,这个非常简单的模型允许查询数据的效率不低于传统数据库(SQL /文档存储/图形数据库/等)。
Datomic部署是一系列(不断增长的)Database Value。写入Datomic包括提交交易请求(表示我们想要应用的更改的数据结构); 此事务请求应用于当前Database Value,该值包括计算要添加到其中的一组数据(事务),从而产生下一个Database Value。
例如,更改问题标题的交易请求可能如下所示:
(def tx-request-changing-question-title
  [[:db/add [:question_id "what-is-event-sourcing-3242599"] :question_title "What is Event Sourcing?"]])

这将导致事务:

comment "Writing to Datomic"
  @(d/transact conn tx-request-changing-question-title)
  => {:db-before datomic.Db @3414ae14                       ;; the Database Value to which the Transaction Request was applied
      :db-after datomic.Db @329932cd                        ;; the resulting next Database Value
      :tx-data                                              ;; the Datoms that were added by the Transaction
      [datom [234 :question_title
"What is Event Sourcing" 896773 false]
       datom [234 :question_title
"What is Event Sourcing?" 896773 true]
       datom [896773 :db/txInstant inst
"2018-11-07T15:32:54" 896773 true]]}
  )

现在我们开始看到Datomic与我们迄今为止已经列出的事件溯源概念之间的深刻相似之处:

  • 事务请求对应于命令
  • 事务或交易概念对应于事件
  • Datomic数据库对应于事件日志

我们也看到了一些重要的差异:
  • 事件由细粒度的Datoms组合而成; 没有具有规定结构的事件类型。
  • 事件不是由应用程序代码直接生成的; 交易请求(命令)却是的。

使用Datomic处理事件
首先,我们注意到Datomic Database Value可以被视为Aggregate; 一个同步维护而不需要额外努力的,包含存储在事件中的所有数据,并且可以被明确地查询。
这个Aggregate可能会涵盖你的大部分查询需求; 从我所看到的,添加下游聚合的最可能的用例是搜索,低延迟聚合和数据导出。
值得注意的是,您可以获取Datomic数据库的任何过去值,因此您可以开箱即用地重现过去的状态 - 无需重新处理整个日志:

(def db-at-last-xmas 
  (d/as-of db inst "2017-12-25"))

您可以使用Log API在2个时间点之间获取事务:

(comment "Reading the changes between t1 and t2 as a sequence of Transactions:"
  (d/tx-range (d/log conn) t0 t1)
  => [{:tx-data [datom [234 :question_id
"what-is-event-sourcing-3242599" 896770 true]
                 datom [234 :question_author 38 896770 true]
                 datom [234 :question_title
"What is Event Sourcing" 896770 true]
                 datom [234 :question_body
"I've heard a lot about Event Sourcing but not sure what it's for exactly, could someone explain?" 896770 true]
                 datom [896770 :db/txInstant inst
"2018-11-07T15:32:09"]]}
      ;; ...
      {:tx-data [datom [234 :question_title
"What is Event Sourcing" 896773 false]
                 datom [234 :question_title
"What is Event Sourcing?" 896773 true]
                 datom [896773 :db/txInstant inst
"2018-11-07T15:32:54"]]}
      ;; ...
      {:tx-data [datom [456 :answer_id uuid
"af1722d5-c9bb-4ac2-928e-cf31e77bb7fa" 896789 true]
                 datom [456 :answer_question 234 896789 true]
                 datom [456 :answer_author 43 896789 true]
                 datom [456 :answer_body
"Event Sourcing is about [...]" 896789 true]
                 datom [896789 :db/txInstant inst
"2018-11-08T14:16:33.825-00:00"]]}
      ;; ...
      {:tx-data [datom [774 :vote_question 234 896823 true]
                 datom [774 :vote_direction :vote_up 896823 true]
                 datom [774 :vote_author 41 896823 true]
                 datom [896823 :db/txInstant inst
"2018-11-08T14:19:31.855-00:00"]]}]
  )

请注意,尽管它们以非常小的形式描述了更改,但可以将事务与 Database Value结合使用,以直接的方式计算更改的效果。 您不再需要“丰富”您的事件以使其更容易处理; 它们已经通过Database Value丰富了。

我们仍然遇到传统事件溯源的困难吗?
好的,我们等着瞧瞧比较结果:

  • “设计事件类型和事件处理程序很难”:我们不再设计事件类型; 我们只设计我们的数据库模式(它倾向于自然地映射到我们的领域模型),而Datomic将完成描述Datoms方面的变化的工作,这些变化可以通用地进行处理。对于描述不够的少数情况,我们可以使用Reified Transactions对其进行扩展。关于事件处理程序,由于我们有足够好的默认聚合(数据库值),因此不再需要它们。
  • “检测间接更改很难”:现在可以直接计算每个更改对下游聚合的影响,因为我们拥有具有高查询能力的每个状态转换(事务和数据库值)的增量视图和全局视图。
  • “交易事务性很难实现”:没有问题,Datomic完全是ACID,具有表达性的写作语言。
  • “混淆命令和事件”:这里没有真正的混淆空间--Datomic不让我们发出事件,我们只能用命令编写。

当然,Datomic有局限性,为了获得这些好处,您必须确保这些限制对您的用例要求不会过高:
  • 写入比例:不要指望在一个Datomic系统上每秒进行数万次写入。(读取比例是可以的。数据库水平扩展读取,希望本文已经明确表示将读取卸载到专门的状态存储很容易。)
  • 数据集大小:如果您需要存储数PB的数据,则需要用其他方式补充或替换Datomic。
  • 数据模型:您的数据必须非常适合在Datomic中表示。Datomic的Universal Schema受到RDF的启发,擅长在表格,文档或图形数据库中存储的内容,但有一些想象力,您可能会想出一些难以在Datomic中表示的内容。(顺便说一句,与流行的看法相反,Datomic 在表示历史数据方面并不是特别擅长。)
  • 基础设施: Datomic适合在大型服务器上运行,通常在云中运行 - 而不是在移动设备或嵌入式系统上运行。
  • 专有: Datomic不是开源的,对某些人而言是需要破解。

结论
除了更改日志之外,Datomic还提供每个更改产生的整个数据库的可查询快照(“状态”),所有这些都由事务性写入指示。这是一项重大的技术成就,这解释了为什么我们可以比传统的Event Sourcing实现更少的工作量和限制来获得Event Sourcing的好处。
在更传统的CQRS术语中:Datomic为您提供了一种同步的表达式命令语言(Datomic事务请求),可操作事件(作为已添加数据集的事务)和强大的关系默认聚合(Datomic数据库值)。
希望这表明事件溯源不必像我们已经习惯的那样苛刻,只要我们愿意重新考虑我们应该如何实施它的假设。

HackerNews的讨论