四年增长 100 倍的 Figma,数据库团队是怎么活下来的!

2024年 3月 19日 35.3k 0

原文链接

Figma 是近几年全球增速最快的 SaaS 服务之一。作为新一代的在线协同设计软件,Adobe 曾一度计划以 200 亿美金收购 Figma,最后因为反垄断的顾虑而终止。本文介绍了 Figma 数据库团队过去 9 个月如何赶在数据库无法支撑业务前,完成了技改。

这是一场持续 9 个月的历程,我们对 Figma 的 Postgres 进行了水平分片,来实现(几乎)无限的可扩展性。

自 2020 年以来,Figma 的数据库增长了近 100 倍。这是一个甜蜜的烦恼,因为这意味着我们的业务在扩张,但也带来了一些棘手的技术挑战。在过去的四年里,我们付出了巨大努力保持领先地位,并避免潜在的成长烦恼。2020 年时,我们运行着一个单体 Postgres 数据库,托管在 AWS 最大的物理实例上;到 2022 年底,我们已经建立起具有缓存、只读副本和十几个垂直分区数据库的分布式架构。我们将相关表组(如「Figma文件」或「组织”」)拆分到它们自己的垂直分区,这使得我们能够获得渐进式扩展收益,并保持足够空间提前应对增长。 尽管我们在渐进式扩展方面取得了进展,但我们始终知道垂直分区只能让我们走到这一步。我们最初的扩展工作集中在减少 Postgres CPU 利用率上。随着我们的集群规模越来越大且更加异构化,我们开始监控一系列瓶颈。我们使用历史数据和负载测试相结合来量化数据库从 CPU 和 IO 到表大小和写入行数的扩展限制。识别这些限制对于预测每个分片还能撑多久至关重要。然后,我们可以在问题发育成主要可靠性风险之前优先处理扩展问题。

数据显示,我们的一些表格包含数 TB级,数十亿行数据,已经变得太大以至于无法放入单个数据库中。在这种规模下,我们开始看到 Postgres vaccume 操作期间出现可靠性问题,这些操作是 保持Postgres 不会用尽事务 ID (Transaction Wraparound) 并崩溃的关键后台操作。我们最高写入量的表格增长太快了,以至于很快就会超过 AWS RDS 支持的每秒最大 IO 操作次数(IOPS)。由于垂直分片不能解决问题,因为分片的最小单位只能一个单独的表。为了防止我们的数据库倒塌,我们需要更大的杠杆。

整装待发

我们列出了一些目标和必备条件,以解决短期挑战,并为顺利长期增长做好准备。我们的目标是:

  • 尽可能不影响开发人员:我们希望能处理大部分应用程序已有的复杂关系数据模型。应用程序开发人员可以专注于在 Figma 中构建令人兴奋的新功能,而不是重构我们代码库中的大部分内容。
  • 应用无感扩展:在未来的扩展中,我们不希望在应用程序层面进行额外的更改。这意味着,在做任何初始工作使表兼容之后,未来的规模扩大对我们的产品团队应该是透明的。
  • 避免昂贵的回填 (Backfill):我们避免了涉及在 Figma 的大表或每个表进行回填的解决方案。考虑到我们表格的大小和 Postgres 吞吐量限制,这些回填将需要数月时间。
  • 递进式扩展:我们确定了可以逐步扩展的方法,以逐步降低生产变更的主要风险。这减少了重大故障的风险,并使数据库团队能够在迁移过程中保持 Figma 的可靠性。
  • 避免单向迁移 (one-way migraiton):即使在完成物理分片操作后,我们仍保持了回滚的能力。这降低了在发生未知情况时没有回头路。
  • 保持数据一致性:我们希望避免像双写这样复杂的解决方案,这些解决方案很难在不停机或牺牲一致性的情况下实施。我们还希望找到一个可以让我们进行水平扩展且几乎零停机时间的解决方案。
  • 发挥我们的优势:由于我们是在有严格的死限压力下开展工作,尽可能地,我们倾向于先能逐步解决我们增长最快的那些表。我们希望利用已经具备的专业知识和技术。

方案调研

有许多流行的开源和托管解决方案可用于水平分片数据库,这些解决方案与 Postgres 或 MySQL 兼容。在我们的评估过程中,我们探索了 CockroachDB、TiDB、Spanner 和 Vitess。然而,切换到任何这些替代数据库都需要进行复杂的数据迁移,以确保两个不同数据库存储之间的一致性和可靠性。此外,在过去几年里,我们已经积累了大量关于如何在内部可靠地运行 RDS Postgres 的专业知识。如果要迁移到这些新数据库,我们将不得不从头开始重建领域专业知识。考虑到我们非常激进的增长速度,剩余时间只有几个月。相比可能更容易但存在更高不确定性的选项,我们更倾向于选择已知的低风险解决方案,可控性更高,

