最近我读到一个新的 Rust Web 框架,叫做 rwf。它在处理数据库迁移方面的思路引起了我的兴趣。近年来,许多框架要么忽视数据库迁移,要么提供一些难以适用于大型项目的机制。而通过文档,我了解到 rwf 属于后者:它生成了两个 SQL 文件,一个用于前进迁移,一个用于回退迁移,并根据迁移方向选择合适的文件。
问题不在于使用 SQL(虽然这确实不理想),而在于系统的设计限制了其规模。当团队或数据库规模增长时,这样的系统很快就显得无用。
我一直在思考一个更好数据迁移解决方案的可能性,让我们来探讨一下我目前的一些想法。
定义共享的术语由于开发者往往热衷于争论术语,为了便于理解我们讨论的主题,我们先定义几个基本概念。
数据库 指的是本文中的关系型数据库,如 PostgreSQL 或 SQLite。尽管我们讨论的许多内容也适用于面向文档的数据库(如 MongoDB),但本文将聚焦于关系型数据库。
应用程序 是指使用数据库并对用户开放的程序。不再赘述此术语。
当数据库规模庞大时,改变其结构或内容的传统方法将耗费大量时间,且需采用非常规的手段来加速这一过程。
应用数据 是应用所需的数据及数据结构,如数据库中的数据或其他存储位置(例如 AWS S3)。
迁移 是一种将应用数据从状态 A 过渡到状态 B 的功能。
迁移具有特定的方向:向上表示时间向前推进(即应用更改),向下表示时间向后退回(即撤销更改)。
迁移过程 指的是运行一个或多个迁移的操作。
定义需求明确术语后,我们来定义迁移过程必须满足的几个需求。
我必须是永恒的:给定某个过去状态 S<sub>1</sub> 的应用数据,我们必须能够将其迁移至未来状态 S<sub>N</sub>,或者相反。
必须具备可扩展性:不仅需要迁移数据库结构,还需要迁移数据,最好还能支持非数据库数据。无论是小型还是大型数据库,都应支持。
必须易于使用:迁移过程应自动化(而非需要大量手动步骤),并且容易触发、理解和在运行时监控。
系统正确性必须可以验证:对于每个迁移,我们应该能够验证其是否有效。简单来说:应易于为迁移编写测试。
根据不同需求,可能会有其他要求,但以上几点是本文将探讨的核心需求。
一个真实的案例为了更好地理解我们要处理的内容,让我们看一个相对复杂的真实软件实例:GitLab。
GitLab 最初是一个 GitHub 的克隆,由少数开发者打造。它使用 Ruby on Rails 提供的迁移框架来执行数据库迁移。随着 GitLab 的受欢迎程度及功能的增多,其数据库规模也随之扩张。2015 年我加入 GitLab 时,GitLab.com 的数据库大约有 200-300 GiB。在我 2021 年 12 月离职时,这一规模已增至 1-2 TiB。我们曾短暂地将数据库缩减至零,但那只是因为我无意中删除了整个生产数据库。
玩笑归玩笑,随着数据库规模的增长,传统的数据库迁移方法已经不再适用。比如,重命名一个列对于大型表已不可行,因为时间成本太高。同样地,传统 Rails 方法进行数据格式迁移可能需要数周才能完成。
解决这些问题需要不同的策略,包括:
将迁移分为“预部署”和“后部署”迁移。预部署迁移在代码更改前运行,只允许进行向后兼容的更改(如添加列)。后部署迁移在代码更改后运行,通常用于清理过往迁移(如删除不再使用的列)。
数据库迁移不再允许重用应用逻辑(如 Rails 模型),而必须自行定义所需的类或方法。这样,迁移实际上是运行代码的快照,使其与应用程序的其他部分相对隔离,提高了可靠性。
对于大规模数据迁移(可能需几天甚至几周的迁移),我们使用 Sidekiq 在后台调度任务。这类迁移可能需要数天甚至数周才能完成。未来的部署会包含一个迁移以检查所有工作是否完成(若未完成则继续执行),随后进行必要的清理工作。
这种方式使 GitLab 能够迁移小型和大型表以及数据库外存储的数据,但也暴露出 Rails 提供的迁移系统的几个问题:
Rails 仅提供结构更改的基本工具,却未提供超出此范围的可扩展支持。
系统缺乏确保永恒性的手段,即无法阻止依赖可能发生意外变化的应用逻辑,从而破坏迁移过程。
Rails 不提供迁移测试工具,需要自己动手解决。
GitLab 的迁移方案同样存在问题:
GitLab 的方案并非永恒性:尽管我们尽量隔离迁移,但有时代码的重复度太高,以至于我们选择复用应用逻辑。撤销迁移也相当困难,在许多情况下,在生产环境中完全无法实现。
引入后台迁移且缺乏良好的监控,使系统变得不易理解和监控。
GitLab 同时作为 SaaS 和定期发布的自托管应用程序,这意味着它并非真正永恒,只允许在同一主版本的不同小版本之间进行升级,而非从 1.2.3 升级到 2.1.0。
尽管这一方案并非理想,但在当时的限制条件下,这是我们能想到的最佳方案。
若要从零开始创建一个框架或应用程序,我们可以做得更好,但需要先理解现有解决方案面临的问题。
现有解决方案的问题第一个问题是现有的迁移系统假设迁移的存在即表示其具备永恒性,且将永远有效。如果迁移仅创建一个表或列,这种假设可能成立,但对于更复杂的操作则未必如此。将迁移与所属的应用隔离可能有帮助,但由于代码重复的需求,很多情况下不实际。即使重复的代码量很少,但在许多迁移中多次重复会造成负担。
我们需要一种方法来捕捉迁移编写时的应用逻辑快照,然后基于这一快照运行迁移。这确保了我们总能按预期的状态向上或向下迁移,类似于最小版本选择,确保依赖项始终使用符合要求的最小版本,而非最大版本。
第二个问题是,构建一个具备可扩展性的迁移系统,需要理解可扩展性的真正含义,这往往意味着经历过不具备可扩展性的系统的痛苦。然而,新迁移系统的开发者似乎要么缺乏这种经验,要么并不关心,导致其解决方案只能满足最基本的情境。
有经验的人则不太愿意构建更好的解决方案,或许是因为已在这个问题上耗尽了精力。我在离开 GitLab 后的头一两年也无心思考此类问题。
第三个问题与第二个相关,少有项目能发展到需要更复杂的数据迁移解决方案的程度。因此,推动超越现状的动力不足。
第四个问题是,不同项目对数据迁移的需求不同,这可能导致不同的解决方案。一个使用小型 SQLite 数据库的移动应用与使用 10 TiB 数据库的 SaaS 应用在数据迁移上有着截然不同的方式,而构建一个适用于这两种情况的通用解决方案可能较为困难。
构建一个更好的系统现在我们已经定义了要求,讨论了一个真实的例子,并列出了现有解决方案面临的一些问题,那么一个更好的解决方案会是什么样子呢?
对于部署在受控环境中的应用程序(即 非 部署到我们无法控制的手机的移动应用程序),我认为我有一个粗略的想法。以下内容仅适用于部署到受控环境。
迁移应当是函数正如我之前提到的,迁移应该是“函数”。函数的定义是编程语言中的函数,而不是仅被称作“函数”的 SQL 片段。这意味着它们可以用 Ruby、Lua、Rust 或其他喜欢的编程语言来编写。我本以为这点显而易见,但新的框架和数据库工具(如 rwf 和 Diesel 等)往往只支持 SQL 文件/表达式,似乎并不提倡这种方式。
在两种迁移方向上都应提供相应的函数,这样在生产环境中发现迁移破坏系统时,可以快速回滚。当然,这假设你可以实际还原迁移(即数据没有被不可逆地改变),即便不能,创建一个新迁移以撤销更改也是有用的,因为在测试和开发环境中仍需要“down”函数。
在特定的 VCS 修订版本上运行迁移迁移应针对特定的 VCS(版本控制系统)修订版本运行,而不是最新的修订版本。为此,需要维护一个文件以追踪要运行的迁移及其修订版本。这也意味着创建迁移的过程分为两个步骤:
先创建、测试并提交迁移。
记录该修订版本并将其提交到迁移修订文件中,作为一个独立的提交。
虽然这看起来麻烦,但自动化第二步其实非常简单,实际使用中不会成为问题。
在执行迁移时,系统会根据当前状态和目标状态确定要运行的迁移范围M<sub>1</sub>,M<sub>2</sub>,…,M<sub>N</sub>M<sub>1</sub>,M<sub>2</sub>,…,M<sub>N</sub>。对于范围中的每个迁移,系统检出相应的修订版本,并对该版本运行迁移。整个过程结束后,系统会重新检出最初的修订版本。
在已知的代码修订版本上运行迁移,确保了它的“时态稳定性”,即便需要重用应用程序逻辑也能稳定运行。这也意味着无需复制迁移所需的应用逻辑代码,使得编写、审查和维护迁移变得更加简单。此外,迁移不再需要一直保留,只要它仍在迁移修订文件中记录,我们就可以随时运行它。
将迁移分为部署前和部署后迁移迁移需要分为“部署前”和“部署后”迁移,类似于 GitLab 的方法。这样可以在“部署前”迁移中进行添加和其他向后兼容的更改,而在“部署后”迁移中进行那些需要先更新代码的更改。比如重命名列的简单例子:一个“部署前”迁移添加新列并复制数据(并可能安装触发器以保持两者同步),部署过程更新代码以使用新列,然后“部署后”迁移在部署后移除不再使用的列。
提供运行大规模数据迁移的手段处理大规模数据迁移是个更棘手的问题,至少需要满足以下几点:
能够以“分叉-合并”的方式将工作负载分配到多个主机上,即迁移在 M 个主机上调度 N 个任务,等待任务完成后继续。
能够在特定修订版本上运行这些后台任务,以便稳定地重用应用逻辑。
能够将某些部署标记为“非阻塞”,即未来的部署无需等待它们完成,这样可以继续进行部署。为此,需要在所有待处理的“部署前”和“部署后”迁移完成后再调度后台迁移,以确保未来的部署不会产生问题(如使用尚未存在的列)。具体如何实施还有待探讨。
这也带来了一个(可能不理想的)要求,即需要提供(或依赖)某种后台处理系统(例如 Sidekiq)。对于全栈框架,这或许并不是问题,但对于独立的数据库工具可能不太合适。
一个重要的要求是:在后台任务完成之前,部署不得结束。这样可以让监控更简单,因为负责后台任务的迁移可以通过输出信息(如 STDOUT)报告进度,这些信息会被收集到部署的输出日志中。这也让系统更容易理解:当部署完成时,所有任务也应该完成,而不是存在未指定的后台任务仍在运行。
便于测试迁移应该提供一套基本的工具,以便轻松为迁移编写测试,验证其确实按预期运行。例如,可以提供一个工具,将测试数据库迁移到预期的初始状态并再返回,以便可以针对预期的初始状态而不是最新状态测试迁移。
这个想法也许并不令人激动,但考虑到它并未广泛采用,说明它并不像我所希望的那样显而易见。
结论以上只是我认为可以提供更好迁移系统的粗略想法。我认为在已知的 VCS 修订版本上运行迁移的想法特别值得关注和进一步探索。或许未来有时间我会在 Inko 中构建一个 Web 框架,深入探讨这个想法。