暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

如何在零宕机的情况下从MongoDB迁移到Postgres?(上)

架构头条 2021-07-22
1764
作者 | Adrian Wilczek
译者 | 平川
策划 | 晓旭

本文最初发布于 Voucherify 官方博客,经原作者授权由 InfoQ 中文站翻译并分享。

Voucherify 诞生于 2015 年,是一个由我们的小型软件公司 rspective 运营的周末黑客马拉松项目。最初,它后台使用的是 MongoDB 数据库。说实话,这个选择很随意——它是我们在项目中最常用的数据库。我们已经有了一些经验,所以在那个阶段,Mongo 相当自然地就成了其中的一个组件。然而,随着 Voucherify 规模的增长,我们增加了第二个数据库——PostgreSQL——它似乎更适合即将到来的特性。然后有一段时间,我们将一部分数据保存在 Mongo 中,另一部分保存在 Postgres 中,直到有一天我们决定将所有数据都转移到 Postgres。本文是相关内容的第一部分。

在开始的时候,我们已经收集了大约 5 年的数据,分布在位于三个大洲的多个数据库实例上,每个实例都专用于不同的 Voucherify 集群。数百万的代金券代码可以随时更新。大约有 1TB 的数据不断变化。更糟糕的是,必须为即将到来的破坏性更改准备大量代码。从时间线上看,我们花了三个月的时间 重写和测试新代码,然后又用三个月的时间迁移所有数据。

那么为什么要费劲做这些事呢?我们这样做有两个正当的理由。

首先,你很容易会想到,维护两种不同的数据库类型会产生双倍的代码库、模型和概念,在添加新特性时必须记住这些。这也是初始设置之后会随机出现问题(通常在周五下午)的原因。如果其中一个看起来是多余的,那么所有这些问题加起来就会让工程团队感到紧张和沮丧。

其次,Compose——我们正在使用的提供 MongoDB 服务的 SaaS 平台,与其他平台相比非常昂贵。它在我们每月的开支中占了很大的比例。此外,我们对其提供的技术支持也不满意。有时,响应延迟可能长达几天。在某些情况下,唯一提供的解决方案是重启数据库,但却无法很好地解释之前为什么会发生奇怪的事情,或者他们是否计划在未来修复它。

为了可以成功地迁移并在流量很高的时候保持平台的稳定性,我们将其划分为几个任务——每个任务对应不同的实体。其中大多数都是相对较小、很少更新的数据块的简单迁移。每个任务都有自己的故事,但本文将讲述最后一个任务的故事。它是关于两个核心实体——代金券和活动——它们是 Voucherify API 的主要对象。你可能会想到,它们在 Mongo 中保存的时间最长。可以说,我们系统的核心就是建立在这个必须被替换的数据库之上

我们使用 AWS 的数据库迁移服务 来帮助我们迁移,主要是希望借助这个已经过成千上万开发人员测试的 SaaS 解决方案来减少准备迁移工具的时间。

我们决定为每个 Mongo 集合创建新的临时表,并在接下来的步骤中将它们安全地合并到生产表。PostgreSQL 数据库有一个很好的特性帮助了我们,这个特性叫做表继承。它使我们能够以分层结构将两个表连接在一起,以获得一个具有多个独立子表的父表。

我们做了以下工作:

  • 对 MongoDB 中的数据做完整性检查。

  • 使用 PostgresInheritance 创建子表。

  • 应用转换触发器。

  • 运行 Amazon 数据库迁移服务脚本。

  • 对子表中的数据做完整性检查。

  • 将“已删除”数据从子表迁移到父表。

  • 切换应用程序逻辑使用 Postgres。

  • 异常检测。

  • 将“活动”数据从子表迁移到父表。

  • 停止和删除 DMS 任务。

1开始迁移

