再见微服务:从100个有问题的子集群到一个超级单机服务

再见微服务:从100个有问题的子集群到一个超级单机服务

原文链接:https://segment.com/blog/goodbye-microservices/
磨蹭了接近一个月,终于读完了按理来说不超过10分钟阅读量的一篇普通热门hacker news,感叹自己英语能力不够用以及拖延症的可怕之时,翻译一下这篇文章,聊以慰藉,不对的地方欢迎指正探讨

除非你生活在地下,不然你应该知道微服务已经是日常生活的基石了。随着这一趋势的到来,Segment很早就已经将这个作为最佳实践,这在某些情况下对我们很有帮助,正如你很快就会了解到的那样,在其他方面也有不如人意的地方。

简而言之,微服务是一种面向服务的软件体系结构,其中服务器端应用程序是通过组合许多单用途,低占用空间的网络服务而构成的。其宣传的好处是减少了模块耦合,减轻了测试负担,更便捷的功能组合,隔离性以及更高的团队自主性。与之相反的是由数量庞大的被统一测试,部署,和扩展的单一服务构成的整体结构。

在2017年早些时候,我们Segment的产品核心部分来到了一个转折点。看起来像是我们从微服务这个大树上摔了下来,途中撞到了每个树枝。这支小团队发现他们不但没能走的更快,反而深陷于爆炸式的复杂性之中。这个架构的便利性开始成为了一种负担。我们的(开发)速度直线下降,缺陷率却快爆炸了。

最终,这支团队发现在花掉3名全职工程师大部分时间的情况下也无法在维持系统运行上取得进展。必须要做出一些改变了,这篇文章讲述了我们是如何后退一步,采用了一种与我们的产品需求和团队需求非常一致的方法的。

微服务工作原理

Segment的基础客户数据每秒接收数十万个事件,并将它们转发给合作伙伴的API,我们称之为服务器端终端。有上百种这样的服务终端,比如谷歌分析,Optimizely,或者定制的网络钩子

数年前,当产品刚开始启动的时候,架构是很简单的。有一个负责接收事件并转发到分布式消息队列的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 500错误,流量限制,以及超时问题。不可重试的错误指的是我们确定终端永远不会接收的请求。举例来说,比如身份认证失败和参数缺失的请求。

从这点上来说,一个单个的队列既包含了最新的事件也包含了在所有终端都重试过多次的请求,这导致了head-of-line blocking。也就是说,在这种特殊情况下,如果一个终端速度变慢或挂掉,重试将会阻塞队列,导致我们所有终端的延迟。

想象一下,节点X正遭遇临时问题导致每个请求都发生超时错误。现在,这不仅会造成大量尚未到达节点X的请求积压,而且每个失败的事件都会被放回队列中重试。虽然我们的系统会随着负载的增加而自动扩展,但是队列深度的陡增会超过我们的扩展能力,导致最新事件的延迟。由于节点X的短暂失效,所有终端的交付时间都会延迟。客户依赖于这种交付的及时性,因此我们不允许在我们pipeline中任一环节的时延。

为了解决head-of-line blocking问题,这个团队为每个目的终端创建了单独的服务和队列。这种新的体系结构包括一个额外的路由器进程,该进程接收入站事件并将该事件的副本分发到每个选定的终端。现在,如果某个节点遇到了问题,则只有它的队列会堵塞,别的节点则不受影响。这种微服务风格的体系结构将节点彼此隔离,这对于某个经常会出现问题的节点来说至关重要。

一个独立仓库的例子

每个接收方的API都使用不同的请求格式,这要求自定义代码来转换事件去匹配此格式。一个基本的例子就是接收方X需要将“traits.dob”作为生日放在请求体中,而我们的API则是将“traits.birthday”作为生日。接收方X那的转换代码类似下面这样:

const traits = {}
traits.dob = segmentEvent.birthday

许多现代的接收方采用了分段式的请求格式,这使得一些转换变得相对简单。与此同时,这种转换也会变得相当复杂,这取决于接收方的API。例如,对于最庞大而又古老的接收方,我们需要往手工编写的XML载荷中塞值。

最初,当接收方被分成不同的服务时,所有的代码都存在一个仓库中。令人沮丧的是一次失败的测试会导致所有代码的失败。当我们想要部署一次变更时,我们必须花时间修复失败测试,即使这些变更与最初的变更无关。所有的接收方已经形成了自己的服务,所以这种转变是自然的。

