扩展后端基础设施以应对高速增长是DoorDash工作的众多令人兴奋的挑战之一。2019年年中,我们面临着重大的扩展挑战和频繁的停机,涉及芹菜和RabbitMQ,这两项技术支持系统处理异步工作,启用我们平台的关键功能,包括订单结账和Dasher分配。
我们用一个简单的、基于Apache Kafka的异步任务处理系统快速解决了这个问题,该系统停止了我们的停机,同时我们继续迭代一个健壮的解决方案。我们的初始版本实现了容纳大部分现有芹菜任务所需的最小功能集。一旦投入生产,我们继续增加对更多芹菜功能的支持,同时解决使用Kafka时出现的新问题。
RabbitMQ和芹菜是我们基础设施的关键任务部分,为DoorDash的900多个不同的异步任务提供支持,包括订单结账、商家订单传输和Dasher位置处理。DoorDash面临的问题是RabbitMQ由于负载过重而频繁停机。如果任务处理能力下降,DoorDash实际上就会下降,订单无法完成,导致我们的商家和Dashers的收入损失,我们的消费者的体验也很差。我们面临以下几个方面的问题:
我们面临的最大问题是停电,而停电往往是在需求高峰期出现的。RabbitMQ会因为负载、过度连接丢失等原因而关闭。订单将被暂停,我们将不得不重新启动我们的系统,有时甚至需要启动一个全新的代理并手动进行故障转移,以便从中断中恢复。
芹菜允许用户使用倒计时或ETA来安排未来的任务。我们大量使用这些倒计时,导致经纪人的负载明显增加。我们的一些停机与具有倒计时的任务增加直接相关。我们最终决定限制倒计时的使用,以支持我们为未来的工作安排而准备的另一个系统。
突如其来的流量将使RabbitMQ处于任务消耗明显低于预期的降级状态。根据我们的经验,这只能通过RabbitMQ反弹来解决。RabbitMQ有一个流控制的概念,它将降低发布过快的连接的速度,以便队列可以跟上。流量控制经常(但不总是)涉及到这些可用性降低。当流量控制生效时,发布者实际上将其视为网络延迟。网络延迟缩短了我们的响应时间;如果在流量高峰期间延迟增加,则随着上游请求的堆积,可能会导致显著的减速。
我们的python uWSGI web工作者有一个名为harakiri的功能,可以终止任何超过超时的进程。在停机或速度减慢期间,harakiri导致RabbitMQ代理的连接中断,因为进程被反复终止并重新启动。由于在任何给定的时间都有数以千计的网络工作者在运行,任何引发harakiri的速度缓慢都会反过来给RabbitMQ增加额外的负载,从而导致速度更慢。
在生产中,我们经历了几次芹菜消费者的任务处理停止的情况,即使在没有显著负载的情况下也是如此。我们的调查工作没有发现任何资源限制的证据,这会导致处理停止,工人们一旦被弹回,就恢复了处理。这个问题从来不是根本原因,尽管我们怀疑问题出在芹菜工人本身,而不是RabbitMQ。
总体而言,所有这些可用性问题对我们来说都是不可接受的,因为高可靠性是我们最优先考虑的问题之一。由于这些停机使我们在错过订单和信誉方面损失惨重,我们需要一个能够尽快解决这些问题的解决方案。
下一个最大的问题是规模。DoorDash发展迅速,我们很快就达到了现有解决方案的极限。我们需要找到能够跟上我们持续增长的东西,因为我们的遗留解决方案存在以下问题:
我们使用的是目前可用的最大的单节点RabbitMQ解决方案。没有进一步垂直扩展的途径,我们已经开始将该节点推向极限。
由于复制,与单节点选项相比,主-辅助高可用性(HA)模式降低了吞吐量,使我们的净空比单节点解决方案更少。我们不能用吞吐量来换取可获得性。
第二,在实际运作中,主次医管局模式并没有减低停电的严重程度。故障转移需要20多分钟才能完成,而且经常会卡在需要人工干预的地方。在这个过程中,信息也经常丢失。
随着DoorDash的持续增长并将我们的任务处理推向极限,我们很快就耗尽了净空空间。我们需要一个可以随着处理需求的增长而横向扩展的解决方案。
了解任何系统中发生的情况对于确保其可用性、可伸缩性和操作完整性至关重要。
我们需要能够看到系统各个方面的实时度量,这意味着还需要解决可观察性限制。
我们经常不得不将RabbitMQ节点故障转移到一个新节点,以解决我们观察到的持续降级问题。对于参与其中的工程师来说,这项操作既费时又费力,而且经常不得不在高峰期以外的深夜进行。
DoorDash没有内部的芹菜或RabbitMQ专家可以帮助我们设计这项技术的扩展策略。
花在操作和维护RabbitMQ上的工程时间是不可持续的。我们需要更好地满足我们当前和未来需求的东西。
将芹菜代理从RabbitMQ更改为Redis或Kafka。这将允许我们继续使用芹菜,并使用不同的、可能更可靠的后备数据存储。
将多代理支持添加到我们的Django应用程序中,这样消费者就可以基于我们想要的任何逻辑向N个不同的代理发布信息。任务处理将跨多个代理进行分片,因此每个代理将经历初始负载的一小部分。
升级到更新版本的芹菜和RabbitMQ。更新版本的芹菜和RabbitMQ有望解决可靠性问题,为我们赢得时间,因为我们已经在并行地从Django整体中提取组件。
迁移到Kafka支持的自定义解决方案。与我们列出的其他选项相比,此解决方案需要付出更多努力,但也更有可能解决我们在使用传统解决方案时遇到的所有问题。
给定我们所需的系统正常运行时间,我们基于以下原则设计了自注册策略,以在最短的时间内最大限度地提高可靠性。这一战略包括三个步骤:
开始运行:我们希望在迭代解决方案的其他部分时利用我们正在构建的解决方案的基础。我们将这一策略比作驾驶一辆赛车,同时换一个新的燃油泵。
开发人员无缝采用的设计选择:我们希望将定义不同接口可能导致的所有开发人员浪费的工作降至最低。
零停机时间的增量推出:我们不是首次在野外测试失败几率更高的大型华而不实的版本,而是将重点放在交付可以在更长时间内单独测试的较小的独立功能上。
改用卡夫卡代表着我们的产品组合中的一项重大技术变革,但也是我们迫切需要的一项变革。我们没有时间浪费,因为由于我们遗留的RabbitMQ解决方案的不稳定,我们每周都会失去业务。我们的首要任务是创建一个最低限度的可行产品(MVP),为我们带来暂时的稳定性,并给我们提供必要的余地来迭代和准备更全面的解决方案,使其得到更广泛的采用。
我们的MVP由向Kafka发布任务完全限定名(FQN)和Pickle参数的生产者组成,同时我们的消费者读取这些消息,从FQN导入任务,并使用指定的参数同步执行它们。
有时,开发人员采用比开发更具挑战性。我们为芹菜的@task注释实现了一个包装器,该包装器基于可动态配置的功能标志将任务提交动态路由到任一系统,从而简化了这一过程。现在,可以使用相同的接口为两个系统编写任务。有了这些决策,工程团队就不必做额外的工作来与新系统集成,除非实现单个功能标志。
我们想在我们的MVP准备好后尽快推出我们的系统,但它还不支持所有与芹菜相同的功能。芹菜允许用户使用任务注释中的参数或在提交任务时配置任务。为了使我们能够更快地启动,我们创建了兼容参数的白名单,并选择支持支持大多数任务所需的最小数量的功能。
如图2所示,根据上面的两个决策,我们在开发两周后启动了MVP,并在启动后的另一个星期实现了RabbitMQ任务负载减少了80%。我们迅速解决了主要的停机问题,并在项目过程中支持越来越多深奥的功能,以支持执行剩余的任务。
能够在不影响业务的情况下动态切换Kafka集群以及在RabbitMQ和Kafka之间切换对我们来说极其重要。此功能还帮助我们进行了各种操作,如群集维护、卸载和逐步迁移。为了实现此部署,我们在消息提交级别和消息消费端都使用了动态功能标志。在这里保持完全活力的代价是保持我们的工人船队以双倍的能力运行。这支舰队的一半投入到RabbitMQ,其余的投入到卡夫卡(Kafka)。以双倍的载客量运作工人船队,肯定会对我们的基础设施造成负担。我们甚至一度建立了一个全新的Kubernetes集群,仅仅是为了容纳我们所有的员工。
在开发的初始阶段,这种灵活性很好地服务于我们。一旦我们对我们的新系统有了更多的信心,我们就会寻找减少基础设施负载的方法,例如在每台工作机器上运行多个消耗进程。随着我们转换各种主题,我们能够开始减少RabbitMQ的工人数量,同时保持较小的备用容量。
随着我们的MVP投入生产,我们有了迭代和完善产品所需的净空。我们根据使用它的任务数量对每个缺失的芹菜功能进行排序,以帮助我们决定首先实现哪些功能。我们的自定义解决方案中没有实现仅由少数任务使用的功能。相反,我们重写了这些任务以不使用该特定功能。有了这个策略,我们最终把所有的任务都从芹菜上移走了。
Kafka主题被分区,以便单个消费者(每个消费者组)按照消息到达的顺序读取分配给它的所有分区的消息。如果单个分区中的消息处理时间过长,它将停止使用该分区中它后面的所有消息,如下面的图3所示。在高优先级主题的情况下,这个问题可能特别灾难性。我们希望在发生延迟的情况下能够继续处理分区中的消息。
虽然从根本上讲,并行性是一个Python问题,但此解决方案的概念也适用于其他语言。我们的解决方案(如下面的图4所示)是每个工作进程容纳一个Kafka消费者进程和多个任务执行进程。Kafka-Consumer进程负责从Kafka获取消息,并将它们放在任务执行进程读取的本地队列中。它会继续消耗,直到本地队列达到用户定义的阈值。此解决方案允许分区中的消息流动,并且只有一个任务执行进程将因缓慢的消息而停滞。该阈值还限制了本地队列中正在传输的消息的数量(在系统崩溃的情况下,这些消息可能会丢失)。
我们每天会多次部署我们的Django应用程序。我们注意到我们的解决方案的一个缺点是,部署会触发Kafka中分区分配的重新平衡。尽管每个主题使用不同的消费者组来限制重新平衡范围,但部署仍然会导致消息处理的短暂减速,因为在重新平衡期间任务消耗必须停止。在大多数情况下,当我们执行计划的发布时,速度减慢可能是可以接受的,但是,例如,当我们正在进行紧急发布以热修复错误时,可能会是灾难性的。其结果将是引入级联处理速度减慢。
较新版本的Kafka和客户端支持增量合作重新平衡,这将极大地降低重新平衡对运营的影响。升级我们的客户以支持这种类型的再平衡解决方案将是我们未来的首选解决方案。不幸的是,我们选择的Kafka客户端还不支持增量协作重新平衡。
随着该项目的结束,我们实现了在正常运行时间、可伸缩性、可观察性和分散化方面的显著改进。这些胜利对于确保我们业务的持续增长至关重要。
几乎在我们开始推出这种定制的卡夫卡方法时,我们就停止了反复的停机。停机导致极差的用户体验。
通过在我们的MVP中只实现最常用的芹菜特性的一小部分,我们能够在两周内将工作代码交付生产。
有了MVP,当我们继续强化我们的解决方案并实现新功能时,我们能够显着降低RabbitMQ和芹菜的负载。
以Kafka作为我们架构的核心,我们构建了一个高度可用和水平可扩展的任务处理系统,使DoorDash和它的客户能够继续增长。
因为这是一个定制的解决方案,所以我们能够在几乎每个级别引入更多的指标。在生产和开发环境中,每个队列、工作人员和任务都可以在非常精细的级别上完全观察到。这种增加的可观察性不仅在生产意义上是一个巨大的胜利,而且在开发人员生产力方面也是一个巨大的胜利。
有了可观察性的改进,我们能够将警报模板化为Terraform模块,并显式地为每个主题以及所有900多个任务指定所有者。
任务处理系统的详细操作指南使所有工程师都可以访问信息,以调试其主题和工作人员的操作问题,并根据需要执行整体Kafka群集管理操作。日常运营是自助式的,我们的基础架构团队几乎不需要支持。
总而言之,我们达到了扩展RabbitMQ的能力上限,不得不寻找替代方案。我们选择的替代方案是定制的基于Kafka的解决方案。虽然使用Kafka有一些缺点,但我们发现了一些解决方法,如上所述。
当关键工作流严重依赖异步任务处理时,确保可伸缩性是最重要的。当遇到类似的问题时,请随意从我们的策略中获得灵感,该策略以20%的努力为我们提供了80%的结果。一般来说,这一战略是一种战术方法,可以迅速缓解可靠性问题,并为更强大的战略解决方案赢得急需的时间。
作者要感谢Clement Fang、Corry Haines、Danial Asif、Jay Weinstein、Luigi Tagliamonte、Matthew Anger、周少华和陈韵瑜对这个项目的贡献。
评论。
DoorDash公司的多元化和包容性工程组织建立了创新和可靠的技术解决方案,为当地经济提供动力。