NoSQL 数据库是另一种常见的默认可扩展解决方案。然而,我们建立在当前 Postgres 架构之上的关系数据模型非常复杂,并且 NoSQL API 并不提供这种多样性。我们希望让工程师专注于发布出色功能和构建新产品,而不是几乎重写整个后端应用程序;NoSQL并非一个可行的解决方案。

一番权衡后,我们开始探索在现有的垂直分区 RDS Postgres 基础架构之上构建一个水平分片解决方案。对于我们的小团队来说,在内部重新实现一个通用的水平分片关系数据库是没有意义的;这样做会使我们与大型开源社区或专门的数据库供应商构建的工具竞争。然而,由于我们将水平分片定制为 Figma 的特定架构,因此可以提供更小的功能集合。例如,我们选择不支持原子跨 Shard 事务,因为我们可以通过解决跨 Shard 事务失败来绕过它们。我们采取了一种最大程度减少应用层所需更改的 colocation 策略。这使得我们能够支持与大多数产品逻辑兼容的 Postgres 子集。同时,我们还能够轻松地在带有 Sharded 和未经 Sharded 处理过的 postgres 之间保持向后兼容性。如果遇到未知问题,那么很容易回滚到未经 Sharded 处理过的 Postgres 上去。

水平分片 (Sharding) 之路

即使有了这些精简的要求,我们知道水平分片将是迄今为止我们最大、最复杂的数据库项目。幸运的是,过去几年里我们采取的增量扩展方法为我们做了准备。在 2022 年底,我们着手解锁几乎无限的数据库可伸缩性,而水平分片——即将单个表或一组表拆分并将数据跨多个物理数据库实例进行划分——就成了关键。一旦一个表在应用层被水平切割,它可以支持任意数量的物理层碎片。通过简单地运行物理碎片拆分,我们总是可以进一步扩展规模。这些操作在后台透明地进行,并且只需很少停机时间,并不需要应用级别变更。这种能力将使我们能够摆脱所剩无几的数据库扩展瓶颈,消除 Figma 的最后一个主要扩展挑战之一。如果垂直分片让我们加速到高速公路速度,那么水平分片则可以关掉限速,并让我们飞起来。

水平分片比我们之前的扩展工作复杂得多。当一个表被分割到多个物理数据库中时,我们失去了在 ACID SQL 数据库中视为理所当然的许多可靠性和一致性属性。例如:

  • 某些 SQL 查询变得低效或无法支持。
  • 必须更新应用程序代码,以提供足够的信息,尽可能高效地将查询路由到正确的分片。
  • 必须协调所有分片之间的 Schema 变更,以确保数据库保持同步。
  • Postgres 不再能强制执行外键和全局唯一索引。事务现在跨多个分片,这意味着 Postgres 不能再用于强制保持事务性。现在可能会出现对某些数据库的写入成功而其他失败的情况。必须注意确保产品逻辑能应对 「部分提交失败」(脑补一下将团队在两个组织间移动,结果发现他们一半数据丢失了!)。

我们知道实现完全的水平分片将是一个持续多年的努力。在交付增量价值的同时,我们需要尽可能降低项目风险。我们的第一个目标是尽快对生产中一个相对简单但访问量非常高的表进行分片。这将证明水平分片的可行性,同时延长我们最繁忙数据库上能继续服务的时间。然后,在逐步对更复杂组合的表进行分片时,我们可以继续构建额外功能。即使是最简单可能的功能集仍然是一项重大工作。从头到尾,我们团队花了大约九个月来对第一张表进行分片。

我们独特的方法

