最初有一个整体:我们如何在不干扰业务的情况下更改架构





你好!我叫Igor Narazin,我是Delivery Club后勤团队的团队负责人。我想告诉您我们如何构建和转换架构,以及它如何影响我们的开发流程。



现在,Delivery Club(就像整个食品技术市场一样)发展非常迅速,这给技术团队带来了巨大的挑战,这可以用两个最重要的标准来概括:



  • 必须确保平台所有部分的高度稳定性和可用性。
  • 同时,保持新功能的高速开发。


看来这两个问题是互斥的:我们要么转换平台,在完成之前尝试尽可能少地进行新更改,要么我们快速开发新功能而无需对系统进行重大更改。



但是我们(到目前为止)都成功了。我们将如何进一步做到这一点。



首先,我将向您介绍我们的平台:考虑到不断增长的数据量,我们对服务应用的标准以及在处理过程中面临的问题,我们将如何对其进行转换。



其次,我将分享我们如何解决在不与平台更改冲突且不会导致系统不必要降级的情况下交付功能问题 让我们从平台开始。







最初有一个整体



Delivery Club代码的第一行是11年前编写的,按照该类型的最佳传统,该体系结构是PHP的整体。7年来,它一直充满着越来越多的功能,直到它遇到了整体架构的经典问题。



最初,我们对它完全满意:它易于维护,测试和部署。而且他可以毫无问题地应对最初的负荷。但是,通常情况下,在某些时候我们达到了如此高的增长率,以至于我们的整体变成了非常危险的瓶颈:



  • 整体中的任何故障或问题将绝对影响我们的所有流程;
  • 整体结构牢固地绑定到无法更改的特定堆栈上;
  • 考虑到开发团队的成长,进行更改变得很困难:组件之间的高连接性无法快速交付功能;
  • 整体无法灵活缩放。


这导致我们进入(惊奇的)微服务架构-关于它的优点和缺点,已经有很多说法和文章了。最主要的是,它解决了我们的主要问题之一,并使我们能够实现整个系统的最大可用性和容错能力。我不会在本文中对此进行详细介绍,而是通过示例告诉我们我们如何做到以及为什么。



我们的主要问题是整体代码库的大小和团队中较差的专业知识(该平台是我们所说的旧平台)。当然,起初我们只是想拿起并剪掉整体,以便完全解决问题。但是我们很快意识到,这将需要一年以上的时间,并且在那里进行的更改数量将永远不会结束。



因此,我们走了另一条路:我们保持原样,并决定围绕整体构建其余的服务。它仍然是订单处理逻辑和数据主机的重点,但开始为其他服务流式传输数据。



生态系统



正如Andrey Evsyukov在有关我们团队文章中所说,我们重点介绍了领域领域的主要领域:研发,物流,消费者,供应商,内部,平台。在这些区域中,服务工作的主要领域已经集中:例如,对于物流,这些是快递和订单,对于供应商-餐馆和职位。



接下来,我们需要上升到一个更高的水平,并围绕平台构建我们的服务生态系统:订单处理位于中心并且是数据主数据,其余服务围绕它构建。同时,对我们来说重要的是使我们的指示自治:如果一部分失败,其余部分继续发挥作用。



在低负载下,构建必要的生态系统非常简单:我们的处理过程和数据存储,并根据需要为其提供推荐服务。



低负载,同步请求,一切正常。



在第一阶段,我们就是这样做的:大多数服务通过同步HTTP请求相互通信。在一定负载下,这是允许的,但是项目和服务数量增长的越多,问题就越多。





高负载,同步请求:每个人都遭受痛苦,甚至是快递员在完全不同的领域中也是如此。



使服务在方向内自治是更加困难的:例如,物流负担的增加不应影响系统的其余部分。对于任何数量的同步请求,这都是一个无法解决的问题。显然,有必要放弃同步请求并继续进行异步通信。



数据总线



因此,我们遇到了许多瓶颈,在这些瓶颈中,我们以同步模式访问数据。这些地方在增加负荷方面非常危险。



