Segment 放弃了微服务

除非你一直生活在石器时代,不然你可能已经知道微服务是当前流行的架构。随着这一趋势的发展,Segment 在早期就将其做为一种最佳实践,在某些状况下,这对咱们颇有帮助,但你很快就会了解到,在其余状况下效果并很差。web

简单来讲,微服务是一种面向服务的软件架构,在这种架构中,服务器端应用程序是经过组合许多单用途、低空间占用的网络服务来构建的。人们极力宣扬的好处是改进模块化、减小测试负担、更好的功能组合、环境隔离和开发团队自治。与之相反的是单体架构,大量的功能存在于一个服务中,该服务做为一个单元进行测试、部署和扩展。缓存

2017 年初,Segment 的一个核心 产品 达到了一个临界点。这就像咱们从微服务的树上掉下来,并在下落的过程当中砸到每根树枝同样。小团队没有让咱们更快地前进,相反,咱们发现本身陷入了复杂性爆炸的泥潭。这种架构的基本好处变成了负担。咱们的速度急剧降低,咱们的缺陷率却呈现爆炸式增加。安全

团队最终发现,他们没法取得进展,3 名全职工程师为了维持系统的运行花费了大部分的时间。有些事情必须改变。这篇文章讲述的是咱们如何后退一步,采用一种能够很好地知足咱们的产品需求和团队需求的方法。服务器

为何微服务曾经有效?网络

Segment 的客户数据基础设施每秒接收数十万个事件,并将它们转发给合做伙伴 API,咱们称之为服务器端目标。这些目标有 100 多种类型,好比谷歌 Analytics、Optimizely 或自定义 webhook。架构

几年前,当产品最初发布时,架构很简单。有一个 API 接收事件并将其转发到分布式消息队列。在本例中,事件是 Web 或移动应用程序生成的 JSON 对象,其中包含关于用户及其操做的信息。下面是一个有效载荷示例:app











{  "type": "identify",  "traits": {    "name": "Alex Noonan",    "email": "anoonan@segment.com",    "company": "Segment",    "title": "Software Engineer"  },  "userId": "97980cfea0067"}

当从队列中消费事件时,将检查客户管理设置,以肯定哪些目标应该接收事件。而后将事件一个接一个地发送到每一个目标 API,这很是有用,由于开发人员只须要将事件发送到单个端点,即 Segment 的 API,而不须要构建几十个可能的集成。Segment 向每一个目标端点发出请求。运维

若是对一个目标的某个请求失败,有时咱们将尝试稍后再次发送该事件。有些失败能够安全地重试,而有些则不能。可重试错误是指目标可能在不作任何更改的状况下接受的错误。例如,HTTP 500、速率限制和超时。不可重试错误是咱们能够肯定目标永远不会接受的请求。例如,具备无效凭据或缺乏必需字段的请求。分布式

此时,一个队列既包含了最新的事件,也包含全部目标的那些可能已经屡次重试的事件,这些事件致使了 队头阻塞。在这种特殊状况下,若是一个目标变慢或宕机,重试将淹没队列,致使全部目标延迟。ide

假设目标 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。若是不存在,它将检查名字,检查属性 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 和内存资源,这使得自动伸缩配置调优更像是艺术而不是科学。

目标的数量继续快速增加,团队平均每个月增长三个目标,这意味着更多的代码库、更多的队列和更多的服务。在咱们的微服务架构中,咱们的运维开销随着目标的增长而线性增长。所以,咱们决定后退一步,从新考虑整个管道。

抛弃微服务和队列

清单上的第一项是将现有的 140 多个服务合并为一个服务。管理全部这些服务的开销对咱们的团队来讲是一个巨大的负担。咱们几乎为此失眠,由于咱们这些随叫随到的工程师要常常处理负载峰值。

然而,当时的架构使迁移到单个服务变得颇具挑战性。因为每一个目标都有一个单独的队列,每一个工做进程必须检查每一个队列是否有工做,这将给目标服务增长一层复杂性,而咱们对这种复杂性感到不适。这是 Centrifuge 的主要灵感来源。Centrifuge 将替换全部单独的队列,并负责将事件发送到一个单体服务。

图片

迁移到单一代码库

假设只有一个服务,那么将全部目标的代码移动到一个代码库中很容易理解,这意味着将全部不一样的依赖关系和测试合并到一个代码库中。咱们知道这会很乱。

对于 120 个独一无二的依赖项中的每个,咱们承诺为全部目标提供一个版本。当咱们迁移目标时,咱们会检查它使用的依赖项,并将它们更新到最新版本。咱们修复了与新版本发生冲突的全部目标。

经过此次迁移,咱们再也不须要跟踪依赖项版本之间的差别。咱们全部的目标都使用相同的版本,这大大下降了整个代码库的复杂性。如今,目标维护变得更省时、风险更小。

咱们还须要一个测试套件,它容许咱们快速、轻松地运行全部的目标测试。在更新咱们前面讨论的共享库时,运行全部测试是主要的障碍之一。

幸运的是,目标测试都具备相似的结构。它们有基本的单元测试来验证咱们的自定义转换逻辑是否正确,并将执行到合做伙伴端点的 HTTP 请求来验证事件是否如预期的那样出如今目标中。

