PostgreSQL中的The Oversized-Attribute Storage Technique(TOAST:超大属性存储技术)

PostgreSQL使用固定的页面大小(通常为8kB),并且不允许元组跨越多个页面。因此,不可能直接存储非常大的字段值。为了克服这种限制,将大字段值压缩和/或分解成多个物理行。这对用户来说是透明的,对大多数后端代码只有很小的影响。该技术被称为TOAST。TOAST架构还用于改进内存中大数据值的处理。

只有某些数据类型支持TOAST - 不需要对无法生成大字段值的数据类型施加开销。为了支持TOAST,数据类型必须具有可变长度(varlena)表示,其中,通常,任何存储值的第一个四字节字包含以字节为单位的值的总长度(包括其自身)。TOAST不限制数据类型其余部分的表示。被TOAST的值的特殊的表示形式,是通过修改或者重新解释此初始长度字。因此,支持可以被TOAST的数据类型的C级别的函数必须注意它们如何处理可能的被TOAST的输入值:输入实际上可能不包含四字节长度的word和内容,直到它被TOAST解释。(这通常通过在对输入值执行任何操作之前调用PG_DETOAST_DATUM来完成,但在某些情况下可以采用更有效的方法)

TOAST使用varlena length word的两个bit (大端机器的高位,小端机器的低位), 从而将可以被TOAST操作的数据类型的任何值的逻辑大小限制为1GB(2的30次方-1 bytes)。

当两个bit都为零时,该值是一个普通的非TOAST的数据类型的值,长度字的其余位以字节为单位给出总数据大小(包括长度字自身)。当设置最高位或最低位时,该值仅具有单字节头而不是正常的四字节头,并且该字节的其余位以字节为单位给出总数据大小(包括长度字节自身)。此备选方案支持空间高效存储短于127字节的值,同时仍允许数据类型在需要时增长到1GB。具有单字节头的值不在任何特定边界上对齐,而具有四字节头的值在至少四字节边界上对齐;与短值相比,省略对齐填充提供了额外的空间节省。作为特殊情况,如果单字节头的剩余位全部为零(对于自包含长度而言是不可能的),则该值是指向外部数据的指针,具有如下面所描述的几种可能的替代方案。这种TOAST指针的类型和大小由存储在数据的第二个字节中的代码确定。最后,当最高位或最低位清零但相邻位置位时,数据的内容已被压缩,必须在使用前解压缩。在这种情况下,四字节长度字的剩余位给出压缩数据的总大小,而不是原始数据。请注意,对于外部数据也可以进行压缩,但是可变长头不会告诉它是否已经发生 - 而是TOAST指针的内容来告诉。

如上所述,有多种类型的TOAST指针datums。最老的和最常见的类型是指向存储在TOAST表中的外部数据的指针,该表与包含TOAST指针数据本身的表分开但与之相关联。当要存储在磁盘上的元组太大而无法按原样存储时,这些磁盘上的指针基准由TOAST管理代码(在access / heap / tuptoaster.c中)创建。更多细节见第68.2.1节。或者,TOAST指针datum可以包含指向内存中其他位置的out-of-line的指针。这些datums必然是短暂的,并且永远不会出现在磁盘上,但它们对于避免复制和冗余处理大数据值非常有用。

用于in-line或out-of-line压缩数据的压缩技术是LZ系列压缩技术中相当简单且非常快速的成员。有关详细信息,请参阅src/common/pg_lzcompress.c。

Out-of-line, on-disk TOAST storage

如果表的任意列是TOAST-able,则该表将具有关联的TOAST表,其OID存储在表的pg_class.reltoastrelid条目中。

