PG之TOAST技术
引入
背景介绍
最近有几套Oracle数据库要迁移到一款基于PostgreSQL的国产化数据库,迁移后发现数据“缩水”了,原本一套数据量为10T的Oracle数据库,全量迁移到国产数据库后只有 4.4T。是源库的碎片化严重还是数据被压缩了?带着这样的疑问,本文我们就来介绍一下,Oracle和PostgreSQL这两款非常优秀的关系型数据库,在数据存储方面有什么差别。
Oracle数据的存储
Oracle中常用的数据类型,像char、varchar、varchar2,不管是定长还是变长的数据类型,都有其最大的存储大小限制(char为2000 字节, varchar和varchar2为4000 字节),那么要存储超过字段最大长度的数据该怎么办呢,答案就是Large Objects (LOBs),这里可能有同学说还有long和long row啊,由于这两种数据类型是Oracle为了向后兼容才保留的,所以我们这里先不做讨论。
什么是LOBs
LOBs 是Oracle数据库中用来存储超长数据的数据类型的集合,可以存储超过4000个字节的字符串、二进制数据、OS文件等大对象信息,其最大的存储容量可以达到 8TB~128T。其中LOBs又分为内部LOB和外部LOB,顾名思义,内部LOB是指数据存储在数据库表空间内部,包括BLOB、CLOB、NCLOB三种数据类型,外部LOB是指数据存储在数据库表空间以外的操作系统文件中,只包括BFILE一种数据类型,我们通常把内部LOB也简称为LOB。
LOB的存储如何实现
对于LOB类型数据的存储来说,默认情况下,LOB存储的数据小于4000 字节时,就会像VARCHAR2一样存储在表中。当LOB存储的数据超过了4,000字节时,会“移出”到单独的LOB段 。每一个被“移出”的LOB数据又称为一个LOB实例,LOB实例由存储在表中LOB列上的locator (定位器)和存储在LOB段中的实际数据组成,定位器指向实际数据的物理位置。LOB段是由chunk组成的 ,而chunk是由逻辑上连续的一个或多个数据库块(block)组成,chunk大小默认等于数据库块大小(默认8K),最大为32K,chunk也是LOB段的最小分配单元,即每个存储在LOB段中的数据值至少会占用一个CHUNK。数据大小和CHUNK大小的关系如下图。
TOAST简介
在PostgreSQL中,像varchar,char,text,bytes 这些数据类型是可以接受任何长度的字段值的。为何PostgreSQL可以支持存储任何长度的字段?其实对于非常大的字段值来说,仍然存储在行内显然是不合适的,PostgreSQL底层通过引入了TOAST技术来解决这个问题。
PostgreSQL使用固定的页面尺寸(通常是8kB),并且不允许元组跨越多个页面(避免了Oracle中行迁移和行链接带来的性能影响),因此不能直接存储非常大的字段值。为了解决这个问题,大的字段值会被压缩并/或分解成多个物理行。这些处理对用户都是透明的,只是在大部分的后端代码上有一些小的影响。这个技术称为TOAST(超尺寸属性存储技术-The Oversized-Attribute Storage Technique)。
TOAST是The OverSized Attribute-Storage Technique(超尺寸字段存储技术)的缩写,顾名思义,是说超长字段在PG的一种存储方式,主要用于存储大字段的值;PG页面的大小是固定的(通常是8KB),并且一个元组不允许跨多个页面存储,所以并不能直接存储大字段数据;Toast就是为此应运而生,它会将大字段值压缩或者分散为多个物理行来存储;对于用户来说完全不用关注这一技术实现,完全是透明的。
在 PG 中,页是数据在文件存储中的基本单位,其大小是固定的且只能在编译期指定,之后无法修改,默认的大小为8 KB 。同时,PG 不允许一行数据跨页存储,那么对于超长的行数据,PG 就会启动 TOAST ,具体就是采用压缩和切片的方式。如果启用了切片,实际数据存储在另一张系统表的多个行中,这张表就叫 TOAST 表,这种存储方式叫行外存储。
PostgreSQL使用固定的页面尺寸(通常是8kB),并且不允许元组跨越多个额页面。因此不可能直接存储非常大的域值。为了克服这个限制,大的域值会被压缩并/或分解成多个物理行。这些处理对用户都是透明的,只是在大部分的后端代码上有一些小的影响。这个技术的昵称是TOAST(或者“切片面包之后的最好的东西”)。TOAST 机制也被用来提升内存中大型数据值的处理。
只有特定的数据类型支持TOAST — 我们没必要在那些不可能生成大域值的数据类型上强加这种负担。要支持TOAST,数据类型必须有变长(varlena)的表现形式, 通常在存储的值中,头四个字节表示值的总长度(包括长度本身,以字节计)。TOAST并不约束该数据类型的表达的剩余部分。这种特殊的表达被统称为已TOAST值, 对它们的操作都必须通过修改或者重新解释这个初始长度字来进行。因此,支持一种可TOAST数据类型的 C 函数必须要小心它们可能会处理被TOAST过的输入值: 一个输入值可能并不真正由一个四字节长度和内容构成,直到它被反TOAST(通常是在对一个输入值做任何事情之前,先调用PG_DETOAST_DATUM
; 但是在某些情况下也存在更高效的方法,详见第 37.13.1 节)。
TOAST占用使用变长类型的长度字的最高两个二进制位( 大端法机器上的高位,小端法机器上的低位), 这样就把任何可TOAST值的 逻辑长度限制在1GB(230 - 1字节)。如果两个位都是零, 那么数值是该数据类型一个普通的未TOAST的值,并且长度字的剩余位给出整 个数据以字节计的大小(包括长度字)。当最高位或者最低位被设置时,该值 只是有一个单字节头部而不是通常的四字节头部,并且该字节的剩余位数给出 了以字节计的总数据尺寸(包括长度字节)。这种节省空间的方案支持对低于 127 字节的值的存储,不过需要时仍然允许数据类型增长到 1GB。带有单字节 头部的值不会按照任何特别的边界对齐,反之带有四字节头部的值会按照至少 一个四字节边界对齐。这种对齐填充的省略额外地节省了空间,这种节省比起 短值来说更加显著。作为一种特殊情况,如果一个单字节头部的剩余位全是零 (对于一个自包含的长度来说是不可能的),该值就是一个线外数据的指针, 这就可能有下文所述的几种可能的情况。这样一个TOAST指针 的类型和尺寸由该数据的第二个字节中存储的一个代码决定。最后,如果最高 位或最低位被清除而另一位被设置,则表示该数据的内容被压缩过并且在使用 前必须先解压。在这种情况中四字节长度字的剩余位指出了压缩过的数据的大 小,而不是原始数据的大小。注意对于线外数据也可能存在压缩,但是变长数 据的头部不会告诉我们压缩是否发生 — TOAST指针 的内容将说明这个问题。
如前所述,有多种类型的TOAST指针数据。最古老且最常见的类型是 指向存储在一个TOAST 表中的线外数据 的指针,TOAST 表与包含该 TOAST指针数据本身的表是相关的,但两者又是被分离存储的。当 一个要被存储在磁盘上的元组过大时,这些磁盘上的指针数据由 TOAST管理代码(在access/heap/tuptoaster.c
中)所创建。第 68.2.1 节中给出了更多的细节。 或者,一个TOAST指针数据能够包含一个出现在内存中某处的线外 数据的指针。这种数据必定是短命的并且将不会出现在磁盘上,但是它们对于避免 大型数据值的复制和冗余处理非常有用。详见 第 68.2.2 节。
线内或者线外压缩数据所使用的压缩技术是 LZ 压缩技术家族中一种 相对简单且非常快速的成员。详见 src/common/pg_lzcompress.c
。
行外磁盘上 TOAST 存储
如果一个表中有任何一个列是可以TOAST的, 那么该表将有一个与之关联的TOAST表,其 OID 存储在表的pg_class
.reltoastrelid
项中。磁盘上的被TOAST过的值保存在TOAST表里,下文有更详细的描述。
线外值被分裂成(如果压缩过,在压缩之后分裂)最大为TOAST_MAX_CHUNK_SIZE
(默认情况下该值应选为使得四个块(chunk)行能放在一个页面中,这个数值大约为2000 字节)字节的块。每个块都作为独立的行存储在从属于所属表的TOAST表中。每个TOAST表都有列chunk_id
(一个表示特定的被TOAST过的数据的OID)、chunk_seq
(一个序列号,存储该块在值中的位置)和一个chunk_data
(该块的实际数据)。在chunk_id
和chunk_seq
上有一个唯一索引, 提供对值的快速检索。因此,一个表示线外磁盘上TOAST过的值的指针数据应存储要查看的TOAST表的OID以及 指定值的OID(它的chunk_id
)。为了方便, 指针数据还存储逻辑数据的尺寸(原始的未压缩的数据长度)以及物理存储的尺寸(如果应用了压缩,则两者不同)。 加上变长数据头部的字节,一个磁盘上TOAST指针数据的总尺寸是18字节,不管它代表的值的实际长度是多大。
TOAST管理代码只有在准备向一个表中存储超过TOAST_TUPLE_THRESHOLD
字节(通常是2kB)的行值的时候才会触发。TOAST代码将压缩和/或行外存储域值,直到行值比TOAST_TUPLE_TARGET
字节(通常也是2kB)短,或者无法得到更好的结果的时候才停止。在一个 UPDATE 操作过程中,未改变的域的值通常原样保存; 所以,如果 UPDATE 一个带有线外值的行时,假如线外值没有变化,那么将不会产生TOAST开销。
TOAST代码代码识别四种不同的在磁盘上存储可TOAST列的策略:
PLAIN
避免压缩或者行外存储;而且它禁用变长类型的单字节头部。这是不可TOAST数据类型列的唯一可能的策略。只是对那些不能TOAST的数据类型才有可能。EXTENDED
允许压缩和行外存储。这是大多数可TOAST数据类型的默认策略。 首先将尝试进行压缩,如果行仍然太大,那么则进行行外存储。EXTERNAL
允许行外存储,但是不许压缩。使用EXTERNAL
将令那些在宽text
和bytea
列上的子串操作更快(代价是增加了存储空间), 因此这些操作被优化为只抓取未压缩线外数据中需要的部分。MAIN
允许压缩,但不允许行外存储(实际上,在这样的列上仍然会进行行外存储,但只是作为没有办法把行变得足以放入一页的情况下的最后手段)。
每个可TOAST的数据类型都为该数据类型的列指定了一个缺省策略, 但是一个给定表的列的存储策略可以用ALTER TABLE ... SET STORAGE
修改。
可以使用ALTER TABLE ... SET (toast_tuple_target = N)
为每个表调整TOAST_TUPLE_TARGET
这个方法比那些更直接的方法(比如允许行值跨越多个页面)有更多优点。 假设查询通常是用相对比较短的键值进行匹配的,那么执行器的大多数工作都将使用主行项完成。TOAST过的属性的大值只是在把结果集发送给客户端的时候才被抽出来(如果它被选中)。 因此,主表要小得多,并且它的能放入到共享缓冲区中的行要比没有任何行外存储的方案更多。 排序集也缩小了,并且排序将更多地在内存里完成。一个小测试表明,一个典型的保存 HTML 页面以及它们的 URL 的表占用的存储(包括TOAST表在内)大约只有裸数据的一半,而主表只包含全部数据的 10%(URL和一些小的 HTML 页面)。与在一个非TOAST的对照表里面存储(把全部 HTML 页面裁剪成 7Kb 以匹配页面大小)同样的数据相比,运行时没有任何区别。
行外内存中 TOAST 存储
TOAST指针可以指向不在磁盘上但在当前服务器进程内存中 的数据。这样的指针显然不是长期存在的,但是它们是有用的。当前有两种 子情况:指向间接数据的指针以及指向 扩展数据的指针。
间接TOAST指针指向存储在内存中某个地方的非间接 varlena 值。这种情况仅仅作为一种概念验证而创建,但是当前它被用来在逻辑解码期间 避免创建超过 1GB 的物理元组(把所有线外域值都拉入元组就会这样)。这种 情况用处有限,因为该指针数据的创建者需要负责确保只要指针存在,被引用数 据就应该存在,并且没有其他设施来帮助它。
扩展的TOAST指针对于复杂数据类型有用,这些数据类型的磁盘上 表示形式不是特别适合计算性的目的。例如,一个PostgreSQL 数组的标准 varlena 表达包括了维度信息、一个空值位图(如果有任何空值元素), 然后按顺序是所有元素的值。当元素类型本身是变长时,找到第N
个元素的唯一方式是扫描所有在它前面的元素。这种表达适合于磁盘上的存储,因为它 很紧凑。但是为了对该数组进行计算,则“扩展”或者“结构”表 达会更好,这些表达中所有元素的开始位置都会被标记出来。为了支持这种需要, TOAST指针机制通过允许一个传引用的数据指向一个标准 varlena 值(磁盘上的表达)或者一个TOAST指针指向内存中某处的一个扩展 表达。这种扩展表达的细节取决于数据类型,不过它必须具有一个标准的头部并且符合 src/include/utils/expandeddatum.h
中给定的其他 API 要求。该数据 类型的 C-级别函数可以选择处理任一表达。不了解扩展表达但简单地在其输入上应用PG_DETOAST_DATUM
的函数将自动地接收到传统的 varlena 表达。 因此对于一种扩展表达的支持可以被增量式地引入,一次一个函数。
扩展值的TOAST指针会被进一步分解成 read-write和read-only指针。两种方式下被 指向的表达是相同的,但是收到一个读写指针的函数被允许就地修改被引用值, 而接收到只读指针的函数则不能。如果后者想要做一个该值的被修改的版本, 它必须先创建一个副本。这种区分和一些相关的惯例使得可以在查询执行期间 避免不必要的扩展值副本。
对于所有类型的内存中TOAST指针,TOAST管理 代码会确保这类指针数据不会意外地被存储在磁盘上。在存储之前内存中 TOAST指针会被自动地扩展成通常的线内 varlena 值 — 然后 可能会被转换成磁盘上的TOAST指针(如果包含的元组不是太大)。
TOAST的存储方式
PG只有部分数据类型支持toast,因为有些字段类型是不会产生大字段数据的,完全没必要用到Toast技术(比如int,date,time,boolean等);
支持Toast的数据类型应当是变长的(variable-length);
当表中字段任何一个有Toast,那这个表都会有这一个相关联的Toast表,OID被存储在pg_class.reltoastrelid里面;
超出的的数值将会被分割成chunks,并最多toast_max_chunk_size 个byte(缺省是2Kb)
当存储的行数据超过toast_tuple_threshold值(通常是2kB),就会触发toast存储;
toast将会压缩或者移动字段值直到超出部分比toast_tuple_targer值小(这个值通常也是2KB)。
Toast的4种策略
策略 | 说明 |
---|---|
PLAIN | 避免压缩和行外存储。 只有那些不需要 TOAST 策略就能存放的数据类型允许选择(例如 int 类型),而对于 text 这类要求存储长度超过页大小的类型,是不允许采用此策略的。 |
EXTENDED | 允许压缩和行外存储。 一般会先压缩,如果还是太大,就会行外存储 |
EXTERNA | 允许行外存储,但不许压缩。 类似字符串这种会对数据的一部分进行操作的字段,采用此策略可能获得更高的性能,因为不需要读取出整行数据再解压。 |
MAIN | 允许压缩,但不许行外存储。 不过实际上,为了保证过大数据的存储,行外存储在其它方式(例如压缩)都无法满足需求的情况下,作为最后手段还是会被启动。因此理解为尽量不使用行外存储更贴切。 |
注: