今年早些时候(2021年),我们对Notion进行了五分钟的定期维护。虽然我们的声明指向“改进的稳定性和性能”,但幕后是数月专注、紧急的团队合作的结晶:将Notion的PostgreSQL整体分片成水平分区的数据库队列。分片命名法被认为起源于MMORPGUltimaOnline,当时游戏开发者需要一个宇宙解释来解释存在多个运行平行世界副本的游戏服务器。具体来说,每一块碎片都是从破碎的水晶中出现的,邪恶的巫师蒙丹曾试图通过它夺取对世界的控制权。https://www.raphkoster.com/2009/01/08/database-sharding-came-from-uo/https://uo.com/虽然转换成功,大家欢欣鼓舞,但我们还是保持沉默,以防万一迁移后的问题。令我们高兴的是,用户很快开始注意到改进。完全“显示不说”。让我告诉你我们如何分片以及我们一路上学到了什么的故事。决定何时进行分片是我们不断努力提高应用程序性能的一个重要里程碑。在过去的几年里,看到越来越多的人将Notion应用到他们生活的方方面面,既令人欣慰又感到羞愧。不出所料,所有新的公司wiki、项目跟踪器和大部头都意味着数十亿个新块、文件和空间。到2020年年中,很明显产品的使用将超过我们信任的Postgres单体的能力,它已经尽职尽责地为我们服务了五年,并实现了四个数量级的增长。值班工程师经常被数据库CPU峰值惊醒,简单的仅目录迁移变得不安全和不确定。https://www.notion.so/blog/data-model-behind-notionhttps://medium.com/paypal-tech/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680在分片中一方面,快速成长的初创公司必须做出微妙的权衡。在此期间,许多博客文章过早地解决了分片的危险:维护负担增加、应用程序级代码中新发现的约束以及架构路径依赖性。1当然,在我们的规模下,分片是不可避免的。问题只是什么时候。https://www.percona.com/blog/2009/08/06/why-you-dont-want-to-shard/http://www.37signals.com/svn/posts/1509-mr-moore-gets-to-punt-on-sharding#https://www.drdobbs.com/errant-architectures/184414966https://www.infoworld.com/article/2073449/think-twice-before-sharding.html来对我们说,当PostgresVACUUM进程开始持续停止时达到拐点,防止数据库从死元组中回收磁盘空间。虽然可以增加磁盘容量,但更令人担忧的是事务ID(TXID)环绕,这是一种安全机制,Postgres将停止处理所有写入以避免损坏现有数据。意识到TXID环绕会对产品构成生存威胁,我们的基础架构团队加倍努力并开始工作。https://blog.sentry.io/2015/07/23/transaction-id-wraparound-in-postgres设计分片方案如果您以前从未对数据库进行过分片,那么想法是:不要使用更多实例规模垂直扩展数据库,但通过跨多个数据库分区数据来水平扩展。现在您可以轻松启动更多主机以适应增长。不幸的是,您的数据现在驻留在多个位置,因此您需要设计一个系统来最大限度地提高分布式环境中的性能和一致性。为什么不保持垂直缩放?正如我们发现的那样,使用RDS“调整实例大小”按钮播放cookieClicker并不是一个可行的长期策略-即使您有预算。查询性能和维护过程通常在express达到最大硬件限制大小之前就开始下降;我们禁用Postgresauto-vacuum就是这个软限制的一个例子。应用程序级分片我们决定实施我们自己的分区方案并从应用程序逻辑路由查询,这种方法称为应用程序级分片。在我们最初的研究中,我们还考虑了打包的分片/集群解决方案,例如用于Postgres的Citus或用于MySQL的Vitess。虽然这些解决方案因其简单性而具有吸引力,并提供开箱即用的跨分片工具,但实际的集群逻辑是不透明的,我们希望控制数据的分布。2https://www.citusdata.com/https://vitess.io/应用程序级分片需要我们做出以下设计决策:我们应该分片哪些数据?使我们的数据集独一无二的部分原因在于,块表反映了用户创建的内容树,其大小、深度和分支因子可以有很大差异。例如,单个大型企业客户产生的负载可能超过许多普通个人工作空间的总和。我们只想对必要的表进行分片,同时保留相关数据的局部性。我们应该如何对数据进行分区?一个好的分区键可以确保元组在分片中均匀分布。分区键的选择还取决于应用程序结构,因为分布式连接很昂贵并且事务保证通常仅限于单个主机。我们应该创建多少个分片?我们应该如何组织这些碎片?这个考虑包括每个表的逻辑分片数量,以及逻辑分片和物理主机之间的具体映射。决策一:对所有与块有传递关系的数据进行分片由于Notion的数据模型围绕着块的概念,每个块在我们的数据库中占据一行,所以块(block)表是分片的最高优先级。但是,块可以引用其他表,例如空间(工作区)或讨论(页面级和内联讨论线程)。反过来,讨论可能会引用评论表中的行,等等。https://www.notion.so/blog/data-model-behind-notion我们决定通过某种外键关系对所有可从块表访问的表进行分片。并非所有这些表都需要分片,但如果一条记录存储在主数据库中,而它的相关块存储在不同的物理分片上,我们可能会在写入不同数据存储时引入不一致。例如,考虑一个存储在一个数据库中的块与另一个数据库中的相关评论。如果删除块,则应更新注释——但是,由于事务保证仅适用于每个数据存储,因此块删除可能会成功,而注释更新可能会失败。决策2:按工作区ID划分块数据一旦我们决定要对哪些表进行分片,我们就必须将它们分开。选择一个好的分区方案很大程度上取决于数据的分布和连通性;由于Notion是一个基于团队的产品,我们的下一个决定是按工作区ID对数据进行分区。3每个工作空间在创建时都会分配一个UUID,因此我们可以将UUID空间划分为统一的桶。因为分片表中的每一行要么是一个块,要么与一个块相关,并且每个块都属于一个工作空间,所以我们使用工作空间ID作为分区键。由于用户通常一次在单个工作区内查询数据,因此我们避免了大多数跨分片连接。决策3:容量规划分区方案确定后,我们的目标是设计一个分片设置,可以处理我们现有的数据和规模,轻松满足我们两年的使用预测。以下是我们的一些限制条件:实例类型:以IOPS量化的磁盘I/O吞吐量受AWS实例类型和磁盘容量的限制。我们需要至少60K的总IOPS才能满足现有需求,并能够在需要时进一步扩展。https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html物理和逻辑分片的数量:为了保持Postgres正常运行并保持RDS复制保证,我们将每个表GB的上限设置为500,设置为每个10TB物理数据库。我们需要选择多个逻辑分片和多个物理数据库,这样分片才能在数据库之间平分。实例数量:更多的实例意味着更高的维护成本,但更强大的系统。成本:我们希望我们的账单随着我们的数据库设置线性扩展,我们希望能够灵活地分别扩展计算和磁盘空间。在计算数字后,我们确定了一个由480个逻辑分片组成的架构,这些逻辑分片均匀分布在32个物理数据库中。层次结构如下:物理库(共32个)块表(每个逻辑片1个,共480个)集合表(每个逻辑片1个,共480个)空间表(每个逻辑片1个1个片,总共480个)等。分片表的所有逻辑分片,表示为Postgres模式(每个数据库15个,总共480个)你可能想知道,“为什么有480个分片?我认为所有的计算机科学都是以2的幂来完成的,那不是我知道的驱动器大小!”导致选择480的因素有很多:23456810、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个子表的分区块表。本机分区表引入了另一种路由逻辑:应用程序代码:工作区ID→物理数据库。分区表:工作区ID→逻辑模式。https://www.postgresql.org/docs/10/ddl-partitioning.html保持单独的表允许我们直接从应用程序路由到特定的数据库和逻辑分片。我们想要从工作区ID到逻辑分片的路由的单一真实来源,因此我们选择单独构建表并在应用程序内执行所有路由。迁移到分片一旦我们建立了分片方案,就该实施它了。我们对任何迁移的一般框架是这样的:双写:传入的写入同时应用于旧数据库和新数据库。回填:双写开始后将旧数据迁移到新数据库。验证:确保新数据库中数据的完整性。切换:实际切换到新数据库。这可以逐步完成,例如:双重读取,然后迁移所有读取。使用审计日志进行双写双写阶段可确保旧数据库和新数据库都填充有新数据,即使新数据库尚未使用也是如此。双重写入有几种选择:直接写入两个数据库:看似简单,但任何一种写入的任何问题都会很快导致数据库之间的不一致,使得这种方法非常适合关键路径生产数据存储说太不稳定了。逻辑复制:一种内置的Postgres功能,使用发布/订阅模型将命令广播到多个数据库。在源数据库和目标数据库之间修改数据的能力有限。https://www.postgresql.org/docs/10/logical-replication.html审计日志和追赶脚本:创建审计日志表以跟踪迁移中对表的所有写入。追赶过程遍历审计日志并将每个更新应用到新数据库,根据需要进行任何修改。我们选择审计日志策略而不是逻辑复制,因为后者在初始快照步骤期间努力跟上块表写入。https://www.postgresql.org/docs/10/logical-replication-architecture.html#LOGICAL-REPLICATION-SNAPSHOT我们还准备并测试了反向审计日志和脚本,以防我们需要从分片单体应用程序切换回来。该脚本将捕获任何传入的对分片数据库的写入,并允许我们在单体应用程序上重放这些编辑。最后,我们不需要恢复,但这是我们应急计划的重要组成部分。回填旧数据一旦传入的写入成功传播到新数据库,我们就会启动回填过程以迁移所有现有数据。在我们配置的m5.24xlarge实例上使用所有96个CPU(!),我们的最终脚本花了大约三天时间来回填生产环境。任何有价值的回填都应该在写入旧数据之前比较记录版本,跳过最近更新的记录。通过以任何顺序运行追赶脚本和回填,新数据库最终将收敛以复制整个数据库。验证数据完整性迁移与底层数据的完整性一样好,因此在分片与整体同步后,我们开始验证正确性的过程。验证脚本:我们的脚本从给定值开始验证UUID空间的连续范围,将单体上的每条记录与相应的分片记录进行比较。因为全表扫描的成本非常高,所以我们随机抽样UUID并验证它们的连续范围。“暗”读:在迁移读查询之前,我们添加了一个标志来从新旧数据库中获取数据(称为暗读)。我们对比了这些记录并丢弃了分片副本,记录了过程中的差异。引入暗读会增加API延迟,但可以确保无缝切换。https://slack.engineering/re-architecting-slacks-workspace-preferences-how-to-move-to-an-eav-model-to-support-scalability/作为预防措施,迁移和验证逻辑由因人而异。否则,两个阶段都犯同样错误的可能性更大,削弱了验证的前提。吸取的惨痛教训虽然分片项目的大部分内容使Notion的工程团队处于最佳状态,但我们事后重新审视了许多决定。以下是一些示例:过早地分片。作为一个小团队,我们敏锐地意识到与过早优化相关的权衡。然而,我们一直等到现有数据库严重紧张,这意味着我们必须非常节俭地迁移,以免增加更多负载。此限制阻止我们使用逻辑复制进行双写。工作区ID(我们的分区键)没有填充到旧数据库中,回填此列会增加我们单体应用的负载。相反,我们在写入分片时动态回填每一行,这需要一个自定义的catchup脚本。专为零停机迁移而设计。双写入吞吐量是我们最终切换的主要瓶颈:一旦我们关闭服务器,我们需要让catchup脚本完成将写入传播到分片。如果我们再花一周时间优化脚本,以便在切换期间用不到30秒的时间赶上分片,我们就可以在负载均衡器级别进行热交换而无需停机。引入复合主键而不是单独的分区键。今天,分区表中的行使用组合键:id,旧数据库中的主键;和space_id,当前排列中的分区键。由于无论如何我们都必须进行全表扫描,因此我们可以将这两个键组合到一个新列中,从而无需在整个应用程序中传递space_ids。尽管有这些假设,分片还是取得了巨大的成功。对于Notion用户,几分钟的停机时间会使产品明显更快。在内部,我们在时间敏感的目标下展示了协调的团队合作和果断的执行力。脚注[1]除了引入不必要的复杂性之外,过早分片的一个被低估的危险是它可以在根据业务明确定义之前限制产品模型。例如,如果一个团队被用户分割,然后转向以团队为中心的产品策略,架构阻抗不匹配会导致严重的技术困难,甚至会限制某些功能。[2]除了打包的解决方案,我们还考虑了一些替代方案:切换到另一个数据库系统,例如DynamoDB(对我们的用例来说风险太大),并在裸机NVMe-heavy实例上运行Postgres以获得更大的磁盘吞吐量(由于备份和复制的维护成本)。https://aws.amazon.com/ec2/instance-types/i3en/[3]除了基于键的分区(根据某些属性对数据进行分区)之外,还有其他方法:按服务进行垂直分区,并使用中间查找表路由所有读取和写入基于目录的分区。https://www.startuplessonlearned.com/2009/01/sharding-for-startups.html?m=1#comment-form
