作者简介:
Hans-Jürgen Schönig从90年代开始接触PostgreSQL,目前是CYBERTEC的CTO以及技术领导。CYBERTEC是PostgreSQL领域的技术领导者之一并且从2000年开始服务于多个国家。
译者简介:
陶进,数据库技术和理论爱好者,熟悉pg和gp以及其他大数据相关并行处理框架,现就职于广联达数据中台部门。
校对者简介:
崔鹏,任职于海能达通信股份有限公司,数据库开发高级工程师,致力于postgresql数据库在专网通信领域、公共安全领域的应用与推广。

在PostgreSQL中,自从早期的MVCC模型被设计出来以后,表膨胀成为主要的忧虑。CYBERTEC曾通过一系列博客来详细讨论这件事情。什么是表膨胀?表膨胀是表或者索引的记录条数没有增加,但是占用的物理空间大小在持续增长。如果要支持事务,记录在被修改时显然不能直接覆盖掉原纪录,因为用户正在修改时可能会有其他用户在读取原纪录,或者事务需要回滚。 对于PostgreSQL的MVCC来说,膨胀是必然的事情。但是,PostgreSQL当前存储数据以及处理事务的机制并不是数据库在事务和并发方面唯一的方案。我们可以看看其他方案: 在MS SQL中,你可以找到一个称作tempdb的东西,而在Oracle和MySQL中,旧版本的纪录放在redo lo g中。你可能知道PostgreSQL在执行Update的时候复制一条新的记录并且将它存储在原表中。Firebird同样将记录的多个旧的版本串联起来。 这里主要想表达两点: Ø 处理旧的记录很难(Getting rid of old rows is hard) Ø 没有一种方案不是经过各方面权衡之后的结果(No solution is without tradeoffs) 如何处理旧的记录是一个实实在在的问题。在PostgreSQL中通常通过vacuum删除旧的记录。但是,在某些场景下,vacuum不会一直执行,或者磁盘空间因为其他原因一直增长(比如,长事务)CYBERTEC的博客曾经大量的讨论过这些场景。 “没有一种方案不是经过各方面权衡之后的结果”表达了存储系统重要的一面。不存在一个完美的存储引擎——只存在于服务于某些特定的负载场景(workloads)的存储引擎。对于PostgreSQL也同样:当前的表结构适用于非常多的应用场景(workloads).但是某些“短板”也会让我们回到最开始的主题:表膨胀。在执行UPDATE非常多的负载下,表膨胀比其他情况下更容易产生,很难去控制表的大小。如果开发人员或者管理员没有预先了解PostgreSQL的工作机制这种状况非常容易发生。

Zheap:将膨胀可控

Zheap是一种让表膨胀可控的方法,它实现了一个运行在大量UPDATE负载时更高效的存储引擎。这个项目最初由EnterpriseDB发起,并且已经做了大量工作。 为了让zheap能用于生产环境,我们很荣幸地看到Heroic Labs 提供了捐赠用于zheap未来的开发并且声明所有的代码将回馈给社区。CYBERTEC也决定将捐赠翻倍,并且提供国内专业能力以及人力来推进zheap。对于有兴趣于推进zheap的个人或者公司,CYBERTEC也渴望和他们一起来使这项技术成功。 回归正题,这里是zheap主要的设计目标: Ø 原地执行update Ø 更小的表(更小的元组的header,改进的算法(smaller tuple headers, improved alignment)) Ø 尽可能的减少写(脏页如果数据没有发生变化则忽略(avoid dirtying pages unless data is modified)) Ø 更快的重用空间 让我们看看这些是如何实现的。

Zheap的基本设计

Zheap是一个完全的新的存储引擎,因此有必要深入其基本架构。三个必要的组成协作运行: Ø Zheap:表格式 Ø Undo:处理事务回滚 Ø WAL:保证至关重要的写操作 让我们先看下zheap页(page)的结构。你可能已经知道,PostgreSQL的表是由8KB(默认设置)大小的数据块组成,所以page的结构至关重要:

PostgreSQL数据库TableAM——Zheap_postgresql


这张图虽然第一眼看上去想PostgreSQL的8k page,但事实上并非如此。你第一个注意到的是tuple和page开始的item按照相同的顺序存储,这是为了快速扫描。第二就是有称之为”slot”的东西置于page的末尾。在一个标准的PostgreSQL表中,可见信息作为行的一部分需要不少空间。在zheap中,事务信息被移到page中能够显著地减少数据大小(反过来则意味着更好的性能)。一个事务占用16byte空间,包含以下信息:事务id,阶段(epoch),以及事务最新的undo中的记录的指针。一个事务信息指向一个transaction slot。一张表中默认的transaction slot的个数是4,即使是大表来通常也够用。但是,有时候需要更多的transaction slot,因此zheap有一个称之为”TDP”仅仅是需要是用于存储额外的事务信息。

PostgreSQL数据库TableAM——Zheap_postgresql_02


有时候一个page需要很多transaction slot,TPD提供了一种弹性的方式来处理这种情况。现在问题是:zheap在那里存储TPD数据。答案是:这种特殊的page和标准的page穿插存储。他们只是通过某种方式标记来确保sequence scan不会读取到这些TDP page。为了追踪这些特殊的page,zheap使用一个meta page来追踪它们。

PostgreSQL数据库TableAM——Zheap_postgresql_03


TDP只是一种方式让transaction slot更容易扩展,在block中有一些slot能够减少访问额外的page,如果需要更多slot,TDP是一种优雅的实现方式。在某种程度上可能是最好的两全其美的方法。 在一个transaction结束以后,Transaction slot能够被重用。

