https://www.infoq.cn/article/31IdBpWgTQZU7e5-uwh1

核心要点

  • 从一个易于抽取的小候选功能开始,以便于尽早获得微服务的体验;
  • 要预先重点关注构建和部署自动化以及监控;
  • 尽早处理横切性的关注点,避免给生产效率带来负面的影响,比如为单体应用继续增加功能或者为每个微服务重新实现横切性的关注点;
  • 将系统的事件驱动功能设计得易于演化,考虑采用事件流的方案以减少数据副本的成本并降低添加新微服务的门槛;
  • 需要注意,转换至微服务的过程并不是独立运转的。相反,它受到很多环境因素的影响。当心那些阻碍你前进或拖你后腿的环境因素,对它们进行相应的调整,或者至少要在整个组织中意识到这些问题。

在开展微服务的过程中,了解要考虑哪些因素可能是非常有挑战性的事情,对于小团队来讲更是如此。遗憾的是,没有可以直接使用的金科玉律。每个过程都是不同的,因为每个组织面临的都是不同的环境。在本文中,我将从初创公司的角度分享我们学习到的经验和面临的挑战,以及我下次引入微服务时,会在哪些方面采取不同的做法。

从单体应用到微服务的旅程该如何开始?

最初,从各个方面看,我都是从单体应用开始的:我们整个团队基于一个相互协作的产品开展工作,将其实现为同一个代码库并且基于同一个技术栈。在一段时间内,这种方式能够很好地运转。

image

随着时间的推移,所有的事情都在演化:团队在增长,我们为产品不断添加越来越多的特性,代码库变得越来越大,用户的数量也在不断增长。这听起来非常不错,对吧?但是……

现在,要完成一件事情需要非常长的时间:会议、讨论和决策都要比以往消耗更长的时间。职责无法清晰地划分,明确具体责任需要花费一定的时间,比如当出现了 bug 的时候。我们的过程变得更加缓慢,生产效率也受到了影响。

我们添加的特性越多,产品使用起来就越复杂。产品的可用性和用户体验因为不断的特性修改而受损。我们不但没有很好地解决用户的问题,反而让他们更加困惑。

因为采用单体软件架构,我们很难在不影响整个系统的情况下添加新的特性,释放新的变更也变得非常复杂,即便我们只修改了几行代码,也需要重新构建和部署整个产品。这导致部署会具有很高的风险性,因此部署的频率也不那么频繁,因为新特性的发布非常缓慢。

image

因此,对系统进行分离和转换的需求就出现了。

在三年前,我们改变了产品策略。我们关注可用性和用户体验的提升并将我们的产品 JUST SOCIAL 拆分成了多个独立的应用,其中每个应用负责特定的场景。我们不断演化这个理念,提供不同的应用来共享文档、实时交流、管理任务、共享可编辑的内容和协作的新闻以及管理 profile。

image

同时,我们将整个团队拆分成了多个更小的团队,并为每个团队分派了特定的一组协作应用(collaboration app),从而实现定义了良好的职责划分。我们想要建立自治化的团队,能够让他们按照自己的节奏独立地围绕系统不同的组成部分开展工作,将跨团队的影响降低到最小。

image

在将我们的产品拆分为多个独立的协作应用并将团队分为多个更小的团队之后,接下来顺理成章的步骤就是将自治性和灵活性反映到软件架构中,这是通过引入微服务实现的。

我们引入微服务的驱动力在于让系统的不同组成部分能够实现自治,让他们按照自己独立的节奏开展工作,将跨团队的影响降到最低。通过独立地开发、部署和扩展协同应用,我们希望能够快速地发布变更。

image

我们的微服务之旅首先是从识别适合采取微服务的候选功能开始的。为了识别合适的候选功能,我们必须要考虑如何建模良好服务的核心概念。核心概念遵循服务间松耦合和服务内高内聚的原则。服务内的高内聚通常反映在保持相关行为的一致性方面。在领域驱动设计中,相关行为反应为限界上下文(Bounded Context)。限界上下文是领域模型中的语义边界,服务会负责定义良好的一个业务功能,限界上下文会对服务进行描述。

