重磅 | 十年来扩展PostgreSQL的一些经验和教训

2021-05-07 15:51:48 浏览数 (1)

工作近十年来,开源关系数据库PostgreSQL一直是OneSignal的核心部分。多年来,我们已经在近40台服务器上扩展了多达75 TB的存储数据。我们的实时分段功能极大地受益于PostgreSQL的性能,但是由于繁重的写入负载和PostgreSQL升级路径的限制而导致的膨胀,有时我们也一直在挣扎。

在本文中,我将解释在扩展PostgreSQL时遇到的一些挑战以及我们已经采用的解决方案。我们很高兴分享在这一领域的经验教训,因为我们不得不努力地解决很多问题,我们希望分享我们的经验将使其他正在扩展PostgreSQL的人变得更容易。

本文中有很多信息-您可以按顺序阅读,也可以根据自己的兴趣跳转到不同的部分。我建议先阅读“数据高级概述”部分,然后再阅读下面列出的其他部分:

  • 溢出
  • 数据库升级
  • XID环绕
  • 分区
  • 分片

最后一点:我们的目标是在高水平上分享我们的经验教训,而不是提供详细的操作指南。我们会适当提供与我们涵盖的主题相关的参考资料。

高层数据概述

作为多渠道消息传递平台,我们的主要数据集为subscribers。就推送通知而言,一个subscriber被标识为支持用户细分的推送令牌,订阅状态和数据标签(key : value可以通过我们的SDK添加到设备的字符串或数字数据的自定义对)。

我们每月有超过十亿的活跃订阅,其中数百亿的subscribers订阅状态为未订阅。这些记录的写入频率非常高-每次打开应用程序时,我们都会在上次看到该订阅者时进行更新。在阅读方面,我们既支持事务发送(即,以特定的参与里程碑发送给特定的订户),也可以发送给具有特定特征的大量受众(即细分受众群)。将通知发送到由各种参数定义的较大段时,查询可能很快变得复杂并且需要花费几分钟的时间执行,因为它们可能从数千万个集合中返回数百万条记录。

其次是subscribersnotifications是我们的下一个最大数据集。记录的大小差异很大,从很小的记录(例如“发送给我的所有用户”通知)到包含特定订户ID列表的很大的记录都很大。这些记录的大部分在创建时就被写入,然后在整个交付过程中添加或更新各种计数器和时间戳。很少读取此数据-几乎所有访问后创建操作都是有针对性的,UPDATE或者是从OneSignal仪表板查询以获取最新通知的概述。有时还会导出客户端应用程序的通知数据,但这些访问数据只占很小的一部分。最后,我们对该数据运行批量删除以实施保留策略。该notification数据集被划分并且类似地分片,以subscribers

还有一些其他数据集占用相对大量的空间(约占存储的全部数据的10%),但是从动力学角度来看,它们却不那么有趣。

总而言之,本文将参考两个数据集:

  • subscribersINSERT和方面都是繁重的工作,UPDATE并且还面临频繁,长时间运行的分析查询以支持向细分受众群投放的附加挑战。
  • notifications除了繁重的UPDATE工作量和频繁的批量删除以实施保留策略外,通常还有相当大的记录。

溢出

让我们谈谈溢出。首先,这是什么?从广义上讲,软件溢出是一个术语,用于描述程序变慢,需要更多硬件空间或在每个后续版本中使用更多处理能力的过程。PostgreSQL中有两种不同类型的溢出。

1

表溢出

表溢出是表中的死元组消耗的磁盘空间,该表可能无法使用该磁盘空间,也可能无法再使用其他表或索引。

想象一下,您创建一个表并插入十条记录,每条记录占用一页磁盘空间,而无需进行遍历。如果删除前九个记录,则这些记录所占用的空间将无法重用!这些条目现在被视为“死元组”,因为任何交易都无法观察到它们。

