告别微服务:究竟是千军易得还是一将难求

2个月前

本文讲述了Segment团队如何放弃微服务,并采纳单体架构作为一种与产品和团队需求很好匹配的方法的心路历程。

本文为翻译发表,转载需要注明来自公众号EAWorld。

作者:Alexandra Noonan

译者:白小白 

原题:Goodbye Microservices: From Hundreds of Problem Children to One Superstar 

原文:https://segment.com/blog/goodbye-microservices/

全文5953字,阅读约需要15分钟

白小白:

初看这篇文章时,我实际上是有些犹豫要不要把他翻译过来的。毕竟多数人,包括我们的团队也都在采用和推崇微服务架构。这个时候谈及“告别微服务”的话题,是否有些不合时宜。然而,文章中关于从单体到微服务再到单体的架构变迁过程,我认为对很多希望从微服务中获得收益的团队是很有意义的。文章中也涉及了很现实很落地的一些关于挑战的应对以致妥协。正如此前火山哥的文章《微服务的4个设计原则和19个解决方案》中所讲的“实际上微服务也不是个万金油”,回顾一下那篇文章中的原则,再看一下本文中的实践过程,将对微服务架构的采用和取舍有更深切的认知。很多时候,不在于是否最好,而在于是否适合自己。

当然,这只是我选择这篇文章理由之一,另一个理由是,我总感觉,作为文章中所举的例子来说,似乎并非只有回归单体应用这一条路线,比如,我并未在文章中看到诸如API网关的相关描述--文中所讲的消息路由只解决了网关的一部分问题,就像是简单的NGINX代理一样--而API网关是微服务架构中非常关键的组成部分。此外,关于代码库的拆分(毕竟这是作者的团队决定回归单体结构的重要原因之一),此前叶婉婷的文章《当持续集成遇上微服务:分治优于集中》中所提到的“多个代码库多个构建的方案”是否能够具有参考意义?

所以,也希望有兴趣的读者在评论区留言讨论一下,是否就作者的情况而言,必须和微服务说 Bye bye ?


除非与世隔绝,几乎所有人都了解微服务是当下最炙手可热的技术架构之一。因应这一趋势,Segment(即作者所在的团队)曾在早期将微服务做为最佳实践,并且也确实在某些领域产生了实效,但在另一方面,正如本文将揭示的情况,微服务并非在任何情况下都普适的技术架构。

简而言之,微服务是一种面向服务的软件架构,在微服务的场景下,服务器端应用程序是通过将许多用途单一、体积小巧的网络服务相结合来构建的。人们常常将微服务带来的如下优势挂在嘴边:提高了模块化、减少了测试负担、更好的功能组合、环境隔离和开发团队自主性。作为反例的则是单体应用结构,在单体应用中,大量的功能驻留在一个服务中,该服务作为单一单元被测试、部署和扩展。

2017年初, 对微服务架构的采用,使得Segment产品的一个核心部分达到了临界点。那场景就像我们从一棵名字叫做微服务的树上掉下来,一路上撞到了每一根树枝。小团队非但没有让我们走得更快,反而让我们陷入了复杂度爆棚的泥淖。这种架构的本质优势成为了一种负担。我们的交付速度骤降,缺陷率爆表。

最终,我们不得不安排 3名全职工程师将大部分时间花在保持系统正常运行上,以至于团队没有办法取得更多有益的进展。此时,我们开始思考转变。这篇文章讲述了我们如何后退一步,并采纳了一种与我们的产品需求和团队需求很好匹配的方法。

微服务是有效的,至少曾经如此

你爱我么?

爱过。

Segment的客户数据基础设施每秒摄取数十万个事件,并将它们转发给合作伙伴API,这些API我们称之为“服务端节点”。这些节点有上百种,比如Google Analytics,Optimizely,或者一个定制的webhook。

倒退数年,当该产品最初推出时,其架构非常简单。有一个API,它摄取事件并将它们转发到分布式消息队列。事件可能是由Web或移动应用程序生成的JSON对象,其中包含有关用户及其操作的信息。像下面的样子:

{

  "type": "identify",

  "traits": {

    "name": "Alex Noonan",

    "email": "anoonan@segment.com",

    "company": "Segment",

    "title": "Software Engineer"

  },

  "userId": "97980cfea0067"

}