image

在我们的场景中,我们使用协作应用作为高层级的限界上下文,它反映了粗粒度的服务边界。这是一个很好的起点,后续我们会将它们拆分为更加细粒度的服务层。

image

我们首先从 JUST DRIVE 的限界上下文开始,也就是负责文档管理的协作应用。每个文档都是由作者创建的。作者相关的数据来自 profile,而后者又是由 profile 管理的限界上下文来进行管理的,这个功能依然位于单体应用中。

image

我们从头构建了一个共存(co-existing)的服务。它实际上并不完全与当前功能的相同,相反,我们引入了新的 UI、添加了更多的特性并将数据结构做了重大的变更。新服务的限界上下文包括负责业务逻辑的领域模型、编排用例的和管理事务的应用服务以及输入输出的适配器,比如 REST 端点和用于持久化管理的适配器。新服务会独占文档状态,也就是说,它是唯一能够读取和写入文档的服务。

如前文所述,每个文档都是由作者创建的,作者的数据来源于单体应用所管理的 profile 数据。

image
那么问题就来了,新服务和单体应用之间该如何交互呢?

为了避免每次展现文档的时候都从 profile 服务中获取作者数据,我们在新的服务中保留了相关作者数据的一个本地副本。只要不破坏数据的所有权,数据冗余是没有问题的,在我们这个场景中,只要 profile 相关的限界上下文依然独占 profile 状态即可。

由于本地副本和原始的数据会随着时间的推移而产生差异,所以单体应用需要在 profile 更新的时候通知我们。在 profile 发生变化的时候,单体应用会发布一个 ProfileUpdatedEvent 事件,新服务需要订阅这个事件。新服务消费该事件并相应地更新本地副本。

image

这种事件驱动的服务集成方式降低了服务之间的耦合,因为我们现在不需要跨上下文远程直接查询单体应用了。这种方式增加了自治性,新服务能够对本地副本做任何事情,而且能够让数据连接(join)更加高效,因为它可以使用本地副本连接作者数据,无需通过网络。

我们从头构建了一个共存的服务,并且为了实现数据复制的目的,引入了事件驱动形式的服务交互。

我们遇到了什么挑战以及是如何解决的

从头开始构建共存的服务通常是一种很好的分解策略,当你想要摆脱某些东西的束缚时,更是如此,比如想要脱离过时的业务逻辑或者现有的技术栈。但是在解耦第一个服务的时候,我们一次性做了太多的事情。如前文所述,我们不仅从头构建了一个共存的服务,还引入了新的 UI、添加了更多的特性,还对数据结构做了重大的变更。在开始的时候,我们承担了太多的责任,所以在很晚的时候才看到结果。但是,在开始阶段,快速得到结果以获取使用微服务的经验和信心是非常重要的。

image

在下一个备选服务中,我们采取了不同的方式。我们关注 chat 应用的高层级限界上下文,并遵循自上而下的渐进式分解策略,逐步抽取已有的代码。我们首先将 UI 抽取为单独的 Web 应用,并在单体应用侧引入了 REST-API,这样被抽取出来 Web 应用可以访问该 API。在这一步,我们可以独立地开发和部署 Web 应用,从而能够对 UI 进行快速迭代。

image

在抽取完 UI 之后,我们就可以更进一步,解耦业务逻辑。分解业务逻辑会对代码带来重大的变更。根据依赖关系,我们可能需要提供一个临时的 REST API 供单体应用使用,以解决业务逻辑抽取后所带来的问题。此时,我们依然共享相同的数据存储。

image

为了实现非耦合的独立服务,我们最终需要切分数据存储,以确保新服务能够独占 chat 的状态。

