本文最初发表于 RedHat 的开发者站点,经原作者 Bilgin Ibryam 许可,由 InfoQ 中文站翻译分享。
“我们建造计算机的方式与建造城市的方式是一样的,那就是随着时间的推移,依然毫无计划,并且要建造在废墟之上。”
Ellen Ullman 在 1998 年写下了这样一句话,但它今天依然适用于我们构建现代应用程序的方式,那就是,随着时间的推移,我们要在遗留的软件上构建应用,而且仅仅有短期的计划。在本文中,我将介绍一些模式和工具,我相信它们对于遗留应用的现代化以及构建现代事件驱动的系统非常有效。
应用现代化概述
应用现代化(Application modernization)指的是对现有遗留应用的基础设施(内部架构)进行现代化的过程,以提高新特性的交付速度、改善性能和可扩展性以及为新的使用场景提供功能等等。对应用程序的现代化和迁移类型已经有了很好的分类,如图 1 所示。
图 1:三种现代化类型以及可能用到的技术
根据你的需求以及对变化的渴望程度,有以下几个等级的现代化:
保持原状(Retention):我们可以做的最简单的事情是保留所拥有的东西,忽略应用程序的现代化需求。如果需求还不是很迫切的话,这么做是有道理的。
完全退出(Retirement):我们可以做的另一件事是退出并彻底摆脱遗留的应用程序。如果发现该应用程序不再被使用的话,这么做是有可能的。
重新托管(Rehosting):我们可以做的下一种方案是重新托管应用程序,这通常意味着会将一个应用程序按原样托管在新的基础设施上,如云基础设施,甚至可以通过像 KubeVirt 这样的工具将应用托管在 Kubernetes 上。如果应用程序不能被容器化,但你仍然想重新使用 Kubernetes 的技能、最佳实践和基础设施来管理作为容器的虚拟机,这也不失为一种可选方案。
重新平台化(Replatforming):如果改变基础设施不足以解决问题,而且你要在不改变架构的情况下对应用程序的边缘做一些改变时,重新平台化是一种可选方案。也许你正在改变应用程序的配置方式,以便将其容器化,或者从传统的 Java EE 运行时转移到一个开源运行时。在这种情况下,你可以使用像 windup 这样的工具来分析你的应用程序,并得出一份报告,该报告会说明需要做的事情。
重构(Refactoring):今天,许多应用程序进行现代化的重点是将单体的、企业内部的应用迁移到支持更快速发布周期的云原生微服务架构。这涉及到重构和重新架构你的应用程序,这也是本文的重点。
在本文中,我们会假设正在处理一个单体的、内部部署的应用程序,这是进行应用现代化的一个常见起点。这里讨论的方法也可以适用于其他场景,如云迁移计划。
迁移单体遗留应用的挑战
对于迁移单体的遗留程序来讲,部署频率是一个常见的挑战。另外一个挑战就是扩大开发规模,让更多的开发人员和团队能够在一个通用的代码库上工作,并且不会相互影响。还有一个问题就是以可靠的方式扩展应用以处理不断增加的负载。另一方面,对应用进行现代化的预期收益包括缩短上市时间、增加团队对代码库的自主权,以及动态扩展以便更有效地处理服务负载。这些收益中的每一项都能够弥补我们进行应用现代化所付出的成本。图 2 展示了为增加负载而扩展遗留应用基础设施的样例。
图 2:将遗留应用重构为事件驱动的微服务
展望目标状态并衡量何为成功
对于我们的场景来说,目标状态是一种遵循微服务原则的架构风格,它会使用一些开源的技术,比如 Kubernetes、Apache Kafka 和 Debezium。我们希望最终能够形成围绕业务领域建模的可独立部署的服务。每个服务应该有自己的数据,发布自己的事件等等。
在规划应用现代化的时候,很重要的一件事就是考虑如何衡量我们努力产生的输出和结果。为此,我们可以使用一些指标,比如变更的准备时间、部署频率、恢复时间、并发用户等。
在接下来的章节中,我们将会介绍三种设计模式以及三项开源技术,即 Kubernetes、Apache Kafka 和 Debezium,我们可以使用它们将现有的系统迁移成全新的、现代化的、事件驱动的服务。我们首先从 Strangler 模式开始介绍。
Strangler 模式
在应用程序迁移方面,Strangler 是最流行的技术。Martin Fowler 以 Strangler Fig Application 的名称介绍并普及了这种模式,它的灵感来自于一种无花果(fig),这种植物能够在一棵树的上部枝条上播种,并逐渐围绕原来的树生长,最终将原来的树取而代之。与应用迁移同时要做的事情是,我们的新服务最初被设置为包裹现有系统。通过这种方式,新旧系统可以共存,从而给新系统以成长的时间,并试图逐渐取代旧的系统。图 3 展示了遗留应用程序迁移时 Strangler 模式的主要组件。
图 3:遗留应用程序迁移中的 Strangler 模式
Strangler 模式的主要好处在于,它允许我们以低风险、渐进式的方式从遗留系统迁移到新系统。接下来,我们看看这个模式所涉及的主要步骤。
第一步:识别功能边界
第一个问题是从哪里开始进行迁移。在这方面,我们可以使用领域驱动设计帮助我们识别聚合和限界上下文,它们分别代表了一个潜在的分解单元和潜在的微服务边界。或者,我们也可以使用 Antonio Brandolini 创建的事件风暴(event storming)技术来获取对领域模型的共同理解。在这里,其他需要考虑的重要事项是这些模型如何与数据库进行交互,以及数据库分解都需要哪些工作。一旦我们有了这些因素的清单,下一步就是识别限界上下文之间的关系和依赖,以了解提取它们的相对难度。
掌握了这些信息之后,我们就可以着手处理下一个问题了:我们是想从依赖最少的服务开始,以便轻松看到效果,还是应该从系统中最困难的部分开始?一个好的折衷办法是挑选一个有代表性的服务,它可以帮助我们建立良好的技术基础。然后,这个基础可以作为估算和迁移其他模块的基准。
第二步:迁移功能
为了使 Strangler 模式发挥作用,我们必须能够清晰地将入站调用映射到我们想要迁移的功能。我们还必须能够将这些调用重定向到新的服务,并在需要时将其返回。根据遗留应用的状态、客户端应用和其他限制因素,权衡这种拦截的可选方案有时候很简单直接,有时候则很难:
最简单的方案就是修改客户端应用,将入站调用重定向到新的服务上。完工!
如果遗留应用程序使用 HTTP 的话,那我们就有了一个良好的开端。HTTP 对重定向非常有利,我们有大量的透明代理方案可供选择。
在实践中,我们的应用程序很可能不仅使用 REST API,还会有 SOAP、FTP、RPC 或某种传统的消息端点。在这种情况下,我们可能需要使用像 Apache Camel 这样的技术建立一个自定义的协议转换层。
使用拦截的方式可能是一个危险的倾向:如果我们开始着手构建一个由多个服务共享的自定义协议转换层,那么就有可能为服务所依赖的共享代理添加太多的智能化。这会与“智能微服务,哑管道”的口号背道而驰。更好的方案是使用 Sidecar 模式,如图 4 所示。
图 4:Sidecar 模式
相对于将自定义代理逻辑放到共享层里,不如将其作为新服务的一部分。但是,我们不会在编译期将自定义代理嵌入到服务中,而是使用 Kubernetes sidecar 模式,使代理成为一个运行时的绑定活动。借助这种模式,遗留客户端使用协议转换的代理,而新的客户端则会使用新的服务 API。在代理内部,调用会被进行转换,并被转移到新的服务上。这样的话,在需要时,我们可以重用代理。更重要的是,当遗留客户端不再需要代理时,我们可以很容易地将其拆除,而对新服务的影响做到了最小。
第三步:迁移数据库
我们确定了功能边界和拦截方法之后,就需要确定如何处理数据库的 Strangler 问题,也就是将我们的遗留数据库与应用服务分离开来。在这方面,我们有多个解决路径可供选择。
数据库优先
在数据库优先的方式中,我们会首先分离模式,这可能会影响到遗留的应用程序。比如,一个 SELECT 可能需要从两个数据库中获取数据,并且一个 UPDATE 可能会导致需要分布式事务。这种方案需要对源应用程序进行修改,并且无法帮助我们在短期内展示迁移的进展。因此,这并不是我们要寻找的方案。
代码优先
代码优先的方法可以让我们快速实现独立部署的服务,并且能够重用遗留的数据库,但它可能会在迁移进度方面给我们带来错误的印象。分离数据库可能会变成一种挑战,并隐藏未来可能出现的性能瓶颈。但这是一个正确的方向,可以帮助我们发现数据的所有者,以及后续分离数据库所需的工作内容。
代码和数据库并行
代码和数据库并行的方式在一开始可能会比较困难,但这是我们最终想要达到的状态。无论我们采取什么方式,最终我们都希望有一个独立的服务和数据库,从这个方式出发,可以帮助我们避免后期的重构。
拥有一个独立的数据库需要进行数据的同步。和往常一样,在这方面我们可以从几种常见的技术方法中选择。
触发器
大多数的数据库都允许我们在数据发生变化的时候执行自定义的行为。在有些情况下,甚至可以调用一个 web 服务并与另外的系统进行集成。但是,在不同的数据库之间,触发器的实现方式以及我们能用它们实现哪些功能都是不同的。这种方式还有一个严重的缺点,那就是使用触发器需要改变遗留的数据库,我们可能并不想这么做。
查询
我们可以使用查询来定期检查源数据库的变化。这些变化通常是通过不同的实现策略检测出来的,比如源数据库中的时间戳、版本号,或者状态列变化。无论使用哪种实现策略,轮询总是会导致两难的困境,要么频繁轮询,这会在源数据库上产生开销,要么就会错过频繁进行的更新。虽然查询的初始安装和使用都很简单,但这种方法有很大的局限性。它不适合于有频繁数据库交互的任务关键型应用。
日志读取器
日志读取器通过扫描数据库的事务日志文件来识别变化。日志文件的存在是为了数据库的备份和恢复,并提供了一种可靠的方式来捕获所有的变化,包括 DELETE 操作。使用日志读取器是破坏性最小的方案,因为它们不需要对源数据库进行修改,也没有查询负载。这种方法的主要缺点是,事务日志文件没有通用的标准,我们需要专门的工具来处理它们。这就是 Debezium 的用武之地。
在进入下一步之前,我们先看看使用 Debezium 与日志读取器的方式是如何运作的。
使用 Debezium 进行变更数据捕获
当一个应用程序将数据写入到数据库时,变更会被记录在日志文件中,然后数据库的表才会被更新。对于 MySQL 来说,日志文件是 binlog;对于 PostgreSQL 来说,是 write-ahead-log;而对于 MongoDB 来说,是 op 日志。好消息是 Debezium 有针对不同数据库的连接器,所以它为我们完成了理解所有这些日志文件格式的艰巨工作。Debezium 可以读取日志文件,并产生一个通用的抽象事件到消息系统中,如 Apache Kafka,其中会包含数据的变化。图 5 显示了 Debezium 连接器是如何作为各种数据库的接口的。
图 5:微服务架构中的 Debezium 连接器
Debezium 是使用最广泛的开源变更数据捕获(change data capture,CDC)项目,其多种连接器和特性使它非常适合 Strangler 模式。
为什么说 Debezium 很适用于 Strangler 模式?
考虑用 Strangler 模式来迁移单体遗留应用程序的最重要的原因之一就是减少风险以及能够回退到遗留应用程序之上。同样,Debezium 对遗留应用是完全透明的,它不需要对遗留的数据模型做任何改变。图 6 显示了 Debezium 在一个微服务架构中的示例。
图 6:在混合云环境中部署 Debezium
通过对遗留数据库的一个最小化配置,我们就可以捕获所有需要的数据。因此,在任何时候,我们都可以移除 Debezium,并在需要时回退到传统的应用程序。
支持遗留应用迁移的 Debezium 特性
如下是 Debezium 支持用 Strangler 模式迁移单体遗留应用程序的一些具体功能:
快照:Debezium 能够对源数据库当前的状态进行快照,我们可以使用这个功能进行批量数据的导入。快照生成之后,Debezium 将会以流的方式传输变化,以保证目标系统处于同步状态。
过滤器:Debezium 能够让我们选择为哪些数据库、表和列的数据传输变化。有了 Strangler 模式,我们并不需要转换整个应用程序。
单一消息转换(Single message transformation,SMT):这个特性就像一个防腐层,能够保护我们的新数据模型不受遗留命名、数据格式的影响,甚至能够让我们过滤掉过时的数据。
组合使用 Debezium 和模式注册表:我们可以组合使用像 Apicurio 这样的模式注册表和 Debezium 来进行模式验证,并且在源数据库模型发生变化时能够使用它来执行版本兼容性检查。这可以防止源数据库的变化影响和破坏新的下游消息消费者。
组合使用 Apache Kafka 和 Debezium:有很多证据可以表明,在进行应用程序的迁移和现代化的过程中,Debezium 和 Apache Kafka 能够很好地进行协作。有很多具体的例子可以证明组合使用这些工具是很好的选择,比如保证数据库变化的顺序、消息压缩、根据需要多次重读变更的能力以及跟踪事务日志的偏移量。
第四步:发布服务
结合对 Debezium 的快速描述,我们看一下在 Strangler 模式下的使用情况。假设,到目前为止,我们已经完成了如下的工作:
确定了功能边界。
迁移了功能。
迁移了数据库。
将服务部署到了 Kubernetes 环境中。
用 Debezium 迁移数据,并保持 Debezium 一直运行以同步正在进行的变化。
此时,还没有任何流量被路由到新服务上,但发布新服务的准备已经做好了。根据我们路由层的能力,我们可以使用诸如暗发布(dark launching)、并行运行和金丝雀发布等技术来减少或消除推出新服务的风险,如图 7 所示。
图 7:将读取流量引导到新的服务上
我们在这里还可以做的是,最初只把读取请求引导到新服务上,而继续把写入请求发送到遗留系统上。这样做是必要的,因为我们只会在单一方向上复制变化。
当我们看到读取操作没有问题时,就可以把写入流量也引导到新服务上。此时,如果我们由于某种原因仍然需要遗留应用运行的话,那么我们需要把新服务中的变化以流的方式同步到遗留应用的数据库中。接下来,我们要停止遗留模块中的任何数据写入或变更活动,并停止从它那里进行数据复制。图 8 说明了模式实现中的这一部分过程。
图 8:将读取和写入的流量引导到新服务上
由于我们仍然有遗留应用的读取操作,所以还要继续从新服务到遗留应用的复制过程。最终,我们将停止遗留模块中的所有操作,并停止数据复制。此时,我们就能够拆除被迁移的模块了。
我们已经大致了解了如何使用 Strangler 模式来迁移一个单体的遗留应用,但我们还没有彻底完成对基于微服务的新架构的现代化。接下来,我们考虑一下现代化过程中随后所面临的一些挑战,以及 Debezium、Apache Kafka 和 Kubernetes 如何帮助我们。
迁移之后:应用现代化的挑战
考虑采用 Strangler 模式进行迁移的最重要原因是降低风险。这种模式可以稳定地提供价值,并允许我们通过频繁的发布来展示进展。但是,如果仅仅是迁移,没有任何功能增强或新的“商业价值”,是很难说服一些利益相关者的。在长期的现代化过程中,我们还希望能够增强现有的服务并增加新的服务。在进行应用现代化的最初设想中,我们通常也会有一项任务就是为构建后续的现代应用奠定基础和最佳实践。通过迁移越来越多的服务并增加新的服务,以及总体上向微服务架构过渡,新的挑战将会出现,包括:
自动部署和运维大量的服务。
以可靠和可扩展的方式执行双重写入和协调长期运行的业务流程。
解决对分析和报告的需求。
这些挑战在传统的应用中可能并不存在。接下来,我们讨论一下,如何组合使用设计模式和技术来解决其中的一些问题。
挑战一:运维大规模的事件驱动服务
我们会从传统的单体应用中剥离出越来越多的服务,同时为了满足新出现的业务需求会不断创建新的服务,此时对自动部署、回滚、应用放置、配置管理、升级、自我修复的需求会变得越来越明显。正是这些特性使得 Kubernetes 变成了运维大规模微服务的绝佳选择。图 9 说明了这一点。
图 9:在 Kubernetes 之上的事件驱动架构的示例。
在处理事件驱动的服务时,我们很快会发现需要自动化,并且要与事件驱动的基础设施集成,这就是 Apache Kafka 及其生态系统中其他项目的用武之地了。此外,我们可以使用 Kubernetes Operator 来帮助自动化管理 Kafka 和以下的支撑服务。
Apicurio Registry 提供了一个用于管理 Kubernetes 上的 Apicurio Schema Registry 的 Operator。
Strimzi 提供了用于在 Kubernetes 上声明式地管理 Kafka 和 Kafka Connect 集群的 Operator。
KEDA(Kubernetes Event-Driven Autoscaling)提供了工作负载自动扩展器,用于扩展和收缩消费 Kafka 的服务。因此,如果消费者的延迟超过一个阈值,Operator 将启动更多的消费者,直至达到分区的数量,以赶上消息生产的速度。
Knative Eventing 提供了由 Apache Kafka 作为支撑的事件驱动的抽象。
注意:Kubernetes 不仅为应用程序的现代化提供了一个目标平台,而且还允许我们在同一基础上将应用程序发展成一个大规模的事件驱动架构。它通过用户工作负载、Kafka 工作负载的自动化以及 Kafka 生态系统的其他工具来做到这一点。也就是说,不是所有东西都必须在你的 Kubernetes 上运行。例如,我们可以使用 Red Hat 完全托管的 Apache Kafka 或模式注册服务,并使用 Kubernetes Operator 将其自动绑定到应用程序上。在 Red Hat OpenShift Streams for Apache Kafka 上创建一个多可用区(multi-AZ)的 Kafka 集群,只需不到一分钟,而且在试用期内完全免费。欢迎读者进行尝试,您的反馈将有助于我们将它变得更好。
现在,让我们看一下如何利用设计模式来应对另外两个应用程序现代化的挑战。
挑战二:避免双重写入
我们一旦创建了多个微服务,很快就会意识到,最难的部分是数据。作为其业务逻辑的一部分,微服务经常要更新其本地的数据存储。同时,它们还需要通知其他服务所发生的变化。这个挑战在单体应用和传统分布式事务的领域中并不明显。我们如何才能以云原生的方式避免或解决这种问题呢?答案是只修改两个资源中的一个(数据库),然后以最终一致的方式驱动第二个资源的更新,比如 Apache Kafka。图 10 说明了这种方法。
图 10:Outbox 模式
使用 Debezium 实现的 Outbox 模式可以让服务以安全和一致的方式执行这两项任务。在更新数据库时,服务不会直接向 Kafka 发送消息,而是使用一个事务来执行正常的更新,并将消息插入到其数据库中一个特定的 outbox 表中。一旦事务被写入数据库的事务日志中,Debezium 就可以从那里获取 outbox 消息并将其发送到 Apache Kafka 中。这种方法给了我们非常好的属性。通过在单个事务中同步写入数据库,该服务能够受益于“读取自己写入”的语义,即对该服务的后续查询将返回新的持久化记录。同时,通过 Apache Kafka,我们能够以可靠、异步的方式传播给其他服务。Outbox 模式是一种成熟的方法,用于避免可扩展的事件驱动的微服务所面临的双重写入问题。它非常优雅地解决了服务间通信的难题,而不需要所有参与者都同时可用,包括 Kafka。我相信 Outbox 将成为设计可扩展事件驱动的微服务的基础模式之一。
挑战三:长时间运行的事务
虽然 Outbox 模式解决了较简单的服务间通信问题,但它并不足以单独解决更复杂的长期运行的分布式业务事务的场景。后者需要跨多个微服务执行多个操作,并且要具备一致的全有或全无的语义。阐述这一需求的一个常见的例子就是预订旅行行程的场景,它由多个部分组成,其中航班和住宿必须要一起预订。在传统的应用程序中,或者在单体架构下,你可能不会注意到这个问题,因为模块之间的协调是在一个进程和一个事务性上下文中完成的。分布式领域需要一种不同的方法,如图 11 所示。
图 11:用 Debezium 实现的 Saga 模式
Saga 模式为该问题提供了一个解决方案,它将一个总体的业务事务分割成了一系列的多个本地数据库事务,分别由参与的服务来执行。一般来说,有两种方法来实现分布式 Saga:
协同式(choreography):在这种方式下,参与其中的某个服务会在执行完自己的本地事务后,发送消息给下一个服务。
编排式(orchestration):在这种方式中,会有一个中央协调服务负责协调并调用参与其中的服务。参与服务之间的通信可能是同步的,比如通过 HTTP 或 gRPC,也可能是异步的,比如通过像 Apache Kafka 这样的消息系统。
注:请参见 InfoQ 的译文“微服务下分布式事务模式的详细对比”。
这里最酷的是,我们可以使用 Debezium、Apache Kafka 和 Outbox 模式实现 Saga。有了这些工具,就可以利用编排式的方法,在一个地方管理 Saga 的流程,并检查 Saga 事务的总体状态。我们还可以将编排与异步通信相结合,将协调服务与参与服务的可用性,甚至与 Kafka 的可用性解耦。这给了我们两全其美的结果:编排式以及参与服务之间异步、非阻塞、并行的通信,没有时间上的耦合。
将 Outbox 模式与 Saga 模式结合起来,对于分布式服务领域中长时间运行的业务事务来说,是一个非常棒的、以事件为驱动的实施方案。详细描述请参见“如何使用发件箱模式实现微服务的 Saga 编排”(InfoQ)。同时可以在 GitHub 上看到这个模式的实现样例。
结论
Strangler 模式、Outbox 模式和 Saga 模式可以帮助我们从遗留系统中迁移出来,同时它们也可以帮助我们建立全新的、现代的、事件驱动的服务,这些服务是面向未来的。
Kubernetes、Apache Kafka 和 Debezium 是开源项目,已经变成了各自领域中的事实标准。你可以用它们来创建标准化的解决方案,它们都有丰富的生态系统,包括支撑工具和最佳实践。
从这篇文章以及我在 2021 年 Red Hat 峰会上的演讲中得到的一个启发是,我意识到现代软件系统就像城市一样。它们随着时间的推移,会在遗留系统的基础上不断发展。使用成熟的模式、标准化的工具和开放的生态系统将帮助我们创建持久的系统,并且能够随着你的需求而成长和变化。
原文链接:
评论 2 条评论