如何将 DoorDash 的后端服务从 Python 迁移到 Kotlin?

发表时间: 2021-07-19 14:21

美国外卖平台 DoorDash 原先的代码库是基于 Django 的单体应用。之前这个平台对业务的支持能力已逼近天花板。为给送餐服务提供更坚实的基础,DoorDash 需要全新设计的技术栈。新平台应能很好地支撑企业的未来增长,并支持团队在构建中持续推陈出新,用上更好的模式。

原系统的每次发布都需更新大量的节点,这显著增加了所需的发布时间。并且每次部署中都有大量的提交,一旦部署存在问题,难以通过对分定位(Bisecting)发现具体导致问题的某次或某些提交,问题定位耗时也更长。此外,原单体应用是基于Python 2和Django构建的,而旧版本 Python 正迅速进入寿命终止期(EOL,End of Life),难以继续获得可靠的支持。

为实现具有更好可扩展性的系统,DoorDash 工程团队需要去分解单体应用,确定新服务的界面和交互行为。接下来的首要问题是如何确定支持团队工作的技术栈。通过对多种语言的调研,团队选定了具有丰富的生态系统、与 Java 良好互操作性和对开发人员友好的Kotlin。针对 Kotlin 逐渐暴露出来的痛点问题,团队做出了一些改进。

确定适用的技术栈

当前存在多种可用的服务器端软件构建方案。但是出于以下方面考虑因素,团队考虑只使用单一语言。

  • 有助于团队聚力,推动最佳开发实践在整个工程组织内的共享。
  • 支持针对企业场景构建优化的通用软件库,很好地适应企业规模和持续增长。
  • 极大降低工程人员在团队间转岗的摩擦,推动相互合作。

综合上述因素,对于团队而言,问题并非是否应该使用单一的开发语言,而是应该选定哪一种语言。

选择适合的编程语言

选择编程语言时,团队要从企业的需求着手,考虑因素包括未来服务的体验以及交互方式等。团队很快取得一致,决定对服务间的相互同步通信机制使用gRPC、消息队列使用Apache Kafka。数据存储将继续使用Postgres和Apache Cassandra,因为团队成员对此经验丰富、技能熟练,并且二者的技术成熟度也非常高,广泛支持所有的现代编程语言。下面还需要考虑其它一些因素。

无论选定何种技术,都需要满足:

  • 高效利用 CPU,可扩展到多核。
  • 易于监控
  • 具有强大的软件库生态支持,使团队可聚焦于业务问题本身。
  • 确保提供良好的开发人员生产率
  • 可靠的扩展性
  • 面向未来,为企业业务增长提供良好支撑。

团队基于上述需求考虑了各种语言,弃用了C++、Ruby、PHP和Scala等主流语言。尽管这些语言颇受欢迎,但它们难以支撑每秒查询数(QPS)和用户数的增长,不能满足团队对未来技术栈的一项或数项核心需求。基于上述需求,选择范围锁定在Kotlin、Java、Go、Rust和Python 3。为比较和对比各语言相互之间的优劣之处,团队形成了如下对比表。

Kotlin

优点:

  • 软件库生态系统强大
  • 对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
  • 继承 Java 的生态系统,速度快,可扩展。
  • 原生支持并发原语,避免了 Java 的繁琐,去除了对复杂的Builder/Factory模式的依赖。
  • Java Agent 提供强大的组件内省(introspection),仅需少量编码,即可自动定义并导出度量和追踪,以实现监控。

不足:

  • 通常很少用于服务器端,开发人员缺少可供参考的示例代码。
  • 相比 Go 而言,并发实现相对繁琐。Go 在语言基础层和标准软件库中集成了 goroutine(译者注:原文是“gothreads”)这一核心理念。

Java

优点:

  • 具有强大的软件库生态系统
  • 对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
  • 速度快、可扩展
  • Java Agent 提供强大的组件内省(introspection),仅需少量编码,即可自动定义并导出度量和追踪,以实现监控。

不足:

  • 相对 Kotlin 和 Go 而言,并发实现相对繁琐。
  • 编码存在极端冗长问题,难以编写整洁的代码。

Go

优点:

  • 具有强大的软件库生态系统
  • 对 gRPC、HTTP、Kafka、Cassandr 和 SQL 提供一等支持
  • 速度快、可扩展
  • 原生支持并发原语,简化了并发代码的编写。
  • 具有大量可用的服务器端例子程序和文档。