现在,运行VACUUM此表上允许的空间内,该表为将来重复使用INSERTUPDATE,但如果,例如,你有第二个大表,可以使用一些额外的空间,这些网页将无法使用。 更新是PostgreSQL中another肿的另一个来源,因为更新是通过DELETE加号实现的INSERT。即使删除在数据集上并不常见,但严重更新的表也可能成为受害者。

那么什么时候真空不是一个足够好的解决方案呢?这将取决于数据的形状和相应的访问模式。对于我们的某些数据集,我们开始无限期地或长时间保留,后来决定添加保留策略。如果这样的策略导致表中存储的数据量从300GB减少到10GB,运行真空将允许表重新使用所有空间。如果稳态存储约为10到15GB,则大部分空间被浪费了。在这种情况下,使用真空吸尘器将无济于事。有关如何解决此问题的详细信息,请跳至有关pg_repack的讨论。

2

索引溢出

在尝试了解索引膨胀是如何产生的之前,让我们首先回顾一下PostgreSQL索引是如何在很高的层次上工作的。

PostgreSQL索引是直接索引—索引条目包含有关其相关元组在磁盘上的位置的信息。再加上每个UPDATE值实际上是一个DELETE加号INSERT,这意味着每次更新一列时,无论索引值是否更改,索引条目也都必须更新。

但是,等等,还有更多!由于PostgreSQL的MVCC方法,不能简单地删除或更新索引条目。还必须添加新的索引条目。这带来了与表膨胀相同的挑战—随着行的更新和删除,无效索引条目会随着时间的推移而累积。因为表可能有很多索引,所以每个写操作都可以级联成许多索引写操作,这种现象称为写放大。由表更新引起的索引内浪费的空间就是索引溢出。

在继续之前,我想指出一下,有些情况和优化没有创建死空间,例如“仅堆元组(HOT)优化”,它允许将元组存储在其先前版本附近,并用于索引并非总是需要更新。但是,HOT会带来一些性能折衷,这些折衷会影响索引扫描的读取性能。

回到我们的用例。我们说过我们的subscribers数据集已经大量更新和大量读取。有21个索引,这意味着每次更新都会创建大约20个失效条目。最终的结果是该表及其索引的磁盘占用量迅速增加。

对于通知,我们没有那么多索引,但是一旦记录到达交付阶段,记录就会非常频繁地更新。加上保留政策的强制执行,这是很多!肿的秘诀!

3

防止溢出

在应对膨胀时,“最好的进攻是良好的防守”。如果您可以避免一开始就创建它,那么您将不需要任何精美的解决方案来摆脱它。

autovacuum是一项功能,其中数据库将VACUUM代表您自动生成进程。但是,什么是吸尘?从文档中:

VACUUM回收死元组占用的存储。在正常的PostgreSQL操作中,被更新删除或过时的元组不会从表中物理删除。它们将保持存在,直到完成VACUUM。因此,有必要定期进行VACUUM,尤其是在频繁更新的表上。

根据该描述,我们可以推测,表清理的频率越高,该关系所需的总存储空间就越低。清理不是免费的,数据库通常有许多需要注意的关系。重要的是,您autovacuum必须经常运行以使死角保持在可接受的水平。

调优autovacuum是一个大话题,不应该发表自己的文章,值得庆幸的是,2ndQuadrant的优秀人士已经写了一篇详尽的博客文章,涵盖了这个确切的话题。

4

模式优化

我将介绍的第一个优化解决如何避免由数据保留策略引起的膨胀。使用PostgreSQL表分区,您可以将一个表变成多个表,并且在您的应用程序中仍然只有一个表的外观。执行表分区时,需要考虑一些性能方面的考虑,因此在开始之前请进行研究。

假设您的数据表中有一个date列,例如,created_at并且您只想保留最近30天的数据。为此,您最多可以创建30个分区,每个分区都将保留一个特定的日期范围。实施保留策略时,使用简单DROP TABLE的方法从数据库中删除单个分区表,而不是尝试从整个表中进行有针对性的删除。此策略可以首先防止膨胀。该pg_partman扩展,甚至可以自动为你这个过程!