首先,我们必须弄清楚应该选择哪个数据库。这次迁移不同于之前的迁移,所以我们仍在纠结 Postgres 是否是最佳选择这个问题。在放弃了几个选项之后,我们仔细研究了 AWS 环境中的 DocumentDB 和 PostgreSQL。DocumentDB 是一个非关系型数据库,于 2019 年 1 月发布,用于存储 JSON 文档。在某种程度上,它的任务与 MongoDB API 类似,这就完全交给了 AWS 团队,也就是说,以合理的价格获得可伸缩性和可用性。

Postgres 有一个明显的好处是可以统一底层技术。而与重用已有的数据库相比,购买 DocumentDB 会额外产生一笔费用。按照设计,虽然 DocumentDB 可以提供更大的可伸缩性或可用性,但它并不能支持所有的 MongoDB 命令。不过,对于大多数情况来说,它支持的特性应该已经够好了。

因此,只有在使用的独特查询不是很多的情况下,我们才能期望迁移不需要更改任何代码。我们估计,代码中大约有 5-10 个地方使用了一些不受支持的 Mongo 特性。通常,此类问题的解决方案是对模式进行一些额外的(通常较小的)迁移,比如添加一些标志来简化状态。在下一步中,这些标志将帮助我们简化更复杂的查询。不过,要做这种更改,首先需要对整个应用程序做许多小的调整。

在做了成本预测之后,事情已经很明显,如果我们设法重用当前的数据库实例,那么与其他选项相比,可能稍微对它们做下增强就会更好。因此,选择并不是那么困难,当做出决定后,剩下的唯一问题就是如何迁移如此庞大的数据。

为了回答这个问题,我们并没有深入研究可用的迁移工具。我们所做的是看下使用 AWS 数据库迁移服务的可能性,看看我们立足这个想法可以走多远。我们希望借助这个已经过成千上万开发人员测试的 SaaS 解决方案来减少准备迁移工具的时间。

2数据库迁移服务

DMS 是一个很棒的工具,可以用来在不同类型的数据库之间迁移数据(包括从 Mongo 到 Postgres 迁移)。开通之后,你将获得一个 EC2 实例,上面有随时可用于迁移数据的软件。为了能够保持数据同步(即使是在长时间的迁移过程中),你可以以 Multi-AZ 模式 启动它,只需要一次单击。DMS 还提供了一个不错的、开箱即用的迁移监视方法。数据库迁移服务提供了几个抽象,其中三个似乎是必不可少的,它们是端点、复制实例和数据库迁移任务。

DMS 的优点

让我从最后一个开始介绍,任务是指只执行一次的特定的复制 - 粘贴作业和 或正在进行的复制。它包括过滤和转换规则以及其他一些选项。

复制实例就无须解释了,但要申明一下,它们是我们可以选择在特定的迁移任务中使用的 EC2 实例。在 AWS admin 创建一个具有所需 RAM 和 CPU 资源的实例会就触发费用计数器。但是,总的来说,在我们这个情况下,使用 DMS 的总成本可以忽略不计。但是,如果你真想知道的话,我就告诉你,我们在这个工具上总共花了 200 美元。与预期可以节省的费用相比,这就是九牛一毛。

最后但同样重要的是,端点描述了如何连接到特定的数据库。除了一些显而易见的参数外,它还包括使用时的方向意图,这是指它在迁移过程中是源还是目标。复制实例处于运行状态之后,你就可以使用它来测试到已定义端点的连接。当你在源 目标端点所在的同一个 VPC 中创建 DMS 复制实例时,你就领先一步了,因为你不必在迁移期间将数据库公开在互联网上。总之,在我们这种情况下,这使得 DMS 非常易于使用。

DMS 的缺点

首先,它的 GUI 并不完美。它提供了两种模式——图形模式和 json 模式——其中第一种模式不能支持 json 模式的所有特性。因此,要理解所有可能的过滤器和转换,就必须认真查阅文档。不过,在使用 json 模式设置好迁移过程之后,我们发现以这种方式使用 DMS 更好。窍门是使用简单的 bash 脚本生成带有精确任务描述的大型 JSON,我们只需要将其整体粘贴到 DMS 网页的文本区里。

