在传统的 Web 应用程序开发中,工程师编写代码、编译、测试, 然后作为一个单元部署并提供功能服务。但是,如果一个网站有数以百万计的终端用户在持续使用,并且有成百上千的工程师在持续开发,这种方法就面临着很大的挑战了。
DoorDash 的平台就面临着类似的情况。起初,该平台是在单个大型代码库中开发的,2019 年,随着公司业务的增长,这种开发模型的缺点暴露了出来,开发人员的适应期越来越长,测试等待时间增加,开发人员的挫败感越来越强,应用程序的脆弱性越来越大。经过一番讨论,公司开始计划从单体迁移到 微服务架构 。
其实,工程团队已经在许多场景中使用了微服务,如流量高、业务重要、需要具备扩展性的 Web 服务。从根本上说,就是单体代码库的功能被分解成单独的、具有容错性的服务。最终,工程师管理的是一些相对较小的对象的生命周期,变更更容易理解了,也就更不容易出错。在部署频率和策略方面,这种架构很灵活。
2019 年底,在 DoorDash 做个变更需要全体总动员,甚至要暂停所有新特性的开发,以便公司可以专注于构建一个面向未来的可靠平台。虽然从单体中提取业务逻辑的工作仍在进行,但我们的微服务架构已经上线运行,为数以百万计的客户、商家和 Dasher(我们对送货司机的称呼)提供服务,他们每天都通过我们的平台下单、备货和配送。
业务增长
2013 年,DoorDash 涉足食品配送领域。那时,从工程师的角度来看,我们的任务是快速构建一个原型,通过电话和电子邮件这种基本的通信方式收集外卖订单并分发给一些企业。这个应用程序应该可以从客户那里接收订单,并将它们传达给餐馆,同时还要吸引 Dasher 接单并把食品送给客户。
起初,团队决定使用 Django (这以前是、现在仍然是一个一流的 Web 框架)构建 DoorDash Web 应用。事实证明,Django 很适合一个小型开发团队在短时间内构建最小可行产品。Django 还支持新特性的快速迭代,在早期,这非常有价值,因为业务逻辑一直在变。随着 DoorDash 的工程师越来越多,网站的复杂性越来越高,技术栈开始围绕 Django 强化。以敏捷性为首要目标,DoorDash 工程团队不断在这个单体系统上迭代,同时还要构建业务基础,扩展应用程序的功能。
在头几年,采用单体架构构建 Web 应用程序体现出了多方面的优势。主要的好处是,在单体上开发缩短了新特性上市时间。Django 为工程团队提供了一个前后端统一的框架,一个代码库,一种编程语言,一套共享的基础设施组件。在开始的时候,这种方法让 DoorDash 的 Web 应用程序走了很远,因为它让开发人员可以快速行动,把新特性提供给客户。由于整个代码库都在一个存储库中,所以可以通过重用组合模式将新增逻辑对接到已有的框架和基础设施上,加速开发。
不管是工程层面,还是操作层面,部署单个服务的成本都包含在里面了。我们只需要维护一个测试和部署管道,以及数量有限的云组件。例如,一个公共数据库支撑着后台大部分功能。
图 1:DoorDash 的原始架构,前端和后端都在一个 Django 应用程序中,数据访问基于单个 PostgreSQL 实例。
除了操作简单外,单体方法的另外一个好处是,当一个组件调用其他组件时不会产生服务间网络延迟成本。在单体部署时,不需要考虑组件间 API 向后兼容性、慢速网络调用、重试模式、断路器、负载分片策略以及其他常见的故障隔离做法。最后,由于所有数据都在一个数据库中,对于需要从多个领域聚合信息的请求,只需一次网络请求就可以从数据源查询到。
成长之痛
虽然在早期,单体架构是推动敏捷开发的有效方案,但随着时间推移,问题开始显现。这在单体应用的生命周期中是一个非常典型的场景,当应用程序和构建它的团队在扩展过程中跨越了特定的门槛时就会出现。DoorDash 是在 2017 年到了这个临界点,依据是构建新功能和扩展框架都越来越困难。
最终,DoorDash 应用程序变得有点脆弱。新增代码有时候会导致意外的副作用。一个看上去无关紧要的变更就可能触发级联测试失败,而那些代码路径本应是不相关的。
单元测试的构建也越来越不注意速度和最佳实践,这使得它们无法在持续集成管道中高效地运行。运行套件中一个测试子集来验证一个特定的功能也让人很难有信心,因为代码交织在一起,修改一个模块中的一个地方就可能导致代码库中看似不相关的部分回归。
在 DoorDash,现在修复 Bug、更新业务逻辑以及开发新特性都需要具备与代码库相关的大量知识,即使是相对简单的任务。新加入的工程师需要掌握有关单体的大量信息,才能有效、自如地处理日常工作。此外,单次部署中包含的变更数量越来越多,这意味着,如果有一个新变更导致了回归,那么回滚工作会对工程团队的总体速度产生不利影响。变更回滚成本增加使得发布团队不得不经常进行关键任务补丁的热修复部署,以此来避免再一次回滚。
此外,我们没有花精力防止不同领域的代码进入同一个模块。我们没有制定足够的保障措施,防止一个领域的业务逻辑调用另一个不同的领域,结果就是,模块划分有时候并不清晰。我们在编写和部署软件时遇到的绝大多数问题,都是由代码库的不同域之间没有隔离直接导致的。
由于平台流量越来越大,我们的技术栈也开始陷入挣扎。当有不熟悉这个大型 Python 代码库的新人加入时,代码就会出现稳定性问题。例如,该语言的静态类型特性让我们很难验证变更在运行时是不是会有意想不到的影响。虽然当时 Python 已经支持类型提示,但相对于我们所面临的问题的程度,要在整个代码库上采用会很复杂。
我们面临的另一个问题是,在编写单体时没有采用任何协作型多任务技术。如果采用了就好了,因为那样在纵向扩展系统时,可以减轻 I/O 密集型任务的影响。考虑到变更潜在的破坏性和难以预测,在单体中引入这种模式并不容易。就因为这样,满足流量增长所需的副本数量在短时间内迅速增加,我们的 Kubernetes 集群达到了容量上限。单体实例数量的增加经常会导致下游缓存和数据库达到连接上限,需要在中间部署像 PgBouncer 这样的连接池。
还有一个问题与数据库负载和数据迁移有关。虽然我们尝试通过为特定的领域单独创建数据库来减轻数据库的负载,但 DoorDash 仍然只有一个 PostgreSQL 主实例作为大部分数据的来源。不管是增加容量纵向扩展,还是增加读副本横向扩展,都因为所采用的技术而达到了某些瓶颈。战术性的缓解措施,如减少每秒查询数,被日益增长的每日订单量所抵消。随着数据库模型的增长,耦合成了主要的问题,将数据迁移到单独的、领域专属的数据库变得越来越困难。
多年来,我们做了不同的尝试,试图解决这些问题,但那都是一些孤立的方案,在网站和基础设施的扩展方面,缺少一个公司级的清晰的愿景。显然,我们需要降低领域间的耦合,我们需要制定一个计划来扩展软件和团队,但主要的问题是如何进行。
微服务初探
2014 年,DoorDash 从单体中提取了物流人工智能(AI)功能,构建了第一个微服务 Deep Red。那时,我们还没有计划重构成完全面向服务的架构,这个服务主要是用 Scala 编写的,因为 Python 不是很适合 CPU 密集型的任务。之后,我们开始构建或从单体中抽取更多的微服务。我们的目标是确保新服务有更好的隔离性,同时也更简洁,从而减少故障,提升工程团队的开发速度。支付、销售点、订单传输服务等也是这个初始阶段的产物。
然而,2018 年,DoorDash 开始面临严重的可靠性问题,使得工程师不得不将主要精力放在缓解问题上,而不是开发新特性。年底的时候,公司冻结了代码,并快速在整个工程部门采取了解决这个问题的行动。这一举措旨在解决站点不同区域的具体的稳定性问题,但并不包括对“架构为什么如此脆弱”的分析。
2019 年,DoorDash 工程部门新任副总裁 Ryan Sokol 开始从三个方面对软件工程做深刻的反思:
- 架构:单体 vs. 微服务;
- 技术栈:编程语言(Python vs. Java虚拟机 )、服务间通信( REST vs. gRPC )、异步处理( RabbitMQ vs. Apache Kafka / Cadence );
- 基础设施:基础设施的组织要便于扩大平台和工程团队的规模。
那时,架构采用了混合方式,部分子系统迁移到了没有公共技术栈的微服务上,而单体仍然是所有关键流程的中心。
DoorDash 开始评估重新设计软件架构的原因是多方面的。首先,我们很关心网站的稳定性。在如何重新设计架构的决策过程中,可靠性成为工程部门的第一要务。另一方面,我们很关心已经整合在一起的不同业务线(如 特色商品配送商城 和 DoorDash Drive )如何隔离,也对利用现有功能和平台单独构建新业务线的可能性很感兴趣。与此同时,公司已经对如何在多个领域中把任务关键的微服务(包括物流、订单传输和核心平台)投入生产应用有了深入的了解。
我们希望能够利用微服务架构多个方面的优势。一方面,DoorDash 希望有一个系统,其功能可以单独构建、执行和运营,服务失败不一定会导致系统失败,也不一定会导致系统中断。另一方面,公司希望可以灵活、快速地推出新业务线和新特性,而且可以依赖于底层平台实现可靠性、可扩展性和隔离性。
此外,该架构允许工程团队采用不同的技术构建不同类型的服务,包括前端、 BFF (Backend For Frontend)、后端。最后,公司希望有一种组织模型,可以根据架构需要灵活地扩大工程团队的规模。事实上,微服务架构就支持这样的模型。规模较小的团队专注于特定的领域,以及相应子系统的运营,认知成本也比较低。
重申下我们转向微服务架构的原因:
- 网站的稳定性
- 业务线的独立性
- 开发的敏捷性
- 工程平台和组织的扩展
- 不同类型的服务可以采用不同的技术栈
确定这些需求有助于我们充分思考、精心规划成功实现微服务架构转型的策略。
微服务转型
对 DoorDash 来说,走出单体并不是一件容易的事。公司决定在这一历史阶段启动这个过程,是因为企业经历了前所未有的增长(包括订单量、新客户、新商家),以及整合在同一平台上的新业务线的推出。
我们可以将这个仍在持续的过程分成四个独立的阶段:
- 初期:微服务策略启动之前
- Reach 项目:处理关键工作流
- Metronome 项目:开始业务逻辑抽取
- Phoenix 项目:设计并实现全盘基于微服务的平台
图 2:我们向微服务架构迁移的时间线自然始于代码冻结,我们停止了新特性的开发,直到我们打好了让我们可以彻底重构平台的基础。重要的是要记住,在整个过程中,不管是迁移前的系统,还是迁移后的系统,都是满足 DoorDash 的业务需求的。
初期
在这个阶段,从 2014 年到 2019 年,DoorDash 构建微服务以及从单体中提取微服务时没有一个具体的愿景或方向,我们并不是很清楚这些服务应该如何交互,以及底层公共的基础设施和技术栈应该是什么样子。这些服务的设计是为了将单体的部分功能交由专门从事特定领域业务逻辑开发的团队。然而,这些服务在执行某些工作流以及访问主数据库的数据时仍然依赖于单体。单体的任何故障或性能退化都会对这些“卫星”服务产生很大的影响。出现这种情况的原因是,这些服务在设计的时候就不是独立运行的,而这又主要是因为我们对于未来的软件架构没有一个统一的战略。
Reach 项目
2019 年,公司第一次有组织地解决代码库、基础设施、技术栈的重构问题。Reach 项目的范围要求我们处理一组关键的功能,并开始将相应的代码提取到新服务中。我们最初的成果主要是集中在业务关键工作流和前一个阶段已经在进行的代码抽取上。Reach 项目的另一项成果是开始标准化技术栈。在这个阶段,DoorDash 使用 Kotlin 作为后端服务的通用语言,使用 gRPC 作为默认的远程过程调用框架,用于服务间通信。其他的成果还包括从 PostgreSQL 签到 Cassandra ,解决一些扩展性问题。该项目是由一个小型工程师工作组在不同团队的代表的帮助下完成的。其主要目标是提升工程团队对于重构必要性的认识,并开始系统性地从单体提取代码。
Metronome 项目
Reach 项目为新的微服务架构奠定了技术基础,统一了新的提取模式,并证明了从单体迁出的可能性和必要性。Reach 项目产生了深刻的影响,工程团队获得了管理高层的支持,他们得以在 2019 年整个第四季度集中精力开展重构和提取工作。在 Metronome 项目期间,每个团队派出的代表会密切关注其领域的提取工作。技术方案管理部门也大力参与了这个过程,跟踪项目初期识别出的里程碑。在这一季度,提取速度加快,DoorDash 在提取一些关键工作流方面取得了重大进展,并分析了剩余的需要提取的功能。
Phoenix 项目
借着 Metronome 项目提取工作的势头,以及在 2019 年从单体中提取功能时积累的深层次知识,我们开始进入严格计划阶段,目的主要有两个:一是识别所有仍然由单体编排组织起来的工作流;二是确定微服务网格的最终结构。此外,计划阶段还有一个目标是针对每个服务和工作流定义所有的上下游依赖,从而使团队能够在提取和滚动上线过程中密切跟踪这一过程对所有利益相关者的影响。为了将遗留架构退役,我们还在计划中包含了将数据从主数据库迁移到领域专属数据库的任务。
完成提取过程所需的每项工作都被正式确定为里程碑。为了确定执行优先级,每个里程碑都被归类到三个层级中。规划阶段之后,工程团队的大部分成员都全身心地投入到了提取工作中,首先处理的是对业务来说最关键的工作流。
经历过这些阶段之后,一个多层微服务架构就形成了:
图 3:上图是我们的微服务架构的最终设计,从用户体验到核心基础设施共包含 5 个层。每一层都向上层提供功能,并利用下层暴露的功能。
- 前端层:提供基于不同前端平台构建的前端系统(DoorDash 移动 App、DoorDash Web 应用等),供顾客、商家和 Dasher 使用。
- BFF 层:前端层通过 BFF 与后端层解耦。BFF 层通过对多个后端服务进行编排来向前端提供功能,并隐藏后端架构。
- 后端层:提供支撑业务逻辑的核心功能(购物车服务、推送服务、发货服务等)。
- 平台层:提供公共功能供其他后端服务(身份服务、通信服务等)使用。
- 基础设施层:提供构建网站所需的基础设施组件(数据库、消息代理等),为将系统与底层环境(云服务提供商)分离奠定基础。
平台重构的主要挑战
在重构过程中,DoorDash 面临多项挑战。第一项挑战是让整个工程团队遵守微服务迁移的策略。这项工作并不简单,从提取工作来看,多年的单体开发使得工程团队形成了很大的惯性。认知偏见,也即“ 宜家效应 ”,是 DoorDash 需要新鲜血液来执行这项工作的原因之一。公司采用“ 保留意见 ,服从大局 ”的方式,在就不同的主题展开辩论后,工程团队会对已经决定的整体策略作出承诺。
让所有人都参与重构工作,需要我们积极宣传这种公司内前所未有的可靠性新模式。为了将可靠性打造成团队将要构建的新系统的基本属性,我们投入了大量的精力。除了关注可靠性外,我们还特别强调隔离性,这是我们在单体开发中不曾考虑的因素。在这方面,核心问题是控制 反模式 的使用,同时在提取敏捷性和技术债务之间寻找一种合理的平衡。
工程团队面临的主要挑战是要为新创建的服务定义新的交互接口,并着手提取功能,而不仅仅是提取代码。这项工作很难,因为在这个过程中,团队不仅要迁移到不同的技术栈,又要完成新特性开发任务。事实上,我们不可能在整个提取期间都使代码处于冻结状态,因为我们还是需要开发新功能来保持业务的增长,并适应客户、商家和 Dasher 不断变化的需求。
此外,不同领域代码提取的速度不同,增加了服务采用的难度。事实上,起初的提取工作并没有提供所有的下游依赖,从而完全迁移到基于微服务的架构。因此,团队不得不先采用临时解决方案,如直接访问数据库,然后等待 API 在相应的服务中落地。即使我们可以在提取过程中采用这样的反模式,表迁移也还是比较困难,因为我们没有一种清晰定义的数据访问模式。我们使用临时 API 来缓解这个问题,但这种方法增加了总的技术债务。因此,至关重要的是,要对采用过程进行持续监控,并在新的提取工作成功完成后进行推广应用。
最后,最复杂的一项工作是数据迁移,从主数据库迁移到新创建的服务专属的数据库,而又不中断业务。这个过程既重要又复杂,而且还存在潜在的破坏性,需要多次迭代才能为首批迁移打下基础。
值得注意的是,所有这些工作都是在食品配送市场受新冠影响订单量大幅增长的情况下完成的。在进行代码提取和数据迁移的同时,我们还要确保当前的架构能够处理不断增加的负载。同时处理所有这些因素可不是一件容易的事。
目标达成
自 Reach 项目启动 16 个月后,DoorDash 取得了重大进展,实现了一个完整的微服务架构,并且具备了退役单体以及与之关联的基础设施的能力。
我们正在将所有业务关键的工作流迁移到微服务中,并计划冻结单体代码库,当然,高优先级的修复属于为数不多的例外。基础设施团队专注于支持这个新架构,并希望它足够灵活,可以支持将来可能会发展的任何新业务线。此外,大部分关键的表都从主数据库中提取了出来,放入了微服务专属的数据库中,消除了该架构的一个主要瓶颈。虽然请求数因为业务增长而增加,但主数据库的负载却成比例地下降。
在新架构中,DoorDash 大约有 50 个微服务,其中 20 个在关键路径上,即它们的失败可能导致中断。因为采用了共同的基础设施组件、共同的技术栈,并且服务间的大部分交互实现了松耦合,所以 DoorDash 的可靠性和隔离性都达到了一个新的高度,将系统正常运行时间指标提升到了一个在单体下无法企及的高度。组织正在形成这样一种结构,由领域专家组成的小型团队获得了缩放、运营和扩展每个组件的知识。虽然我们取得了一些令人瞩目的成果,但也还面临着一些挑战。在本系列接下来的文章中,我们将探讨实现微服务时遇到的一些挑战,包括处理严格的数据契约,克服向平台添加新服务并运营的障碍,解决跨服务边界的失败问题。