Zheap:元组格式

另一个重要的谜题是元组的结构。在PostgreSQL中,一个标准的堆表(heap table)元组有20+ byte的header,因为所有的事务信息被存在tuple中。但现在不同,所有的事务信息都被移到page级别(transaction slot)。这及其重要,header被缩减到仅仅只有5byte。而且这里还有其它的优化:一个标准的元组需要在每行的元组header以及实际数据之间进行cpu对齐(即padding),这可能对表中的每一行都会浪费一些byte。zheap不会这样做来产生一些更紧凑的字节对齐的存储。(A standard tuple has to use CPU alignment (padding) between the tuple header and the real data in the row. This can burn some bytes for every single row in the table. zheap is not doing that leading to more tightly packed storage.)通过去掉按值传递的数据类型的内存对齐能节省额外的空间(Additional space is saved by removing the padding from pass-by-value data types.)所有这些优化意味着我们可以在表的每行节省有价值的空间。

下面是一个标准的PostgreSQL 元组header的内容:

PostgreSQL数据库TableAM——Zheap_数据_04


Zheap的元组的header:

PostgreSQL数据库TableAM——Zheap_数据_05


你可以看到一个zheap元组比通常的元组小很多。因为事务信息被统一在transaction slot中,我们不用在行级处理数据可见性而是更高效的在page级别做这件事。

通过减少存储的“足迹”,zheap能够达成更好的性能。

Undo:让事情有序

在谈论zheap的时候最重要的话题之一就是“undo“的概念。它首要的目的是什么?我们来看下以下操作:
BEGIN; UPDATE tab SET x = 7 WHERE x = 5; … COMMIT / ROLLBACK;
为了确保这个事务执行正确,UPDATE不能只是覆盖旧的值并且丢弃它们。有两个理由:首先,我们需要支持并发,数据在被修改时其他用户要能读取。其次就是更新一条记录并不意味着它一定被commit,因此我们需要一种有效的方式来处理rollback。经典的PostgreSQL存储格式会在标准表中复制一条记录从而导致上面提到过的所有膨胀相关的问题。 Zheap处理这事有一点不同:数据更新发生时,系统通过记录“undo“信息来处理事务可能因为任何原因被终止。这是基本的概念也适用于INSERT,UPDATE和DELETE,我们可以逐一看下这些操作中它是如何工作的:
INSERT:增加行
对于INSERT,zheap申请一个transaction slot并且发出一个undo 记录来处理error相关的问题。INSERT中的TID是undo所需的最重要的信息。如果INSERT被回滚空间可以马上被再次利用,这也是zheap和PostgreSQL标准存储最主要的不同。
UPDATE:修改数据

UPDATE要复杂的多,主要有两种类型: Ø 新的记录能被写入之前的存储空间 Ø 新的记录无法写入之前的存储空间 如果记得纪录比新的记录占用空间小,我们可以直接覆盖它,并且发出一个undo entry来持有旧的记录中的数据。简单来说就是将新的记录写入zheap然后旧的记录的副本在undo中,这样在需要的时候可以把它复制回去。 如果新的记录无法放入旧的空间会发生什么?这种情况下性能会变差因为zheap基本上会执行DELETE/INSERT操作,这个操作当然不如原地UPDATE高效。 空间在以下情况下会很快被回收利用: Ø 更新一条记录为一个占用空间更小的版本 Ø 非原地UPDATE被执行。
DELETE:删除行

处理行的删除,zheap会发出一个undo记录来旧的的行,如果ROLLBACK则写回去。在DELETE中,zheap会删除掉该行。
UNDO和ROLLBACK的行为

到现在我们讲了一点undo和update,我们再深入一点看下如何进行undo,roolback,以及他们如何相互作用。 ROLLBACK发生的时候,undo需要确保表的历史状态被重新加载。因此在这之前设定的undo操作需要马上被执行。如果发生error,undo操作也需要被作为一个新事务的一部分并且确保成功。 理想情况下,undo操作相关的单个page在被WAL写入的时候会立即移走。这种策略的有个好的副作用就是我们可以减少在page级别持有锁的时间到最低,这样可以减少锁冲突来改善性能。 目前这听起来很容易,但是我们考虑一个重要的使用场景:在一个长事务中会发生什么?吐过TB级别的数据需要被立即回滚?终端用户当然对长时间回滚操作不满意。另外也要意识到对于rollback过程中的崩溃我们也需要有所防备。 如果一个undo操作大于配置的阈值,那么由后台的工作进程来完成这个操作。这是一种非常优雅的解决方案,能带给用户很好的体验。 Undo可以在以下情况被移除: Ø 当前没有事务关联到这部分数据 Ø 当前所有undo操作被执行完 Ø 已提交的事务可见的时候(For committed transactions till the time they are all-visible) 我们看一下基本架构图:

PostgreSQL数据库TableAM——Zheap_postgresql_06


PostgreSQL数据库TableAM——Zheap_元组_07


你可以看到,过程还是非常复杂。

索引:重要的话题

为了确保zheap是在当前堆表的记录原地发生变更,确保记录的索引号(indexing code)没有发生变更非常重要。Zheap可以在PostgreSQL的标准访问方法中运行。当然,还有空间提高效率。但是,对当前来说,不改变索引号是必要的。这也表示在PostgreSQL中可用的索引现在都可以无限制的使用。

最后

当前zheap仍在开发中,我们也很高兴看到Heroic Labs的为这项技术的未来发展做的贡献。当前已经实现了zheap的逻辑解码(logical decoding)以及支持PostgreSQL。未来将会寻求更多资源来使它能用于生产。