永别了,微服务架构!
0 前言
除非你一直生活在岩石下面,否则你可能已经知道微服务是当今流行的架构趋势。与这一趋势一同成长,Segment早期就采用了这种最佳实践,这在某些情况下对我们很有帮助,但正如你将很快了解到的,在其他情况下则并非如此。
简单来说,微服务是一种面向服务的软件架构,其中服务器端应用程序通过组合许多单一用途、低开销的网络服务构建。微服务的好处包括:
- 提高模块化
- 减少测试负担
- 改进功能组合
- 环境隔离
- 开发团队自治
与之相对的是单体架构,在单体架构中,大量功能存在于一个服务中,该服务作为一个单元进行测试、部署和扩展。
2017年初,我们的核心产品之一达到了一个临界点。看起来我们从微服务树上掉下来,碰到了每一个分支。小团队不仅没能更快推进工作,反而陷入不断增加的复杂性。微服务架构本质优势变成负担。开发速度急剧下降,缺陷率迅速上升。
最终,团队发现自己无法取得进展,三名全职工程师大部分时间都在维持系统正常运行。须做出改变。本文讲述了我们如何退一步,采用一种更符合我们产品需求和团队需求的方法。
1 微服务的优势
Segment的客户数据基础设施每秒处理数十万个事件并将其转发到合作伙伴的API,称之为服务器端目的地。有超过一百种这样的目的地,如Google Analytics、Optimizely或自定义webhook。
几年前,当产品首次推出时,架构非常简单。有一个API接收事件并将其转发到一个分布式MQ。事件在这里指由Web或APP生成的JSON对象,包含有关用户及其行为的信息。
1.1 一个示例负载
当事件从队列中被消费时,会检查客户管理的设置以决定哪些目的地应接收该事件。然后,事件依次发送到每个目的地的API,这对开发人员很有用,因为他们只需将事件发送到一个端点,即Segment的API,而无需构建多个集成。Segment负责向每个目的地端点发出请求。
若对某个目的地的请求失败,有时会尝试在稍后时间再次发送该事件。有些失败可重试,而有些不行:
- 可重试的错误是那些可能被目的地接受的错误,如HTTP 500、速率限制和超时
- 不可重试的错误是那些我们确定目的地永远不会接受的请求,如无效凭据或缺少必填字段的请求
此时,一个队列包含最新的事件及那些可能已重试多次的事件,涉及所有目的地,导致队首阻塞。即若一个目的地变慢或宕机,重试请求会充斥队列,导致所有目的地的延迟。
假设目的地X出现临时问题,每个请求都会超时。这不仅会创建一个大的请求积压,还会导致每个失败的事件在队列中重试。虽然我们的系统会自动扩展以应对增加的负载,但队列深度的突然增加会超出扩展能力,导致最新事件的延迟。所有目的地的交付时间都增加,因为目的地X出现短暂故障。客户依赖于交付的及时性,所以我们不能在管道的任何地方容忍等待时间的增加。
为解决队首阻塞,团队为每个目的地创建一个单独的服务和队列。这种新架构包括一个额外的路由进程,该进程接收入站事件并将事件的副本分发到每个选定的目的地。
现在,若一个目的地异常,只有其队列会积压,其他目的地不影响。这样的微服务架构将各目的地隔离,这在一个目的地频繁出现问题时尤其重要。
2 独立代码库的理由
每个目的地API使用不同的请求格式,需要自定义代码将事件转换为匹配的格式。一个基本的例子是目的地X需要将生日发送为traits.dob
,而我们的API接受traits.birthday
。目的地X的转换代码可能如下:
许多现代目的地端点采用Segment的请求格式,使得某些转换相对简单。然而,这些转换可能非常复杂,具体取决于目的地API的结构。如一些较旧且庞大的目的地,我们需要将值插入手工制作的XML负载。
最初,当目的地被分成单独服务时,所有代码都在一个代码库。一个巨大挫折点是单个失败的测试会导致所有目的地的测试失败。当我们想部署一个更改时,即使这些更改与初始更改无关,也须花时间修复失败的测试。为此,决定将每个目的地的代码拆分到各自代码库。所有目的地已分离成各自的服务,所以这一转变很自然。
拆分成独立的代码库使我们能够轻松地隔离目的地的测试套件。这种隔离使开发团队在维护目的地时能够快速行动。
3 扩展微服务和代码库
随时间推移,新增50多个新目的地,意味着50多个新代码库。为减轻开发和维护这些代码库的负担,创建共享库,以便在所有目的地之间简化常见的转换和功能,如HTTP请求处理。
如若想从事件获取用户的名字,可在任何目的地的代码中调用event.name()
。共享库会检查事件中的属性键name
和Name
。若这些不存在,它会检查firstName
、first_name
和FirstName
。对于姓氏,它会以相同方式检查这些属性,并将两个名字组合形成全名。
共享库使构建新目的地变得快捷。统一的一组共享功能带来的熟悉感使维护变得不再头疼。
3.1 新问题开始出现
测试和部署这些共享库的更改影响了我们所有的目的地。维护这些代码库需要大量精力。改进时,需要测试和部署几十个服务,这是高风险任务。时间紧迫时,工程师们只会在单个目的地的代码库中包含更新版本的共享库。
随时间推移,这些共享库的版本在不同目的地的代码库之间开始分化。我们曾在减少定制化方面的巨大优势开始逆转。最终,所有目的地都在使用不同版本的共享库。本可构建工具来自动化部署更改,但此时,不仅开发人员的生产力受到了影响,还遇到微服务架构其他问题。
另一个问题是
3.2 每个服务都有不同负载模式
一些服务每天处理少量事件,而其他服务每秒处理数千个事件。对于处理少量事件的目的地,每当负载出现意外峰值时,操作员必须手动扩展服务。
虽然有了自动扩展,但每个服务所需的CPU和内存资源不同,使得自动扩展配置更像是一门艺术而非科学。
随着目的地数量迅增,团队平均每月增加三个目的地,这意味着更多代码库、更多队列和更多服务。微服务架构导致操作开销与增加的目的地数量成线性增长。因此,决定退一步,重新思考整个管道。
4 放弃微服务和队列
计划列表上的第一项是将现在超过140个服务合并为一个服务。管理所有这些服务的开销对我们的团队来说是一个巨大负担。我们的工程师经常因为负载峰值被叫醒,导致无法安睡。
然而,当时的架构使得迁移到单一服务变得具有挑战性。每个目的地都有一个单独的队列和服务。这意味着我们必须重构每个队列中的消息处理逻辑。我们决定从零开始构建一个新的目的地系统。
目标是创建一个单一的、共享的队列,所有事件在这里排队,并由一个共享的目的地处理器进行处理。新架构避免了我们在微服务架构中遇到的队首阻塞和队列深度增加的问题。
然而,当时的架构使得转向单一服务具有挑战性。由于每个目的地都有一个单独的队列,每个工作人员都必须检查每个队列的工作情况,这会给目的地服务增加一层复杂性,而我们对此感到不舒服。这是离心机的主要灵感。 Centrifuge 将取代我们所有的单独队列,并负责将事件发送到单个整体服务。
5 迁移到 Monorepo
鉴于只有一项服务,将所有目标代码移至一个存储库中是有意义的,即将所有不同的依赖项和测试合并到一个存储库中。我们知道这会很混乱。
对于 120 个独特依赖项中的每一个,我们致力于为所有目的地提供一个版本。当我们移动目的地时,我们会检查它正在使用的依赖项并将其更新到最新版本。我们修复了目的地中与新版本不符的任何内容。
通过这种转变,我们不再需要跟踪依赖版本之间的差异。所有的目的地都使用相同的版本,这显着降低了代码库的复杂性。现在,维护目的地变得更省时、风险更低。
我们还想要一个测试套件,使我们能够快速轻松地运行所有目标测试。在更新我们之前讨论的共享库时,运行所有测试是主要障碍之一。
幸运的是,目的地测试都有相似的结构。他们进行了基本的单元测试,以验证我们的自定义转换逻辑是否正确,并向合作伙伴的端点执行 HTTP 请求,以验证事件是否按预期显示在目标中。
6 复盘
将每个目标代码库分离到自己的存储库中的最初动机是为了隔离测试失败。然而,事实证明这是一个虚假的优势。发出 HTTP 请求的测试仍然经常失败。由于目的地被分成自己的存储库,因此没有动力去清理失败的测试。这种糟糕的卫生状况导致了令人沮丧的技术债务的持续来源。通常,原本只需要一两个小时的小改变最终需要几天到一周的时间才能完成。
7 构建弹性测试套件
测试运行期间对目标端点的出站 HTTP 请求是测试失败的主要原因。诸如过期凭证之类的不相关问题不应导致测试失败。根据经验,我们还知道某些目标端点比其他端点慢得多。某些目的地需要长达 5 分钟的时间来运行测试。对于 140 多个目的地,我们的测试套件可能需要长达一个小时才能运行。
为了解决这两个问题,我们创建了 Traffic Recorder。 Traffic Recorder构建在yakbak之上,负责记录和保存目的地的测试流量。每当测试第一次运行时,任何请求及其相应的响应都会记录到文件中。在后续测试运行中,将回放文件中的请求和响应,而不是请求目标端点。这些文件被签入存储库,以便测试在每次更改中保持一致。现在测试套件不再依赖于互联网上的这些 HTTP 请求,我们的测试变得更加有弹性,这是迁移到单个存储库的必备条件。
我记得在我们集成了 Traffic Recorder 之后,第一次对每个目的地进行了测试。我们花了几毫秒的时间才完成对所有 140 多个目的地的测试。过去,只需几分钟即可完成一个目的地。感觉就像魔法一样。
8 单体系统的工作原理
一旦所有目的地的代码都集中在一个版本库中,它们就可以合并到一个服务中。由于每个目的地都在一个服务中,我们的开发人员的工作效率大大提高。我们不再需要为了修改一个共享库而部署 140 多个服务。一名工程师就能在几分钟内部署服务。
速度的提高就是最好的证明。2016 年,当我们的微服务架构还在运行时,我们对共享库进行了 32 次改进。就在今年,我们进行了 46 项改进。在过去 6 个月中,我们对库的改进比 2016 年全年都要多。
这一变化也让我们的运营故事受益匪浅。由于每个目的地都在一个服务中,我们拥有 CPU 和内存密集型目的地的良好组合,这使得扩展服务以满足需求变得更加容易。庞大的工人池可以吸收峰值负载,因此我们不再为处理少量负载的目的地分页。
9 Trade Offs
从我们的微服务架构转变为单体架构总体上是一个巨大的进步,但是,这其中也有取舍:
- 故障隔离很困难。由于所有服务都在单体中运行,如果某个目的地出现错误导致服务崩溃,那么所有目的地的服务都会崩溃。我们有全面的自动测试,但测试只能到此为止。我们目前正在开发一种更强大的方法,以防止一个目的地导致整个服务宕机,同时仍将所有目的地保持在一个整体中
- 内存缓存的效果较差。以前,由于每个目的地只有一个服务,我们的低流量目的地只有少量进程,这意味着其控制平面数据的内存缓存会一直处于热状态。而现在,缓存分散在 3000 多个进程中,被攻击的可能性大大降低。我们可以使用像 Redis 这样的东西来解决这个问题,但这又是一个我们必须考虑的扩展问题。最终,考虑到可观的运行效益,我们接受了这种效率损失
- 更新依赖关系的版本可能会破坏多个目的地。虽然将所有内容转移到一个版本库中解决了之前依赖关系混乱的问题,但这也意味着如果我们想使用最新版本的库,就有可能需要更新其他目的地才能使用新版本。不过我们认为,这种方法的简便性值得权衡利弊。有了我们全面的自动测试套件,我们就能快速了解更新依赖版本后会出现哪些问题
8 总结
我们最初的微服务架构运行了一段时间,通过将目的地相互隔离,解决了管道中的直接性能问题。但是,我们的架构并不适合扩展。在需要进行批量更新时,我们缺乏测试和部署微服务的适当工具。因此,我们的开发人员工作效率迅速下降。
转向单体后,我们摆脱了管道的运行问题,同时显著提高了开发人员的工作效率。但我们并没有轻率地完成这一转变,我们知道要想成功,我们必须考虑一些事情。
我们需要一个坚如磐石的测试套件,将所有东西都放到一个 repo 中。如果不这样做,我们就会陷入与最初决定将它们分开时同样的境地。过去,不断失败的测试损害了我们的工作效率,我们不希望这种情况再次发生。
我们接受了单体架构中固有的取舍,并确保为每种架构编写了一个好故事。我们必须接受这种改变所带来的一些牺牲。
在决定使用微服务还是单体架构时,需要考虑的因素各有不同。在我们基础架构的某些部分,微服务运行良好,但我们的服务器端目的地是一个完美的例子,说明了这种流行趋势实际上会损害生产率和性能。事实证明,我们的解决方案是单体。
关注我,紧跟本系列专栏文章,咱们下篇再续!
作者简介:魔都技术专家,多家大厂后端一线研发经验,在分布式系统、和大数据系统等方面有多年的研究和实践经验,拥有从零到一的大数据平台和基础架构研发经验,对分布式存储、数据平台架构、数据仓库等领域都有丰富实践经验。
各大技术社区头部专家博主。具有丰富的引领团队经验,深厚业务架构和解决方案的积累。
负责:
- 中央/分销预订系统性能优化
- 活动&优惠券等营销中台建设
- 交易平台及数据中台等架构和开发设计
- 车联网核心平台-物联网连接平台、大数据平台架构设计及优化
目前主攻降低软件复杂性设计、构建高可用系统方向。
参考:
本文由博客一文多发平台 OpenWrite 发布!