Out-of-line值(在压缩后使用)划分为最多TOAST_MAX_CHUNK_SIZE个字节的块(默认情况下,选择此值使得四个chunk行适合一个页面,使其大约为2000个字节)。每个chunk都作为TOAST表中的单独行存储。每个TOAST表都有列chunk_id(标识特定TOASTed值的OID),chunk_seq(其值中的块的序列号)和chunk_data(块的实际数据)。chunk_id和chunk_seq上的唯一索引提供了对值的快速检索。因此,表示out-of-line基于磁盘TOASTed值的指针datum需要存储要查看的TOAST表的OID以及特定值的OID(其chunk_id)。为方便起见,指针datum还存储逻辑datum大小(原始未压缩数据长度)和物理存储大小(如果应用压缩则不同)。因此,允许可变长的头字节,基于磁盘的TOAST指针数据的总大小为18字节,而不管所表示的值的实际大小。

仅当要存储在表中的行值宽于TOAST_TUPLE_THRESHOLD字节(通常为2kB)时,才会触发TOAST管理代码。TOAST代码将压缩和/或移动字段值out-of-line,直到行值短于TOAST_TUPLE_TARGET字节(通常也是2kB,可调)或者不能获得更多增益。在UPDATE操作期间,未更改字段的值通常保持原样; 因此,如果没有任何out-of-line值发生更改,则具有out-of-line值的行的更新不会产生任何TOAST成本。

TOAST管理代码识别用于在磁盘上存储TOAST-able列的四种不同策略:
1.PLAIN:禁止压缩或out-of-line存储;对于可变长类型,禁止使用单字节头部。对于非TOAST数据类型列,这是唯一可行的策略。
2.EXTENDED:支持压缩或out-of-line存储。这是大多数支持TOAST数据类型的默认值。首先尝试压缩,然后在行仍然太大的情况下进行out-of-line存储。
3.EXTERNAL:允许out-of-line存储但不允许压缩。使用EXTERNAL将使宽文本和bytea列上的子字符串操作更快(以增加的存储空间为代价),因为这些操作被优化为在未压缩时仅获取外部值的所需部分。
4.MAIN:允许压缩但不允许out-of-line存储。(实际上,仍然会为这些列执行out-of-line存储,但只有在没有其他方法使行足够小以适合页面时才作为最后的手段)

每个TOAST-able数据类型为该数据类型的列指定默认策略,但是可以使用ALTER TABLE ... SET STORAGE更改给定表列的策略。

可以使用ALTER TABLE ... SET(toast_tuple_target = N)为每个表调整TOAST_TUPLE_TARGET

与更简单的方法(例如允许行值跨越页面)相比,该方案具有许多优点。假设查询通常通过与相对较小的键值进行比较来限定,执行程序的大部分工作将使用main row entry完成。TOASTed属性的大值只会在结果集发送到客户端时被拉出(如果选中的话)。因此,与没有任何out-of-line存储的情况相比,主表更小并且其更多行适合共享缓冲区高速缓存。排序集也缩小,排序通常完全在内存中完成。一个小小的测试显示,包含典型HTML页面及其URL的表格存储在大约一半的原始数据大小(包括TOAST表)中,并且主表仅包含大约10%的整个数据(URL和一些小的HTML)页)。与un-TOAST比较表相比,没有运行时间差异,其中所有HTML页面都被削减到7kB以适应。

Out-of-line, in-memory TOAST storage

TOAST指针可以指向不在磁盘上,但在当前服务器进程的内存中的其他位置的数据。这样的指针显然不能长久存在,但它们仍然有用。目前有两个子案例:指向间接数据的指针和指向扩展数据的指针。

间接TOAST指针只是指向存储在内存中的非间接可变长值。此案例最初仅作为概念证明创建,但它目前在逻辑解码期间使用,以避免可能必须创建超过1 GB的物理元组(因为将所有out-of-line字段值拉入元组可能会这样做)。该案例的用途有限,因为指针数据的创建者完全负责所引用的数据只要指针存在就能存活,并且没有基础设施可以帮助解决这个问题。