我们不喜欢 DMS 的另一个原因是,当创建一个新的迁移作业时,默认会选中在目标数据库中删除目标表的选项。当然,在某些情况下,这种行为是可取的,但为什么最危险的选择是默认的呢? 我们计划以一种非常可控的方式逐个项目地迁移数据,因此,我们预计要创建数百个迁移任务。一个简单的错误就可能删除所有生产数据的风险,让我们不得不选择一个更安全但更难实现的迁移过程。我们的解决方案是分两步迁移数据——首先迁移到 DMS 可能意外清除的临时表,然后迁移到目标生产表。

在确定 DMS 是一种可接受的工具之前,我们仍然需要克服许多技术障碍。最初,我们计划进行两轮迁移,第一轮用于活动,第二轮用于代金券。我们设想,对于每个项目(在 Voucherify 是工作空间),每一轮只有两个步骤——运行 DMS 进行复制,并在项目的配置中切换一个布尔标识。如你所想,这个过程变得更加复杂了。

3准备

首先,在迁移之前,我们需要明确每个项目如何设置。从 DMS 任务的角度来看,每个项目基本上是一个活动列表,以及一些独立的或属于某个活动的代金券。可行吗?当然,但是如果将其乘以数百个项目,就会清楚地发现,不能手工输入所有这些设置。

在开始这样的大任务之前,最好先清除遗留逻辑中的一些代码,以稍微简化下迁移。举例来说,在我们的情况下,就是检查是否所有的 Campaign 和 Voucher 字段仍在使用。删除旧代码是这个过程中最简单的部分,这可以缩短整个过程的时间。遗憾的是,我们在方面没什么工作要做。

这让我们的核心迁移脚本非常简单易行,无疑这也是我们的目标之一。为了节省后续步骤的时间,我们在描述每个实体类型的有效模型时做了一些假设。你可能知道,MongoDB 是一个文档数据库,这意味着它是无模式的。如果像我们所做的那样,使用它来存储有模式的数据,那么你不仅要将保持数据处于良好状态的责任转移到应用程序层,而且还必须预见到,在迁移的时候数据的某些部分会存在脏数据。是的,正如我们稍后将看到的,Mongo 中的数据在很多方面都是脏的(或者是有弹性的,这取决于你的观点)。考虑到这些假设,我们在迁移每个项目之前都要执行适当的完整性检查,从而找出受污染的条目。这使我们能够在隔离状态下快速修复损坏的条目,并安全地执行核心迁移。简要声明:在这些假设中,有一部分检查在目标数据库中进行会更快,因此,我们设置两个完整性检查阶段是有意义的——第一个阶段在 Mongo 中,第二个阶段在 Postgres 中。

4完整性检查

当 DMS 将数据复制到临时表时,我们在临时表中执行完整性检查。后面会有更详细的介绍,但我可以提一下,我们为每个 MongoDB 集合创建了短期目标表,当数据被加载时,我们将这些临时子表一个接一个地合并到它们的父表中。

为了让你更好地了解我们做了什么类型的检查,让我们来看下第一段代码,我们用它来检查实体是否存在两种简单的错误类型:

+db["campaigns-TENANT-PROJECT"].count({$or: [{ campaign_type: { $exists: false } }, { deleted_at: { $exists: true } }]})+

复制

我们首先想知道的是,代金券和活动类型之前的迁移是否完全完成。我们决定在最终的 SQL 中使用 NON NULL 来约束这些类型,因此,在迁移之前,所有的条目都必须具有某个值。也可以在最终的数据库中检查和修复这个问题,但从根上解决问题更容易。我们检查的第二件事是,是否有任何条目在保存删除时间时使用了旧的变量命名方式 deleted_at。目前,我们有一个驼峰式命名的替代方案 deletedAt,后来,为了简化迁移脚本,我们决定先清理下旧数据。因此,如果上述查询返回的总数不是零,我们就列出所有错误的条目,并在合理的前提下修复或删除它们。

