花费四年完成Dropbox重构:用 Rust 写的新同步引擎

2020-04-13 14:23:02来源:InfoQ  

开源 GO 语言工具库、研究 iOS 和 Android 的 C++ 跨平台开发,花费五年时间从云平台向数据中心反向迁移......Dropbox 从未停止对技术的“折腾”。如今,这家公司又花费了四年时间重构了内部最古老、最重要的同步引擎核心代码。

Dropbox 花费四年完成重构

过去四年,我们一直在努力重构 Dropbox 桌面客户端同步引擎,这是 Dropbox 文件夹背后的重要技术,也是 Dropbox 最古老、最重要的代码之一。经过四年努力,我们已经向所有 Dropbox 用户发布了用 Rust 写的新同步引擎(代号为 “Nucleus” )。

重写同步引擎很难,我们也并不想盲目庆祝新版同步引擎的发布,因为在很多情景下,重写是一个糟糕的想法。不过,事实证明,重写对于 Dropbox 来说是一个好想法,但这只是因为我们对整个过程考虑得非常周全。我们将在本文分享关于如何考虑一个重要软件的重写问题,并强调使该项目取得成功的关键举措,比如,拥有一个非常干净的数据模型。

重构实属无奈:问题太多

2008 年,Dropbox 同步首次进入测试阶段。用户安装 Dropbox 应用程序,Dropbox 就会在他们电脑上创建一个文件夹,只要将文件保存在这个文件夹中,就可以把它们同步到 Dropbox 服务器和用户的其他设备上。Dropbox 服务器可以持久而安全地存储文件,并且这些文件还可以通过互联网连接在任何地点访问。

简单地说,同步引擎驻留在计算机上,负责对用户文件上传和下载到远程文件系统进行协调。

大规模同步很难

我们的第一个同步引擎称之为 “Sync Engine Classic” (意即 “经典同步引擎” ),它的数据模型存在一些基本问题,这些问题只有在大规模的情况下才会表现出来,使得渐进式改进变得不可能。

分布式系统很难

单就 Dropbox 的规模而言,构建分布式系统本身就是一项艰巨的任务。撇开原始规模不谈,文件同步就是一个独特的分布式系统问题,因为允许客户端长时间离线,并在重新上线时协调它们的更改。对许多分布式系统算法来说,网络分区是异常情况,但对我们来说却是标准操作。

正确处理这一点很重要:用户信任 Dropbox,并将自己最珍贵的内容托付给 Dropbox,因此,Dropbox 必须保证这些内容的安全,这没有商量余地。但是,双向同步有许多极端情况,持久性比仅仅确保不删除或破坏服务器上的数据要难得多。例如,Sync Engine Classic 将 “移动” 表示为一对操作:在旧位置上进行 “删除” 操作,并在新位置上进行 “添加” 操作。如果发生网络中断,删除操作会进行,但对应的添加操作却没有进行。然后,用户将会发现服务器和其他设备上出现文件丢失,即使他们只是在本地对文件进行移动操作也是如此。

保持持久性很难

Dropbox 的目标是:无论用户的计算机配置如何,都能 “正常工作” 。我们支持 Windows、macOS 和 Linux,这些平台都有各种各样的文件系统,且所有这些文件系统的行为略有不同。在操作系统下,硬件有很大差异,何况用户还会安装不同的内核扩展或驱动程序来改变操作系统行为。而在 Dropbox 之上,应用程序以不同的方式使用文件系统,并且依赖的行为可能实际上并不是其规范的一部分。

要保证特定环境的持久性,就需要理解其实现,有时甚至需要在调试生产问题时,对其进行逆向工程。这些问题通常会影响大量用户,而一个罕见的文件系统错误可能只影响很小一部分用户。因此,从规模上来看,在大部分环境下能够“正常工作”,并提供强大的持久性保证,从根本上是对立的。

测试文件同步很难

有了足够大的用户群,几乎所有理论上可能发生的事情都会在生产环境中发生。生产环境中的调试问题比开发环境中的调试问题要贵得多,特别是对于在用户设备上运行的软件而言。因此,在大规模生产之前,通过自动化测试来捕捉回归至关重要。

然而,同步引擎的测试很难,因为文件状态和用户动作的可能组合是一个天文数字。一个共享文件夹可能有数千个成员,每个成员都有一个同步引擎,该引擎具有不同的连接性,以及不同的 Dropbox 文件系统的过期视图。每个用户可能有不同的本地更改等待上传,并且他们从服务器下载文件的部分进度也可能有所不同。因此,系统有许多可能的 “快照” ,所以,所有这些都必须进行测试。

从系统状态中采取的有效操作的数量也非常庞大。同步文件是一个高度并发的过程,用户可能会同时上传和下载许多文件。单个文件的同步可能涉及并行传输内容块、将内容写入磁盘或从本地文件系统读取内容。全面测试需要尝试这些操作的不同顺序,以确保我们的系统不存在并发错误。

