https://www.notion.so/blog/sharding-postgres-at-notion

今年(2021)早些时候,我们对 Notion 进行了五分钟的定期维护。 虽然我们的声明指向“提高稳定性和性能”,但在幕后是数月专注、紧迫的团队合作的结果:将 Notion 的 PostgreSQL 整体分片成一个水平分区的数据库舰队。

分片命名法被认为起源于 MMORPG Ultima Online,当时游戏开发者需要一个宇宙解释来解释存在多个运行平行世界副本的游戏服务器。 具体来说,每一个碎片都是从一个破碎的水晶中出现的,邪恶的巫师蒙丹曾试图通过它夺取世界的控制权。

虽然转换成功让大家欢欣鼓舞,但我们仍然保持沉默,以防迁移后出现任何问题。 令我们高兴的是,用户很快开始注意到改进。完全是 “show don't tell”。

让我告诉你我们如何分片的故事以及我们在此过程中学到的东西。

决定何时分片

分片是我们不断努力提高应用程序性能的一个重要里程碑。 在过去的几年里,看到越来越多的人将 Notion 应用到他们生活的方方面面,这令人欣慰和谦卑。不出所料,所有新的公司 wiki、项目跟踪器和图鉴都意味着数十亿新的blocks、files 和 spaces。到 2020 年年中,很明显,产品的使用将超过我们值得信赖的 Postgres 单体的能力,后者在五年和四个数量级的增长中尽职尽责地为我们服务。随叫随到的工程师经常被数据库 CPU 峰值唤醒,简单的仅目录迁移变得不安全和不确定。

在分片方面,快速发展的初创公司必须进行微妙的权衡。 在此期间,大量博客文章过早地阐述了分片的危险:增加的维护负担、应用程序级代码中新发现的约束以及架构路径依赖性。¹当然,在我们的规模上,分片是不可避免的。问题只是什么时候。

对我们来说,当 Postgres VACUUM 进程开始持续停止时,拐点就到了,阻止了数据库从死元组中回收磁盘空间。虽然可以增加磁盘容量,但更令人担忧的是 transaction ID (TXID) wraparound,这是一种 Postgres 将停止处理所有写入以避免破坏现有数据的安全机制。意识到 TXID wraparound 会对产品构成生存威胁,我们的基础架构团队加倍努力并开始工作。

设计分片方案

如果您以前从未对数据库进行过分片,那么这里的想法是:不要使用越来越多的实例垂直扩展数据库,而是通过跨多个数据库分区数据来水平扩展。现在,您可以轻松启动其他主机以适应增长。 不幸的是,现在您的数据位于多个位置,因此您需要设计一个在分布式环境中最大限度地提高性能和一致性的系统。

为什么不保持垂直缩放? 正如我们发现的那样,使用 RDS“调整实例大小”按钮玩 Cookie Clicker 并不是一个可行的长期策略——即使你有预算。 查询性能和维护过程通常在表达到最大硬件绑定大小之前就开始下降; 我们停止的 Postgres auto-vacuum 就是这种软限制的一个例子。

应用级分片

我们决定实现我们自己的分区方案并从应用程序逻辑路由查询,这种方法称为应用程序级分片。 在我们最初的研究中,我们还考虑了打包的分片/集群解决方案,例如用于 Postgres 的 Citus 或用于 MySQL 的 Vitess。虽然这些解决方案因其简单性而吸引人,并提供开箱即用的跨分片工具,但实际的集群逻辑是不透明的,我们希望控制数据的分布。²

应用程序级分片要求我们做出以下设计决策:

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

决策 1:对所有与块有传递关系的数据进行分片

由于 Notion 的数据模型围绕块的概念展开,每个块在我们的数据库中占据一行,因此 block(块) 表是分片的最高优先级。 但是,块可能会引用其他表,例如space(工作区)或 discussion(page-level 和 inline discussion 线程)。 反过来,discussion 可能会引用 comment 表中的行,等等。

我们决定通过某种外键关系对所有可从 block 表访问的表进行分片。 并非所有这些表都需要分片,但是如果一条记录存储在主数据库中,而其相关块存储在不同的物理分片上,我们可能会在写入不同的数据存储时引入不一致。