在数据进入 Postgres 数据库后,我们立即执行了第二轮完整性检查。让我们往前跳几步,快速描述一下在这个阶段中检查了什么。首先,我们使用以下查询搜索内部损坏的条目:

--- wrong vouchers
SELECT * FROM voucherify.vouchers_migration WHERE
...
OR id = 'MISSING'
OR discount IS NULL
OR discount = 'null'::jsonb
OR publish ->> 'count' LIKE '%.%'
OR (jsonb_array_length(publish->'entries') > 0 AND publish::text LIKE '%$date%')

复制

你可以看下在保存代金券的表上执行的部分检查。这是最有趣的部分。在此之后,对活动数据执行同样的查询。每当有一些有趣的发现,我们就在 Mongo 中修复它们,这样,如果迁移由于某种原因不得不中止,这个动作的效果就会被保留下来。

检查详解

让我们仔细研究一下上面这段代码。第一句现在还很难理解。在描述核心迁移脚本时,我们会回过头来看这个问题。接下来的两行将'discount'字段与 SQL 'NULL'和 json 'null'值进行比较。这两种类型的 NULL 是我们面临的第一个问题。新旧代码在 GET 响应中都很好地涵盖了这种可能性,但我们希望确保数据在通过迁移脚本推送后是完全完整的。对于缺失的数据,SQL 的 NULL 值符合我们的预期,而 json null 就有点令人惊讶了。我们当时没有可以设置 null 值的代码,所以,也许我们之前有一段这样的代码或者这些 null 值是以前一些手动操作的结果。无论如何,像这样读取和解析损坏的数据效果还不错,然而,jsonb 连接' || '应用于一个存储在 PostgreSQL 中的 null 时会导致一个严重的错误。所以请注意这个小怪癖。

下一行是关于发行数量,我们检查 Mongo 中是否有任何整数类的字段存储为浮点形式的字符串值。例如,我们获得的不是预期的 1,而是“1.0000”。如果我们的代码中没有足够的(CAST .. AS INT)SQL 转换,也许我们甚至不会注意到。在迁移两个旧的测试项目之后,这种混合导致了意想不到的错误,所以这可以称之为我们发现的第二个怪癖。这样的情况很少,所以我们都手动修复了。这两个问题——“1.0000”和 json null 值——我们稍后在迁移脚本中解决了,但是我们在这里保留了所有这些检查,以再次检查是否一切正常。

最后一个检查“(jsonb_array_length(publish->'entries') > 0 AND publish::text LIKE '%$date%') ”的目的是,找出发行条目以无效方式存储的代金券。在这一点上,我们的代码忽略了存储在那里的数据,只有一个查询检查这些条目的总数。因此,我们决定迁移所有这些代金券及发行条目,并在稍后将所有数据放入一个数据库后修复这个问题。但首先,我们必须修复在第一次手动测试后捕获的一个糟糕的 Bug。在极少数情况下,日期在 MongoDB 中是作为 ISODates 存储的,由 DMS 以 json 对象的形式传输,其中一个 $date 字段包含一个数字时间戳值。即使我们不再真正使用这些数据,我们的 ORM 系统仍然在解析它,而它显然无法读取这种格式的日期。和以前一样,这样的情况并不多,所以手动修复是最有效的。

除了检查内部代金券和活动数据外,我们在这一步还检查了各种关系。当代金券和活动在一个数据库里,其他部分在另一个数据库里的时候,这是不可能的。这是因为这个脚本会运行很长一段时间,很可能会给我们很多误报。所以,我们核实了每个活动的代金券数量。在迁移之前,使用一个纯 MongoDB 脚本也可以进行这种检查,但是编写 SQL 版本的脚本要容易得多。SQL 还可以保证代金券计数不受并行操作(如添加或删除代金券)的影响,因此,将它与活动的‘vouchers_count’进行比较总是会得到可靠的结果。我们还检查了代金券数据中存储的赎回总额和发行数量。这种检查只有在所有数据都在一个数据库中之后才能进行,即使这样也要花费大量的时间。

