PostgreSQL数据库MVCC事务机制的四个问题 - ottertune


MVCC 的 PostgreSQL 实现是一种设计选择,可实现 ANSI 标准概述的并发隔离级别。

MVCC优势(引用自PostgreSQL 文档):
使用并发控制的 MVCC 模型而不是锁定的主要优点是,在 MVCC 中,为查询(读取)数据获取的锁与为写入数据获取的锁不冲突,因此读取不会阻塞写入,写入也不会阻塞读取。即使在提供最严格级别的事务隔离时,PostgreSQL 也保持这种保证......
…正确使用 MVCC 通常会提供比锁更好的性能。

我们最讨厌的 PostgreSQL 部分一文认为:我们在卡内基梅隆大学的研究和在 Amazon RDS 上优化 PostgreSQL 数据库实例的经验表明,其 MVCC 实现是其他广泛使用的关系 DBMS(包括 MySQL、Oracle 和 Microsoft SQL Server)中最差的。

PostgreSQL的多版本并发控制
PostgreSQL 的 MVCC 方案的核心思想看似简单:当查询更新表中的现有行时,DBMS 会复制该行并将更改应用到这个新版本,而不是覆盖原始行。我们将这种方法称为仅附加版本存储方案。但正如我们现在所描述的,这种方法对系统的其余部分有几个重要的影响。

PostgreSQL 将所有行版本存储在同一存储空间中的表中。要更新现有元组,DBMS 首先从表中为新行版本获取一个空槽。然后它将当前版本的行内容复制到新版本,并将修改应用于新分配的版本槽中的行

现在有两个物理版本代表相同的逻辑行,DBMS 需要记录这些版本的历史轨迹,以便知道将来如何找到它们。MVCC DBMS通过单链表创建版本链来实现这一点。

版本链只有一个方向,以减少存储和维护开销。这意味着 DBMS 必须决定使用什么顺序:最新到最旧(N2O) 顺序或最旧到最新(O2N)。

  • 对于 N2O 顺序,每个元组版本都指向其先前版本,版本链的头部始终是最新版本。
  • 对于 O2N 顺序,每个元组版本指向它的新版本,头部是最旧的​​元组版本。

O2N 方法避免了 DBMS 每次修改索引时都需要更新索引以指向元组的更新版本。但是,DBMS 在查询处理期间可能需要更长的时间才能找到最新版本,可能会遍历一个长版本链。

大多数 DBMS,包括 Oracle 和 MySQL,都实现了 N2O。
但 PostgreSQL 在使用 O2N 方面独树一帜(Microsoft 的SQL Server In-Memory OLTP 引擎除外)

下一个问题是 PostgreSQL 如何确定为这些版本指针记录什么。PostgreSQL 中每一行的标题都包含下一个版本的元组 ID 字段 ( t_tcid )(如果是最新版本,则包含它自己的元组 ID)。当查询请求行的最新版本时,DBMS 遍历索引,找到最旧的版本,然后跟随指针直到找到它需要的版本。

PostgreSQL 开发人员很早就意识到其 MVCC 方案存在两个问题:

  • 首先,每次更新时都复制整个元组的成本很高。
  • 其次,遍历整个版本链只是为了找到最新版本(这是大多数查询想要的)是一种浪费。

为了避免遍历整个版本链,PostgreSQL 为行的每个物理版本添加一个条目到表的索引中。这意味着如果一个逻辑行有五个物理版本,那么索引中该元组将(最多)有五个条目!

PostgreSQL 试图通过在与旧版本相同的磁盘页面(块)中创建新副本来减少磁盘 I/O,从而避免必须安装多个索引条目并将相关版本存储在多个页面上。这种优化称为仅堆元组 (HOT)更新。
如果更新不修改表索引引用的任何列并且新版本存储在与旧版本相同的数据页上(如果该页中有空间),则 DBMS 使用 HOT 方法。
在正常运行期间,PostgreSQL 通过删除旧版本以修剪版本链来进一步优化此过程。

版本vacuum
我们已经确定,每当应用程序更新行时,PostgreSQL 都会制作行的副本。下一个问题是系统如何删除旧版本(称为“死元组”)。

1980 年代的原始 PostgreSQL 版本并没有删除死元组。这个想法是保留所有旧版本允许应用程序执行“时间旅行”查询以在特定时间点检查数据库(例如,运行一个SELECT查询上周末存在的数据库状态)。