我们的水平分片工作也是基于前人的经验,但有一些不同寻常的设计选择。以下是一些要点:

  • Colos:我们将相关的表组队放在一起,称为 colos,这些表共享相同的分片键和物理分片布局。这为开发人员提供了一个友好的抽象,以与水平分片表进行交互。
  • 逻辑分片:我们在应用层将“逻辑分片”的概念与 Postgres 层的“物理分片”区分开来。我们利用视图执行更安全、成本更低的逻辑分片部署,然后再执行风险较高的分布式物理 failover。
  • DBProxy 查询引擎:我们构建了一个 DBProxy 服务,拦截应用程序层生成的 SQL 查询,并动态路由查询到各个Postgres 数据库。DBProxy 包含一个能够解析和执行复杂水平分片查询的查询引擎。DBProxy 还允许我们实现诸如动态负载调节等功能。
  • 影子应用准备就绪:我们添加了一个“影子应用准备就绪”框架,能够预测活跃生产流量在不同分片切割下会如何工作。这使产品团队清楚地知道需要重构或删除哪些应用逻辑以准备应用进行水平切割。
  • 完整逻辑复制:我们避免实施“过滤式的逻辑复制”(仅将数据子集复制到每个分片)。相反,我们复制整个数据集,然后只允许读/写操作针对给定分片所属数据子集进行操作。

我们的分片实现

在水平分片中,最重要的决定之一是选择使用哪个分片键。水平分片增加了许多围绕着分片键的数据模型约束。例如,大多数查询需要包含分片键,以便将请求路由到正确的分片上。某些数据库约束(如外键)只有在外键是分区键时才有效。为了避免引起可靠性问题或影响可伸缩性的热点,分片键还需要将数据均匀地分布在所有分片上。

Figma 跑在网页端,许多用户可以同时在同一个 Figma 文件上进行协作。这意味着我们的产品由一个相当复杂的关系数据模型驱动,保存文件元数据、组织元数据、评论、文件版本等等。

我们考虑使用相同的分片键来处理每个表,但在我们现有的数据模型中没有一个单一的好候选项。要添加统一的分片键,我们必须创建一个复合键,在每个表的架构中添加该列,运行昂贵的回填以填充它,然后大幅重构我们产品逻辑。相反,我们根据 Figma 独特的数据模型量身定制了我们的方法,并选择了像 UserID、FileID 或 OrgID 这样少数几个分片键。Figma 几乎每张表都可以使用这些关键字进行分片。

我们引入了 colo 的概念,为产品开发人员提供友好的抽象:在 colo 内部的表支持跨表连接和完整事务,当限制为单个分片键时。大多数应用程序代码已经以这种方式与数据库交互,这最大程度地减少了应用程序开发人员需要做的工作,使表适合水平切分。下图展示了使用 UserID 和 FileID 的分区各自组合在了一起。

一旦我们选择了分片键,就需要确保数据在所有后端数据库中均匀分布。不幸的是,我们选择的许多分片键使用了自增或雪花时间戳前缀 ID。这将导致存在显著热点,其中一个单个分片包含大部分数据。我们探索过迁移到更随机化的ID,但这需要进行昂贵且耗时的数据迁移。相反,我们决定使用分片键的哈希值进行路由。只要选择足够随机的哈希函数,就能确保数据均匀分布。其中一个缺点是,在范围扫描 shard keys 时效率较低,因为连续键会被散列到不同的数据库 shards 上。然而,在我们代码库中这种查询模式并不常见,所以这是一个可以接受的权衡方案。

「符合逻辑」的方案

为了降低水平分片的风险,我们希望将在应用程序层准备表格的过程与运行分片拆分的物理过程隔离开来。为此,我们将「逻辑分片」与「物理分片」进行了区分。然后,我们可以解耦迁移的两个部分以独立实施和降低风险。逻辑上的切割使我们对技术栈有信心,并采用低风险、基于百分比的发布方式。当发现错误时回滚逻辑分片只需简单更改配置。回滚物理碎片操作是可能的,但需要更复杂的协调以确保数据一致性。

一旦一个表被逻辑分片后,所有读写操作都会像该表已经被水平分片那样执行。从可靠性、延迟和一致性角度看,尽管数据仍然位于单个数据库主机上,但我们似乎已经完成了水平分片工作。当我们确信逻辑分片按预期工作时,则执行物理分片操作。这是从单个数据库复制数据、到把分片散步到不同数据库上,然后通过新数据库重新路由读写流量的过程。

两个物理分片,每个物理分片又包含两个逻辑分片。

刚好的查询引擎

为了支持水平分片,我们不得不对后端技术栈进行重大改造。最初,我们的应用服务直接与连接池层 PGBouncer 通信。然而,水平分片需要更复杂的查询解析、规划和执行。为了支持这一点,我们构建了一个新的 golang 服务DBProxy。DBProxy 位于应用程序层和 PGBouncer 之间。它包括负载均衡逻辑、改进的可观测性、事务支持、数据库拓扑管理以及轻量级查询引擎。