扩展的TOAST指针对于复杂数据类型非常有用,这些数据类型的磁盘表示不是特别适合计算目的。例如,PostgreSQL数组的标准可变长表示包括维度信息,如果有任何null元素则有nulls位图,然后是所有元素的值。当元素类型本身是可变长度时,找到第N个元素的唯一方法是扫描所有前面的元素。由于其紧凑性,这种表示适用于磁盘存储,但是对于具有数组的计算,具有“扩展的”或“解构的”表示更好,其中已经识别了所有元素起始位置。TOAST指针机制通过允许传递引用的Datum指向标准的可变长值(磁盘上表示)或指向内存中某个扩展表示的TOAST指针来支持这种需要。此扩展表示的详细信息取决于数据类型,但它必须具有标准标头并满足src/include/utils/expandeddatum.h中给出的其他API要求。使用数据类型的C级函数可以选择处理任一表示。不了解扩展表示但只是将PG_DETOAST_DATUM应用于其输入的函数将自动接收传统的可变长表示;因此,可以逐步引入对扩展表示的支持,一次一个函数。

TOAST指向扩展值的指针进一步细分为读写指针和只读指针。指向的表示方式是相同的,但是接收读写指针的函数允许就地修改引用的值,而接收只读指针的函数则不能; 它必须首先创建一个副本,如果它想要修改该值的版本。这种区别和一些相关的约定使得可以避免在查询执行期间不必要地复制扩展值。

对于所有类型的内存TOAST指针,TOAST管理代码确保不会意外地将这样的指针datum存储在磁盘上。内存TOAST指针在存储之前自动扩展为正常的in-line可变长值 - 然后可能转换为磁盘上的TOAST指针,否则包含的元组会过大。

示例学习:

首先创建一张表

postgres=# create table t_toast(id int, title text, contents text);
CREATE TABLE
postgres=# d t_toast
               Table "public.t_toast"
  Column  |  Type   | Collation | Nullable | Default 
----------+---------+-----------+----------+---------
 id       | integer |           |          | 
 title    | text    |           |          | 
 contents | text    |           |          | 

postgres=# d+ t_toast
                                   Table "public.t_toast"
  Column  |  Type   | Collation | Nullable | Default | Storage  | Stats target | Description 
----------+---------+-----------+----------+---------+----------+--------------+-------------
 id       | integer |           |          |         | plain    |              | 
 title    | text    |           |          |         | extended |              | 
 contents | text    |           |          |         | extended |              | 

postgres=# 

可以看到,interger默认的TOAST策略为plain;而text默认的TOAST策略为extended。
如果表中有字段需要TOAST,那么系统会自动创建一张TOAST表负责out-of-line存储,那么这张表在哪里?

postgres=# select relname,relfilenode,reltoastrelid from pg_class where relname='t_toast';
 relname | relfilenode | reltoastrelid 
---------+-------------+---------------
 t_toast |       24736 |         24739
(1 row)

postgres=# 

通过上面的语句可以查到t_toast表的oid为24736,其对应TOAST表的oid为24739,那么其对应TOAST表名则为:pg_toast.pg_toast_24736(注意这里是t_toast表的oid),我们看下其定义:

postgres=# d pg_toast.pg_toast_24736;
TOAST table "pg_toast.pg_toast_24736"
   Column   |  Type   
------------+---------
 chunk_id   | oid
 chunk_seq  | integer
 chunk_data | bytea

postgres=# d+ pg_toast.pg_toast_24736;
TOAST table "pg_toast.pg_toast_24736"
   Column   |  Type   | Storage 
------------+---------+---------
 chunk_id   | oid     | plain
 chunk_seq  | integer | plain
 chunk_data | bytea   | plain

postgres=# 

TOAST 表有3个字段:
·chunk_id:用来表示特定TOAST值的OID,可以理解为具有同样chunk_id值的所有行组成原表(这里的t_toast)的TOAST字段的一行数据
·chunk_seq:用来表示该行数据在整个数据中的位置
·chunk_data:实际存储的数据。

现在来实际验证下:

postgres=# insert into t_toast values(1, 'title', '1234567890');
INSERT 0 1
postgres=# select * from pg_toast.pg_toast_24736;
 chunk_id | chunk_seq | chunk_data 
----------+-----------+------------
(0 rows)

postgres=# 

可以看到因为contents只有10个字符,所以没有压缩,也没有out-of-line存储。然后我们使用如下SQL语句增加contents的长度,每次增长1倍,同时观察contents的长度,看看会发生什么情况:

postgres=# update t_toast set contents=contents||contents where id=1;
UPDATE 1
postgres=# select id,title,length(contents) from t_toast;
 id | title | length 
----+-------+--------
  1 | title |     20
(1 row)

postgres=# select * from pg_toast.pg_toast_24736;
 chunk_id | chunk_seq | chunk_data 
----------+-----------+------------
(0 rows)

postgres=# 

反复执行如上过程,直到pg_toast_24736表中有数据:

postgres=# update t_toast set contents=contents||contents where id=1;
UPDATE 1
postgres=# select id,title,length(contents) from t_toast;
 id | title | length 
----+-------+--------
  1 | title | 327680
(1 row)

postgres=# select chunk_id,chunk_seq,length(chunk_data) from pg_toast.pg_toast_24736;
 chunk_id | chunk_seq | length 
----------+-----------+--------
    24742 |         0 |   1996
    24742 |         1 |   1773
(2 rows)

postgres=# 

可以看到,直到contents的长度为327680时(已远远超过页大小8K),对应TOAST表中才有了2行数据,且长度都是略小于2K,这是因为extended策略下,先启用了压缩,然后才使用out-of-line存储。

下面我们将contents的TOAST策略改为EXTERNA,禁止压缩。

postgres=# alter table t_toast alter contents set storage external;
ALTER TABLE
postgres=# d t_toast
               Table "public.t_toast"
  Column  |  Type   | Collation | Nullable | Default 
----------+---------+-----------+----------+---------
 id       | integer |           |          | 
 title    | text    |           |          | 
 contents | text    |           |          | 

postgres=# d+ t_toast
                                   Table "public.t_toast"
  Column  |  Type   | Collation | Nullable | Default | Storage  | Stats target | Description 
----------+---------+-----------+----------+---------+----------+--------------+-------------
 id       | integer |           |          |         | plain    |              | 
 title    | text    |           |          |         | extended |              | 
 contents | text    |           |          |         | external |              | 

postgres=# 

然后再插入一条数据:

postgres=# insert into t_toast values(2, 'title', '1234567890');
INSERT 0 1
postgres=# select id,title,length(contents) from t_toast;
 id | title | length 
----+-------+--------
  1 | title | 327680
  1 | title |     10
(2 rows)

postgres=# 

然后重复以下步骤,直到TOAST表中产生新的行:

postgres=# update t_toast set contents=contents||contents where id=2;
UPDATE 1
postgres=# select id,title,length(contents) from t_toast;
 id | title | length 
----+-------+--------
  2 | title |   2560
  1 | title | 327680
(2 rows)

postgres=# select chunk_id,chunk_seq,length(chunk_data) from pg_toast.pg_toast_24736;
 chunk_id | chunk_seq | length 
----------+-----------+--------
    24742 |         0 |   1996
    24742 |         1 |   1773
    24743 |         0 |   1996
    24743 |         1 |    564
(4 rows)

postgres=# 

这次可以看到当contents长度达到2560(按照官方文档,应该是超过2KB左右),TOAST表中产生了新的2条chunk_id为24743的行,且2行数据的chunk_data的长度之和正好等于2560。通过以上操作得出以下结论:
·如果策略允许压缩,则TOAST优先选择压缩;
·不管是否压缩,一旦数据超过2KB左右,就会启用out-of-line存储;
·修改TOAST策略,不会影响现有数据的存储方式。

原文地址:https://www.cnblogs.com/abclife/p/11341248.html