Notion网站如何将单体PostgreSQL分片成一个水平分区的数据库群?


在我们不断努力提高应用程序性能的过程中,分片是一个重要的里程碑。在过去的几年里,看到越来越多的人将 Notion 应用到他们生活的方方面面,我感到欣慰和欣慰。不出所料,所有新的公司 wiki、项目跟踪器和图鉴都意味着要存储数十亿个新块、文件和空间。到 2020 年年中,很明显,产品使用量将超过我们值得信赖的 Postgres 整体的能力,后者已经尽职尽责地为我们服务了五年和四个数量级的增长。随叫随到的工程师经常被数据库 CPU 峰值唤醒,并且简单的仅目录迁移变得不安全和不确定。
在分片方面,快速发展的初创公司必须进行微妙的权衡。
 
设计分片方案
如果您以前从未对数据库进行过分片,这里有一个想法:不是使用逐渐增加的实例垂直扩展数据库,而是通过跨多个数据库对数据进行分区来进行水平扩展。现在,您可以轻松启动额外的主机以适应增长。不幸的是,现在您的数据位于多个地方,因此您需要设计一个在分布式环境中最大化性能和一致性的系统。
 
应用级分片
我们决定实施我们自己的分区方案并从应用程序逻辑路由查询开始,这种方法称为应用程序级分片。在我们最初的研究,我们还考虑打包分片/集群解决方案,如Postgres的Citus或MySQL的Vitess。虽然这些解决方案以其简单性而吸引人,并提供开箱即用的跨分片工具,但实际的集群逻辑是不透明的,我们希望控制数据的分布。
应用级分片要求我们做出以下设计决策:

  • 我们应该分片哪些数据?使我们的数据集独一无二的部分原因是:block表反映了用户创建内容的树,这些在大小、深度和分支因子上可能有很大差异。例如,单个大型企业客户产生的负载比许多普通个人工作空间的总和还要多。我们只想对必要的表进行分片,同时保留相关数据的局部性。
  • 我们应该如何对数据进行分区?好的分区键确保元组在分片之间均匀分布。分区键的选择还取决于应用程序结构,因为分布式连接很昂贵,而且事务性保证通常仅限于单个主机。
  • 我们应该创建多少个分片?这些碎片应该如何组织?这种考虑既包括每个表的逻辑分片数量,也包括逻辑分片和物理主机之间的具体映射。

  
决策 1:将所有与BLOCK相关的数据分片
由于 Notion 的数据模型围绕块BLOCK的概念展开,每个块BLOCK在我们的数据库中占据一行,因此block表是分片的最高优先级。但是,块可能会引用其他表。我们决定通过某种外键关系对表中所有可访问的表进行分片。并非所有这些表都需要分片,但如果一条记录存储在主数据库中,而其相关块存储在不同的物理分片上,我们可能会在写入不同的数据存储时引入不一致。
  
决策 2:按工作区 ID 对块数据进行分区
一旦我们决定要分片哪些表,我们就必须将它们分开。选择一个好的分区方案很大程度上取决于数据的分布和连通性;由于 Notion 是基于团队的产品,因此我们的下一个决定是按工作区 ID对数据进行分区。
每个工作区在创建时都会分配一个 UUID,因此我们可以将 UUID 空间划分为统一的存储区。因为分片表中的每一行都是一个块或与一个块相关,并且每个块都属于一个工作区,所以我们使用工作区 ID 作为分区键。由于用户通常一次在单个工作区中查询数据,因此我们避免了大多数跨分片连接。
  
决策3:容量规划
决定了分区方案后,我们的目标是设计一个分片设置,该设置将处理我们现有的数据规模,以轻松满足我们的两年使用预测。以下是我们的一些限制条件:
  • 实例类型:磁盘 I/O 吞吐量,以IOPS量化,受 AWS 实例类型和磁盘容量的限制。我们需要至少 60K 的总 IOPS 来满足现有需求,并在需要时能够进一步扩展。
  • 物理和逻辑分片的数量:为了保持 Postgres 正常运行并保留 RDS 复制保证,我们设置了每个表 500 GB 和每个物理数据库 10 TB 的上限。我们需要选择多个逻辑分片和多个物理数据库,以便分片可以在数据库之间平均分配。
  • 实例数量:更多的实例意味着更高的维护成本,但一个更强大的系统。
  • 成本:我们希望我们的账单随着我们的数据库设置线性扩展,并且我们希望能够灵活地分别扩展计算和磁盘空间。

在对数字进行分析之后,我们确定了一个由480 个逻辑分片组成的架构,这些分片均匀分布在32 个物理数据库中。层次结构如下所示:

  • 物理数据库(共 32 个)
    • 逻辑分片,表示为 PostgresSchema表(每个数据库 15 个,总共 480 个)
      • block 表(每个逻辑分片 1 个,总共 480 个)
      • collection 表(每个逻辑分片 1 个,总共 480 个)
      • space 表(每个逻辑分片 1 个,总共 480 个)
      • 等所有分片表