当从队列中转发事件时,系统会检查客户管理设定,以决定哪些目标应该接收该事件。然后,事件被一个接一个地发送到对应的API,这很有效,因为开发人员只需要将事件发送到单个端点,即Segment的API即可,而不需要面对复杂的集成场景。Segment负责处理对每个目标端点的请求。

如果其中一个请求失败了,有时我们会尝试稍后重发该事件。对于有些失败来说,重试是安全的,而另一些则不是。可重试的是那些原封不动就可以被目标节点接受的错误。例如,HTTP 500s、速率限制和超时。而像无效凭据或缺少必需字段这类错误,是确定的不可能被目标节点接受的,因此也不会重试。




此时,一个队列里挤满了涵盖所有目标节点的事件,既有新的事件,也有尝试多次的请求,从而将导致著名的“排头阻塞”。这意味着,在这种情况下,如果某个目标节点响应减慢或速度下降,重试将淹没队列,导致跨越所有目标节点的延迟。

想象一下,目标节点X临时出了点问题,导致每个请求都产生超时错误。这不仅会造成大量尚未到达目标节点X的请求积压,而且每个失败的事件都会被放回队列中进行重试。虽然我们的系统会自动伸缩以响应增加的负载,但队列深度的突然增加将超过我们的扩展能力,从而导致对最新事件的延迟。因为目标节点X有临时的问题,导致所有目标节点的交付时间都会增加。客户指望着系统的及时交付,因此我们无法承受在分发渠道上的任何等待时间的增加。



为了解决排头阻塞问题,我们的团队为每个目标创建了一个单独的服务和队列。这个新架构设置了一个额外的路由进程来接收入站事件并将事件副本分发给每个选定的目标。如果某一目标遇到问题,只有它的队列会重试,其他目标则不会受到影响。这种微服务风格的体系结构将目标彼此隔离开来,当一个目标经常遇到问题时,这一点至关重要。



关于单独的代码库


在上述的微服务场景下,由于每个目标API使用不同的请求格式,需要自定义代码来翻译事件以匹配此格式。举个简单的例子,目标节点X需要在消息中以traits.dob的形式发送生日,而我们的API能接受的格式是 traits.birthday 。那么节点X中的转换代码看起来就是像下面这个样子:

const traits = {}

traits.dob = segmentEvent.birthday

有许多晚近的目标节点采用了Segment的请求格式,使得一些转换相对简单。但是,有些转换可能非常复杂,这取决于目标API的结构。例如,对于一些早期的接口杂乱无章的目标节点来说,我们将不得不在手工构建的XML消息中插入参数来完成使命。

最初,当目标被划分为不同的服务时,所有代码都放在一个代码库中。在这种情况下,最让人抓狂的就是,针对某一节点的测试失败将导致针对所有节点的失败。如果想要部署一个变更,我们还得花时间来修复此前失败的测试,而对这个测试的修复完全与我们要做的变更无关。针对这个问题,我们决定将每个节点的代码放在各自单独的代码库中。所有节点都已划分为单独的微服务,这样的设置也是顺理成章的。

拆分成单独的代码库使我们能够轻松地隔离目标测试。这种隔离允许开发团队在维护目标节点时快速交付。

微服务和代码库的扩容

随着时间的推移,我们增加了50多个新的目标节点,这意味着50个新的代码库。为了减轻开发和维护这些代码库的负担,我们创建了共享库,以使跨目标的通用转换和功能(如HTTP请求处理)更容易、更统一。

例如,如果我们希望从事件中获得用户的名称,则在任何节点代码中都可以调用 event.name() 。共享库将从事件中检索属性值 name 和 Name ,如果这些名称不存在,则会检查姓名里“名”的部分,如属性值 firstName、first_Name和FirstName。然后对“姓”同样操作,检查大小写,并将两者结合起来形成全名。

Identify.prototype.name = function() {

  var name = this.proxy('traits.name');

  if (typeof name === 'string') {

    return trim(name)

  }

  
  var firstName = this.firstName();

  var lastName = this.lastName();

  if (firstName && lastName) {

    return trim(firstName + ' ' + lastName)

  }

}

共享库使得新目标节点的构建快速进行。一组统一的共享功能所带来的熟悉性使维护变得不那么令人头痛。