在每个 chat 讨论中,都会涉及到参与者。chat 参与者的数据来源于单体应用中的 profile 数据。如前面描述的 DRIVE 样例类似,我们保存一个 chat 参与者数据的本地副本,并订阅 ProfileUpdatedEvent 事件,从而让本地副本数据与单体应用中原始数据的保持同步。

image

从此处开始,我们就可以继续从单体应用中抽取下一个限界上下文,或者将我们的粗粒度服务随后拆分为更细粒度的服务。

另外一项挑战是对授权的处理

几乎对于每个服务,我们都会面临如何授权的问题。我为你描述一个背景:授权处理是非常细粒度的,一直向下延伸到领域对象级别。每个协作应用都要控制其领域对象的权限,比如文档的权限是由该文档所在的父文件夹的授权设置来控制的。

另一方面,授权不仅仅是细粒度的,还依赖于服务之间的交互,在某些场景下,领域对象的授权还依赖于父领域对象的授权信息,而父领域对象的授权信息是位于其他服务中的,比如,要读取某个内容页相关的文档或者为内容页添加文档的话,需要依赖于这个页面的授权设置,而这个页面的授权配置位于与文档本身不同的服务中。

image

因为这些复杂的需求,解决分布式授权的问题给我们带来了很大的困扰,而且我们没有在早期提供解决方案。这样带来的结果完全适得其反。其中一个后果就是我们添加了一个新的服务到单体应用中,而单体应用其实早就已经解决过授权的问题了。我们让单体应用变得更大了,而不是让它变得更小。另外一个后果就是,我们开始在每个服务上都实现授权。起初,这种做法看上去是合理的,因为我们最初的假设是授权属于领域模型所在的限界上下文,但是我们忽略了服务之间的依赖关系。所以,我们不断地来回复制数据,增加了冲突的风险。

image

长话短说:我们最终将授权处理合并到了一个中心化的微服务中。

与中心化服务一并出现的是引入分布式单体应用的风险。当修改系统中的某一部分时,你必须要同时修改其他的组成部分,这是已引入分布式单体应用的强烈信号。以我们的场景为例,当引入需要授权的新协作应用时,我们需要同时修改中心化的授权服务。我们同时遇到了单体应用和分布式应用的缺点:服务是紧耦合的,而且服务还需要通过缓慢、不稳定的网络来进行通信。

image

于是,我们提供了一个通用的契约,这个契约属于授权服务,所有的下游服务都必须要遵守该契约。在我们的场景中,服务会将授权相关的行为转换成授权服务能够理解的契约,授权服务不需要额外的转换。这种转换是在每个下游服务中发生的,而不是在中心化的授权服务中发生的。这种通用契约能够确保我们在引入新的服务时,不需要同时修改和重新部署中心化的认证服务了。有个先决条件是这个通用的契约是稳定的,或者说至少向下兼容,否则的话,我们会将问题转移给下游服务,这会导致它们需要不断进行更新。

image

我们学习到了什么

在开始阶段需要特别注意,最好从易于提取的小型服务开始,以便于快速得到结果并获取使用微服务的早期经验。如果要处理粗粒度的大型服务,就我们而言,将拆分过程分为增量式的步骤会更加易于管理,例如增量式地由上到下进行分解,也就是每次只执行一个可管理的步骤。

image

尽早处理横切性的关注点非常重要,这样能够避免适得其反的后果,比如不断扩大单体应用而不是缩减它,或者在每个服务中都重新实现横切性的关注点。

image

在引入中心化的横切服务时,需要注意不要引入分布式单体应用。在这种情况下,通用且稳定的契约能够帮助我们避免出现分布式单体应用。

image

要设计易于演化的系统,事件驱动的服务交互方式是实现服务间高度解耦的关键。事件可以用作通知,也可以用于生成数据副本(关于事件驱动的状态转移,参见上文关于从头构建共存服务的内容),我们还可以通过长期保留事件将事件存储作为主要的数据源。