下一步的优化更加细微。比方说,你有两个数据列的表,big_columnint_columnbig_column每个记录中存储的数据通常约为1千字节,并且int_column更新非常频繁。对的每次更新int_column也会导致big_column被复制。因为这些数据列是链接的,所以更新将创建大量的浪费空间,每次更新大约为1kb(模块化磁盘分页机制)。

在这种情况下,您可以做的是将工作拆分int_column到一个单独的表中。在该单独的表中更新它时,不会big_column生成任何重复项。尽管拆分这些列意味着您需要使用一个JOIN来访问两个表,但是根据您的用例,可能值得权衡取舍。我们针对subscribersnotifications数据集都使用了这一技巧。订户上的数据标签可以是多个千字节,并且像列这样last_seen_time的更新非常频繁。这显着降低了肿率。

数据库升级

PostgreSQL的主要升级被用作改变磁盘上数据格式的机会。换句话说,不可能简单地关闭版本12并打开版本13。升级需要以新格式重写数据。

有两种升级方法可为服务可用性提供不同的余量。第一个选项是pg_upgrade。该工具将数据库从旧格式重写为新格式。它要求数据库在升级过程中处于脱机状态。如果您甚至没有适当大小的数据集和可用性要求,那么这个要求就是一个大问题-这就是为什么我们从未使用这种方法来升级数据库。

相反,我们使用逻辑复制来执行主要版本升级。逻辑复制是流复制的扩展,通常用于热备份。流复制通过将原始磁盘块更改从上游服务器写入副本来工作,这使其不适合执行主要升级。可以使用逻辑复制的原因是,对更改进行了解码和应用,就像将SQL语句流发送到副本一样(而不是简单地将页面更改写入磁盘)。

从高层次看,该过程看起来像:

  1. 使用升级的PostgreSQL版本设置新服务器。
  2. 设置逻辑复制,在新版本上有效地创建热备用。
  3. 切换或正常切换到热备用。为了实现正常切换,与内置的逻辑复制功能相比,pgologic扩展 提供了更多的旋钮来调整复制流的应用方式以及如何处理冲突。

但是,有一个主要警告。目标数据库上的解码过程是单线程的。如果数据库上的写负载足够高,它将使解码过程不堪重负,并导致延迟增加,直到达到某个限制(通常是可用磁盘空间)为止。

如果发现自己处于逻辑复制无法“保持”的情况,则基本上有一个选择:一次将数据移动到另一个数据库一个表(使用逻辑复制,因为它支持这种细粒度的复制)。复制目标可以在PostgreSQL的升级版本上。这意味着您的应用程序必须能够为不同的表选择不同的数据库,并且要求您处理应用程序代码中的切换。

要开始使用逻辑复制,我建议您先阅读PostgreSQL官方手册,然后检查pgologic扩展名,该扩展名对逻辑复制下的冲突解决提供了更复杂的控制。

XID环绕

在我们旅途的早期,另一个问题导致了一些服务丢失:一种称为事务ID(也称为TXID或XID)的回绕预防故障模式。

PostgreSQL的MVCC实现依赖于32位事务ID。该XID用于跟踪行版本,并确定特定事务可以看到哪些行版本。如果您每秒要处理成千上万的事务,那么很快就可以达到XID最大值。如果要绕开XID计数器,那么过去的事务似乎就在将来,这将导致数据损坏。

短语“最大值”很简单,但概念有些细微差别。XID可以视为位于圆形或圆形缓冲区上。只要该缓冲区的末尾没有跳到最前面,系统就可以正常运行。

为了防止XID用尽并避免回绕,真空过程还负责“冻结”超过一定期限的行版本(默认情况下,成千上万个事务已过期)。但是,有一些故障模式可以防止冻结极旧的元组,而最旧的未冻结元组会限制事务可见的过去ID的数量(仅可见20亿过去的ID)。如果剩余的XID计数达到一百万,则数据库将停止接受命令,并且必须以单用户模式重新启动以进行恢复。因此,监视剩余的XID极为重要,这样数据库就永远不会进入此状态。