例如,考虑一个存储在一个数据库中的块,在另一个数据库中具有相关的评论。如果块被删除,评论应该被更新 — 但是,由于事务性保证只适用于每个数据存储,所以块删除可能成功,而评论更新可能失败。

决策 2:按 Workspace ID 划分块数据

一旦我们决定分片哪些表,我们就必须将它们分开。选择一个好的分区方案很大程度上取决于数据的分布和连通性; 由于 Notion 是基于团队的产品,我们的下一个决定是按 workspace ID 对数据进行分区。³

每个工作空间在创建时都分配了一个 UUID,因此我们可以将 UUID 空间划分为统一的存储桶。 因为分片表中的每一行要么是一个块,要么与一个块相关,并且每个块都属于一个工作区,所以我们使用 workspace ID 作为分区键(partition key)。由于用户通常一次在单个工作空间内查询数据,因此我们避免了大多数跨分片连接。

决策 3:容量规划

决定了分区方案后,我们的目标是设计一个分片设置,以处理我们现有的数据和规模,以轻松满足我们两年的使用预测。 以下是我们的一些限制条件:

  • 实例类型: 以 IOPS 量化的磁盘 I/O 吞吐量受 AWS 实例类型和磁盘容量的限制。我们需要至少 60K 的总 IOPS 来满足现有需求,并在需要时具有进一步扩展的能力。

  • 物理和逻辑分片的数量: 为了保持 Postgres 正常运行并保留 RDS 复制保证,我们将每个表的上限设置为 500 GB,每个物理数据库设置为 10 TB。 我们需要选择多个逻辑分片和多个物理数据库,以便分片可以在数据库之间均匀划分。
  • 实例数: 更多实例意味着更高的维护成本,但是系统更健壮。
  • 成本: 我们希望我们的账单随着我们的数据库设置线性扩展,并且我们希望能够灵活地分别扩展计算和磁盘空间。

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

  • 物理数据库(共 32 个)

    • 逻辑分片,表示为 Postgres 模式(每个数据库 15 个,总共 480 个)

      • block 表(每个逻辑分片 1 个,总共 480 个)
      • collection 表(每个逻辑分片 1 个,总共 480 个)
      • space 表(每个逻辑分片 1 个,总共 480 个)
      • 等所有分片表

您可能想知道,“为什么要 480 个分片?我认为所有计算机科学都是以 2 的幂次方完成的,这不是我认识的驱动器大小!”

有很多因素导致选择 480:

  • 2
  • 3
  • 4
  • 5
  • 6
  • 8
  • 10, 12, 15, 16, 20, 24, 30, 32, 40, 48, 60, 80, 96, 120, 160, 240

    关键是,480 可以被很多数字整除——这提供了添加或删除物理主机的灵活性,同时保持统一的分片分布。 例如,将来我们可以从 32 台扩展到 40 台再到 48 台主机,每次都进行增量跳跃。

    相比之下,假设我们有 512 个逻辑分片。 512 的因数都是 2 的幂,这意味着如果我们想保持分片均匀,我们会从 32 台主机跳到 64 台主机。任何 2 的幂都需要我们将物理主机的数量增加一倍以进行升级。选择具有很多因素的值!

我们从包含每张表的单个数据库发展为由 32 个物理数据库组成的舰队,每个数据库包含 15 个逻辑分片,每个分片包含每个分片表中的一个。我们总共有 480 个逻辑分片。

我们选择将 schema001.block、schema002.block 等构建为单独的表,而不是为每个数据库维护一个具有 15 个子表的分区 block 表。原生分区表引入了另一条路由逻辑:

  1. 应用代码:workspace ID → 物理数据库。
  2. 分区表:workspace ID → 逻辑 schema。

https://www.postgresql.org/docs/10/ddl-partitioning.html

保留单独的表允许我们直接从应用程序路由到特定的数据库和逻辑分片。

我们想要从 workspace ID 路由到逻辑分片的单一事实来源,因此我们选择单独构建表并在应用程序中执行所有路由。

迁移到分片