image

当事件单纯用于通知的目的时,其他上下文中的额外数据通常会以跨上下文查询的方式直接进行请求,比如 REST 请求。我们可能会更喜欢远程查询的简洁性,而不愿处理本地维护数据集所带来的开销,在数据集会不断增长的情况下更是如此。但是远程查询增加了服务之间的耦合性,并且在运行时将服务绑定在了一起。

我们可以将对其他上下文的远程查询进行内部化处理,这是通过引入相关跨上下文数据的本地副本来实现的。如上面的 JUST DRIVE 样例所述,为了避免每次展现文档的时候都从 profile 服务中请求相关的作者数据,我们复制了作者数据,并在文档微服务中保留了一个本地副本。我们需要保证副本数据和原始数据的同步,这意味着当原始数据变化的时候,要立即同步我们的本地副本。为了获取已修改数据的通知,服务需要订阅包含数据变化的事件并相应地更新本地副本。在本例中,事件是用来生成数据副本的,这样能够避免远程查询并降低服务之间的耦合性。这种方式也能实现更好的自治性,因为服务能够对本地副本执行任何操作。

对于事件驱动服务的交互,我们在早期就引入了 Apache Kafka,这是一个分布式、具有容错性、可扩展的日志提交服务。最初,我们使用 Apache Kafka 的主要目的是实现通知和生成数据副本的功能。最近,我们引入 Apache Kafka Streams 作为共享的事实源,以减少数据复制的开销并实现服务的高可插拔性,降低新服务进入的壁垒。

流是无界有序且持续更新的结构化数据记录组成的序列。数据记录有一个 key-value 对组成。

image

当你的服务在 Apache Kafka 流上下文中启动时,Kafka 主题将会加载到你的流中,你可以在服务的范围内处理它。主题通常是一个逻辑分类,表明了哪些服务可以发布和订阅。每个流都会缓冲到一个状态存储中,这是一个轻量级的基于硬盘的数据。加载的流会在你自己的代码中使用,不会在 Kafka 代理中运行,它运行在你的微服务进程中。流能够让数据出现在任何需要的地方,这会增强性能和自治性。

image

Apache Kafka 提供了一个 Stream API。Stream 可以借助领域特定语言(Domain Specific Language,DSL)进行连接、过滤、分组或聚合,流中的每条消息都可以使用类似函数的操作进行处理,比如映射、转换或窥探等。

在实现流处理的时候,通常会同时需要流以及进行功能增强的数据库。Kafka 的 Streams API 通过对流和表的核心抽象提供了该功能。在流和表之前其实存在紧密的关联关系,也就是所谓的流 - 表二元性(stream-table duality)。流可以看做表的变更日志,流中的每条数据记录都捕获了表中的一次状态变更。表可以视为快照,对应于流中每个 key 的最新值。

image

当我们想要展现一条文档及其作者数据时,借助 Kafka Streams,我们可以这样做:文档服务根据 document 主题创建一个 KStream,并根据 profile 主题得到的作者相关 profile 数据来完善该文档。在这个增强的过程中,文档服务会根据 profile 主题创建 KTable。现在,我们可以将流和表进行连接,并将它的结果保存为新的状态存储,这样就可以在外部进行访问了,运行方式类似于内置的 Materialized View。每当 profile 或文档更新的时候,它相关的 Materialized View 也会进行更新。

image

将 Apache Kafka Streams 与其他的事件驱动方式进行对比的话,它不需要维护本地副本,这减少了维护数据副本和保持数据同步的开销。Apache Kafka Streams 会将数据推送到需要的地方,并且运行在与服务相同的进程中。它增加了可插拔性,你可以插入新的服务并立即使用流,不需要搭建额外的数据存储。它能够减少开销,增强性能、自治性并降低新服务的进入壁垒。

image