回想一下,将每一个目标代码库划分到它本身的代码库中,最初的动机是为了隔离测试失败。然而,事实证实这是一个不成立的优点。发出 HTTP 请求的测试仍然以必定的频率失败。因为目标被划分到它们本身的代码库中,因此咱们几乎没有动力去清理失败的测试。这种不良习惯致使了源源不断的技术债务。一般,一个本来只须要一两个小时就能完成的小变动,最终可能须要几天到一周的时间才能完成。

构建一个有弹性的测试套件

测试运行期间对目标端点的出站 HTTP 请求是测试失败的主要缘由。不相关的问题,好比凭证过时,不该该致使测试失败。根据经验,咱们还知道,有些目标端点比其余端点慢不少。有些目标运行测试的时间长达 5 分钟。咱们有超过 140 个目标,咱们的测试套件可能须要一个小时来运行。

为了解决这两个问题,咱们建立了 Traffic Recorder。它基于 yakbak 构建,负责记录和保存目标的测试流量。每当测试第一次运行时,任何请求及其相应的响应都会被记录到一个文件中。随后的测试将回放文件中的请求和响应,而不是向目标端点发送请求。这些文件被检入代码库中,以保证每次变动时测试都是一致的。如今,测试套件再也不依赖于互联网上的这些 HTTP 请求,咱们的测试变得更有弹性,这是迁移到单个代码库的必要条件。

我还记得,在咱们集成了 Traffic Recorder 以后,第一次针对每一个目标运行测试。完成针对全部 140 多个目标的测试须要几毫秒的时间。在过去,一个目标可能就须要几分钟才能完成。感受就像魔法同样。

为何单体架构有效?

一旦全部目标的代码都存在于一个代码库中,就能够将它们合并到一个服务中。因为每一个目标都存在于一个服务中,咱们的开发人员的工做效率获得了显著提高。咱们再也不须要由于变动一个共享库而部署 140 多个服务。一个工程师能够在几分钟内完成服务部署。

证据就在于改进的速度。2016 年,当咱们还在使用微服务架构时,咱们对共享库进行了 32 次变动。而今年,咱们已经作了 46 项改进。过去 6 个月,咱们对库的改进比 2016 年整年都要多。

这种变化也使咱们的运营从中受益。因为每一个目标都存在于一个服务中,咱们很好地组合了 CPU 密集型和内存密集型目标,这使得扩展服务以知足需求变得很是容易。大型工做池能够承受负载峰值,所以,咱们再也不为处理少许负载的目标分页。

妥   协

从微服务架构到总体的单体架构是一个巨大的改进,可是,也有一些妥协:

  1. 故障隔离很困难。因为全部内容都在一个总体中运行,若是在一个目标引入了致使服务崩溃的 Bug,那么全部目标服务都会崩溃。咱们有全面的自动化测试,但测试有其局限性。咱们目前正在研究一种更加健壮的方法,以防止一个目标使整个服务宕掉,同时又保持全部目标都在一个单体中。

  2. 内存缓存的效率较低。之前,每一个目标一个服务,咱们的低流量目标只有少数几个进程,这意味着它们的控制平面数据的内存缓存将保持热状态。如今,缓存被分散到 3000 多个进程中,因此它命中的可能性要小得多。咱们能够用像 Redis 这样的东西来解决整个问题,但这是另外一个咱们须要考虑的扩展点。最后,咱们接受了这种效率的损失,由于它带来了巨大的运营效益。

  3. 更新依赖项的版本可能会破坏多个目标。虽然将全部内容都迁移到一个代码库中解决了以前的依赖关系混乱问题,但这意味着若是咱们想要使用库的最新版本,咱们可能必须更新其余目标。然而,在咱们看来,这种方法的简单性是值得作出这种妥协的。经过全面的自动化测试套件,咱们能够很快地看到更新的依赖项版本带来了什么破坏。

小   结

咱们最初的微服务架构在一段时间内是有效的,经过将目标彼此隔离来解决管道中的即时性能问题。然而,咱们并无作好扩展准备。当须要大量更新时,咱们缺少测试和部署微服务的适当工具。结果,咱们的开发人员的生产效率迅速降低。

迁移到一个单体架构中,能够在显著提升开发人员生产力的同时,消除运维问题。不过,咱们并无轻率地实施此次迁移。咱们知道,若是要成功,有些事情是必须考虑的。

  1. 咱们须要一个健壮的测试套件,把全部的东西都放在一个代码库中。若是没有这个,咱们就会和当初决定把它们分开时同样。在过去,不断失败的测试损害了咱们的生产力,咱们不但愿这种状况再次发生。

  2. 咱们接受了单体架构中须要作出的妥协,并确保每一个方面都有一个好的故事。咱们必须适应这种变化带来的一些牺牲。

在决定采用微服务仍是单体服务时,须要考虑不一样的因素。在咱们的基础设施的某些部分,微服务工做得很好,可是咱们的服务器端目标是这种流行趋势如何实际损害生产力和性能的一个完美示例。原来,咱们的解决方案是一个单体架构。

Stephen Mathieson、Rick Branson、Achille Roussel、Tom Holmes 等人促成了向单体架构的转变。

相关文章
相关标签/搜索