Shop 应用使用 Vitess 水平扩展了 Ruby on Rails 应用。本博客介绍了 Vitess 以及我们将 Vitess 引入 Rails 应用的详细方法。
好问题在推出Shop 应用后,我们经历了曲棍球棒式增长。 我们目不转睛地盯着仪表盘,看到数百万用户使用该应用。这令人欣慰,但随着后端越来越接近极限,我们变得越来越紧张。
我们用 Ruby on Rails 编写了后端,并使用了 MySQL 数据库(Shopify 托管系统,名为KateSQL)。首要任务是找出瓶颈。我们进行了迭代,水平扩展了后台作业系统、缓存系统,并在适当的情况下使用了水平扩展的消息总线。然后,我们投入精力检测常见的问题:查询速度慢、连接有限等。我们还 从“ The Shop app ”中删除了“ The ”,因为这样更简洁。
由于 MySQL 是主数据存储,因此这也将成为主要瓶颈。为了解决这个问题,我们首先将主数据库拆分为单独的部分 - Rails 使与多个数据库的交互变得容易。我们确定了可能存在于单独数据库中的大型表组,并使用GhostFerry将一些表移动到新数据库中。我们还为不同域中的表创建了新数据库 - 这具有额外的好处,可以限制一个域影响其他域的问题的波及范围。
随着应用程序的进一步发展,我们开始达到单个 MySQL 的极限。我们开始规划下一阶段的增长。我们推迟了对主数据库进行分片,因为我们担心它会增加复杂性。随着时间的推移,磁盘大小增长到数 TB,架构迁移需要数周时间,当数据库太忙时,我们会限制更多的后台作业。我们无法进一步拆分主数据库,因为这会增加应用程序层的复杂性,并需要跨数据库事务。我们已经到了增量增强不再足够的地步。是时候彻底改变我们的扩展方法了。
为 Vitess 做准备扩展系统的方法有很多种,每种方法都有各自的优缺点。以下是一些示例:
多租户架构:当 Shopify 水平扩展时,整个系统被划分为一个“Pod 架构” (博客文章中有详细信息)。每个“Pod”都有自己的专用资源,包括 MySQL。这使得 Shopify 能够水平扩展,并获得系统优势,即一个商家的问题不会影响其他商家。Shop app 的属性与 Shopify 的其他部分不同。虽然 Shopify 拥有数百万商家,但 Shop app 的用户数量要多一个数量级。与大型商家的影响相比,一个用户的影响很小。将频繁写入移至键值存储:另一种方法是将频繁更新的所有内容移至水平扩展的键值存储,并使用 MySQL 存储很少更新的核心数据。这种方法的缺点是我们失去了定义自定义索引的能力,因为键值存储通常仅由主键获取。我们还希望坚持使用 SQL 作为数据存储的主要查询语言。但也存在例外情况,我们将一些数据存储在内存存储中,但它们无法提供我们所需的持久性保证。将数据存储在单独的 MySQL 中:这种扩展方法称为“联合”,其中表存储在不同的 MySQL 中。这是我们最初的方法,并且取得了很大的进展。Rails 使同时使用多个数据库变得非常容易(文档)。这种方法的缺点是:1) 对于较大的表,模式迁移需要很长时间,2) 跨不同数据库高效连接表具有挑战性,3) 应用程序层变得更加复杂,因为开发人员必须考虑每个表的存储位置。云提供商提供的水平扩展系统:我们选择不采用这种方法,因为我们更喜欢在 Shopify 上运行自己的主数据存储。由于我们的用例是一个成熟的 Rails 应用程序,因此坚持使用与 SQL 兼容的系统对我们来说也很重要。在探索了各种选择之后,我们发现 Vitess 与其他选择相比脱颖而出。
为什么选择 Vitess?Vitess 是一个基于 MySQL 的开源数据库系统抽象,它提供了许多好处(详细文档):
它允许您对 MySQL 进行分片和重新分片(文档)它可以轻松协调跨多个分片的架构迁移(文档)它为查询提供了连接池,以及一些针对错误查询的保护。它与 SQL 查询语言(文档)基本兼容。请参阅此处的完整功能列表 以及其背后的哲学。我们选择 Vitess 是因为它提供了我们需要的许多功能。在深入研究 Vitess 的方法之前,让我们先了解一下本文中使用的基本术语。
术语分片
分片是数据库的一个子集。在我们的例子中,它由一个写入器和多个副本组成。键空间
键空间是一个逻辑数据库,表示存储在一个或多个分片上的一组表。键空间可以是分片的(多个分片)或非分片的(单个分片)。分片键空间需要为每个表定义一个分片键,以确定将行存储在哪个分片上。维基百科
VSchema 或 Vitess Schema 描述了键空间和分片的组织方式。它帮助 Vitess 将查询路由到适当的分片,并协调更高级的功能(文档)。VTGate 系统
使用 Vitess,查询路径如下所示 。VTGate 是一个代理,负责执行查询规划、事务协调和查询路由到 VTTablet。VTGate 还会跟踪所有 VSchemas。App → VTGate → VTTablet → MySQLVT平板电脑
VTTablet 与 MySQL 服务器部署在同一台机器上,负责包括连接池在内的许多功能。选择分片键在考虑其他任何事情之前,第一件事就是选择分片键。我们数据库中的大多数表都与用户相关联,因此自然而然地选择 user_id 作为分片键。所有数据都将移动到 Vitess,但只有用户拥有的数据才会被分片。这些数据将成为“用户”键空间,其余数据将成为未分片的“全局”键空间。请注意,虽然 Vitess 允许键空间中的表由多个分片键分片,但我们选择强制“用户”键空间仅包含用户拥有的表。这使得测试和推理系统变得更加容易。
重新组织数据模型在开始迁移 Vitess 之前,我们必须确保与用户数据相关的所有表都确实有一个 user_id 列。不幸的是,我们的数据建模方式是,很多数据属于一个帐户(该帐户属于一个用户),而许多表没有 user_id 列,只有 account_id。
我们必须运行多次迁移才能添加 user_id 列并回填数据。迁移和回填都是耗时的过程,因为这些表非常大(有数十亿行),并且数据库在高峰时段满负荷运行。要点:尽早完成此操作,可能比预期花费的时间更长。
设置查询验证器我们在应用层构建了查询验证器,帮助我们找到与 Vitess 不兼容的查询。这些查询验证器对于迁移到 Vitess 中的分片数据库至关重要。它们验证查询的正确性、路由和数据分布,防止查询错误、跨分片查询导致的性能问题以及部分提交的事务导致的数据不一致。如果我们可以将任何一件事归功于成功迁移到 Vitess,那就是验证器的正确性。
我们以某种方式设置了验证器,以便我们能够表示系统的未来状态。例如,我们可以表示某些表将存在于当时不存在的键空间中。我们还可以表示键空间将来会被分片。
验证者的类型缺少分片键:此验证器对于确保针对分片表的查询在 WHERE 子句中包含分片键至关重要。通过包含分片键,Vitess 可以将查询路由到正确的分片。跨数据库事务:此验证器确保事务中执行的所有写入查询都在同一数据库上执行,无论该数据库是否在 Vitess 中。它通过检查事务中用于写入查询的连接来实现这一点。跨分片事务:此验证器确保在事务内执行的所有写入查询(重点关注 Vitess 中的分片键空间)均在具有匹配分片键的同一分片上执行。目标是防止在事务回滚的情况下对分片进行部分提交。我们限制事务仅更新单个用户的数据,以确保一致性并避免跨多个分片进行分布式更新可能带来的复杂性。Vitess 的默认原子性模型允许跨分片事务(文档)。考虑跨分片事务对 SHARD_1 和 SHARD_2 进行更改的情况。如果更改提交到 SHARD_1,但 SHARD_2 出现错误,则 SHARD_1 上的更改将不会回滚。我们选择删除所有跨分片事务以避免任何问题。跨分片写入:此验证器的作用与跨分片事务验证器类似,但重点关注未明确位于事务内的查询。它专门检查在事务之外操作的调用,例如 update_all() 或 delete_all()。跨键空间查询:此验证程序识别了包含多个键空间中的表的查询。它识别了当一个键空间中的表与另一个键空间中的表连接时发生的违规行为。例如,如果 KEYSPACE_1 中的表与 KEYSPACE_2 中的表连接,则此验证程序会将其标记为违规。将验证器应用于生产代码我们分步实施验证器的部署,以确保其正确性。这涉及多轮全面测试,包括单元测试和手动测试,涵盖各种查询类型和极端情况。此外,我们还创建了一个违规查询列表,使我们能够尽早为新添加的代码启用验证器,同时逐步解决现有的违规问题。
最初,我们在开发和测试环境中启用了验证器。这意味着,如果存在任何验证器违规行为,任何新添加的代码都会在这些环境中失败。我们还运行了以 Vitess 为后端的 CI 管道副本,并与 MySQL 数据库并行运行,使我们能够在 CI 过程中捕获新的查询违规行为。
为了解决违规问题,我们首先检查了违规查询列表。对查询所做的主要更改包括确保尽可能包含分片键。我们还重写了许多查询以符合 Vitess 要求,并添加了补丁以在适当的时候自动将分片键注入查询中。
对于跨分片和非分片键空间的事务,我们仔细地将它们拆分并分析其原子性,以确保部分故障可以自我修复。同样,我们重新设计了跨分片事务的复杂流程,通过重写它们来避免 Vitess 上不支持的操作。例如,UPDATE <table_name> SET <sharding_key>= ? WHERE id = ? 一旦我们分片,就会失败,因为这需要将行移动到不同的分片。我们将其变成了一个两步过程,首先在目标分片中插入一个新行,然后从源分片中删除旧行。
为了允许某些查询在特定条件下跳过验证器,我们实现了一些“Danger”辅助方法。这些方法对于跨不同用户运行维护任务或在用户信息不可用时执行跨分片查询等场景非常有用。
最后,我们在生产环境中启用了验证器,但只使用日志级别,而不是引发异常,以避免意外中断。为了更好地了解可能的违规行为,我们生成了一份每周报告,重点介绍生产环境中发生的总违规行为及其相应的调用站点。这使我们能够调查测试过程中遗漏的任何潜在违规行为或验证器未涵盖的任何边缘情况。
确保所有查询都包含分片键通常,MySQL 使用 SQL 查询来规划如何搜索表和索引。具体来说,它使用 WHERE 子句中的列来找出最佳索引。对于分片,尤其是 Vitess,还有另一层,其中 Vitess(VTGate 代理)必须首先检查查询并将其路由到适当的分片。一些查询在 Vitess 上有效,例如SELECT * FROM orders WHERE id = ?,但强制 Vitess 将查询发送到所有分片(分散查询),然后合并结果。如果我们将查询稍微调整为SELECT * FROM orders WHERE user_id = ? AND id = ?,Vitess 可以确定这些数据只能存在于特定分片上,即存放该用户数据的分片。
使用上面描述的验证器,我们可以确保所有针对分片键空间中的表的查询也包含分片键。对于某些查询来说,这很简单,但对于具有复杂连接和表重命名的查询来说,这更难。通过验证器和违规查询的日志,我们发现了一些需要修复的常见查询模式:
某些数据集的初始加载。加载额外的关联数据。数据的变异。当时我们正在使用 Rails 7.0。我们意识到需要向 ActiveRecord(Rails 中处理需要持久存储的数据的对象的核心抽象组件)添加补丁。我们希望避免补丁,因为这会使长期维护 Rails 变得非常困难。唉,这些只是暂时的,因为在未来版本的 Rails 中,复合键(文档)将使这变得容易得多。
我们首先创建一个抽象,使我们能够识别任何给定 ActiveRecord 对象的分片键。之后,我们创建了一个 ActiveRecord 补丁,以便将对象的分片键传递给update, delete, lock/reload语句。默认情况下,rails has_many、belongs_to和has_one关系会生成 Vitess 无法限定到单个分片的查询。我们创建了一个join_condition选项,它使用一些补丁将分片键传递给关联。
架构迁移能够快速运行数据库迁移是分片数据库的原因之一——我们的一些最大表需要数周才能迁移。这已成为一个主要痛点,减缓了产品开发并带来了技术债务。
Vitess 支持不同的迁移策略,包括vitess(本机)和gh-ost(来自 Github)。vitess 迁移策略起初看起来很可靠,并且具有许多优点,例如,迁移可以在数据库故障转移后继续进行并立即恢复。在我们最初的实验中,我们运行的是 Vitess V14,其中 vitess 策略仍处于实验阶段。在使用更大的表进行测试时,我们开始遇到一些问题。当迁移被限制超过 10 分钟时(例如由于复制滞后),迁移将终止。我们在 Vitess 社区 slack 上提出了这个问题,社区反应迅速且乐于助人。他们确认了该错误并提交了一个错误修复。 今天,vitess这是推荐的策略,也是我们在 V15 生产中使用的策略(编者注:一个不相关的教训是,如果你在一家名字中有“披萨”这个词的餐馆,你应该点他们的披萨)。
为了管理迁移,我们构建了一个自定义 UI。Vitess 附带了许多 SQL 命令来查看/管理 迁移,我们利用这些命令来实现这一点。
架构缓存我们依靠Rails 中的模式缓存 功能来防止在部署期间启动时加载模式时数据库过载。以前,我们有一个钩子,在迁移完成时调用,以转储新更新的模式 - 然后在下一次部署时获取该模式。Vitess 没有一种简单的方法可以从应用程序代码中挂钩到迁移。我们在应用程序层构建了一个后台作业来执行此操作。当迁移提交给 Vitess 时,该作业将排队并定期检查迁移的状态。当所有分片上的迁移完成并且没有其他活动迁移正在运行时,它将转储新模式。这是必要的,因为 Rails 在查询表模式时可以连接到任何分片,如果迁移在某些分片上完成但不是全部,则可能导致返回不一致的模式。
第一阶段:“活力化”启动状态:Shopify 应用程序的主数据库运行常规 MySQL 数据库(Shopify 托管的系统名为KateSQL),所有连接都通过名为 ProxySQL 的代理进行。
最终状态:Shop 应用程序正在运行一个 Vitessified MySQL,该 MySQL 具有单个未分片的用户键空间,其中包含所有表,并且所有连接都通过VTGate。
Vitessifying是我们的内部术语,将现有的MySQL转换为Vitess集群中的键空间的过程。这使我们能够开始使用核心Vitess功能,而无需明确移动数据。核心变化是:
在每个 mysqld 进程旁边添加一个 VTTablet 进程(文档)。VTTablet 配置为新键空间的唯一分片。我们在同一台主机上运行它们,通过套接字进行通信,但从技术上讲,它们可以在通过网络连接进行通信的不同主机上运行。请注意,您需要为 VTTablet 分配相当多的资源。Vitess 的经验法则是 VTTablet 和 mysqld 的 CPU 数量相等(源),尽管 VTTablet 的内存消耗通常很低。确保可以通过 VTGates (文档)访问新的键空间。该过程不需要停机并且对应用程序完全透明。
安全切换连接在将我们的主数据库 Vitess 化之后,我们继续在应用程序代码中添加通过 VTGate 建立与底层数据库的连接的功能。
由于这是 Shopify 在生产环境中首次部署 Vitess,因此我们在部署过程中采取了谨慎的态度。我们的主要目标是确保实施不会对系统造成任何不可逆转或重大的影响。我们实现了一个动态连接切换器,使我们能够在整个部署过程中控制整个过程。此切换器集成在应用程序层,利用我们之前开发的分阶段部署原语。
动态连接切换器允许我们修改请求的路由,使我们能够选择是通过原始 SQL 连接 (ProxySQL) 还是通过 VTGate 进行路由。这种控制级别使我们能够仔细管理通过 Vitess 路由的请求百分比。通过逐渐增加通过 Vitess 的流量,我们能够密切监控其性能并及时解决出现的任何问题或意外行为。
为了将风险降至最低,我们最初在生产中从风险最小的组件开始切换连接。例如,我们针对具有内置机制的后台作业,以便在作业处理过程中出现任何错误时自动重试。这种方法确保我们可以安全地测试和验证新的 VTGate 连接,而不会出现数据丢失或关键流程中断的风险。全面推出后,ProxySQL 被删除。
第二阶段:拆分成多个键空间启动状态: Shop 应用程序正在运行具有单个未分片键空间的 Vitessified MySQL。
最终状态: Shop 应用程序有三个未分片的键空间:global、users和configuration。
在第 1 阶段进行 Vitessifying 之后,作为初步步骤,我们所有的表都存储在同一个键空间中。现在是时候将这些表拆分到它们适当的未来键空间中了。我们决定进行以下拆分:
用户:这是包含所有用户相关数据的键空间。全局:这是不属于用户的数据的键空间。配置:此键空间是很少写入的表。它也将在第 3 阶段用于序列表。在键空间之间移动表。Vitess 提供了一个 MoveTables 工作流(文档),可轻松在键空间之间移动表。在将其投入生产之前,我们在临时环境中练习了每一步。在临时环境中彻底练习是我们早期学到的一个教训,而且事实证明,这始终是正确的,因为我们发现了 Vitess 的一些错误和我们的设置问题。我们准备了一个大清单,这个清单包含了在遇到问题时退出该过程的命令。
为了准备在生产中执行同样的操作,我们阻止了模式迁移,以避免操作期间出现任何问题。我们还禁用了模式缓存,以确保安全,并防止 Rails 查询模式缓存中不存在的表时引发错误的问题。之后,我们创建了两个新的全局和配置键空间。我们之前已经列出了需要移至其他键空间的表。我们之前还创建了一个“跨键空间查询”验证器,它使我们能够识别和删除任何未来的跨键空间查询。我们在 Rails 的 database.yml 文件中为每个键空间设置了一个单独的条目,将每个键空间视为一个单独的数据库。
在进行准备的过程中,我们发现并解决了一些问题。我们发现错误或取消的操作可能会留下日记帐分录和工件,从而干扰未来的操作。我们必须在尝试第二轮之前清理掉这些。一旦我们获得了更多的信心,我们就开始在生产中移动表。
所有表数据都移动完毕后,我们执行了Vdiffs验证移动完整性的操作(文档)。此外,我们还执行了手动检查,包括验证排序规则和字符集是否保持不变。然后,我们切换流量,并使用选项完成操作--keep_data --keep_routing_rules。我们不想从源键空间中删除表,因为删除大型表可能会使数据库停滞(这是 MySQL 5.7 的问题)。我们重命名了旧键空间中的表,在其名称中添加“_old”后缀,然后删除了路由规则。
阶段 3:对“用户”键空间进行分片。起始状态: Shop 应用程序有三个未分片的键空间:global、users和configuration。
最终状态: Shop 应用程序有一个分片的用户键空间、两个未分片的全局和配置键空间以及一个分片的查找键空间Lookup Vindexes。
如果您坚持使用未分片的键空间,Vitess 与常规 MySQL 非常相似。您实际上不需要管理定义键空间如何分片的 VSchema (文档)。一旦分片,真正的复杂性就开始了。在开始分片之前,我们需要处理两个主要先决条件:序列和 Vindexes。
自动递增主 ID 序列Rails 应用默认创建具有自动递增的整数主 ID 的表。这在分片系统中不起作用,因为分片系统中的主 ID 需要在各个分片中保持唯一。Vitess 中的序列充当 auto_increment 的角色,确保单调递增的 ID 在各个分片中保持唯一。
Vitess 中的序列本身由未分片键空间中的常规 MySQL 表支持(docs)。这是强制性要求,因为我们希望单个实体负责协调主 ID 的递增,而分布式解决方案在出现网络问题时会引入问题。
VTTablet 负责保留和缓存来自 Sequences 表的 ID 块,这通过缓存的大小减少了对底层表所需的写入。在我们的生产环境中,我们将此缓存值设置为 1000。缓存值是一个需要仔细考虑的关键参数。它需要足够大,以确保 MySQL 在写入高峰期间不会成为瓶颈,并且可以处理底层 MySQL 的短时间停机。同时,它需要足够小,以确保在 VTTablet 进程重新启动或停止时不会“丢失”大块 ID。
让现有表开始使用 Sequences 非常棘手。在使用 Sequence 表之前,我们需要将 Sequence 表中的 next_id 列更新为大于每个表的当前最大 id。我们使用经过全面测试且比平时更具防御性的应用层代码完成了此操作。这三个步骤是:1) 识别表的当前最大 id,2) 使用 max_id 加上大缓冲区更新相关 Sequence 表,3) 更新 VSchema,以便 Vitess 开始使用 Sequence 表进行自动递增。
Vindexes 用于维护全局唯一性并减少跨分片查询一旦对键空间进行分片,还有两个额外的考虑因素:行唯一性和跨分片查询。如果您想确保某一行是唯一的,添加唯一的 MySQL 索引已经不够了,因为这只能保证每个分片的唯一性。此外,如果分片键未传递给 SQL 查询,则将执行跨分片查询。如果不经常进行跨分片查询,则还不错,但在更大规模的情况下可能会导致问题。
Lookup Vindexes用于解决这两个问题。Lookup Vindexes 由 Vitess 维护的 MySQL 表支持。当插入、更新或删除一行时,Vitess 也会在查找表中进行相应的更改。这允许 Vitess 在 Lookup Vindex 表中执行查找,以确定某一行在分片中是否唯一。有许多不同类型的 Vindex (文档)可供选择。
为了达到我们的目的,我们必须在 Vindex和较旧的 Vindex 之间做出选择,因为 Vindex 速度较慢,需要两阶段提交。 consistent_lookup_uniquelookup_unique我们选择仅使用consistent_lookup_uniqueVindex来强制实现跨分片的全局唯一性。我们在分片之前创建了这些 Lookup Vindex,因为没有它们进行分片会破坏唯一性保证。 随着我们积累了更多经验,我们决定在非必要的情况下避免使用 Lookup Vindex。
除了复杂性之外,还有两个主要缺点:1)写入速度较慢,因为 Vitess 做了额外的工作来维护 Lookup Vindex 表,2)测试套件变得更慢,因为我们必须在更新由 Vindex 支持的列时运行非事务测试consistent_lookup 。这是由于consistent_lookupVindexes 的限制,不支持在同一事务中对相同的一致查找列值进行插入后再进行更新或删除(文档)。为了避免这个问题,我们考虑在开发和测试环境中使用速度较慢的 lookup_uniqueVindex。我们选择不这样做,因为我们希望开发和生产环境之间保持一致——存在生产中失败的代码不会在 CI 中失败的风险。 运行一些非事务测试并不理想,因此我们将重新审视这种方法。
幸运的是,在两种情况下,Lookup Vindexes 不需要强制跨分片唯一性:1) 密钥是随机生成的碰撞安全密钥(例如 UUID),或 2) 唯一 MySQL 索引中的一列仅存在于单个分片中。为了说明这一点,请考虑所有表都按 user_id 分片的示例。像 ["user_id", "name"] 这样的唯一 MySQL 索引将是全局唯一的,因为单个分片将包含不同 user_id 的所有行,并且将保证该分片内“name”列的唯一性。同样,如果“Product”表也按 user_id 分片,那么不同的 product_id 只能存在于包含用户的分片中。在这种情况下,像 ["product_id", "line_item_id"] 这样的唯一 MySQL 索引也将是全局唯一的。这是可行的,因为用户的所有数据都保存在同一个分片中,并且 MySQL 唯一索引强制该分片内的唯一性。这里不需要 Lookup Vindexes。请参阅下面的示例。
# All tables have a user_id column, and the keyspace is sharded by this column.
CREATE TABLE `products` (
`id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
PRIMARY KEY (`id`)
)
CREATE TABLE `orders` (
`id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`product_id` BIGINT NOT NULL,
`line_item_id` BIGINT NOT NULL,
PRIMARY KEY (`id`),
# Not unique across shards. We would require a Lookup Vindex.
UNIQUE KEY `index_on_line_item_id` (`line_item_id`),
# Unique across shards, because each 'user_id' is only in one shard.
UNIQUE KEY `index_orders_on_user_id_and_line_item_id` (`user_id`,`line_item_id`),
# Unique across shards, because the 'products' table is sharded by 'user_id'. Uniqueness is guaranteed as a 'product_id' will only exist in a single shard.
UNIQUE KEY `index_orders_on_product_id_and_line_item_id` (`product_id`,`line_item_id`),
)
view rawexample.sql hosted with ❤ by GitHub
可以在任何键空间中创建查找 Vindexes。我们选择为 Vindexes 创建单独的分片键空间,consistent_lookup_unique原因如下:1) 在实际分片用户键空间之前,我们希望获得更多有关分片键空间的经验;2) 我们希望强制用户键空间中的所有表都具有正确的分片 ID;3) 我们希望改进可观察性和工具;4) 我们不希望单个未分片的数据库成为插入分片键空间的瓶颈。
添加更多分片现在,我们已经拥有了添加更多分片所需的所有部件。这最后一步也是最危险的一步。从单个数据库切换到多个分片时,会有很多变动的部分,而且一切都需要完美无缺。从我们之前移动表的经验来看,我们意识到这并不容易。我们是 Shopify 的第一批用户,所以我们必须支付一些早期采用者的税。
我们首先组织了一场为期一周的黑客马拉松,邀请项目的所有成员参与。目标是正确理解 Vindexes、Sequences 以及实际对用户密钥空间进行分片。我们经历了严峻考验,但最终还是坚持了下来。总而言之,我们遇到了大约 25 个错误。其中一些是 Vitess 本身的错误(例如1、2、3、4、5、6 ),而其他则是应用程序和内部基础架构层的错误。经过这样的练习后,人们对移动部件产生了健康的怀疑。
随着我们越来越有信心,我们准备对生产数据库进行分片。与 MoveTables 操作类似,我们在开始之前禁用了模式缓存和迁移。我们禁用了 Vitess 平板电脑节流器( docs ) ,因为它在我们的测试中切换流量时造成了一些问题。然后,我们创建了与源分片规格匹配的新分片。我们启动了 Reshard 工作流程( docs ) 。整个过程大约需要一周时间。值得注意的是,我们在复制数据时看到了很多暂停,因为当 MySQL 历史列表长度 (HLL) 太高时,复制流会受到限制。当 HLL 恢复时,复制流将恢复。
复制数据后,我们运行 VDiffs 来验证移动的完整性。执行 VDiff 时,我们发现 HLL 在大约 15 分钟内增长到 100 多万。我们还发现 VDiff 会遇到瞬时连接错误,但它通常会自动恢复。VDiff 在源和目标平板电脑上使用一系列单个长时间运行的查询。当我们在较大的表上运行 VDiff 时,我们遇到了一个错误,它实际上停止了工作流。我们停止 VDiff 后,工作流恢复了。积极的一面是,我们发现工作流对故障( VReplication )非常有弹性。它能够经受住计划外的故障转移和需要更换实例的配置更改。
完成 VDiff 后,我们将副本读取切换为指向新分片并查找问题。鉴于我们对查询验证器的高度关注,切换读取后我们没有发现重大问题。我们等了几个小时,然后终于切换了主读取和写入。此时,写入将转到新分片,并复制回原始源分片。大约一小时后,反向复制停止并出现以下错误:
Duplicate entry REDACTED for key 'index_name_on_table' (errno 1062) (sqlstate 23000) during query: insert into table_name(<REDACTED>) values (REDACTED)
发生此问题的原因是流程复杂,需要在短时间内更新、插入和删除记录:这在单个源分片上有效,但当 row_1 和 row_2 位于不同的分片上时就会中断。无法保证来自不同分片的事件的顺序,因此插入可能出现在更新之前,这违反了唯一性约束。Vitess 有一个功能可以最小化偏差,但这只能缓解问题而不能消除它UPDATE row_1 → INSERT row_2 → DELETE row_1. (文档)。我们正在从副本平板电脑进行复制,通过消除 MySQL 复制滞后作为一个因素,这可能已经减少了偏差,但我们不知道这是否足以完全防止竞争条件。鉴于我们没有看到新分片的任何问题,我们决定继续完成重新分片操作。
清理现在我们已经对用户键空间进行了分片,我们花了一些时间进行清理。首先,我们意识到在分片系统上运行架构迁移还有一些我们需要考虑的极端情况。每个分片将在不同时间完成架构迁移。为了减少这种影响,我们要求所有添加或删除的列都包含在 Rails ( docs )中的 ignore_columns 列表中。这可确保这些列不会被 SQL 查询引用。我们还使用该--singleton标志运行架构迁移,因此我们每个键空间一次只运行一次架构迁移。
另一项清理任务是从所有表中删除 MySQL 自动增量。既然序列负责自动增量,我们不需要或不希望 MySQL 负责自动增量。所以我们在所有表上运行了架构迁移。这降低了发生概率极低但影响巨大的故障模式的可能性,在这种情况下,分片出于某种原因使用 MySQL 自动增量而不是序列表。
临别感想水平扩展我们的主要数据存储已解锁了不受限制的增长。它还使我们摆脱了不断进行边际优化的循环,以使之前的设置正常工作。总体而言,我们对 Vitess 对我们系统的影响感到非常满意。架构迁移只需几个小时而不是几周。我们不再遇到容量问题,当数据库被推到极限时(副本滞后、mysql 线程运行等),后台作业会受到限制。最重要的是,我们现在只需添加更多分片即可进一步扩展。
这样做的代价是增加了复杂性。为了让开发人员有效地使用 Vitess,他们确实需要学习更多抽象。我们将 Vitess 抽象与 MySQL 索引进行比较。在 MySQL 上构建的开发人员应该了解如何设置索引。让我们考虑一下如果您要切换到 Vitess 时需要考虑的一些主要事项。
Vitess 的主要考虑因素Vitess 有许多不同的功能(文档)。如果我们只关注大多数类似 Rails 的应用程序所需的功能,那么剩下的就是 Sequences、Vindexes 和 VSchemas。您的团队需要对这些概念有基本的了解。
序列是协调分片间自动递增主 ID 所必需的。Rails 应用程序默认使用自动递增主 ID,因此您很可能需要这些。幸运的是,它们相当简单。主 Vindexes定义数据如何分发到分片(即分片键)。开发人员需要了解这一点,以避免跨分片交易。查找 Vindexes强制跨分片的唯一性并减少跨分片查询。VSchemas描述每个键空间的组织方式。分片键空间需要 VSchemas。迁移到 Vitess 的经验教训如果你读完整篇文章,你可能会考虑转向 Vitess。以下是可能对你有帮助的核心课程:
提前选择分片策略。选择分片策略后,重新组织数据模型,然后回填大型表格确实非常繁琐且耗时。理想情况下,您应该提前设置 linters,以强制所有新表都必须具有此不可为空的分片键。您必须在准备环境中练习重要步骤。Vitess 功能强大,但失败模式令人生畏。在尝试投入生产之前,请设置一个出色的准备环境并练习每个步骤(我们有两个准备环境)。我们在此过程中发现了一些错误,您很可能也会发现。您还应该有一种在准备环境中大量创建虚拟数据的方法,因为这将有助于您识别潜在问题。您应该在查询验证器上投入大量资金。删除跨键空间/分片交易。减少跨键空间/分片查询。维护查询列表并尽可能减少查询。三重检查验证器的有效性。下一步我们的下一步是稳定和简化。我们在切换到 Vitess 时发现了一些问题。其中一些是 Vitess 的错误,而其他问题与我们的特定设置有关。我们计划解决这些问题。一旦我们升级到引入复合键( docs )的 Rails v7.1 ,我们将删除大部分自定义补丁,并使我们的方法与 Rails 的未来保持一致。
本博客由以下人士共同撰写:
Hammad Khalid, 高级开发人员(LinkedIn、X),
Vahe Khachikyan,高级开发人员 ( LinkedIn ),
Hanying (Elsa) Huang , 高级开发人员 ( LinkedIn ),
Thibault Gautriaud,员工开发人员(LinkedIn),
Adam Renberg Tamm,首席工程师(LinkedIn),以及
Brendan Dougherty, 员工生产工程师(LinkedIn)。
出处:https://shopify.engineering/horizontally-scaling-the-rails-backend-of-shop-app-with-vitess