剩余的XID可以通过以下查询获取:

代码语言:javascript复制
SELECT power(2, 31) - age(datfrozenxid) AS remaining
FROM pg_database
WHERE datname = current_database();

如果该值小于2.5亿,我们将触发警报。影响真空冷冻的最重要的自动真空参数之一是autovacuum_freeze_max_age。这是强制真空之前所需的最大XID年龄。触发吸尘时剩余的XID数为2^31 - autovacuum_freeze_max_age

过去,当环绕式真空似乎无限期地运行时,我们已经陷入或接近XID耗尽。我们认为这是由于该关系上的一些极其漫长的交易被取消了,但是我们不能确切地说出发生这种情况的原因。几年来我们一直没有遇到这个问题,并且对自动真空的一些最新更改可能已解决了我们遇到的问题。尽管如此,我们仍在此数字上保留标签,因为它具有强制数据库退出服务的能力。

遗憾的是,我们没有使用此故障模式对事件进行公开的验尸。sentry.io上的优秀人士实际上在几年前也遇到过类似的问题,并撰写了精彩的验尸报告,其中更详细地介绍了相同的问题和恢复。

有关其他信息,请查看有关防止XID环绕失败的手册部分。如果您打算自己操作PostgreSQL,则可以考虑阅读手册的这一部分。

分区

大表可能有很多问题,其中包括:磁盘大小,服务查询的CPU数量,索引扫描所花费的时间,自动清理所花费的时间,管理膨胀的能力等等。要解决这些问题,可以对表进行分区。

较新的PostgreSQL版本提供了强大的支持,可以使用其内置的分区功能来拆分表。使用内置支持的一个优势是,您可以查询一个逻辑表并获取结果,或者在多个基础表之间拆分数据。

目前,我们通过租户在256个分区中对我们的数据subscribersnotifications数据进行分区。由于规模的扩大,我们可能会将其扩展到4096个分区,这既是出于希望利用更多服务器的原因,又是为了提高查询和维护流程的效率。

当我们最初将分区从16个增加到256个时,我们考虑立即跳到4096个。但是,所有分区当时都位于一台服务器上,并且对于单个文件中有这么多文件的文件系统性能存在一些担忧。目录。在像EXT4这样的现代文件系统上,这不是问题,因为哈希索引用于目录内容(与使用无序列表的旧文件系统相比)。我们一直使用EXT4,因此这是毫无根据的问题。如果您发现自己快速增长并需要进行分区,建议您提前创建很多分区,以免日后遇到麻烦。

分片

分片是分区的自然扩展,尽管没有内置支持。简而言之,分片是指将数据拆分到多个数据库进程中,通常是在单独的服务器上。这意味着更多的存储容量,更多的CPU容量等等。

典型的应用程序只有几个大数据集,因此只有这些数据集需要分片。您可能首先对这些数据集进行分区,然后将这些分区分布在多个服务器上。

如果您有多个应用程序,通常最好将应用程序之外的数据库拓扑知识(包括分区和分片级别)都排除在外。我们最初并没有这样做,但我们仍在处理该决定带来的技术债务。但是,我们正在朝着创建数据代理的方向大步前进,该代理是唯一了解分区和分片拓扑的应用程序。

最初,我们通过租户ID对分区进行分区-租户ID是根据ID范围确定性地处理的过程。我们的租户ID是v4 UUID,这意味着我们在每个分区上的租户分布大致均匀。这在早期就足够了,但是现在我们希望能够灵活地将分区作为增量数据库升级的一部分来回移动,并隔离较大的租户。我们正在进行的数据代理计划将在将来支持这一点。

结论

我们在这里做了很多介绍,而且只是从高层次上讲。疯狂的部分是,我们可以涵盖更多的内容。我们选择的主题贴近我们的心。如果有你有兴趣了解或有PostgreSQL的比例问题,另一个PostgreSQL的缩放主题,请不要犹豫,伸出手让我们知道。如果我们不知道您问题的答案,我们将尝试将您引向有帮助的人。

0 人点赞