这是一个例子。至少一次通过Delivery Club进行订购的人都知道,快递员领取订单后,卡片就可见了。您可以在其上实时跟踪快递员的运动。此功能涉及多个微服务,主要是:



  • mobile-gateway这是移动应用程序前端的后端;
  • courier-tracker,它存储接收和发送坐标的逻辑;
  • logistics-couriers存储这些坐标。它们是从快递移动应用程序发送的。






在原始方案中,这一切都是同步进行的:每分钟一次来自移动应用程序的请求传递mobile-gatewaycourier-tracker访问logistics-couriers和接收坐标的服务当然,在这种方案中并不是那么简单,但是最终归结为一个简单的结论:我们拥有的订单越活跃,收到的坐标请求就越多logistics-couriers



我们的增长有时是不可预测的,最重要的是,它的增长很快-这种计划失败之前的时间问题。这意味着我们需要重做异步交互的过程:使对坐标的请求尽可能便宜。为此,我们需要转换数据流。



运输



我们已经使用RabbitMQ,包括用于服务之间的通信。但是,作为主要的运输方式,我们选择了已经被充分证明的工具-Apache Kafka。我们将撰写有关它的单独的详细文章,但是现在我想简要地谈谈我们如何使用它。



当我们第一次开始将Kafka实施为传输工具时,我们以原始形式使用它,直接连接到代理并向他们发送消息。这种方法使我们能够在战斗中快速测试卡夫卡,并决定是否继续将其用作我们的主要运输方式。



但是这种方法有一个很大的缺点:消息没有任何类型和验证-我们不确定从主题中读取哪种消息格式。



这增加了提供数据的服务和使用数据的服务之间出现错误和不一致的风险。



为了解决这个问题,我们编写了一个包装器-Go中的微服务,将Kafka隐藏在其API的后面。这增加了两个好处:



  • 发送和接收时的数据验证。实际上,这些是相同的DTO,因此我们始终对预期数据的格式充满信心。
  • 通过这种运输方式将我们的服务快速整合。


因此,与Kafka的合作对于我们的服务而言已变得尽可能抽象:它们仅与该包装器的顶级API配合使用。



让我们回到示例



通过将同步通信传递到事件总线,我们需要反转数据流:我们所要求的现在应该通过Kafka本身到达我们。在示例中,我们讨论的是快递员的坐标,为此,我们将为其创建一个特殊的主题,并在我们从快递员那里收到它们时将其生成logistics-couriers



该服务仅需要courier-tracker在所需的时间段内累积所需数量的坐标。结果,我们的端点变得尽可能简单:从服务数据库中获取数据并将其提供给移动应用程序。现在,增加负担对我们是安全的。







除了解决特定问题外,最后我们还会获得一个数据主题,其中包含快递人员的实际坐标,我们的任何服务都可以将其用于自己的目的。



最终一致性



在此示例中,一切工作都很酷,除了与同步选项相比,快递员的坐标不会始终是最新的:在基于异步交互的体系结构中,在任何给定时间都存在数据相关性的问题。但是我们没有很多需要保持最新的关键数据,因此该方案对我们而言是理想的:我们牺牲某些信息的相关性以提高系统可用性。但是,我们保证最终,在系统的所有部分中,所有数据都是相关且一致的(最终是一致的)。



当涉及到高负载系统和微服务体系结构时,必须对数据进行非规范化:每个服务本身都可以确保其需要工作的数据的存储。例如,我们网域的主要实体之一是快递员。许多服务都使用它,但是它们都需要不同的数据集:有人需要个人数据,而有人只需要有关移动类型的信息。该域的数据主机将把整个实体产生到流中,并且服务会累积必要的部分:







因此,我们清楚地将我们的服务划分为数据主机和使用此数据的服务。实际上,这与演化架构无关,它是无头贸易-我们已经清楚地将所有“店面”(网站,移动应用程序)与该数据的产生者区分开了。



非规范化



另一个例子:我们有一种针对快递员的通知的机制-这些消息将在应用程序中传递给他们。在后端,有一个强大的API用于发送此类通知。在其中,您可以配置邮件过滤器:根据特定条件,从特定的快递员到快递员组。