查询引擎是 DBProxy 的核心。其主要组件包括:

  • 查询解析器读取应用程序发送的 SQL,并将其转换为抽象语法树(AST)。
  • 逻辑规划器解析 AST 并从查询计划中提取查询类型(插入、更新等)和逻辑分片 ID。 物理规划器将查询从逻辑分片 ID 映射到物理数据库。它重写查询以在相应的物理分片上执行。

在水平分片的世界中,一些查询相对容易实现。例如,单片段查询被过滤到一个单个分片键。我们的查询引擎只需要提取分片键并将查询路由到适当的物理数据库。我们可以将查询执行的复杂性「下推」到 Postgres 中。然而,如果查询缺少分片键,则我们的查询引擎必须执行更复杂的「发散-归并」。在这种情况下,我们需要将查询发到所有分片(发散阶段),然后汇总结果(归并阶段)。在某些情况下,如复杂聚合、连接和嵌套 SQL 等情况下,这种发散-归并可能非常复杂。此外,有太多发散-归并会影响水平划分可伸缩性。因为这些查询必须触及每个单独的数据库,在未经划分时每次发散-归并都会产生同样数量的负载。

如果我们支持完整的 SQL 兼容性,我们的 DBProxy 服务将开始看起来很像 Postgres 数据库查询引擎。我们希望简化 API 以减少 DBProxy 的复杂性,同时也减少应用程序开发人员需要重新编写任何不受支持查询的工作量。为了确定正确的子集,我们构建了一个“影子规划”框架,允许用户为他们的表定义潜在分片方案,然后在实时生产流量之上运行逻辑规划阶段。我们将查询和相关查询计划记录到 Snowflake 数据库中,在那里可以进行离线分析。根据这些数据,我们选择了一种支持最常见 90% 查询的查询语言,但避免了在查询引擎中出现最坏情况下的复杂性。例如,所有范围扫描和点查都是被允许的,但只有当连接两个位于相同 colo 中且连接在分片键上时才允许 join 操作。

面向未来的 View(视图)

然后我们需要想办法封装我们的逻辑分片。我们探索了使用单独的 Postgres 数据库或 Postgres schemas 对数据进行分区。不幸的是,当我们在应用程序中逻辑地分片时,这将需要物理数据更改,这与执行物理分片拆分一样复杂。相反,我们选择用 Postgres 视图来表示我们的分片。我们可以为每个表创建多个视图,每个视图对应给定分片中数据子集。看起来像:

