Figma如何实现Postgres数据库垂直扩展?

2020 年,由于新功能的组合、准备推出第二个产品以及更多的用户(数据库流量每年增长约 3 倍),Figma 的基础设施遇到了一些成长的烦恼。我们知道,早年支持 Figma 的基础设施无法扩展以满足我们的需求。我们仍然使用单个大型 Amazon RDS数据库来保存我们的大部分元数据(如权限、文件信息和评论),虽然它可以无缝处理我们的许多核心协作功能,但一台机器有其局限性。

最明显的是,由于一个数据库服务的查询量,我们观察到在高峰流量期间 CPU 利用率高达 65%。

随着使用量接近极限,数据库延迟变得越来越不可预测,从而影响核心用户体验。

如果我们的数据库完全饱和,Figma 将停止工作。

远非如此,作为一个基础架构团队,我们的目标是在可扩展性问题接近迫在眉睫的威胁之前主动识别并修复它们。

我们需要设计一个解决方案来减少潜在的不稳定性并为未来的规模铺平道路。此外,在我们实施该解决方案时,性能和可靠性将继续成为首要考虑因素;我们的团队旨在构建一个可持续发展的平台,让工程师能够在不影响用户体验的情况下快速迭代 Figma 的产品。如果 Figma 的基础设施是一系列道路,我们就不能在施工时暂时关闭高速公路。

我们从一些战术修复开始,以确保额外一年的跑道,同时我们为更全面的方法奠定了基础:

  1. 将我们的数据库升级到可用的最大实例(从r5.12xlarge 到 r5.24xlarge)以最大化 CPU 利用率跑道
  2. 创建多个只读副本以扩展读取流量
  3. 为新用例建立新数据库以限制原始数据库的增长
  4. 将PgBouncer添加为连接池,以限制连接数量不断增加(以数千计)的影响



上图我们添加了 PgBouncer 作为连接管理器

尽管这些修复起到了推动作用,但它们也有局限性。通过分析我们的数据库流量,我们了解到写入(如收集、更新或删除数据)占数据库利用率的很大一部分。此外,由于应用程序对复制延迟敏感,并非所有读取或数据提取都可以移动到副本。因此,从读写的角度来看,我们仍然需要从我们的原始数据库中卸载更多的工作。是时候放弃增量更改并寻找更长期的解决方案了。

选择垂直扩展
我们首先探索了水平扩展数据库的选项。许多流行的托管解决方案本身并不兼容我们在 Figma 使用的数据库管理系统Postgres 。如果我们决定使用水平可扩展的数据库,我们要么必须找到与 Postgres 兼容的托管解决方案,要么自行托管。

迁移到NoSQL 数据库Vitess (MySQL) 将需要复杂的双读写迁移,尤其是 NoSQL 还需要进行重大的应用程序端更改。对于与 Postgres 兼容的NewSQL,对于云管理的分布式 Postgres,我们将拥有最大的单集群足迹之一。我们不想成为第一个遇到某些扩展问题的客户。我们对托管解决方案几乎没有控制权,因此在没有按我们的规模进行压力测试的情况下依赖它们会使我们面临更多风险。

如果不选择托管解决方案,我们的另一个选择是自托管。但由于迄今为止我们一直依赖托管解决方案,因此我们的团队需要进行大量的前期工作来获得支持自托管所需的培训、知识和技能。这将意味着巨大的运营成本,这将使我们不再关注可扩展性——这是一个更存在的问题。

在决定反对水平分片的之后,我们不得不转向。
我们决定按表对数据库进行垂直分区,而不是水平分片。
我们不会将每个表拆分到多个数据库中,而是将表组移动到它们自己的数据库中。
这被证明具有短期和长期的好处:垂直分区现在减轻了我们原来的数据库的负担,同时为将来水平分片我们的表的子集提供了前进的道路。
垂直分区方法

然而,在我们开始这个过程之前,我们首先必须确定要分区到它们自己的数据库中的表。有两个重要因素:

  1. 影响:移动表格应该会移动很大一部分工作负载
  2. 隔离:表不应与其他表强连接