该服务负责这些通知logistics-courier-notifications在他收到发送请求之后,他的任务是为那些已成为目标的快递员生成消息。为此,他需要了解所有Delivery Club快递人员的必要信息。我们有两种解决此问题的方法:



  • 在服务端建立一个端点-快递数据向导(logistics-couriers),它将能够根据传输的字段过滤并返回必要的快递;
  • 将所有必要的信息直接存储在服务中,从相关主题中使用它,并存储将来我们需要用来过滤的数据。


生成消息和过滤快递公司的某些逻辑未加载,而是在后台执行,因此没有服务加载的问题logistics-couriers但是,如果我们选择第一个选项,我们将面临一系列问题:



  • 您将必须在第三方服务中支持高度专业化的终结点,这很可能仅是我们所需要的;
  • 如果选择的过滤器过宽,则样本中将包含所有根本不适合HTTP响应的信使,并且您将必须实现分页(并在轮询服务时对其进行迭代)。


显然,我们停止在服务本身中存储数据。它独立地自主执行所有工作,不访问任何地方,而仅从Kafka主题中收集自身的所有必要数据。有风险,我们稍后会收到有关创建新快递的消息,并且不会包含在某些选择中。但是,异步体系结构的这种缺点是不可避免的。



因此,我们制定了一些设计服务的重要原则:



  • 服务必须负有特定责任。如果仍然需要一项服务来实现其全面的功能,则这是一个设计错误,必须将其合并或必须修改体系结构。
  • 我们认真研究任何同步呼叫。对于一个方向的服务,这是可以接受的,但是对于不同方向的服务之间的通信,则不可接受
  • 什么都不分享。我们不会绕过它们进入服务数据库。所有请求仅通过API。
  • 规格第一。首先,我们描述并批准协议。


因此,根据公认的原理和方法迭代地转换我们的系统,我们得出以下体系结构:







我们已经有一个Kafka形式的数据总线,它已经有大量的数据流,但是在方向之间仍然存在同步请求。



我们如何计划发展我们的架构



正如我在开始时所说,交付俱乐部正在迅速发展,我们将大量新功能投入生产。我们尝试更(尼古拉·阿尔希波夫谈到这个详细)和测试假设。所有这些都带来了大量的数据源,甚至还有更多可供使用的选择。正确地管理数据流,这对于正确构建非常重要-这是我们的任务。



从现在开始,我们将继续对所有Delivery Club服务实施已开发的方法:围绕平台构建服务生态系统,并以数据总线的形式进行传输。



主要任务是确保将系统中所有域上的信息都提供给数据总线。对于具有新数据的新服务,这不是问题:在准备服务的阶段,他将不得不将其域数据流式传输到Kafka。



但是除了新服务外,我们还有大型的传统服务,这些服务在我们的主要领域(订单和快递公司)上提供数据。由于“数据原样”存储在数十个表中,因此按“原样”流式传输数据存在问题,并且构建最终实体来每次产生所有更改的成本将非常高昂。



因此,我们决定将Debezium用于旧服务,它使您可以直接根据bin-log从表中流式传输信息:结果,您会获得一个现成的主题,其中包含表中的原始数据。但是它们不适合以其原始形式使用,因此通过Kafka级别的转换器,它们将被转换为消费者可以理解的格式,并被推入新的主题。因此,我们将拥有一组私有主题,其中包含来自表的原始数据,这些私有数据将被转换成一种方便的格式,并广播给公共主题以供消费者使用。







将有多个用于编写Kafka和不同类型主题的入口点,因此,我们还将在存储方面通过角色实现访问权限,并通过Confluent在数据总线方面添加模式验证



远离数据总线,服务将使用必要主题中的数据。而且我们自己将这些数据用于我们的系统:例如,通过Kafka Connect流到ElasticSearch或DWH。对于后者,过程将更加复杂:为了使其中的信息对所有人都可用,必须清除所有个人数据。



我们还需要最终用整体解决这个问题:在不久的将来,我们仍然会经历一些关键的过程。最近,我们已经推出了一项单独的服务,用于处理创建订单的第一阶段:形成购物篮,收据和付款。然后,他将此数据发送到整体进行进一步处理。好吧,所有其他操作不再需要同步。