为什么是 480 个分片?关键是,480 可以被很多数字整除——这提供了添加或删除物理主机的灵活性,同时保持统一的分片分布。例如,将来我们可以从 32 台主机扩展到 40 台主机,再到 48 台主机,每次都进行增量跳转。
相比之下,假设我们有 512 个逻辑分片。512 的因数都是 2 的幂,这意味着如果我们想保持碎片均匀,我们会从 32 台主机跳到64 台主机。任何 2 的幂都需要我们将物理主机的数量加倍以进行升级。选择具有很多因素的值!
 
迁移到分片
一旦我们建立了分片方案,就该实施它了。对于任何迁移,我们的一般框架是这样的:
  1. 双重写入:传入的写入同时应用于旧数据库和新数据库。
  2. 回填:一旦开始双写,将旧数据迁移到新数据库。
  3. 验证:确保新数据库中数据的完整性。
  4. 切换:实际切换到新的数据库。这可以增量地完成,例如双读,然后迁移所有读。

  
用审计日志双重写入
双写阶段可确保新数据同时填充旧数据库和新数据库,即使新数据库尚未使用。双重写入有几种选择:
  • 直接写入两个数据库:看似简单,但任何写入的任何问题都可能很快导致数据库之间的不一致,使得这种方法对于关键路径生产数据存储来说太不稳定了。
  • 逻辑复制:内置Postgres 功能,使用发布/订阅模型将命令广播到多个数据库。在源数据库和目标数据库之间修改数据的能力有限。
  • 审计日志和追赶脚本:创建审计日志表以跟踪对迁移表的所有写入。追赶过程遍历审计日志并将每次更新应用到新数据库,根据需要进行任何修改。

我们选择了审计日志策略而不是逻辑复制,因为后者block在初始快照步骤中难以跟上表写入量。
 
回填旧数据
一旦传入的写入成功传播到新数据库,我们就会启动回填过程以迁移所有现有数据。由于m5.24xlarge我们配置的实例上有全部 96 个 CPU (!) ,我们的最终脚本需要大约三天的时间来回填生产环境。
任何值得回味的回填都应该在写入旧数据之前比较记录版本,跳过最近更新的记录。通过以任何顺序运行追赶脚本和回填,新数据库最终会聚合以复制单体。
 
验证数据完整性
迁移与底层数据的完整性一样好,所以在分片与单体更新后,我们开始验证正确性的过程。
  • 验证脚本:我们的脚本从给定值开始验证 UUID 空间的连续范围,将单体应用上的每条记录与相应的分片记录进行比较。由于全表扫描的成本高得令人望而却步,因此我们随机抽取了 UUID 并验证了它们的相邻范围。
  • “暗读”:在迁移读查询之前,我们添加了一个标志来从新旧数据库中获取数据(称为暗读)。我们比较了这些记录并丢弃了分片副本,记录了过程中的差异。引入暗读增加了 API 延迟,但提供了无缝切换的信心。

作为预防措施,迁移和验证逻辑由不同的人实现。否则,有人在两个阶段犯同样错误的可能性更大,削弱了验证的前提。
 
艰难的教训
虽然分片项目的大部分内容都让 Notion 的工程团队处于最佳状态,但我们事后会重新考虑许多决定。这里有一些例子:

  • 早点分片。作为一个小团队,我们敏锐地意识到与过早优化相关的权衡。但是,我们一直等到现有数据库严重紧张,这意味着我们必须非常节俭地迁移,以免增加更多负载。这种限制使我们无法使用逻辑复制进行双写。工作区 ID(我们的分区键)尚未填充到旧数据库中,回填此列会加剧单体应用的负载。相反,我们在写入分片时即时回填每一行,需要一个自定义的追赶脚本。
  • 旨在实现零停机迁移。双写吞吐量是我们最终切换的主要瓶颈:一旦我们关闭服务器,我们需要让追赶脚本完成将写入传播到分片。如果我们再花一周时间优化脚本,以便在切换期间花费 <30 秒来赶上分片,则可能可以在负载均衡器级别进行热交换而不会停机。
  • 引入组合主键而不是单独的分区键。今天,分表中的行使用复合键:id,旧数据库中的主键;和space_id,当前排列中的分区键。由于无论如何我们都必须进行全表扫描,因此我们可以将两个键组合成一个新列,从而无需在space_ids整个应用程序中传递。

尽管有这些假设,分片还是取得了巨大的成功。对于 Notion 用户来说,几分钟的停机时间使产品明显更快。在内部,鉴于时间敏感的目标,我们展示了协调的团队合作和果断的执行。