但是从不删除死元组意味着如果应用程序不删除元组,表的大小永远不会缩小。
这也意味着频繁更新的元组的版本链较长,这会减慢查询速度,除了 PostgreSQL 添加索引条目允许查询快速跳转到正确的版本而不是遍历链。
但是现在,这意味着索引更大,使它们变慢并增加了额外的内存压力。

所有这些问题都是相互关联的。

为了克服这些问题,PostgreSQL 使用vacuum 过程从表中清除死元组。

vacuum 对自上次运行以来修改过的表页执行顺序扫描,并查找过期版本。如果一个版本对任何活动事务都不可见,则DBMS 认为该版本“已过期”。这意味着当前没有交易正在访问该版本,未来的交易将使用最新的“实时”版本。因此,删除过期版本并回收空间以供重用是安全的。

PostgreSQL 会根据其配置设置定期自动执行此清理过程 (autovacuum)。除了影响所有表的 vacuum 频率的全局设置之外,PostgreSQL 还提供了在表级别配置 autovacuum 的灵活性,以微调特定表的过程。

下面说说PostgreSQL的MVCC出现的四个问题。我们还将讨论为什么其他 MVCC DBMS(如 Oracle 和 MySQL)会避免这些问题。
问题 #1:版本复制
使用 MVCC 中的仅追加存储方案,如果查询更新元组,DBMS 会将其所有列复制到新版本中。无论查询更新单个列还是所有列,都会发生此复制。可以想象,仅追加 MVCC 会导致大量数据重复和增加存储需求。这种方法意味着 PostgreSQL 需要比其他 DBMS 更多的内存和磁盘存储来存储数据库,这意味着更慢的查询和更高的云成本。

MySQL 和 Oracle 不是为新版本复制整个元组,而是存储新版本和当前版本之间的紧凑增量(将其视为 git diff)。使用增量意味着如果查询仅更新具有 1000 列的表的元组中的单个列,则 DBMS 仅存储一个增量记录以及对该列的更改。

曾尝试使 PostgreSQL 的版本存储实现现代化。EnterpriseDB 于 2013 年启动了zheap 项目,以取代 append-only 存储引擎以使用 delta 版本。不幸的是,最后一次官方更新是在 2021 年,据我们所知,这项努力已经失败。


问题 #2:表膨胀
PostgreSQL 中的过期版本(即死元组)也比增量版本占用更多空间。尽管 PostgreSQL 的 autovacuum 最终会删除这些死元组,但写入繁重的工作负载可能导致它们积累的速度快于 vacuum 赶上的速度,从而导致数据库持续增长。

DBMS 必须在查询执行期间将死元组加载到内存中,因为系统将死元组与页面中的活元组混合在一起。不受约束的膨胀会导致 DBMS 在表扫描期间产生更多的 IOPS 并消耗比必要更多的内存,从而降低查询性能。此外,死元组导致的优化器统计信息不准确可能会导致查询计划不佳。

即使您确保 PostgreSQL 的 autovacuum 定期运行并且能够跟上您的工作负载,autovacuum 也无法回收存储空间。autovacuum 仅删除死元组并在每个页面中重新定位活动元组,但它不会从磁盘中回收空页面。

问题 #3:二级索引维护
对元组的单个更新需要 PostgreSQL 更新该表的所有索引。更新所有索引是必要的,因为 PostgreSQL 在主索引和辅助索引中都使用版本的确切物理位置。除非 DBMS 将新版本存储在与先前版本相同的页面中(热更新),否则系统会为每次更新执行此操作。

PostgreSQL 需要为每次更新修改表的所有索引,这对性能有几个影响。显然,这会使更新查询变慢,因为系统必须做更多的工作。DBMS 会产生额外的 I/O 来遍历每个索引并插入新条目。访问索引会在索引和 DBMS 的内部数据结构(例如,缓冲池的页表)中引入锁/闩锁争用。

有许多用户在 PostgreSQL 的 MVCC 实现的这个方面苦苦挣扎的例子。最著名的证明是优步 2016 年关于他们为什么从 Postgres 切换到 MySQL 的博客文章。他们的写入繁重的工作负载在具有许多二级索引的表上遇到了严重的性能问题。