如何为客户透明地进行此重构



我再讲一个例子:餐厅目录。显然,这是一个非常繁忙的地方,我们决定将其移至Go上的单独服务。为了加快开发速度,我们将外卖分为两个阶段:



  1. 首先,在服务内部,我们直接转到整体的基础副本,并从那里获取数据。
  2. 然后,我们开始通过Debezium传输所需的数据,并将其累积在服务本身的数据库中。






准备好服务后,就会出现一个问题,即如何将其透明地集成到当前工作流程中。我们使用了流量拆分方案:来自客户端的所有流量都流向了该服务mobile-gateway,然后在整体和新服务之间进行了划分。最初,我们继续处理通过整体的所有流量,但是我们将其中的一些流量复制到了新服务中,比较了它们的响应,并记录了有关指标差异的日志。这样,我们确保了在战斗条件下测试服务的透明度。之后,仅需逐步切换并增加其流量,直到新服务完全取代整体。



总的来说,我们有很多雄心勃勃的计划和想法。我们只是在制定进一步战略的开始,而其最终形式尚不明确,也不知道它是否会按照我们的预期运作。一旦实施并得出结论,我们肯定会分享结果。



除了所有这些概念上的更改,我们将继续积极开发产品并向其交付功能,这需要花费大量时间。在这里,我们谈到第二个问题,我在开始时就谈到了这个问题:考虑到开发人员的数量(180人),出现了验证新服务的体系结构和质量的问题。新版本不会降低系统性能,应该从一开始就正确地构建它。但是如何在工业规模上控制它呢?



建筑委员会



并没有立即产生对它的需求。当开发团队较小时,对系统的任何更改都易于控制。但是人越多,做起来就越困难。



这既引起实际问题(由于设计不当导致服务无法承受负载)又产生概念性问题(“同步进行,负载很小”)。



显然,大多数问题都是在团队级别解决的。但是,如果我们正在谈论某种复杂的集成到当前系统中,那么团队可能根本没有足够的专业知识。因此,我想建立一个全方位的人际交往,任何关于建筑的问题都可以带给他们一个详尽的答案。



因此,我们开始建立一个建筑委员会,其中包括团队负责人,指导负责人和CTO。我们每两周开会一次,讨论系统中计划进行的重大更改,或者仅解决特定问题。



结果,我们通过控制大型变更解决了问题,而交付俱乐部中代码质量的通用方法仍然存在问题:不同团队中代码或框架的特定问题可以通过不同的方式解决。我们来到了有关Spotify模型的行会:这些人是一群对某种技术无动于衷的人。例如,有行会Go,PHP和Frontend。



他们开发统一的编程风格,设计和架构方法,帮助形成和维护工程文化在最高级别。他们也有自己的待办事项列表,可以在其中改进内部工具,例如,我们的微服务Go模板。



产品代码



除了重大的更改要经过建筑委员会,并且行业协会通常会监视代码的文化之外,我们在准备生产服务时仍处于重要阶段:在Confluence中编制清单。首先,在制定清单时,开发人员会再次评估自己的决定。其次,这是一项操作要求,因为他们需要了解生产中将出现哪种新服务。



该清单通常指示:



  • 负责服务(通常是服务的技术负责人);
  • 链接到具有自定义警报的仪表板;
  • 服务说明和Swagger链接;
  • 与之交互的服务的描述;
  • 服务的估计负载;
  • health-check. URL, . Health-check - : 200, , - . , health check URL’ , , , PostgreSQL Redis.


服务警报是在体系结构批准阶段设计的。开发人员必须了解该服务是有效的,并且不仅要考虑技术指标,还要考虑产品指标,这一点很重要。这并不意味着任何业务转换,而是表示该服务正在按预期方式工作的指标。



例如,您可以使用上面已经讨论的服务courier-tracker,该服务在地图上跟踪快递公司。其中的主要指标之一是已更新其坐标的快递员的数量。如果突然有些路由很长时间没有更新,则会出现“出现问题”的警报。也许他们没有去找数据,或者他们不正确地输入了数据库,或者其他服务中断了。这不是技术或产品指标,但是它显示了服务的可行性。