一旦我们建立了分片方案,就该实施它了。 对于任何迁移,我们的一般框架都是这样的:

  1. 双写(Double-write):传入的写入同时应用于旧数据库和新数据库。
  2. 回填(Backfill):双写开始后,将旧数据迁移到新数据库。
  3. 验证(Verification):确保新数据库中数据的完整性。
  4. 切换(Switch-over):实际切换到新数据库。这可以逐步完成,例如:双读,然后迁移所有的读。

用审计日志双重写入

双写阶段确保新数据同时填充新旧数据库,即使新数据库尚未使用。双写有几种选择:

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

我们选择了 audit log 策略而不是逻辑复制,因为后者在初始快照步骤中难以跟上 block 表写入量。

我们还准备并测试了一个反向审计日志和脚本,以防我们需要从分片切换回单体应用。 该脚本将捕获对分片数据库的任何传入写入,并允许我们在单体应用程序上重放这些编辑。 最后,我们不需要恢复,但这是我们应急计划的重要组成部分。

回填旧数据

一旦传入的写入成功传播到新数据库,我们就会启动回填过程以迁移所有现有数据。使用我们预置的 m5.24xlarge 实例上的所有 96 CPUs(!),我们的最终脚本大约需要三天时间来回填生产环境。

任何值得称道的回填都应该在写入旧数据之前比较记录版本,跳过具有最近更新的记录。 通过以任何顺序运行追赶脚本和回填,新数据库最终将聚合以复制整体。

验证数据完整性

迁移仅与底层数据的完整性一样好,因此在分片与单体应用保持同步后,我们开始验证正确性的过程。

  • 验证脚本:我们的脚本验证了从给定值开始的 UUID 空间的连续范围,将单体上的每条记录与相应的分片记录进行比较。因为全表扫描会非常昂贵,所以我们随机抽样 UUID 并验证它们的相邻范围。
  • “暗”读:在迁移读查询之前,我们添加了一个标志来从新旧数据库中获取数据(称为暗读)。 我们比较了这些记录并丢弃了分片副本,记录了过程中的差异。引入暗读增加了 API 延迟,但提供了无缝切换的信心。

作为预防措施,迁移和验证逻辑是由不同的人实现的。 否则,在两个阶段都犯同样错误的可能性更大,削弱了验证的前提。

艰难的教训

虽然分片项目的大部分内容都让 Notion 的工程团队处于最佳状态,但我们事后会重新考虑许多决定。这里有一些例子:

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

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

脚注

  • [1] 除了引入不必要的复杂性之外,过早分片的一个被低估的危险是它可以在产品模型在业务方面得到明确定义之前对其进行约束。例如,如果一个团队按用户分片并随后转向以团队为中心的产品策略,那么架构阻抗不匹配可能会导致严重的技术难题,甚至会限制某些功能。
  • [2] 除了打包的解决方案外,我们还考虑了一些替代方案:切换到另一个数据库系统,如 DynamoDB(对于我们的用例来说风险太大),并在裸机 NVMe 重型实例上运行 Postgres,以获得更大的磁盘吞吐量(由于备份和复制的维护成本而被拒绝)。
  • [3] 除了基于键的分区(基于某些属性划分数据)之外,还有其他方法:按服务进行垂直分区,以及使用中间查找表路由所有读写的基于目录的分区。

更多