独立的仓库使得我们隔离测试用例变得容易;这种隔离使得开发团队在维护时更高效。

扩展微服务和代码仓库

随着时间的推移,我们增加了50个接收方,这意味着需要新增50个代码仓库。为了减轻开发和维护这些代码库的负担,我们创建了共享库,使我们不同接收方之间的通用转换和功能(如HTTP请求处理)更加容易和统一。

举例来说,如果我们想知道用户的姓名,我们可以在客户端的代码中调用event.name()这个方法。共享的库会去检查键值 nameName。如果这些不存在,它会去检查”名字”,诸如属性值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)
  }
}

共享的库代码使得新的开发变得迅速。统一的共享功能所带来的熟悉感使得维护不那么令人头痛。

然而,一个新的问题开始出现。测试和部署这些共享库的变更将会影响所有使用方。维护它将会耗费大量的时间精力。在需要测试和部署很多服务的时候去更新代码提升共享库的性能将会是一个冒险的提议。当时间紧迫时,工程师只会将这些库的更新版本包含在单个使用方的代码库中。

随着时间的推移,这些共享库的版本开始在不同使用方的代码库中出现差异。我们当初减少不同使用方的定制代码而带来的便利性正在消逝。最终我们都用着不同版本的共享库代码。我们本来可以构建工具来自适应这些变化,但同时,不仅开发人员的效率开始受到影响,我们还遭受了其他的一些微服务架构方面的问题。

另外一个问题就是每个服务都有不同的加载模式。一些服务每天处理几个请求,而另一些服务每秒处理几千个事件。对于处理少量请求的使用方,每当负载意外激增时,运营商必须手动扩展服务以满足需求。

尽管我们已经实现了自动扩展,但是每个服务对CPU和内存资源的要求都不同,这就使得配置自动扩展比科学更加艺术。

使用方的数量持续激增,团队平均每个月新增3个使用方,这意味着需要更多的代码仓库,更多的消息队列以及更多的服务。每增加一个新的使用方,我们团队的微服务体系架构,运营开销都会线性的增加。因此,我们决定后退一步,重新考虑整个事宜。

放弃微服务和消息队列

清单中要做的第一件事就是目前超过140个微服务整合成一个微服务。管理这些服务的巨大开销成了我们团队的一个负担。因为对于我们工程师来说在高峰期需要一直待命处理问题已经成了家常便饭,因此我们的睡眠正越发不足。

然而,当时的架构使得迁移到单一服务变得具有挑战性。由于每个调用方都使用了不同的队列,我们需要检查每个队列的工作情况,这会给那些我们不满意的调用方增加一层复杂性。这是Centrifuge系统的主要灵感来源。Centrifuge系统会取代所有的队列,负责将消息发送到单一的整体服务。

转向Monorepo

假定只有一项服务,那不如将所有代码移动到一个代码库中,这意味着我们需要合并不同的依赖项和测试用例到同一个代码库。我们知道这将会变得一团糟。

我们承诺给所用调用方使用的超过120个依赖项使用的都是同一个版本。

由于这种转变,我们不再需要跟踪不同依赖库版本之间的差异。所有调用方都使用同一个版本,这显著降低了代码库的复杂性。维护调用方代码现在也变得更加省时,风险也更小。

我们希望有一套测试用例允许我们迅速而又方便的在所有调用方上执行测试。运行所有测试是更新我们前面讨论的共享库时的主要障碍之一。

幸运的是,单元测试用例都有类似的结构。他们都有基本的单元测试来验证我们的自定义的转换逻辑是否正确,并且会向合作方的服务器发送http请求来验证代码是否如预期执行。

回想一下最初将每个目标工程的代码库分开的初衷就是隔离失败的测试用例带来的影响。但是事实表明这是一个虚假的优势。HTTP请求测试仍然时不时的失败。随着不同工程的代码分布在不同的代码库中,清理失败测试的动力也变弱。这种糟糕的状况是源源不断的技术债的来源。往往只需要几个小时工作量的变更会以几天的工作量而告终。

构建弹性测试用例

发往接收方的HTTP请求是导致测试失败的主要原因。诸如不相关的证书过期之类的问题不应导致测试失败。我们从经验中得知有些节点会比其他的慢得多。有些工程的测试需要5分钟才能执行完。这样我们就要花上1个小时才能跑完整个测试流程。