对于指标,我们使用Graylog和Prometheus,构建仪表板并在Grafana中设置警报。



尽管准备工作量很大,但将服务交付到生产环境的速度还是相当快的:最初将所有服务打包在Docker中,在形成Kubernetes的类型化图表后将其自动推出到阶段,然后一切由Jenkins中的按钮决定。



向产品推出新服务包括在Jira中为管理员分配任务,该任务提供了我们之前准备的所有信息。



引擎盖下



现在,我们有162个用PHP和Go编写的微服务。它们在大约50%到50%的服务之间分配。最初,我们在Go中重写了一些高负载服务。然后很明显,Go在生产中更易于维护和监视,入门门槛低,因此最近我们一直只在其中编写服务。没有目的在Go中重写其余的PHP服务:它非常成功地处理了其功能。



在PHP服务中,我们有Symfony,在上面我们使用我们自己的小框架。它在服务上强加了一种通用的体系结构,因此,我们降低了输入服务源代码的门槛:无论您打开哪种服务,都将始终清楚其中包含的内容以及位置。该框架还封装了服务之间的通信传输层,对于开发人员而言,对第三方服务的请求看起来具有很高的抽象性:

$courierResponse = $this->courierProtocol->get($courierRequest);

在这里,我们形成了请求($courierRequest的DTO ,调用了特定服务协议对象的方法,该服务是特定端点上的包装器。在后台,我们的对象被$courierRequest转换为一个请求对象,其中填充了DTO中的字段。这非常灵活:可以在标头和请求URL本身中插入字段。接下来,通过cURL发送请求,我们获得Response对象,并将其转换回我们期望的对象$courierResponse



这使开发人员可以专注于业务逻辑,而没有任何交互细节。协议,服务的请求和响应的对象位于单独的存储库中-该服务的SDK。因此,任何要使用其协议的服务都将在导入SDK后收到整个类型的协议包。



但是此过程有一个很大的缺点:SDK的存储库很难维护,因为所有DTO都是手动编写的,并且便捷的代码生成也不容易:有尝试,但是最后,考虑到向Go的过渡,他们没有花时间在上面。



结果,服务协议中的更改可能会变成几个请求请求:服务本身,SDK和需要此协议的服务。在后者中,我们需要提高导入的SDK的版本,以便所做的更改能够到达那里。这通常会引起新开发人员的疑问:“我刚刚更改了参数,为什么我需要对三个不同的存储库发出三个请求?!”



在Go中,一切都变得更加简单:我们有一个出色的代码生成器(Sergey Popov撰写了一篇详细的文章),由于要键入整个协议,因此现在甚至在讨论将所有规范存储在单独的存储库中的选项。因此,如果有人更改了规格,则依赖于该规格的所有服务将立即开始使用更新的版本。



技术雷达







除了已经提到的Go和PHP,我们还使用了大量其他技术。它们因方向而异,并取决于特定任务。基本上,我们在后端使用:



  • Python,数据科学团队在上面写。
  • KotlinSwift-用于移动应用的发展。
  • PostgreSQL作为数据库,但某些较旧的服务仍在运行MySQL。在微服务中,我们使用几种方法:每个服务都有其自己的数据库并且不共享任何内容-我们不会仅通过其API访问绕过服务的数据库。
  • ClickHouse -用于与分析相关的高度专业化的服务。
  • RedisMemcached作为内存存储。




在选择技术时,我们会遵循特殊原则主要要求之一是易用性:我们为开发人员使用最简单易懂的技术,并尽可能地坚持公认的堆栈。对于那些想了解整个特定技术堆栈的人,我们编写了非常详细的技术雷达



长话短说



结果,我们从单片架构切换到了微服务架构,现在我们已经有了由平台周围的方向(领域)组成的服务组,而平台是核心和数据主控。



我们对如何重组数据流以及如何在不影响新功能开发速度的情况下实现远见卓识。将来,我们一定会告诉您这是什么导致了我们。



并且由于积极的知识转移和正式的变更流程,我们能够提供许多功能,而这些功能不会拖慢架构转换的过程。



这就是我的全部,感谢您的阅读!



All Articles