这个转换的过程并不是隔离运行的,它会受到各种环境因素的影响:团队的规模、结构和技能都会影响到怎样做才是可控的,尤其是在开始阶段,如果是一个的团队并且 DevOps 经验很欠缺的话,将会对转换的速度造成一定的影响。

你的转换过程还会受到一个因素的影响,那就是你依然要处理遗留的系统。维护它所耗费的时间会相应地减少进行转换的时间。运行时环境也会影响这个过程。你是在内部环境中运行还是作为云原生应用运行?你是否能够依赖托管服务,比如托管的 API- 网关,还是需要自行搭建和维护?

如果你的策略是在短期内引入新特性的话,那么就会面临决策上的纠结,那就是将新需求在何处实现:如果作为新的独立服务的话,会耗费一定的时间,如果采取快捷的方式,将其添加到单体应用上,那就会带来让单体应用越来越大,而不能对其进行缩减的风险。

image

注意那些阻碍前进或减缓速度的环境因素,并相应地调整它们,或者至少在你的组织中引起注意。记住: 每一次过程都是不同的,你的过程可能和我们的完全不同。

image

如果下次继续引入微服务的话,在哪些方面的做法会有所不同

首先,我会检查组织的战略是否与微服务的目标相一致,那就是最大化产品的敏捷性以及独立快速地发布变更,例如,如果你的组织关注较长的发布周期并希望将所有内容部署在一起,那么微服务可能不是最佳选择,因为无法充分利用微服务的优势。

如果你决定采用微服务的话,每个人都必须投入其中,包括管理层。每个人都需要意识到这个过程是非常复杂和耗时的,当你还没有多少经验的时候更是如此。

与产品相符的、跨功能的、自治的团队可以很好地与微服务架构模式协作,但是应该尽早考虑向 DevOps 文化的转变。每个团队都应该为持续的迭代做好准备,并且能够开发、发布、运维和监控他们负责的服务。

将单体应用拆分成多个独立的服务,只是整个过程的一部分,而如何运维它们则是另外一回事儿。你拥有的服务越多,它们的自动化构建和部署流程就变得越重要。

如果我重做一次的话,我将从一个易于抽取的小型候选服务开始,不仅要关注它的拆分,还要关注构建和部署的自动化,并预先监控第一个服务,它可以作为后续服务的基础。要搭建这个基础环境,可能需要从每个组抽取一个人形成一个临时的任务组。

每个微服务从一开始就应该有自己的 CI/CD 管道。另一个需要考虑的问题是将每个微服务进行容器化,从而能够得到轻量级、封装好的运行时环境,它能够在各个阶段中保持一致,如果你以后想要在云环境中运行服务的话,更需如此。

另外,还需要尽早考虑监控的问题,包括日志聚合。监控不仅包括服务器,还包括服务指标,如请求延迟、吞吐量和错误率,以便于跟踪服务的健康状况和可用性。要形成结构化和标准化的日志输出,如时间格式(如 ISO8601)和时区(如 UTC),并引入具有 correlation id 和日志聚合的请求上下文,这有助于问题的诊断和剖析。

很多事情需要预先处理,这非常耗时并且需要得到整个组织的关注。微服务是实现最大化产品敏捷性的投资,而不在于削减成本。

为了保持在市场上的竞争力,产品的敏捷性和持续改进是区别于竞争对手的关键因素。微服务可以提升产品的敏捷性并持续改善,但是它需要每个人的贡献,包括管理者。

关于作者

Susanne Kaiser 是来自德国汉堡的独立技术咨询师,她曾经担任过初创公司的 CTO,并将该公司的 SaaS 解决方案从单体架构迁移为微服务架构。她具有计算机科学的背景,在软件开发和软件架构方面有超过 15 年的经验,经常在国际性的技术会议上演讲。

查看英文原文:Microservices From a Startup Perspective

posted on 2019-02-20 10:30  一天不进步,就是退步  阅读(141)  评论(0编辑  收藏  举报