为了解决这两个问题,我们建立了流量监控系统。流量监控系统是建立在yakbak之上的,负责记录和存储目标工程的测试流量。不管测试何时执行,任何请求和响应都会被记录到一个文件上。在随后的测试中,文件中记录的请求和响应被重放,而不是重新向目标 发送http请求。这些文件被检录到代码仓库中,以便这些测试在每次变化中都能保持一致。既然我们的测试用例不在依赖于互联网的这些HTTP请求,我们的测试变得明显更富弹性,而这正是迁移到单一仓库的必备条件。

我记得我们在集成了流量监控系统后第一次在项目中执行测试的情况。在所有工程中执行这些测试只花了几毫秒。而在过去,单单在一个工程中执行完这些测试就要花上几分钟。我感到神奇。

为何一个庞然大物会有效

一旦所有工程的代码都在一个代码库中,他们就可以被合并到一个单一的服务中。由于所有工程都在同一个服务中,我们的开发效率大幅提升。我们不用为了更改一出共享的代码而部署超过140个服务。一个工程师也只要花几分钟就能部署这项服务。

证据就是开发速度加快了。在2016年,当我们的微服务架构依然存在时,我们对共享库进行过32次提升。单单今年,我们就完成了46次升级。我们在6个月中进行的升级次数就超过了2016年整年。

这一改变也有利于我们的运营。由于所有的项目都在同一个服务中,我们很好的混合了CPU和内存密集型的项目,这使得满足要求的弹性伸缩变得容易。巨大的工作池可以吸收大量的请求,因此我们不用在花费精力在只有少数负载的项目上。

巨大的挑战

将我们的微服务架构转变成单一集群服务是一次巨大的提升,同时也存在着巨大的挑战。

  1. 故障隔离变得困难, 由于所有的东西都是一个整体,由于一个项目的bug导致的该服务崩溃将会导致所有的项目崩溃。我们有完整自动化测试,但测试也仅仅只能做到发现问题。我们正在研究一种更稳健的方式来防止这种因为一个项目挂掉导致所有工程挂掉的问题,同时让所有项目都成为一个整体。

  2. 缓存的效率较低, 在以前,由于一个工程就是一个服务,一些低流量工程只有少数进程,这意味着他们的缓存区域可以一直保持高效。但现在缓存被超过3000个进程共享,这使得他们就很难命中缓存。我们可以用Redis来解决这个问题,但是这是我们必须考虑的另一个扩展点了。最后,考虑到运营带来的巨大好处,我们接受了这种效率损失。

  3. 依赖项的版本更新将会破坏数个工程。
    虽然将所有工程都放到同一个代码库中解决了我们之前的依赖项混乱的问题,这也意味着如果我们想要使用最新版本的库,我们必须要更新其他所有工程。在我们看来,这个方法的便利性值得这样的牺牲。有了我们的全面的自动化测试用例。我们可以很快的看到更新的依赖版本有什么不同。

结论

我们最初的微服务体系结构运行过一段时间,也确实通过隔离应用解决了当时迫在眉睫的管道性能问题。然而,我们并未配置应用的伸缩服务。 而需要批量更新时,我们缺乏了适当的工具用于测试和部署微服务。因此,我们开发人员的生产率迅速下降。 众多微服务迁移到一个整体后使我们摆脱了业务上的一些问题,同时极大地提高开发人员的生产率。迁移过程实属不易,还要考虑有些工作做了是否真的有效。

  1. 为了把代码都放到一个仓库里,我们需要十分稳定的测试用例来支持。 没有这一点,很难在相同业务情况下,把这些逻辑理清楚。不断失败的测试,严重降低的生产力,我们不想再发生那样的情况了。

  2. 为了维持一个整体的体系结构中固有的平衡,确保我们每一件事都很完美,在这个变迁过程中,我们不得不接受一些折中的方案。

在决定使用微服务还是一个整体服务时,每个不同的因素都要考虑。 在我们的基础设施的某些部分,微服务工作完美,但我们的服务器端的应用是一个典型的反面例子:在这种微服务流行的趋势下,它正在降低的工作效率和服务性能。 事实证明,我们的解决方案是选择一个整体服务。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据