从 Notion 分片 Postgres 中吸取的教训(Notion 工程团队)的更多相关文章

  1. postgres中几个复杂的sql语句

    postgres中几个复杂的sql语句 需求一 需要获取一个问题列表,这个问题列表的排序方式是分为两个部分,第一部分是一个已有的数组[0,579489,579482,579453,561983,561 ...

  2. postgres中的中文分词zhparser

    postgres中的中文分词zhparser postgres中的中文分词方法 基本查了下网络,postgres的中文分词大概有两种方法: Bamboo zhparser 其中的Bamboo安装和使用 ...

  3. postgres中的视图和物化视图

    视图和物化视图区别 postgres中的视图和mysql中的视图是一样的,在查询的时候进行扫描子表的操作,而物化视图则是实实在在地将数据存成一张表.说说版本,物化视图是在9.3 之后才有的逻辑. 比较 ...

  4. Postgres中postmaster代码解析(上)

    之前我的一些文章都是在说Postgres的一些查询相关的代码.但是对于Postgres服务端是如何启动,后台进程是如何加载,服务端在哪里以及如何监听客户端的连接都没有一个清晰的逻辑.那么今天我来说说P ...

  5. Postgres中postmaster代码解析(中)

    今天我们对postmaster的以下细节进行讨论: backend的启动和client的连接请求的认证 客户端取消查询时的处理 接受pg_ctl的shutdown请求进行shutdown处理 2.与前 ...

  6. iOS开发中 workspace 与 static lib 工程的联合使用

    在iOS开发中,其实workspace的使用没有完全发挥出来,最近做了一些研究,也想把之前写过的代码整理下,因为iOS里面的布局方式,交互方式也就那么几种.所以,整理好了之后,更能快捷开发,而且能够形 ...

  7. 那些盒模型在IE6中的BUG们,工程狮的你可曾遇到过?

    HTML5学堂 那些盒模型在IE6中的BUG们,工程狮的你可曾遇到过? IE6已经渐渐的开始退出浏览器的历史舞台.虽然当年IE6作为微软的一款利器击败网景,但之后也因为版本的持续不更新而被火狐和谷歌三 ...

  8. 在VS工程中,添加c/c++工程中外部头文件及库

    在VS工程中,添加c/c++工程中外部头文件及库的基本步骤: 1.添加工程的头文件目录:工程---属性---配置属性---c/c++---常规---附加包含目录:加上头文件存放目录. 2.添加文件引用 ...

  9. Eclipse中创建Maven多模块工程

    1.先创建父项目 在Eclipse里面New -> Maven Project: 在弹出界面中选择“Create a simple project” 这样,我们就按常规模版创建了一个Maven工 ...

随机推荐

  1. Linux用户无法使用sudo命令

    新建的用无法使用sudo命令,出现这样的提示: xiaojing is not in the sudoers file. This incident will be reported 原来是新建的用户 ...

  2. hadoop 分布式系统与Hadoop MapReduce

    Hadoop分为两部分 Hadoop MapReduce和Hadoop分布式文件系统 1分布式系统由Master Node 和多台 slave Node组成. 1.1MasterNode Master ...

  3. Servlet的url-pattern配置

    url匹配规则 1)精确配置 精确匹配是指<servlet-mapping>中配置的值必须与请求中的url完全精确匹配. <servlet-mapping> <servl ...

  4. 微信小程序wx.login()获取openid,附:前端+后端代码

    微信小程序开放了微信登录的api,无论是个人还是企业申请的小程序均可使用. 首先创建一个项目,把这些代码都清空,我们自己写! 然后,开始写了!首先index.wxml,写一个button用于发起登录 ...

  5. C#委托、多播委托极简案例,一看就懂

    废话不多讲,直接上代码,看完代码再讲解: class Class1 { public delegate void Del();//声明委托 public static void F1() { Cons ...

  6. java中Array(数组)的用法

    8.Array(数组)    数组是作为对象来实现的.(really occupy the memopry,真实的占用内存 ) An array is a data structure that st ...

  7. CSS简单样式练习(五)

    运行效果: 源代码: 1 <!DOCTYPE html> 2 <html lang="zh"> 3 <head> 4 <meta char ...

  8. Blazor组件自做九: 用20行代码实现文件上传,浏览目录功能 (3)

    接上篇 Blazor组件自做九: 用20行代码实现文件上传,浏览目录功能 (2) 7. 使用配置文件指定监听地址 打开 appsettings.json 文件,加入一行 "UseUrls&q ...

  9. 前端框架小实验-在umi框架中以worker线程方式使用SQL.js的wasm

    总述:在Win7环境下配置umijs框架,在框架中用worker线程方式使用SQL.js的wasm,在浏览器端实现数据的增删改查以及数据库导出导入. 一.安装node.js 1.Win7系统只支持no ...

  10. oracle各种用户登录的方式

    Oracle有3种用户: system.sys.scott 1.system和sys的差别在与是否能创建数据库2.sys用户登录创建数据库,3.scott是给刚開始学习的人学习的用户.学习者能够用Sc ...