然而,一个新的问题开始出现。测试和部署对这些共享库的变更同样会影响到我们的所有目标节点。这需要大量的时间和精力来进行维护。对共享库作出的变更,将导致我们必须测试和部署几十个服务,这里存在一定的风险。在时间紧迫的情况下,工程师只会将这些库的较新版本引入在目标节点的codebase里。

随着时间的推移,这些共享库的版本在不同的目标codebase之间开始出现差异。在目标代码库之间减少定制化所获得的巨大好处开始逆转。因为最终,所有目标都使用了这些共享库的不同版本。我们本可以构建一些工具来自动滚动更新,但此时,不仅开发人员的生产力受到了影响,而且我们开始遇到微服务体系结构中的其他问题。

比如,每个服务都有一个不同的负载模式。有些服务每天只处理几个事件,而另一些服务则每秒处理数千个事件。对于处理少量事件的目标,每当负载意外激增时,运维团队就必须手动扩展服务以满足需求。

虽然我们已经实现了自动伸缩,但每个服务都有一个所需的CPU和内存资源的配置组合,这使得自动伸缩的调优与其实说是一门科学更不如说是一门艺术。

目标节点的数量继续快速增长,基本上平均每月增加3个节点,这意味着更多的代码库、更多的队列和更多的服务。在我们的微服务体系结构中,我们的运维开销随每个添加的目标节点线性增加。因此,我们决定后退一步,重新考虑整个分发渠道的架构。

丢弃微服务和队列

首要工作是将现在超过140个服务合并成一个服务。管理所有这些服务的开销对我们的团队来说是一个巨大的负担。随时待命的工程师通常会被传呼来处理负载高峰,这让我们夜不能寐。

然而,此时的架构将面临单一服务的挑战。当每个目标都有一个单独的队列时,需要设置专门的Worker进程检查队列的工作状态,这增加了节点服务的复杂性,让人感觉很烦。这是我们采用Centrifuge的初衷。Centrifuge将取代我们所有的单独队列,并负责将事件发送到唯一的单体服务。



转向Monorepo

考虑到我们现在只有一个服务,将所有目标代码移动到一个代码库中是有意义的,当然,这也意味着将所有不同的依赖项和测试合并到一个代码库中。这会很乱。

对于120个单独的依赖项中的每一个,我们都努力为所有目标提供一个统一版本。当迁移目标的过程中,我们会检查它使用的依赖项,并将它们更新为最新版本。我们修复了目标节点中的任何与新版本不同的地方。

通过这种转换,我们不再需要跟踪依赖之间的版本差异。我们所有的目标都使用相同的版本,这大大降低了整个代码库的复杂性。现在,维护目标节点变得更省时,风险也更小。

我们还需要一个测试套件,使我们能够快速、轻松地运行所有的目标测试。上文我们也讲过,在对共享库的更新过程中,测试是主要的麻烦之一。

幸运的是,目标节点的测试都有相似的结构。这些结构中包含了基本的单元测试,以验证我们的自定义转换逻辑是正确的,并将对合作伙伴的端点执行HTTP请求,以验证事件是否如预期的那样出现在目标节点中。

回想一下,将每个节点的codebase分离为单独代码库的最初动机是隔离测试失败。然而,事实证明,隔离带来的优势是一种假象。发出HTTP请求的测试仍然以某种频率失败。由于目的地被分割成自己的代码库,所以没有人有动力去清理失败的测试。这种不良的卫生状况导致了技术债务的不断恶化。通常情况下,一个本应只需一两个小时的小变更 最终需要几天到一周的时间才能完成。

构建弹性测试套件

在测试运行期间,对目标端点的出站HTTP请求是测试失败的主要原因。而有一些不相关的问题,如过期的凭据,本不应导致测试的失败。从经验数据还可以知道,一些目标端点比其他端点慢得多,有些目标甚至要花费5分钟进行测试。这样,对于超过140个目标,我们的测试套件可能需要一个小时的运行时间。

为了解决这两个问题,我们创建了流量记录仪。流量记录仪建立在yakbak之上,负责记录和保存目标节点的测试流量。每当第一次运行测试时,任何请求及其相应的响应都会记录到文件中。在随后的测试运行中,将回放文件中的请求和响应,而不是请求目标的端点。这些文件被签入代码库,以便测试在每次变更中都是一致的。现在测试套件不再依赖于网络层的HTTP请求,测试变得更有弹性,这是迁移到单个代码库的必备条件。