为了衡量影响,我们查看了查询的平均活动会话 (AAS),它描述了在特定时间点专用于给定查询的平均活动线程数。我们通过以pg_stat_activity10 毫秒为间隔进行查询来计算此信息,以识别与查询相关的 CPU 等待,然后按表名聚合信息。

每个表的“隔离”程度证明了它是否易于分区的核心。当我们将表移动到不同的数据库时,我们将失去重要的功能,例如表之间的原子事务、外键验证和连接。因此,就开发人员必须重写多少 Figma 应用程序而言,移动表格的成本可能很高。我们必须通过专注于识别易于分区的查询模式和表来制定战略。

事实证明,这对我们的后端技术堆栈来说很困难。

我们使用Ruby作为应用程序后端,它为我们的大部分 Web 请求提供服务。反过来,这些会生成我们的大部分数据库查询。我们的开发人员使用ActiveRecord来编写这些查询。由于 Ruby 和 ActiveRecord 的动态特性,仅通过静态代码分析很难确定哪些物理表受到 ActiveRecord 查询的影响。

作为第一步,我们创建了连接到 ActiveRecord 的运行时验证器。这些验证器将生产查询和交易信息(例如调用者位置和涉及的表)发送到Snowflake,我们在云端的数据仓库。我们使用此信息来查找始终引用同一组表的查询和事务。如果这些工作负载的成本很高,那么这些表将被确定为垂直分区的主要候选者。

管理迁移
一旦我们确定了要分区的表,我们就必须想出一个在数据库之间迁移它们的计划。虽然这在离线执行时很简单,但离线并不是 Figma 的选择——Figma 需要始终保持高性能以支持用户的实时协作。我们需要协调数以千计的应用程序后端实例之间的数据移动,以便它们可以在正确的时刻将查询路由到新数据库。这将允许我们对数据库进行分区,而无需为每个操作使用维护窗口或停机时间,这将对我们的用户造成干扰(并且还需要工程师在下班时间工作!)。我们想要一个满足以下目标的解决方案:

  1. 将潜在的可用性影响限制在 <1 分钟内
  2. 使程序自动化,使其易于重复
  3. 能够撤消最近的分区

我们找不到满足我们要求的预构建解决方案,而且我们还希望能够灵活地调整解决方案以适应未来的用例。只有一个选择:建立我们自己的。

我们的定制解决方案
在高层次上,我们实施了以下操作(步骤 3-6 在几秒钟内完成,以最大限度地减少停机时间):

  1. 准备客户端应用程序以从多个数据库分区进行查询
  2. 将表从原始数据库复制到新数据库,直到复制延迟接近 0
  3. 暂停原始数据库上的活动
  4. 等待数据库同步
  5. 将查询流量重新路由到新数据库
  6. 恢复活动

正确准备客户端应用程序是一个重要问题,我们应用程序后端的复杂性让我们感到焦虑。如果我们错过了分区后崩溃的边缘情况怎么办?为了降低操作风险,我们利用 PgBouncer 层来获得运行时可见性和对我们的应用程序配置正确的信心。

在与产品团队合作使应用程序与分区数据库兼容后,我们创建了单独的 PgBouncer 服务来虚拟地拆分流量。安全组确保只有 PgBouncer 可以直接访问数据库,这意味着客户端应用程序始终通过 PgBouncer 连接。首先对 PgBouncer 层进行分区会给客户端提供错误路由查询的余地。我们能够检测到路由不匹配,但是由于两个 PgBouncer 具有相同的目标数据库,客户端仍然可以成功查询数据。

上图我们的开始状态


上图PgBouncer分区后数据库的状态

一旦我们确认应用程序为每个 PgBouncer 准备了单独的连接(并适当地发送流量),我们就会继续。


上图分区数据后数据库的状态

“合乎逻辑”的选择
在 Postgres 中,有两种复制数据的方式:流复制逻辑复制。我们选择逻辑复制是因为它允许我们:

  1. 移植表的一个子集,这样我们就可以从目标数据库中更小的存储空间开始(减少存储硬件空间可以提高可靠性)。
  2. 复制到运行不同 Postgres 主要版本的数据库,这意味着我们可以使用此工具执行停机时间最短的主要版本升级。AWS 有针对主要版本升级的蓝/绿部署,但该功能还不适用于 RDS Postgres。
  3. 设置反向复制,它允许我们回滚操作。

