MVCC and VACUUM
有经验的PostgreSQL用户和开发者经常滔滔不绝的说MVCC和VACUUM者两个专业的术语,好像每个人都应该知道它们是什么以及如何工作,但是实际上真正理解这些人的人并不多。这篇博文我将从我的角度解释MVCC是什么以及为什么PostgreSQL使用它,VACUUM是什么以及它是如何工作的,以及为什么我们需要VACUUM来实现MVCC。 此外,我还会为那些想要了解更多信息的人提供一些有用的链接供进一步阅读。
PostgreSQL是一个关系数据库,因此提供对事务的支持,即ACID:原子性,一致性,隔离性和持久性。让我们暂时忽略一致性和持久性。原子性意味着交易是“全有或全无”,要么事务提交则其所有更改生效,或者中止则所有更改都不会生效。隔离意味着每个事务都应该不知道还有其他事务同时执行。例如,如果事务A更新a=1的两行,使a=2,与此同时事务B删除a = 2的所有行。正常应该是如果 A“先发生”,更新完成之后两行都被B删除,或者B“先发生”,因此两行都不会被删除,而不应该出现A更新之后其中一行被删除,另一行因为B删除时还未更新因此保留下来的情况。总而言之,这意味着数据库希望给用户一种印象,即在他们的事务正在进行时不会发生并发更改,并且当他们的事务提交时,它所做的所有更改都会立即变为每个人同时可见。
不仅仅是PostgreSQL,一般的关系型数据库基本上使用两种方法来解决这个问题。 第一种是两阶段锁定:事务对其读取的每一位数据采用共享锁,并对其写入的每一位数据进行独占锁定。 提交时,会释放这些锁; 如果它中止,它会回滚它所做的更改然后释放锁定。 由于任何已修改的数据都受独占锁保护,因此在提交之前,并发事务不会看到该更改。 同时,读取操作可以共存,因为他们的锁都是共享的。 不幸的是,当读取和写入数据时,这种方法的并发性很差:读取操作和写入操作相互阻塞,因为读取共享的锁与写入所采用的排他锁相冲突。 锁对于并发性是不利的,并且两阶段锁需要在事务的整个持续时间内保持独占锁(以及共享锁,以获得完全正确性)。
提供原子性和隔离事务的第二种方法是多版本并发控制(MVCC)。基本思路很简单:不要锁定我们想要更新的行,而是创建它的新版本,最初只对创建它的事务可见。一旦更新事务提交,我们将使新行对在该点之后开始的所有新事务可见,而现有事务继续查看旧行。这样,每个事务实质上看到是数据库的一致快照。最后,当没有剩余的事务可以看到旧版本的行时,我们可以丢弃它。这种方法比两阶段锁具有更好的并发性,这就是许多现代数据库系统使用它的原因。但是,我们已经将并发问题转化为空间管理问题。通过两阶段锁定,可以在原地更新行,因为更新事务持有独占锁,在释放锁之前,没有其他人可以看到修改。对于MVCC,这将无法工作,因为我们需要至少在短时间内保留这两个版本(或者如果有长时间运行的并发事务,可能需要很长时间)。旧行版本必须保存在其他位置,新版本放在其位置,或旧行版本必须保持不变,新版本存储在其他位置。不同的数据库采用不同的处理方法。
PostgreSQL解决这个问题的方法是存储UPDATE创建的新行版本,基本上与存储INSERT生成的全新行的方式相同。一旦UPDATE提交,未来的SQL语句(或未来的事务,取决于您选择的隔离级别)将看到新的行版本,而旧的版本只对已经运行的语句(或事务)可见。最终,将不再有任何可以看到旧行版本的运行,此时它们已经dead。类似地,当DELETE提交时,最终将没有任何运行可以看到旧行版本,此时它们同样dead。类似地,如果INSERT中止,它插入的新行版本从仅对插入事务可见到完全对任何人不可见,因此也是dead,同样当UPDATE中止时,任何它创造的新版本都是dead。这也是为什采用这种方式的数据库的回滚是瞬间完成的。 Heroku开发中心有一篇写得很好的文章,解释了数据库内部原理的一些细节.
这使我们需要VACUUM。任何使用MVCC实现事务隔离的系统都必须有一些方法来清理过期数据。如果没有,过期版本的数量将无限制地增长,数据库大小将无限制地增长,这是不能接受的。在某些时候,我们必须摆脱过期版本,以便可以重用空间。在PostgreSQL中,这是VACUUM的工作。其他一些系统对不同类型的过期版本使用不同的清理机制。例如,由中止事务创建的过期版本可能与由已提交事务创建的死行版本不同地处理。在PostgreSQL中,我们以相同的方式处理这些问题。值得一提的是,在某些情况下,可以使用称为HOT修剪的一次性页面机制清理死行版本(有关技术细节,请参阅README.HOT)。这些处理方式实际上很常见;然而,HOT修剪是一种“尽力而为”的策略,VACUUM是确保工作完成并完成HOT修剪无法完成的任何清理的后盾。(这个简短的解释省略了许多有趣的细节,但这篇文章已经变得很长了。)
现在我们来讲讲VACUUM的工作原理。首先,它扫描表中所有可能包含过期数据的页面。一种被称为visibility map的数据结构记录了自上一个VACUUM以来哪些页面已被修改,没有记录在里面的页面会被跳过。就这样,它会从这些页面中删除过期版本,并使这些元组占用的空间可供重用。在此过程中,过期版本被替换为一种类似墓碑的记号 - 在技术上,指向过期数据行的指针会被标记为LP_DEAD。另外,它会扫描表中的每个索引,并删除指向第一阶段中标识的LP_DEAD指针的所有索引条目。一旦完成此阶段,它将返回到表并删除过期标志 - 从技术角度来说,LP_DEAD行指针标记为LP_UNUSED。完成后,这些行指针可以重用于新元组。如果在删除索引条目之前重用这些行指针,我们可能最终会得到与它们指向的行版本不匹配的索引条目,这可能导致查询返回错误。
上一段中使用术语“line pointer”,但没有定义它。 每个页面都以一系列行指针开头,这些行指针标识页面上每个元组的起始位置以及该元组的长度。 有一些特殊类型的行指针,包括LP_UNUSED(这意味着数组槽可用于新元组),LP_DEAD(如上所述,这意味着元组已从页面中删除但是行指针槽还不能重用)和LP_REDIRECT(与HOT有关,但超出了本文章的范围)。
简而言之,VACUUM扫描自上一个VACUUM以来已更改的表的部分,然后完整地扫描索引,然后再次扫描表的更改部分。 通过这样做,它确保删除过期版本和引用它们的索引条目,并确保引用这些过期版本的行指针可供重用。
最后的一些说明:
1.普通的VACUUM与VACUUM FULL非常不同,后者实际上重建了整张表。
2.除了删除过期元组的功能外,VACUUM还可以防止事务ID重复,更新统计信息,以及更新visibility map。 更新visibility map非常重要,不仅可以提高未来的VACUUM操作效率,还可以使index-only扫描更好地工作。 这些都包含在文档中。
3.通常,您不需要手动运行VACUUM,因为autovacuum守护程序将安排自动运行它。 但是,有时候手动运行VACUUM很有用。 这可能是一个好主意,例如,在大量装载之后,或在一天业务低峰是运行,减少在高峰时区运行vacuum。