指定同步行为很难

最后,通常很难精确定义同步引擎的正确行为。例如,考虑这样一种情况:假设我们有三个文件夹,其中一个文件夹嵌套在另一个文件夹中。

假设两个用户 Alberto 和 Beatrice,他们脱机使用这个文件夹。Alberto 将 “Archives” 文件夹移到 “January” 文件夹;而 Beatrice 将 “Drafts” 文件夹移到 “Archives” 文件夹。

当他们重新联网后会发生什么情况呢?如果直接应用这些步骤,那么,我们的文件系统图中将会出现一个循环:“Archives” 文件夹是 “Drafts” 文件夹的父目录,“Drafts” 文件夹是 “January” 文件夹的父目录,而 “January” 文件夹是 “Archives” 文件夹的父目录。

在这种情况下,正确的最终系统状态是什么?Sync Engine Classic 复制每个文件夹,合并 Alberto 和 Bratrice 的目录树。使用 Nuclues,我们保留原始目录,最终的顺序取决于哪个同步引擎先上传它们的移动操作。

在这种三个文件夹和两个动作的简单情况下,Nucleus 有一个令人满意的最后状态。但是,我们该如何在一般情况下指定同步行为,而不会淹没在一系列的边角案例中呢?

译注:边角案例(Corner case),或病态案例(pathological case)是指其操作参数在正常范围以外的问题或是情形,而且多半是几个环境变量或是条件都在极端值的情形,即使这些极端值都还在参数规格范围内(或是边界),也算是边角案例。

例如有某个扬音器会扭曲声音,但只有在音量最大、低音最大及高湿度的环境下才会出现。或者服务器会有不稳定的情形,但条件是在最多 64 个辅助微处理器、内存为最大值是 512 Gigabyte,同时一万个用户上线时才会不稳定,这些都是边角案例。

边角案例和边缘案例不同,边缘条件只是单一个变量为最大值或最小值。若某个扬音器只要音量最大,不论其他条件是否正常或是极端,声音都会扭曲,这是边缘案例。

如何解决这些问题?

大规模同步文件很难。在 2016 年,我们已经很好地解决了这一问题。我们有数以亿计的用户,像 Smart Sync 这样的新产品特性正在开发中,还有一支强大的同步专家团队。Sync Engine Classic 经过多年生产强化,花了很多时间来寻找并修复最罕见的错误。

Joel Spolsky 称从头开始重写代码是 “任何软件公司都可能会犯的最严重的战略错误” 。要想成功地完成重写通常需要减缓特性开发速度,因为在旧系统上取得的进展需要移植到新系统上。当然,还有很多面向用户的项目,我们的同步工程师可以进行。

尽管 Sync Engine Classic 取得了成功,但却非常不健康。在构建 Smart Sync 的过程中,我们对系统做了许多渐进式改进,清理了低劣代码,并重构了接口,甚至还添加了 Python 类型注释。我们添加了大量遥测技术,并建立了流程,以确保维护既安全又简单。但是,这些渐进的改进还是远远不够。

交付任何更改以同步行为都需要进行艰苦的部署,而且我们仍然会在生产中发现复杂的不一致性。团队必须放下一切,诊断问题,解决问题,然后花时间让他们的应用程序恢复到良好的状态。尽管我们有一支强大的专家团队,但要让新工程师融入这个系统还是需要花费数年时间。最后,我们投入了大量时间来提高性能,但未能明显扩展同步引擎可以管理的文件总数。

这些问题有几个根本原因,但最重要的是 Sync Engine Classic 的数据模型。数据模型是为一个没有共享的、更简单的世界而设计的,并且文件缺少稳定的标识符,这个标识符可以在移动的过程中保持。我们会花费数个小时来调试理论上可能但 “极不可能” 出现在生产环境中的问题。改变一个系统的基本名词通常不可能一蹴而就,很快我们就没有有效的渐进式改进的方法了。

其次,该系统并不是为可测试性而设计的。我们依赖于缓慢的发布和现场调试问题,而不是自动化的预发布测试。Sync Engine Classic 容许数据模型意味着我们不能在压力测试中进行太多检查,因为有大量不受欢迎但仍然合法的结果而我们无法断言。拥有一个具有约束不变量(tight invariant)的强大数据模型对于测试非常有价值,因为检查系统是否处于有效状态总是一件很容易做到的事情。

我们在前文讨论了为什么同步是一个并发问题,并且测试和调试并发代码出了名的难。Sync Engine Classic 的基于线程的架构根本一点用都没有。它将所有调度决策都交给了操作系统,使得集成测试变得不可重现。在实践中,我们最终使用的是长时间持有的非常粗粒度的锁。虽然这种架构牺牲了并行性的优点,但使系统变得更易于推理。

