[关闭]
@levinzhang 2021-11-01T09:33:30.000000Z 字数 4140 阅读 611

反应式单体:如何从CRUD转向事件溯源

摘要

本文是一个系列文章的第一部分,阐述了如何基于事件溯源的理念在不影响既有业务的情况下,对单体式的CRUD应用进行改造。


本文最初发表于Wix Engineering网站,经原作者Jonathan David授权由InfoQ中文站翻译分享。

我们都听过这样的故事:大型的单体应用曾经给我们带来过巨大的业务价值并且很好地为我们的客户提供了服务,但是现在这种方式已经开始拖累我们了。产品的愿景逐渐朝反应式特性演化,这意味着要在正确的背景下对多个领域事件作出实时反应。但是,问题在于我们的单体应用被设计成了一个典型的CRUD系统,也就是在状态发生变化时同步运行业务逻辑。

本文是系列文章的第一篇,会讲述如何将事件溯源和事件驱动架构引入到我们的客户支持平台(customer support platform)中,在这个过程中,我们允许逐步迁移,并且在没有将现有功能置于风险之中的前提下,已经开始为我们提供新的商业价值。按照传统的CRUD方式进行系统设计时,我们主要关注的是状态以及如何在一个分布式环境中由多个用户进行状态的创建、更新和删除操作,而事件溯源方式关注的是领域事件,它们何时发生以及它们如何表达业务意图。在事件溯源方式中,状态是事件的具体化(materialization),这只是领域事件多种可能的使用方式之一。

客户支持平台是实践反应式能力的一个很好的用例。因为客户代理会处理来自不同渠道的案例,在这个过程中,很容易错失对高优先级案例的跟踪。而事件驱动系统能够单独跟踪每个支持案例,能够帮助客户代理保持对正确案例的关注,并在其他案例需要关注的时候发出告警。这只是众多示例中的一个。另外一个示例是当某个种类的案例在给定的时间段内大量出现的时候,我们就需要采取一定的措施。

Wix Answers是一个客户支持解决方案,它将工单、帮助中心和呼叫中心等支持工具集成到了一个直观的平台中,具有先进的内置自动化和分析能力。

如果我们能重新开始的话,系统会是什么样子呢?

如果能够重新开始的话,我们会选择事件溯源架构。我不会深入介绍事件溯源架构是什么,如果你想了解更多知识的话,我强烈推荐Martin Fowler的这篇较旧的文章和Neha Narkhede的这篇较新的文章

我喜欢事件溯源的原因在于,它将领域事件放在优先的位置,并且以此为中心。如果你仔细倾听客户阐述他们的需求的话,你会经常听到他们这样说:“当发生这种情况时,我希望系统那样做。”实际上,他们是在用领域事件的方式在说话。作为开发者,如果能够理解我们的主要目标就是产生领域事件时,事件就开始步入正轨了,我们就会理解事件溯源的威力。

在讨论我们采取了哪些行动将单体应用变得具有反应式特征之前,我想要描述一下如果没有任何的遗留代码,能够重新开始的情况下,理想的解决方案是什么。我认为这样的话,你就能更好地理解我们所采取的路线以及我们必须要做出的妥协。

这是事件溯源架构中事件的一般流程:命令(command)是由客户发起的,旨在改变某个实体(通过entity-id进行唯一标识)的状态。命令则是由聚合(aggregate)处理的,聚合要根据当前的实体状态决定接受或拒绝命令。如果一条命令被接受的话,聚合要发布一个或多个领域事件同时要更新当前实体的状态。我们必须要假定聚合能够访问到最新的实体状态,并且没有其他的进程正在并行地对特定的实体id进行决策,否则的话,我们就会面临状态一致性的问题,这是分布式系统所固有的问题。由此可见,实体当前状态(entity-current-state)的存储是实体真实情况的来源(source of truth)。实体其他形式的表述最终都将是一致的,这是基于事件的具体化实现的。

使用Kafka Streams作为事件溯源框架

有很多相关的文章讨论如何在Kafka之上使用Kafka Streams实现事件溯源。我认为关于这个话题还有很多需要讨论的,但是我会在一篇单独的文章中进行讲解。现在我只想说,Kafka Streams使得编写从命令主题到事件主题的状态转换变得很简单,它会使用内部状态存储作为当前实体的状态。内部状态存储是一个由Kafka主题作为备份的rocks-db数据库。Kafka Streams保证能够提供所有数据库的特性:你的数据会以事务化的方式被持久化、创建副本并保存,换句话说,只有当状态被成功保存在内部状态存储并备份到内部Kafka主题时,你的转换才会将事件发布到下游主题中。如果采用exactly-once语义的话,这一点是能够得到保证的。通过依靠Kafka的分区,我们能够保证某个特定的实体id总是由一个进程来处理,并且它在状态存储中总是拥有最新的实体状态。