5大方向

对于每个项目,有两个要迁移的 Mongo 集合。我们决定将每个集合转移到一个临时表,以规避其中一个 DMS 作业删除保存着现行活动或代金券的目标表的风险。在准备迁移时,选择迁移到最终表还是临时表至关重要。我们选择了看起来最安全的选项,以防我们在设置 DMS 任务时犯错。这种情况不太可能发生,但我们想使用最安全的方法,看看我们是否能承受将强加在我们身上的负担。

此外,正如你稍后将看到的那样,我们需要额外的字段来完成迁移,将它们放在最终表中会增加通过 API 返回数据或将其存储在系统事件数据中的风险。我们需要为这种风险准备代码,但我们仍然可能会漏掉一些东西。因此,让子表扩展模型可以保证这种情况绝对不会发生。很快你就会看到,如果我们选择将数据直接迁移到生产表中,我们所面临的一些问题将不复存在。很难说另一条道路的结果会怎样,但这篇文章将至少呈现硬币的一面。从时间的角度来看,我可以有把握地说,我们选择的道路是正确的,因为它给我们带来了最好的表现和预期的结果。

关于表的简要说明

我们决定为每个 Mongo 集合创建新的临时表,并在接下来的步骤中将它们安全地合并到生产表。PostgreSQL 数据库有一个很好的特性帮助了我们,这就是表继承。它允许我们按照层次结构将两个表连接在一起,以获得具有多个独立子表的父表。每个子表单独存储数据和索引,但是当从父表读取数据时,得到的是所有相关表的聚合结果,就好像只有一个逻辑表一样。显然,要以这种继承关系连接两个表,子表必须拥有父表的所有列,但就像任何编程语言中的继承一样,它们可以拥有更多列。在通过父表读取数据时不能访问这些列,但它们可能在很多方面都很有用。在迁移过程中,我们使用这些额外的列来存储 MongoDB 的原始数据和每个对象的 _id。

我再强调一次,每个子表都有单独的数据和索引。对于惟一索引,这尤其重要,因为在从父表读取数据时,可能会获得看似不可能的结果。例如,尽管代金券编码有惟一索引,但查询结果仍然可能会包含两个具有相同编码的代金券。这将打破应用程序所基于的基本假设,因此考虑这个场景非常重要。

继承的另一个不太重要的优点是在创建子表的简单脚本中可见。这是因为,如果你命令 Postgres 在一个调用中创建这样的关系,那么它会为你添加所有父类的列。让我们看看下面的例子:

CREATE TABLE 
voucherify."vouchers-test@voucherify.io-proj_f1r5Tpr0J3Ct"
(_id varchar(200), _doc text) INHERITS (voucherify.vouchers);

复制

在这里,我们创建了一个名为 vouchers-test@voucherify.io-proj_f1r5Tpr0J3Ct 的子表,它继承自父表 vouchers,并有两个额外的列表示 Mongodb 的 _id 和以文本类型存储的整个 json 文档(_doc)。在迁移过程中,我们为每个项目的活动和代金券创建了两个子表。当特定项目的迁移完成且子表为空时,我们将它们全部删除,以便为数据库中的 VACUUM 进程腾出 CPU。让我们看一下到目前为止描述的迁移过程:

该图显示了迁移过程。运行 Voucherify 的服务器有连接到 Mongodb 和 Postgres 数据库的连接池。它们根据项目标识读写其中一个。在后台,DMS 任务 只复制一次数据,然后根据需要持续运行正在进行的复制。这样,DMS 就转发了 Mongodb 集合中的所有更新,而我们的主要工作就是在最好的时机同时切换读和写。