Oracle 和 MySQL 在他们的 MVCC 实现中没有这个问题,因为他们的二级索引不存储新版本的物理地址。相反,它们存储一个逻辑标识符(例如,元组 ID、主键),然后 DBMS 使用该标识符来查找当前版本的物理地址。现在这可能会使二级索引读取速度变慢,因为 DBMS 必须解析逻辑标识符,但这些 DBMS 在其 MVCC 实现中具有其他优势以减少开销。

问题 #4:vacuum管理
PostgreSQL 的性能在很大程度上依赖于 autovacuum 删除过时数据和回收空间的有效性(这就是 OtterTune 在您首次连接数据库时立即检查 autovacuum 的健康状态的原因)。无论您运行的是 RDS、Aurora 还是 Aurora Serverless,都没有关系;PostgreSQL 的所有变体都有相同的 autovacuum 问题。

但由于其复杂性,要​​确保 PostgreSQL 的 autovacuum 尽可能最佳地运行是很困难的。PostgreSQL 用于调整 autovacuum 的默认设置并不适用于所有表,尤其是大型表。

例如,控制 PostgreSQL 在 autovacuum 启动之前必须更新表的百分比的配置旋钮的默认设置 ( autovacuum_vacuum_scale_factor ) 是 20%。这个阈值意味着如果一个表有 1 亿个元组,DBMS 不会触发 autovacuum,直到查询更新至少 2000 万个元组。因此,PostgreSQL 可能会长时间不必要地在表中保留大量死元组(从而产生 IO 和内存成本)。

PostgreSQL 中 autovacuum 的另一个问题是它可能会被长时间运行的事务阻塞,这会导致更多死元组和陈旧统计数据的积累。未能及时清理过期版本会导致许多性能问题,从而导致更多长时间运行的事务阻塞 autovacuum 进程。它变成了一个恶性循环,需要人类通过终止长时间运行的事务来手动干预。

除了以上四个问题,在另外一篇《为 PostgreSQL MVCC 和 Vacuuming 辩护》谈到:
MVCC 的 PostgreSQL 实现可能会导致表膨胀事务 ID 环绕以及可见性映射引入的开销浪费

事务 ID 环绕
PostgreSQL 使用 32 位无符号整数作为事务 ID,总共提供约 40 亿个事务 ID。
当 PostgreSQL 使用了所有可用的 ID 时,它会“环绕”并开始重新使用它们。

如果您的应用程序每秒执行 100 个单独的事务(6,000/分钟),如果 PostgreSQL 在整个时间内完全无法运行 vacuum 进程,无论是什么原因,理论上都需要大约 230+/- 天才能遇到环绕问题。

对于当今的高吞吐量工作负载和 ORM 优先的应用程序,这三个问题现在可能比前几代应用程序更引人注目是有充分理由的。事实上,当许多开发人员发现他们的表比他们应该的大得多,或者查询随着时间的推移变得越来越慢时,他们会措手不及,因为错误配置的服务器会阻止vacuum 有效工作。

不过,当您获得低于 300 万个可重用事务 ID 时,现代版本的 PostgreSQL 将进入故障安全模式以防止数据出现损失。

其他MVCC实现
Yugabyte在他们的事务 ID 中添加了一个时间组件,这样它就永远不会回绕,并且他们已经完全修改了存储层以避免典型的vacuum 问题,至少对于某些工作负载而言。

OrioleDB正致力于一种可插拔存储架构,该架构在数据库或单个表级别提供了传统 MVCC 问题的替代方案。

《PostgreSQL 四个问题在 YugabyteDB 中得到了解决》
要了解 PostgreSQL 在许多情况下也足够好, YugabyteDB 解决了有关 MVCC 的所有问题,但也有一些权衡:
我在类似的实例大小(AWS 上的 4 个 vCPU 16 GiB RAM)上运行了 PostgreSQL 和 YugabyteDB。您已经看到一些操作在 PostgreSQL 上更快。那是因为 PostgreSQL 是单一的并且在共享内存中工作。
速度快,但在发生故障或维护时会停止。

YugabyteDB 集群在每个可用区上都有一个实例,如果一个可用区由于计划或计划外的原因而关闭,应用程序将继续运行。这提供了高可用性,但增加了一些延迟。