在我们的单体CRUD系统中,是如何引入领域事件的?

我们首先要问的是,真实情况的来源是什么。我们的单体系统通过REST API接收变更命令,更新MySQL实体,然后返回更新后的实体给调用者。

这使得MySQL成为了我们的事实来源。如果不对我们的单体和它与客户端的通信方式作出重大变更的话,我们就无法改变这一点,通信必须要变成异步的。这势必导致客户端的重大变化。

变更数据捕获(Change Data Capture,CDC)

将数据库的binlog以流的方式传向Kafka是一个众所周知的实践,这样做的目的是复制数据库。表中数据行的每一个变化都会被保存在binlog中,这样的记录包含之前和当前的行状态,这种方式能够有效地将每个表转换为一个流,从而能够以一致的方式具体化为实体状态。我们使用Debezium源连接器将binlog流向Kafka。

借助Kafka Streams进行无状态转换,我们能够将CDC记录转换为命令,发布到聚合命令主题。我们这样做有几个原因:

随着聚合不断处理命令,它会逐渐更新Kafka中的实体状态。我们可以重新创建源连接器,并实现相同表的再次流化处理,然而,我们的聚合会根据CDC数据和从Kafka检索的当前实体状态之间的差异来生成事件。在某种程度上来讲,Kafka成为了我们的流平台的事实情况来源,该平台是与单体应用并存的。

CDC记录代表了已提交的变化,为什么它们不是事件呢?

CDC feed的目的是以最终一致的方式复制数据库,而不是生成领域事件。CDC记录包含了变更前后的元素,通过变更前后的差异将其转换成领域事件是一种很有诱惑力的方案。但是,仅仅依靠CDC记录有一些严重的缺陷。

当执行无状态转换时,我们无法对来自不同表的CDC记录做出正确的反应,因为不同的表之间无法保证顺序。最终,我们可能会在获得Order记录之前就处理了OrderLine记录。一个好的领域事件将提供一些关于Order的上下文,将其作为OrderLine事件的一部分。采用有状态的转换允许我们使用聚合状态作为OrderLine的存储,并且只有在Order数据到达之后才发布OrderLine事件。这是聚合作为实体事件源的责任的一部分。记住,我们现在无法实现纯粹的架构,而是一种并行的模式。

引入Snapshot阶段

binlog永远不会包含所有表的全部变更历史,为此,当为一个新的表配置新的CDC连接器时都会从Snapshot阶段开始。连接器将标记binlog中当前所在的位置,然后执行一次全表扫描,并将当前所有数据行的当前状态以一个特殊的CDC记录进行流式处理,也就是会带有一个snapshot标记。这本质上意味着在每次快照中,我们都会丢失领域事件信息。如果订单状态随着时间的推移发生了多次变化,快照将只给我们提供最新的状态。这是因为binlog的目标是复制状态,而不是成为事件溯源的支撑。这就是聚合状态存储和聚合命令主题之所以重要的关键所在。我们想把我们的解决方案设计成每个表只进行一次快照的方式。

事件溯源的强大功能之一就是能够通过回放历史事件或命令来重建状态或重建领域事件。但在这里再次执行快照并不是正确的解决方案,因为快照将导致事件信息的丢失。

如果想重新创建我们的领域事件,那么我们需要重置命令主题的消费者所采取的行为。命令主题将CDC记录打包成命令,并且已经将来自不同表的命令以正确的顺序(或聚合知道如何处理的顺序)存储起来了。

在本文中,我们只涉及了使单体应用具备反应性特征的基本步骤。我们讨论了如何使用CDC来建立一个命令主题,以及为什么不能使用CDC记录作为命令。我们有了命令主题之后,就可以使用有状态的转换来创建事件,进而能够开始享受事件溯源的好处:重放命令以重新创建事件,重新处理事件以具体化状态。

在接下来的文章中,我们将讨论更高级的话题,将会涉及到:

参考资料:
1. Martin Fowler,2005,https://martinfowler.com/eaaDev/EventSourcing.html
2. Neha Narkhede, 2016,https://www.confluent.io/blog/event-sourcing-cqrs-stream-processing-apache-kafka-whats-connection

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注