重写前,需要评估什么?

让我们将决定重写的原因提炼成一份关于重写的清单,它可以帮助在其他系统中进行此类决策。

你是否已经用尽渐进式改进方法?

1、你是否尝试过将代码重构为更好的模块?

代码质量低劣本身并非重写系统的重要原因。重命名变量和解开纠缠在一起的模块都可以用渐进式改进来完成,我们在 Sync Engine Classic 中花了很多时间来完成这些工作。Python 的动态性可能会让这一点变得困难,因此,我们添加了 MyPy 注释,以便在编译时逐渐捕获更多的错误。但是,系统的核心原语仍然保持不变,因为仅靠重构并不能改变基本数据模型。

2、你是否尝试通过优化来提高性能?

软件通常把大部分时间花在很少的代码上。许多性能问题都不是根本问题,优化分析器识别的热点是一种渐进式改进性能的好方法。几个月以来,团队一直致力于性能和规模方面的工作,他们在提高文件内容传输性能方面取得了巨大成果。但是,对内存占用的改进(比如增加系统可以管理的文件数量)仍然难以实现。

3、你能提供更多价值吗?

即使你决定重写,你能通过提高价值来降低风险吗?这样做可以验证早期的技术决策,帮助项目保持发展势头,并减轻缓慢的特性开发带来的痛苦。

你能进行重写吗?

1、你是否深刻理解并尊重当前的系统?

编写新代码比完全理解现有代码要容易得多。因此,在重写之前,你必须深刻理解并尊重 “经典” 系统。这就是你的团队和业务存在的全部原因,它通过在生产环境中运行积累了多年的智慧。去做一番考古研究,探究为什么这一切都是这样的。

2、你有时间吗?

从头开始重写系统是一项艰苦的工作,而且要实现完整的特性需要花费大量的时间。你有这些资源吗?你的组织是否足够健康,能够维持如此大规模的项目?

3、你能接受较慢的特性开发速度吗?

我们并没有完全停止 Sync Engine Classic 的特性开发,但是旧系统的每一次改变都会将新系统的终点线推得更远。我们决定交付一些项目,在不拖累重写团队的情况下,我们必须有意识地分配资源来指导这些项目的发布。我们还对 Sync Engine Classic 遥测技术进行了大量投资,以将其稳态维护成本维持在最低水平。

你的目标是什么?

1、为什么第二次会更好?

如果你已经走到这一步,那么你已经彻底地理解了旧系统,以及需要吸取的教训。但是,重写也应该受到不断变化的要求或业务需求的推动。我们在上文中阐述了文件同步是如何变化的,但是,我们重写的决定也是具有前瞻性的。Dropbox 了解协作用户在工作中日益增长的需求,为这些用户构建新特性需要一个灵活、健壮的同步引擎。

2、你对新系统的原则是什么?

对一个团队来说,从头开始是一个重塑技术文化的绝佳机会。鉴于我们操作 Sync Engine Classic 的经验,我们从一开始就非常强调测试、正确性和可调试性,将所有这些原则编码到数据模型中。我们在项目生命周期的早期就写出了这些原则,它们为自己带来了一次又一次的回报。

我们用 Rust 重构核心代码

最终,我们用 Rust 编写了 Nucleus。对我们团队来说,押注 Rust 是我们做出的最好决定之一。除了性能之外,它对正确性的关注帮助我们克服了同步的复杂性。我们可以在类型系统中对系统的复杂不变量进行编码,并让编译器为我们检查它们。

我们几乎所有的代码都在一个线程( “控制线程” )上运行,并使用 Rust 的 futures 库在这个线程上调度许多并发操作。我们只在需要的时候才将工作转移给其他线程:网络 IO 到事件循环,计算开销大的工作,如哈希到线程池,文件系统 IO 到专用线程。这大大降低了开发人员在添加新特性时必须考虑的范围和复杂性。

当控制线程的输入和调度决策是固定的时,它被设计为完全确定的。我们使用这一性质,用伪随机模拟测试对其进行模糊处理。利用随机数生成器的种子,我们可以生成随机的初始文件系统状态、时间表和系统扰动,并让引擎运行到完成状态。然后,如果我们没有通过任何同步正确性检查,我们总是可以从原始种子中重现错误。我们每天在测试基础设施中运行数以百万计的各种场景。

我们重新设计了客户端 - 服务器协议,使其具有很强的一致性。该协议确保服务器和客户端在考虑更改之前具有相同的远程文件视图。共享文件夹和文件具有全局唯一的标识符,客户端永远不会在临时复制或丢失状态下观察到它们。现在,我们在客户端和服务器的远程文件系统视图之间进行了强有力的一致性检查,任何差异都是错误。

参考链接:

https://dropbox.tech/infrastructure/rewriting-the-heart-of-our-sync-engine

相关阅读

精彩推荐

最新推送

推荐阅读