我记得在集成了流量记录仪之后,我第一次为每个目标运行测试。完成所有140多个目标的测试只花了几毫秒的时间。而在过去,对于一个目标可能就需要几分钟才能完成。这感觉就像做梦一样。

为什么单体架构有效了?

一旦所有目标的代码驻留在一个代码库中,它们就可以合并到一个服务中。因为所有目标节点都在一个统一的服务中进行维护,我们的开发人员生产力大大提高了。我们不再需要部署140多个服务来更改一个共享库。工程师可以在几分钟内部署服务。

改进的速度可以证明这一点。2016年,当我们仍采用微服务架构的时候,我们对我们的共享库进行了32次改进。就在今年,我们进行了46次改进。在过去的6个月里,我们的共享库比2016年全年做了更多的改进。

这一变化也有益于我们的运维情况。由于每个目标都集成在一个统一服务中,我们得以创建一个更好的配置组合来应对CPU和内存密集型目标节点的需要,这使得按需扩展变得非常容易。大型资源池可以吸收负载中的峰值,我们再也不需要为处理少量负载的激增而疲于奔命。

缺点

从微服务体系结构向单一体系结构的转变从整体上来说是巨大的改进,然而,也产生了一些不足:

1.故障隔离变得困难了。如果某个目标中引入了一个bug,导致该服务崩溃,则该服务将对所有目标节点崩溃(一般我们称之为单点失败)。我们具备全面的自动化测试,但也仅此而已。我们目前正在研究一种更有力的方法,在保持目标节点单体式架构的前提下预防单点失败。

2.内存中的缓存效率较低。以前,每个节点有一个单独的服务,一些低流量节点只有少数几个进程,这意味着内存缓存将得以充分利用。现在,缓存在3000多个进程中分散得很细,因此被命中的可能性要小得多。是可以用Redis这样的方法来解决这个问题,但这样一来又将面临另一个需要考虑伸缩的方面。最后,我们选择接受了这种效率的损失,毕竟这将带来巨大的运维效益。

3.更新依赖项的版本可能会破坏多个目标节点。虽然统一的代码库解决了以前我们所遇到的依赖关系混乱的问题,但这也意味着如果我们想对某目标使用库的最新版本,就需要更新其他不相关的目标节点来适配这一新的版本。然而,在我们看来,现有方案简单性就值回了票价。而且使用我们的全面自动化测试套件,我们可以快速地发现新的依赖版本的究竟在何处不兼容目标节点。

结语

我们最初的微服务架构在一段时间是有效的,并且通过将目标的彼此隔离来解决分发渠道中的即时性能问题。然而,那时我们系统尚没有形成规模。当需要大量更新时,我们缺乏测试和部署微服务的适当工具。结果,我们的开发人员生产力迅速下降。

转向单体应用架构可以使我们摆脱运维问题,同时显著地提高了开发人员的生产力。当然,这一转变并不容易,如果想让这一架构持续起作用,还有很多事情需要考虑:

1.我们需要一个坚不可摧的测试套件来应对一个统一的代码库。如果没有这一点,我们的处境就会与我们当初决定拆散它们时的情况一样。过去,不断失败的测试损害了我们的生产力,我们不希望这种情况再次发生。

2.我们接受了单体架构固有的缺陷,并确保对这些缺陷良好应对。我们也必须适应这种变化所带来的一些牺牲。

在决定采用微服务架构还是单体架构时,有不同的考虑因素。在我们基础设施的某些部分,微服务运行良好,但我们的服务端节点的样例则很好的说明了这种流行趋势实际上会损害生产力和性能。最终结果是,我们选择了单体架构。

Stephen Mathieson, Rick Branson, Achille Roussel, Tom Holmes和更多人促成了向单体应用的转变。特别感谢Rick Branson帮助审查和编辑这篇文章的每一个阶段。

关于作者:Alexandra Noonan,Software Engineer, Segment

关于EAWorld:微服务,DevOps,数据治理,移动架构原创技术分享,长按二维码关注

COMMENTS

需要 后方可回复
如果没有账号可以 一个帐号。