如果我们的服务器在某个时间窗口对新旧数据库进行双重写操作,那么我们也可以进行不同的设计,然后切换读操作,最后禁用双重写操作。不过,这种方法需要更多的时间来准备代码。同样,在执行双写的代码中某个地方仍然可能存在 Bug。使 DMS 中正在进行的复制完全正常地工作是一个不小的挑战,但我们很高兴,那最终并不是特别困难。

另一方面,如果我们在代码中进行双写,那么如果出现任何问题,就可以停止某个特定项目的迁移,并将所有东西都切换回来。在我们的模型中,DMS 并没有在两个方向上保持状态同步,而是保持单向同步。我们决定让它成为单程迁移,没有回头路。也许,我们可以建立一个并行的 DMS 任务,将更改从目标复制到源,但这样就会有新的问题要解决,比如如何防止更改传输的无限循环。最后我们发现,这样的想法在我们的案例中并没有什么用。为了尽量减少可能存在的缺陷,我们将工作按项目进行分割,并在此过程中应用修复,以便在迁移最重要的项目时,几乎所有的问题都被捕获。

在讨论真正的问题之前,让我们先喘口气,快速描述一下我们如何在 DMS 中实现运行中复制。为了使复制以合理的方式工作,源数据库需要公开某种形式的更改日志 Feed。在 MongoDB 中,这个特性被称为 Replica Set Oplog。你可以使用下面的命令试用下 oplog:

db.oplog.rs.find({ ns: 
"voucherify.vouchers-test@voucherify.io-proj_f1r5Tpr0J3Ct",
ts: { $gte: Timestamp(1575648464, 1) } })

复制

首先,用一个 Kubernetes pod 提供的临时 MongoDB 实例进行手动测试,结果表明,DMS 可以很好地实现持续复制。然而,当从 Compose 平台测试 MongoDB 的连接 url 时,这个特性却不起作用。在 DMS 中,这会被标记为部分完成的任务,因为只是成功地复制了数据。深入研究 DMS 任务日志(针对任务启用 CloudWatch 特性后就可以访问)证实了我们的猜测,访问 Mongodb 的更改信息存在问题。幸运的是,可以购买一个 插件 来额外公开一个 MongoDB 数据库的 url,用于从 oplog 读取数据。同样,与维护 MongoDB 的总成本相比,这个插件的成本很低。

下一个需要回答的大问题是如何在两种不同类型的数据库之间转换数据。连接 Mongo 的无模式 SQL 世界不是问题,因为我们在代码的类中很好地实现了模式。此外,我们决定将对象的字段与 SQL 列进行直接映射,而不做任何奇怪的修改。例如,文本字段转换为文本列,json 对象转换为 json 或 jsonb 列。在 DMS 端点中选择 MongoDB 作为源数据库之后,你将面临一个问题:应该采用这两种元数据模式中的哪一种。AWS 管理员可以在此步骤中 选择表或文档模式。

在第一种模式下,DMS 将尝试从 N 个文档中提取模式,并在链接作为目标数据库的 SQL 数据库时将数据映射到适当的表列。Endpoint 创建者可以自由选择数字 N,而且似乎没有上限。乍一看,在我们的案例中,这种模式似乎是一个不错的选择。然而,测试结果表明,在大多数情况下,DMS 提取的模式并不符合我们的需求。主要原因是它将所有嵌套的 json 扁平化为单独的列,我们希望它们仍然是作为单独的 json。此外,在 DMS 创建了模式之后,当遇到之前没有扫描过的条目时,任务将跳过新增的未映射字段。所以我们不可能对 N 参数做出准确的估计,因为我们必须假设我们的数据可能很不一致。经过几次尝试后,事情已经很明显,带有自定义转换脚本的文档模式是唯一的选择。

原文链接:

https://www.voucherify.io/blog/how-we-moved-from-mongodb-to-postgres-without-downtime-and-cut-our-costs-by-30

点个在看少个 bug 👇

文章转载自架构头条,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论