不足:

  • 对于不熟悉 Go 语言的开发人员,配置数据模型并非易事。
  • 尽管 Go 最终会支持泛型(generics),但当前尚不支持。这意味着一些软件库中的类相对难以在Go中构建。

Rust

优点:

  • 运行速度非常快
  • 没有垃圾回收机制,依然内存和并发安全。
  • 一些大型企业开始采用该语言,因此具有大量投资及很好的发展。
  • 相比其它语言,其提供强大的类型系统,更易于表达复杂理念和模式。

不足:

  • 语言较新,这意味着例子代码、软件库以及具有模式构建和调试经验的开发人员相对不足。
  • 相比其它语言,生态系统不算强大。
  • 当前 async/await 尚未实现标准化。
  • 要掌握内存模型,需要一定的学习时间。

Python 3

优点:

  • 提供强大的软件库生态系统
  • 易用
  • 团队已经具有丰富的经验
  • 易于招聘开发人员
  • 对 GRPC、HTTP、Cassandra 和 SQL 提供一等支持
  • 提供 REPL,便于 App 运行时的测试和调试。

不足:

  • 相比其它语言,运行速度较慢。
  • 解释器全局锁(GIL)难以完全高效利用团队的多核机器。
  • 缺少强大的类型检查特性。
  • 当前对 Kafka 支持不够,特性发布存在延迟。

根据以上对比,团队决定开发一个经过测试和扩展的 Kotlin 组件的“黄金标准”。kotlin 本质上是一种更适合团队的 Java 版本,但缓解了 Java 存在的痛点问题。由此团队选择了 Kotlin,但必须要去解决进一步发展中经历的一些问题。

相对 Java,Kotlin 的优点

相对 Java,Kotlin 的一个最大优点是“空值安全”(null safety)。Kotlin 中,开发人员必须明确定义可为空值的对象,并强制开发人员采用安全方式处理,避免了必须处理大量潜在的运行时异常的痛点。也可使用空值合并(null-coalescing)操作符“?.”,用单行代码实现对可为空值子域的安全访问。例如,Java 实现为:

int subLength = 0;if (obj != null) {  if (obj.subObj != null) {    subLenth = obj.subObj.length();  } }

复制代码

使用 Kotlin 实现为:

val subLength = obj?.subObj?.length() ?: 0

复制代码

尽管上面给出的例子非常简单,但已经足够体现出空值合并操作符的强大之处,即大大降低了代码中条件语句的数量,提高了代码的可读性。

相比其它语言,在实现服务度量的仪表盘监控中,使用 Kotlin 更易于迁移到Prometheus事件监测系统。团队开发了一个注解处理器(Annotation Processor),自动按度量生成相应的函数,确保正确数量的标注按正确的顺序给出。

下面例子给出了 Prometheus 软件库的标准集成代码:

// to declareval SuccessfulRequests = Counter.build(     "successful_requests",    "successful proxying of requests",).labelNames("handler", "method", "regex", "downstream").register()// to useSuccessfulRequests.label("handlerName", "GET", ".*", "www.google.com").inc()

复制代码

修改为如下代码,API 更不易出错:

// to declare@PromMetric(  PromMetricType.Counter,   "successful_requests",   "successful proxying of requests",   ["handler", "method", "regex", "downstream"])object SuccessfulRequests// to useSuccessfulRequests("handlerName", "GET", ".*", "www.google.com").inc()

复制代码

采用如上的集成方式,不必再去记住某个度量所具有标注的数量和顺序,而是由编译器和所使用的 IDE 去确保标注的正确数量和名称。DoorDash 使用了分布式追踪,监控的集成可简化为类似于在运行时添加Java Agent。。对于一个新服务,不需要代码属主团队去修改代码,可观测性和架构团队就能快速地推出对应的分布式追踪。

在团队看来,Kotlin 的另一个非常强大之处是协程(Coroutines)。协程模式让开发人员无需纠结于回调这个天坑,能使用近乎命令式编程的方式去编写代码,这正是大部分开发人员更为驾轻就熟的方式。协程也非常易于组合,必要时可并行运行。下面例子给出了团队使用的某个 Kafka 消费者:

val awaiting = msgs.partitions().map { topicPartition ->   async {       val records = msgs.records(topicPartition)       val processor = processors[topicPartition.topic()]       if (processor == null) {           logger.withValues(               Pair("topic", topicPartition.topic()),           ).error("No processor configured for topic for which we have received messages")       } else {           try {               processRecords(records, processor)           } catch (e: Exception) {               logger.withValues(                   Pair("topic", topicPartition.topic()),                   Pair("partition", topicPartition.partition()),               ).error("Failed to process and commit a batch of records")           }       }   }}awaiting.awaitAll()

复制代码

Kotlin 协程支持在编码中按分区快速地切分消息,并对每个分区启动一个处理消息的协程,不破坏消息插入队列时的顺序。此后,在检查偏移并返回 Broker 前,连接所有的 Future。

Kotlin 支持团队以更可靠和可扩展的方式快速推进。从上面的例子中可见一斑。

解决推广 Kotlin 中遇到的问题

为更好地利用 Kotlin 的全部特性,团队必须要解决以下问题:

  • 如何培训团队更高效地使用 Kotlin
  • 建立使用协程的最佳实践
  • 解决与 Java 互操作上的痛点
  • 进一步简化依赖管理

下面展开介绍团队时如何解决上述问题的

培训团队使用 Kotlin

采用 Kotlin 的一个最大问题,就是如何确保提升团队的开发速度。团队中大多数人具备优秀的 Python 开发背景,后端团队具有一些 Java 和 Ruby 经验。考虑到在后端开发中很少使用 Kotlin,因此团队必须要建立指导后端开发人员使用 Kotlin 的良好指南。

尽管在线上可以找到大量的学习教程,但是大多数Kotlin线上社区主要专注于安卓开发。团队的高级开发人员编写了“如何使用 Kotlin 编程”,其中给出了编程建议和代码片段。我们团队发布了“碎片化学习教程”(Lunch and Learns session),告诉开发人员如何避免一些常见的坑,如何有效地使用 IntelliJ IDE 开展工作。

团队更多地传授开发人员 Kotlin 的函数式编程方面内容,包括如何使用模式匹配、不可变性默认优先等理念。团队建立了人人可加入、提问并获得建议的线上交流小组(Slack Channel),形成了一个 Kotlin 工程互助指导社区。通过以上工作,团队构建一个强大的、熟练掌握 Kotlin 的工程人员团队,并在团队进一步扩展时能传承知识,形成可持续发展和改进的内循环。

避免掉进协程中的坑

团队在选择 Kotlin 时,尚缺少对协程的支持(译者注:2018年10月,Kotlin 1.3推出了coroutines稳定特性)。因此团队选定 gRPC 作为服务间通信方法,为充分利用 Kotlin 需做一定改进。当时 gRPC-Java 是 Kotlin gRPC 服务的唯一选择,因为 Java 中并不存在协程,因此 gRPC-Java 也缺少对协程的支持。

两个开源项目Kroto-plus和Protokruft可以解决这个问题,团队最终分别使用了这两个解决方案的各一部分去设计服务,创建更具原生感的解决方案。最近gRPC-Kotlin发布了一般可用(GA)版。为提供更好的 Kotlin 构建系统体验,团队我们正在迁移服务以使用官方绑定版本。

对于已转向 Kotlin 的安卓开发人员,对协程中存在的其它坑应该并不陌生。例如,不要在请求中重用CoroutineContexts,因为一旦取消或出现异常,CoroutineContext 就会转入“cancelled”状态,这意味着任何进一步尝试在此 Context 中加载协程将会产生失败。

正因为此,需对服务器处理的每个请求新建一个 CoroutineContext,不能再依赖于ThreadLocal变量,因为协程可在 Context 中换入换出,导致数据不正确或被覆盖。另一个需要警惕的坑是避免使用未绑定的GlobalScope加载协程,会导致资源上的问题。

解决虚引用 Java NIO 问题

支持现代Java非阻塞IO(NIO)标准的软件库,可以很好地与 Kotlin 协程互操作。但在选定 Kotlin 后,我们发现很多宣称支持 Java NIO 的软件库的实现方式并非可扩展的。它们在底层协议和标准实现中并非基于 NIO 原语,而是使用线程池包裹阻塞 IO。我们称这种 NIO 实现策略为“虚引用(Phantom)Java NIO”。