CREATE VIEW table_shard1 AS SELECT * FROM table WHERE hash(shard_key) >= min_shard_range AND hash(shard_key) < max_shard_range`)

所有对表格的读取和写入都通过这些视图发送。通过在现有未经过划分的物理数据库之上创建划分视图,在执行任何风险较高的物理重新划分操作之前,我们可以在逻辑上进行划分。每个视图通过自己独立的连接池服务,连接到它们所属于的分片实例上。连接池仍然指向未经过分片处理的物理实例,从而呈现出被进行了分片处理后效果。通过查询引擎中特性开关(feature flag)渐进地发布,以减少风险。并且回滚到主表,只需几秒钟即可,将流量重定向回去。当我们第一次执行重新分片操作时, 我们已经对于分片拓扑的安全性胸有成足。

通过在非分片数据库中创建多个视图,我们可以像数据已经被物理分片一样查询这些视图。

当然,依赖视图也引入了额外的风险。视图会增加性能开销,并且在某些情况下可能从根本上改变 Postgres 查询规划器优化查询的方式。为了验证这种方法,我们收集了一组经过脱敏的生产查询语句,并进行了带有和不带有视图的负载测试。我们确认,在大多数情况下,视图只会增加最小的性能开销,在最糟糕的情况下不到 10%。我们还构建了一个影子读取框架,可以通过视图发送所有实时读取流量,比较使用视图与不使用视图查询之间的性能和正确性。然后我们确认,视图是一种可行的解决方案,并且对性能几乎没有影响。

应对我们的拓扑

为了执行查询路由,DBProxy 必须理解我们表和物理数据库的拓扑结构。因为我们已经将逻辑分片与物理分片的概念分开,所以我们需要一种方式在拓扑结构中表示这些抽象概念。例如,我们需要能够将一个表(用户)映射到其分片键(user_id)。同样地,我们需要能够将逻辑分片 ID(123)映射到相应的逻辑和物理数据库。在垂直划分方面,我们依赖于一个简单、硬编码的配置文件来将表映射到它们的分区。然而,在向水平切割转变时,我们需要更复杂的东西。当进行切片拆分时,我们的拓扑结构会动态改变,并且 DBProxy 需要迅速更新其状态以避免请求被路由至错误的数据库。由于每次对拓扑结构进行更改都是向后兼容的,在网站关键路径上从未出现过这些更改。我们建立了一个包含复杂水平切割元数据并可以在不到一秒内提供实时更新信息的数据库拓扑结构。

拥有独立的逻辑和物理拓扑结构也使我们能够简化一些数据库管理工作。例如,在非生产环境中,我们可以保持与生产相同的逻辑拓扑结构,但从更少的物理数据库中提供数据。这样既节约成本又降低复杂性,而不会在各个环境之间进行过多变更。拓扑库还使我们能够强制保证跨整个拓扑结构(例如,每个分片 ID 应映射到一个物理数据库)的约束,这对于维护系统正确性至关重要,特别是在我们建立水平分片的整个过程中。

物理分片操作

一旦表准备好进行分片,最后一步是从未分片到已分片数据库的物理 failover 转移。我们能够重复使用许多相同的逻辑来进行水平切分,但也有一些显著的不同之处:我们不再将数据从一个数据库移动到另一个数据库,而是从一个数据库移动到N个数据库。我们需要使 failover 过程能应对新的失败模式,在这些模式下,分片操作可能仅在我们的部分数据库上成功。尽管如此,在做垂直分片的时候,许多风险最高的组件已经被解决了。我们能够比以往更快地朝着第一次物理分片操作迈进,这本来是不可能实现的。

筚路蓝缕

当我们开始这段旅程时,我们知道水平分片将是对 Figma 未来可扩展性的多年投资。我们在 2023 年 9 月交付了第一个水平分片表。我们成功进行了故障切换,数据库主节点仅出现了十秒的部分可用性问题,副本没有受到影响。在分片后,我们没有看到延迟或可用性方面的退化。从那时起,我们一直在处理写入速率最高的,并且相对简单的数据库分片。今年,我们将对越来越复杂的数据库进行分片处理,这些数据库有数十个表和成千上万个代码调用点。

为了拔掉最后阻碍我们无限扩展的钉子,我们需要在 Figma 上水平分片每个表。一个完全水平分片的世界将带来许多其他好处:提高可靠性、节约成本和开发速度。在这过程中,我们需要解决所有这些问题:

  • 支持水平分片模式更新
  • 全局唯一 ID 用于生成水平分片主键
  • 用于核心业务的跨 shard 的原子事务
  • 分布式全局唯一索引(目前仅支持包含 sharding key 的索引上的唯一索引)
  • 一个可以增加研发效能,但不会受到水平分片影响的 ORM
  • 完全自动化的重分片操作,一健运行。

一旦我们有足够的时间余地,我们还将重新评估内部 RDS 水平分片的整套方案。18个月前,我们开始了这段旅程,并面临极其紧迫的时间压力。NewSQL 持续精进,而我们也终于将有时间来重新评估沿着当前路径继续下去还是转向开源或者托管方案。我们在水平分片旅程中取得了许多令人振奋的进展,但挑战才刚刚开始。

点评:Figma 在时间压力下,采用了更稳妥成熟的水平分片方案。类似方案之前已经在国内外互联网公司里广泛应用。国外的 Instgram (Postgres),GitHub (MySQL) 也都做过详细的分析。而最后 Figma 列出的剩余问题,也正是 NewSQL 路线的强项。比如国内的 TiDB, OceanBase 都能比较好的解决。所以就像 Figma 所说的,等他们缓过来后,也要重新评估一下。

另外一方面,Figma 也提到了水平分片后对于研发带来的挑战。本来只要变更一个数据库,现在可能就要变更 100 个数据库。如何保证这些变更的一致性?Bytebase 里正好提供了批量变更模式,可以把水平分片的数据库放在一组,一起进行变更。

💡 更多资讯,请关注 Bytebase 公号:Bytebase

相关文章

塑造我成为 CTO 之路的“秘诀”
“人工智能教母”的公司估值达 10 亿美金
教授吐槽:985 高校成高级蓝翔!研究生基本废了,只为房子、票子……
Windows 蓝屏中断提醒开发者:Rust 比 C/C++ 更好
Claude 3.5 Sonnet 在伽利略幻觉指数中名列前茅
上海新增 11 款已完成登记生成式 AI 服务

发布评论