由 Andy Pavlo 十月 15, 2025
安德鲁・帕夫洛(Andrew Pavlo)是卡内基梅隆大学计算机科学系数据库领域的助理教授。在卡内基梅隆大学,他是数据库研究组和并行数据实验室的成员。此外,他的研究工作还与英特尔大数据科学与技术中心保持合作。下面是他在 2023 年发表的一篇文章。
当前数据库领域的选择繁多(截至 2023 年 4 月,已有 897 种数据库)。面对如此多的系统,人们往往难以抉择!但存在一个有趣的现象:互联网行业会集体为新应用选择一款“默认数据库”。21 世纪 00 年代,行业共识选择了 MySQL,因为当时 Google、Facebook 等新兴科技巨头都在使用它;到了 2010 年代,MongoDB 成为主流,原因是其非持久化写入特性使其具备“互联网级可扩展性”;而在过去五年中,PostgreSQL 已成为互联网领域的“宠儿”数据库。这背后的原因很充分:它可靠、功能丰富、可扩展,且适用于大多数业务负载场景。
不过,尽管我们在 OtterTune 非常喜爱 PostgreSQL,但它的某些特性确实不尽如人意。因此,我们不想像其他人那样再写一篇吹捧这款以“大象”为标志的热门数据库有多出色的博文,而是想谈谈它一个主要的“槽点”:PostgreSQL 对多版本并发控制(MVCC)的实现方式。我们在卡内基梅隆大学的研究,以及在亚马逊云服务(Amazon RDS)上优化 PostgreSQL 数据库实例的经验表明:在 MySQL、Oracle、微软 SQL Server 等主流关系型数据库管理系统中,PostgreSQL 的 MVCC 实现是最差的。而且,亚马逊的 PostgreSQL Aurora(托管版 PostgreSQL)也同样存在这些问题。
在本文中,我们将深入探讨 MVCC:它是什么、PostgreSQL 如何实现 MVCC,以及为何这种实现方式存在明显缺陷。OtterTune 的目标是为用户减少数据库管理方面的顾虑,因此我们对如何解决这一问题进行了深入思考。下周,我们将在后续文章中介绍 OtterTune 针对 RDS 和 Aurora 数据库的 MVCC 问题自动管理解决方案。
什么是多版本并发控制(MVCC)?
数据库管理系统中 MVCC 的目标是:在条件允许的情况下,允许多个查询同时对数据库进行读写操作,且互不干扰。MVCC 的核心思想是:数据库管理系统从不覆盖现有行数据,而是为每一行(逻辑行)维护多个物理版本。当应用程序执行查询时,数据库管理系统会根据某种版本排序规则(如创建时间戳),确定应读取哪个版本的数据以满足查询请求。这种方式的优势在于:多个查询可以读取行的旧版本,而不会被另一个正在更新该行的查询阻塞。查询所看到的是数据库在其事务启动时的快照(即“快照隔离”,snapshot isolation)。这种机制避免了使用显式记录锁,传统锁机制会导致“写操作阻塞读操作”(读者等待写者释放锁才能访问数据)。
我们认为,大卫·里德(David Reed)在 1978 年于麻省理工学院提交的博士论文《分布式数据库系统中的并发控制》(Concurrency Control in Distributed Database Systems),是首个对 MVCC 进行描述的公开文献。20 世纪 80 年代,InterBase 数据库首次在商业数据库管理系统中实现了 MVCC。此后二十年间,几乎所有支持事务功能的新型数据库管理系统,都实现了 MVCC。
开发人员在构建支持 MVCC 的数据库管理系统时,需要做出多项设计决策。从宏观层面来看,核心决策可归纳为以下三点:
- 如何存储对现有行的更新数据;
- 运行时如何为查询找到行的正确版本;
- 如何删除不再可见的过期版本。
这些决策并非相互独立。以 PostgreSQL 为例,其在 20 世纪 80 年代对“如何存储和更新数据”这一问题的决策,直接导致了另外两个问题的产生,而这些问题至今仍困扰着用户。
为便于讨论,我们将以一个存储电影信息的表为例。该表的每一行包含电影名称、上映年份、导演,以及一个作为主键的唯一 ID,同时在“电影名称”和“导演”字段上建立了二级索引。以下是创建该表的 DDL 命令:
CREATE TABLE movies (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name VARCHAR(256) NOT NULL,
year SMALLINT NOT NULL,
director VARCHAR(128)
);
CREATE INDEX idx_name ON movies (name);
CREATE INDEX idx_director ON movies (director);
该表包含一个主键索引(movies_pkey
)和两个二级索引(idx_name
、idx_director
)。
PostgreSQL 的多版本并发控制(MVCC)实现
正如斯通布雷克(Stonebraker)在 1987 年的系统设计文档中所提及的,PostgreSQL 从设计之初就支持多版本特性。其 MVCC 机制的核心思想看似简单:当查询更新表中的某一行时,数据库管理系统会复制该行数据,在新副本上应用更新,而非直接覆盖原行。我们将这种方式称为“仅追加式版本存储方案”(append-only version storage scheme)。但如下文所述,这种实现方式给系统其他部分带来了诸多棘手问题。
多版本存储
PostgreSQL 将表的所有行版本都存储在同一存储空间中。要更新某个元组(tuple,即行数据),数据库管理系统会先从表中分配一个空槽位用于存储新行版本,然后将当前版本的行数据复制到新槽位,并在新分配的版本中应用修改。以下示例可直观展示这一过程:当应用程序执行更新查询,将电影 Shaolin and Wu Tang 的上映年份从 1985 年改为 1983 年时:
当 UPDATE 查询修改表中的元组时,PostgreSQL 会复制该元组的原版本,然后在新版本上应用修改。在本例中,表页面 1 已无剩余空间,因此 PostgreSQL 在表页面 2 中创建了新版本。
此时,同一逻辑行对应两个物理版本,数据库管理系统需要记录这些版本的“谱系”,以便后续查找。MVCC 数据库通常通过“单向链表”构建“版本链”来实现这一点。版本链仅采用单向指向,以减少存储和维护开销。这意味着数据库管理系统需要选择版本链的排序方向:“新到旧”(N2O,newest-to-oldest)或“旧到新”(O2N,oldest-to-newest)。
- 若采用 N2O 排序:每个元组版本都指向其前一个版本(旧版本),版本链的“头部”始终是最新版本;
- 若采用 O2N 排序:每个元组版本都指向其下一个版本(新版本),版本链的“头部”是最旧版本。
O2N 方式的优势在于:元组每次被修改时,数据库管理系统无需更新索引以指向新版本;但缺点是查询过程中查找最新版本可能需要遍历较长的版本链,耗时更长。包括 Oracle、MySQL 在内的大多数数据库管理系统都采用 N2O 方式,而 PostgreSQL 则独树一帜地使用 O2N(微软 SQL Server 的内存 OLTP 引擎除外)。
接下来的问题是:PostgreSQL 如何记录这些版本指针?PostgreSQL 中每一行的头部都包含一个“元组 ID 字段”(t_tcid),用于存储下一个版本的 ID(若当前版本是最新版本,则存储自身的元组 ID)。因此,如以下示例所示,当查询请求某一行的最新版本时,数据库管理系统会先遍历索引找到最旧版本,再沿着指针依次查找,直至找到目标版本。
SELECT 查询会遍历索引以查找包含目标电影名称的元组。索引条目指向该元组的最旧版本,这意味着 PostgreSQL 需要沿着最旧版本中嵌入的版本链,逐步找到新版本。
PostgreSQL 开发人员很早就意识到其 MVCC 方案存在两个问题:
- 每次更新元组时都复制整个元组,成本过高;
- 为查找最新版本(大多数查询的需求)而遍历整个版本链,存在资源浪费。
当然,还存在“如何清理旧版本”的问题,这一点我们将在下文介绍。
为避免遍历完整版本链,PostgreSQL 会为行的每个物理版本在表的索引中添加一条记录。这意味着:若一个逻辑行对应 5 个物理版本,那么该元组在索引中最多会有 5 条记录!在以下示例中,idx_name
索引包含了 “Shaolin and Wu Tang” 这一电影在不同页面上的所有版本记录,这使得数据库可以直接访问元组的最新版本,无需从表页面 1 开始遍历版本链。
在该示例中,索引包含了 “Shaolin and Wu Tang” 元组的多个版本记录(每个版本对应一条索引项)。此时,PostgreSQL 可通过索引直接找到最新版本,并从表页面 2 中读取数据,无需遍历从表页面 1 开始的版本链。
PostgreSQL 还尝试通过“在原版本所在的磁盘页面(block)中创建新副本”来避免创建多条索引记录及跨页面存储版本,以减少磁盘I/O。这种优化机制被称为“仅堆元组更新”(HOT,Heap-Only Tuple updates)。只有满足以下两个条件时,数据库才会使用 HOT 方式:
- 更新操作未修改表索引所引用的任何列;
- 原版本所在的数据页面有足够空间存储新版本。
在这种情况下,更新后索引仍指向旧版本,查询需通过遍历版本链获取最新版本。在正常运行过程中,PostgreSQL 还会通过删除旧版本来“修剪”版本链,进一步优化这一过程。
行版本清理
前文已说明,PostgreSQL 在更新行数据时会创建副本。接下来的问题是:系统如何删除这些旧版本(即“死元组”,dead tuples)?
20 世纪 80 年代的早期 PostgreSQL 版本并不删除死元组。当时的设计思路是:保留所有旧版本可支持应用程序执行“时间旅行查询”(time-travel queries),即查看数据库在某个特定时间点的状态(例如,查询上周结束时数据库的状态)。但不删除死元组会导致两个问题:
- 若应用程序删除元组,表的存储空间不会减少;
- 频繁更新的元组会形成较长的版本链,拖慢查询速度(尽管 PostgreSQL 通过添加索引项避免了遍历链的开销,但这会导致索引体积增大,进而降低索引速度并增加内存压力)。
显然,这些问题是相互关联的。
为解决这些问题,PostgreSQL 引入了“清理程序”(vacuum)来清理表中的死元组。清理程序会对自上次运行以来修改过的表页面进行顺序扫描,找出“过期版本”。若一个版本对所有活跃事务都不可见(即当前没有事务在访问该版本,且未来事务会使用最新的“存活版本”),则该版本会被标记为“过期”。此时,删除过期版本并回收空间以供复用是安全的。
PostgreSQL 会根据配置定期自动执行清理程序(即“自动清理”,autovacuum)。除了影响所有表清理频率的全局配置外,PostgreSQL 还支持在表级别配置自动清理,以便为特定表调优清理过程。用户也可通过执行 SQL 命令VACUUM
手动触发清理,以优化数据库性能。
为何 PostgreSQL 的 MVCC 实现最差?
我们直言不讳地说:如果现在有人要开发一款新的 MVCC 数据库管理系统,绝不应采用 PostgreSQL 的方式(如“仅追加式存储+自动清理”)。在我们 2018 年发表于 VLDB(国际超大型数据库会议)的论文中,尚未发现其他数据库管理系统采用与 PostgreSQL 相同的 MVCC 实现方式。这种设计是 20 世纪 80 年代的“遗留产物”,早于 20 世纪 90 年代“日志结构系统模式”(log-structured system patterns)的普及。
以下我们将介绍 PostgreSQL MVCC 实现的四个主要问题,并说明为何 Oracle、MySQL 等其他 MVCC 数据库管理系统能避免这些问题。
问题 1:行版本复制
在 PostgreSQL 的“仅追加式 MVCC 存储方案”中,更新元组时会将该元组的所有列复制到新版本,无论更新的是单个列还是所有列。可想而知,这种方式会导致严重的数据冗余和存储需求激增。这意味着:与其他数据库管理系统相比,PostgreSQL 需要更多的内存和磁盘空间来存储数据库,进而导致查询速度变慢和云服务成本上升。
相比之下,MySQL 和 Oracle 不会为新版本复制整个元组,而是存储“新旧版本之间的紧凑差异”(类似Git的diff
功能)。采用差异存储的优势在于:若一个表包含 1000 列,而查询仅更新其中 1 列,数据库只需存储该列的修改差异;而 PostgreSQL 会创建一个包含“1 个修改列 + 999 个未修改列”的完整新版本。(注:本文暂不讨论 TOAST 属性,PostgreSQL 对其采用特殊处理方式。)
曾有人尝试改进 PostgreSQL 的版本存储实现:企业数据库厂商 EnterpriseDB 在 2013 年启动了 zheap 项目,计划用“差异版本存储”替代“仅追加式存储引擎”。但遗憾的是,该项目的最后一次官方更新停留在 2021 年,据我们所知,该项目已无后续进展。
问题 2:表膨胀
PostgreSQL 中的过期版本(即死元组)占用的空间也比“差异版本”更大。尽管自动清理最终会删除这些死元组,但在“写密集型负载”场景下,死元组的产生速度可能超过清理速度,导致数据库体积持续增大。由于死元组与活元组混合存储在数据页面中,查询执行时数据库必须将死元组加载到内存中。无节制的膨胀会导致以下问题:
- 表扫描时需要更多的 I/O 操作和内存资源,降低查询性能;
- 死元组导致优化器统计信息不准确,进而生成低效的查询计划。
举个例子:假设我们的movies
表包含 1000 万个活元组和 4000 万个死元组,死元组占比达 80%;且表的列数比示例中更多,每个元组的平均大小为 1KB。在这种情况下,活元组占用 10GB 存储,死元组占用约 40GB 存储,表的总大小为 50GB。当查询对该表执行全表扫描时,PostgreSQL 必须从磁盘读取全部 50GB 数据并加载到内存,即便其中 80% 是无用的死元组。尽管 PostgreSQL 有“避免顺序扫描污染缓冲池缓存”的保护机制,但这无法减少 I/O 成本。
更糟糕的是:即使你确保自动清理定期运行且能跟上业务负载(这本身就不易实现,详见下文),自动清理也无法回收存储空间。自动清理仅能删除每个表页面中的死元组,并在页面内重新排列活元组,但无法从磁盘中回收空页面。
只有当表的最后一个页面因无任何元组而被截断时,其他页面仍会保留在磁盘上。以上述示例为例:即使 PostgreSQL 删除了movies
表中 40GB 的死元组,它仍会占用操作系统(或 Amazon RDS)分配的 50GB 存储空间。要回收并释放这些未使用的空间,必须使用 VACUUM FULL 命令或 pg_repack 扩展,它们会将整个表重写到新的存储空间,以消除冗余。但这些操作对生产数据库的性能影响极大:它们消耗大量资源且耗时较长,可能导致查询性能骤降。以下是VACUUM
与VACUUM FULL
的工作原理对比:
常规的 VACUUM 仅删除每个表页面中的死元组,并将活元组重新排列到页面末尾;VACUUM FULL 会删除每个页面中的死元组,将剩余活元组合并压缩到新页面(如“表页面 3”),然后删除无用的旧页面(如“表页面 1”和“表页面 2”)。
问题 3:二级索引维护
在 PostgreSQL 中,更新一个元组需要更新该表的所有索引。这是因为 PostgreSQL 的主键索引和二级索引都存储了版本的“物理地址”。除非数据库能通过 HOT 更新将新版本存储在原页面中,否则每次更新都必须更新所有索引。
回到前文的 UPDATE 查询示例:PostgreSQL 会像之前那样将原版本复制到新页面以创建新版本,同时还需在表的主键索引(movies_pkey
)和两个二级索引(idx_director
、idx_name
)中插入指向新版本的记录。
这种“每次更新需修改所有索引”的机制对性能有多重负面影响:
- 更新查询速度变慢:系统需要执行更多操作;
- I/O 开销增加:需遍历每个索引并插入新记录;
- 锁/闩锁竞争加剧:访问索引时会对索引及数据库内部数据结构(如缓冲池的页面表)产生锁竞争。
更不合理的是:即使某些索引从未被查询使用,PostgreSQL 仍会为这些索引执行维护操作。对于按 I/O 操作量计费的数据库服务(如 Amazon Aurora),这些额外的读写操作会显著增加成本。
如前文所述,PostgreSQL 可通过 HOT 更新避免每次更新都修改索引(当新版本与原版本存储在同一页面时)。我们对 OtterTune 客户的 PostgreSQL 数据库分析显示:平均约 46% 的更新会使用 HOT 优化。尽管这一比例已算可观,但仍意味着超过 50% 的更新需要承担“全索引维护”的性能代价。
许多用户都曾受此问题困扰,最著名的案例是 Uber 在 2016 年发布的博文,文中解释了为何他们从 PostgreSQL 迁移到 MySQL。当时 Uber 的写密集型负载在“包含多个二级索引的表”上出现了严重的性能问题。
MySQL 的 MVCC 实现则不存在这一问题,因为它的二级索引不存储新版本的物理地址,而是存储“逻辑标识符”(如元组 ID、主键)。数据库会通过逻辑标识符查询新版本的物理地址。虽然这可能会使二级索引的读取速度略有下降,但这类数据库的 MVCC 实现还具备其他优势,可抵消这一开销。
补充说明:Uber 的博文中对 PostgreSQL 版本存储的描述存在一处错误。文中称 “PostgreSQL 的每个元组存储指向前一个版本的指针”,但实际情况是“每个元组存储指向下一个版本的指针”。这一错误导致 Uber 误将 PostgreSQL 的“旧到新(O2N)版本链”当成了“新到旧(N2O)版本链”。
问题 4:VACUUM 管理
PostgreSQL 的性能在很大程度上依赖于自动清理的有效性,自动清理需及时删除过期数据并回收空间。无论是运行在 RDS、Aurora 还是 Aurora Serverless 上,所有 PostgreSQL 变体都存在相同的自动清理问题。但由于自动清理的复杂性,要确保其高效运行并不容易。
PostgreSQL 的自动清理默认配置并非对所有表都适用,尤其是大型表。例如,配置项 autovacuum_vacuum_scale_factor(控制“表更新比例达到多少时触发自动清理”)的默认值为 20%。这意味着:若一个表包含 1 亿个元组,只有当至少 2000 万个元组被更新时,数据库才会触发自动清理。这种阈值设置会导致表中长时间保留大量死元组,进而增加 I/O 和内存成本。
PostgreSQL 自动清理的另一个问题是:它可能被“长时间运行的事务”阻塞,导致死元组堆积和统计信息过期。未能及时清理过期版本会引发一系列性能问题,而性能下降又会导致更多长时间运行的事务,进一步阻塞自动清理,形成“恶性循环”。这种情况往往需要人工干预,手动终止长时间运行的事务。以下图表展示了 OtterTune 某客户数据库在两周内的死元组数量变化:
图表呈现出“锯齿状”模式,表明自动清理约每天执行一次大规模清理。例如,2 月 14 日数据库清理了 320 万个死元组。但该图表实际反映的是一个“不健康的 PostgreSQL 数据库”,死元组数量整体呈上升趋势,说明自动清理已无法跟上死元组的产生速度。
在 OtterTune,我们经常在客户的数据库中发现这类问题。曾有一个 PostgreSQL RDS 实例:批量插入操作后统计信息过期,导致出现长时间运行的查询;该查询阻塞了自动清理对统计信息的更新,进而引发更多长时间运行的查询。OtterTune 的自动健康检查识别出了这一问题,但管理员仍需手动终止该查询,并在批量插入后执行 ANALYZE 命令(更新统计信息)。好在问题解决后,该长查询的执行时间从 52 分钟缩短到了仅 34 秒。
结语
开发数据库管理系统时,总会面临艰难的设计抉择,而这些抉择会导致不同数据库在不同负载场景下表现各异。对于 Uber 的写密集型负载而言,PostgreSQL MVCC 导致的“索引写入放大”问题,正是他们迁移到 MySQL 的原因。但请不要误解我们的“吐槽”,这并不意味着我们认为不应使用 PostgreSQL。尽管其 MVCC 实现存在缺陷,但 PostgreSQL 仍是我们最喜爱的数据库管理系统。爱一样事物,就意味着要接受它的不完美(正如丹·萨维奇(Dan Savage)在 “The Price of Admission” 一文中所阐述的观点)。
那么,该如何应对 PostgreSQL 的这些特性呢?你可以投入大量时间和精力自行调优,祝你好运!
参考
Andy Pavlo:The Part of PostgreSQL We Hate the Most