虚引用 NIO 策略实现的副作用是线程池在协程环境中很容易耗尽,由于其本质上是阻塞 IO,会导致高峰值延迟。为确保线程池的规模能满足团队的需求,虚引用 NIO 软件库都需要对线程池做调优,恰当调优和资源维护的需求增加了开发人员工作量。因此,使用真正的 NIO 或 Kotlin 原生库,通常会提供更好的性能、更易于扩展,实现更优的开发人员工作流。

依赖项管理:使用Gradle颇具挑战

相比 Rust Cargo 和 Go module 等最新解决方案,构建系统和依赖管理无论对新手还是熟悉 Java/JVM 生态者都相当不够直观。尤其是对于团队而言,一些依赖直接或间接地对版本升级非常敏感。Kafka 和 Scala 等项目并不遵循语义化版本管理(semantic versioning),这会导致编译成功而应用却由于一些看上去毫不相干的奇怪回溯(backtrace)而启动失败的问题。

寸积铢累,团队逐渐掌握了哪些项目通常会导致此类问题,积累了一些如何捕获并过滤问题的例子。特别是,Gradle 针对如何查看依赖树提供了一些有参考的页面,非常适用于此类问题。掌握多项目代码库的进入导出情况,需假以时日,期间非常容易导致冲突需求和环形依赖。

预先规划多项目代码库的布局,对项目的长期发展是大有裨益的。尽量确保依赖树简单,避免基础代码库对任一子项目的依赖(并且永不依赖),进而在此基础上做迭代构建,防止出现难以调试或厘清的依赖链。DoorDash 主要使用了JFrog Artifactory,简化了软件库在代码库间的共享。

Kotlin 在 DoorDash 的未来发展

DoorDash 服务标准将继续完全采用 Kotlin,平台团队正基于Guice和Armeria努力构建下一代服务标准,并通过预先部署监控、分布式追踪、异常追踪、运行时配置管理工具和安全集成等工具和功能,简化团队的开发工作流。

这些工作有助于 DooDash 开发共享性更好的代码,减轻开发人员查找依赖项、协同工作和维持最新依赖的负担。构建此类系统的投资,已体现在团队具备了针对涌现的需求而快速启动新服务的能力。Kotlin 支持开发人员聚焦于业务用例,减少了编写 Java 生态中不可避免的模板代码所用的时间。总而言之,Kotlin 是很好的选择,我们期待这一语言和生态的持续改进。

基于 DoorDash 的经验,强烈推荐后端工程人员首选 Kotlin。Kotlin 是更好的 Java 语言,该理念在 DoorDash 得到了验证,带来了更大的开发人员生产率,降低了运行时发现的错误。这些优点支持团队聚焦于解决业务需求,增加敏捷性和速度。未来 DoorDash 将继续投资于 Kotlin,希望继续与更广泛的生态合作,开发以 Kotlin 为主的更强大服务器端用例。

答疑

问题:为什么没有选定 Python 3?

答:尽管 Python 3 具有更强大的生态,但整个生态系统依然不够健壮。Pip 在依赖管理上存在很多问题,而 conda、poetry、pipend、pip-tools 等工具也并未解决问题。对于构建和软件包工具存在同样问题。

使用协程时遇到的最大坑:取消或异常会导致 CoroutineContext 进入“cancelled”状态,这意味着进一步尝试在此上下文中加载协程将会失败,对于服务器处理的每个请求,需要创建一个新的 CoroutineContext。更坏情况时,新的上下文每次创建的代价很大。需要建立一类发生异常后无需取消的特殊任务类型,以及建立很好的协程异常处理。

团队使用 Kotlin 在 Apache Flink 中实现流处理。为解决虚引用 NIO 问题,团队拟出了一个符合“黄金准则”的软件库列表。其中软件库或是很好地实现了协程,或是提供预优化版本的库。

问题:非阻塞 IO 是如何实现的?DoorDash 最终使用了第三方软件库,还是推出了自己的?DoorDash 的主要 IO 是网络调用、文件系统还是消息代理?

答:DoorDash 构建了自己的软件库,针对特定服务使用 gRPC。

问题:DoorDash 在从 Python 迁移到 Kotlin 中,是如何解决 CI/CD 问题的?

答:团队采用 CI/CD 的层级和版本一直在演进,至少在今年就发生了不少变化,每次迭代都会向前推进一步。

原文链接:

https://doordash.engineering/2021/05/04/migrating-from-python-to-kotlin-for-our-backend-services/