使用逻辑复制的主要问题是我们正在处理数 TB的生产数据,因此初始数据复制可能需要数天甚至数周才能完成。我们希望避免这种情况,不仅可以最小化复制失败的窗口,还可以减少重新启动的成本。我们仔细考虑过协调快照恢复并在正确的时间点开始复制,但恢复消除了拥有更小存储空间的可能性。

相反,我们决定调查为什么逻辑复制的性能如此低迷。我们发现慢速副本是 Postgres 如何在目标数据库中维护索引的结果。虽然逻辑复制批量复制行,但更新效率低下一次索引一行。通过删除目标数据库中的索引并在初始数据复制后重建索引,我们将复制时间缩短为几小时。

通过逻辑复制,我们能够从新分区的数据库构建反向复制流并返回到原始数据库。这个复制流在原始数据库停止接收流量后立即被激活(更多内容见下文)。对新数据库的修改将被复制回旧数据库,如果我们回滚,旧数据库就会有这些更新。

关键步骤
随着复制的解决,我们发现自己处于协调查询重新路由的关键步骤。每天,数以千计的客户端服务在任何给定时间查询数据库。协调这么多客户端节点很容易失败。通过分两个阶段执行我们的分片操作(先分区 PgBouncer,然后分区数据),分区数据的关键操作只需要在为分区表服务的少数 PgBouncer 节点之间进行协调。

以下是正在进行的操作的概述:我们跨节点协调以仅短暂停止所有相关数据库流量,以便逻辑复制同步新数据库。(PgBouncer 方便地支持暂停新连接和重新路由。)在 PgBouncer 暂停新连接的同时,我们撤销了客户端对原始数据库中分区表的查询权限。在短暂的宽限期后,我们会取消任何剩余的航班查询。由于我们的应用程序主要发出持续时间较短的查询,因此我们通常会取消 10 个以下的查询。此时,随着流量暂停,我们需要验证我们的数据库是否相同。

在重新路由客户端之前确保两个数据库相同是防止数据丢失的基本要求。我们使用LSN来确定两个数据库是否同步。如果我们在确信没有新的写入后从我们的原始数据库中采样一个 LSN,那么我们可以等待副本重放通过这个 LSN。此时,原始数据和副本中的数据是相同的。


上图我们的同步机制的可视化

在我们检查副本是否同步后,我们停止复制并将副本提升到新数据库。反向复制的设置如前所述。然后,我们恢复 PgBouncer 中的流量,但现在查询被路由到新数据库。

规划我们的横向扩展
从那以后,我们已经在生产中成功执行了多次分区操作,并且每次都实现了我们最初的目标:在不影响可靠性的情况下解决可扩展性问题。我们的第一个操作涉及移动两个高流量表,而我们在 2022 年 10 月的最后一个操作涉及 50 个。在每个操作期间,我们观察到约 30 秒的部分可用性影响(约 2% 的请求被丢弃)。如今,每个数据库分区的运行空间都大大增加了。我们最大的分区的 CPU 利用率徘徊在 ~10%,我们已经减少了分配给一些较低流量分区的资源。

然而,我们在这里的工作还没有完成。现在有许多数据库,客户端应用程序必须维护每个数据库的知识,并且随着我们添加更多数据库和客户端,路由的复杂性成倍增加。

我们已经引入了一个新的查询路由服务,当我们扩展到更多分区时,它将集中和简化路由逻辑。

我们的一些表具有高写入流量或数十亿行和 TB 的磁盘占用空间,这些表将分别遇到磁盘利用率、CPU 和 I/O 瓶颈。

我们一直都知道,只要我们依靠垂直分区,我们最终会达到扩展限制。回到我们最大化杠杆作用的目标,我们为垂直分区创建的工具将使我们更好地配备具有高写入流量的水平分片表。

它为我们提供了足够的跑道来处理我们当前的项目并保持 Figma 的“高速公路”畅通,同时也能看到转弯处。