变长数据类型
对于任意类型的关系型数据库,比如MySQL、PostgreSQL、DB2或Oracle等,其内部均支持可变长的数据类型,它们用于存储文本字符串数据,比如PostgreSQL中的CHARACTER VARYING(n),简写为VARCHAR(n) ,变长类型,其中n指明该变长类型的长度限制,也可不填写n;CHARACTER(n),简写为CHAR(n),没有参数n,则等于CHAR(1),以及TEXT、BYTEA、BLOB、CBLOB等。我们知道,关系型数据库是将用户插入的每一条数据按照行(元组、记录)的方式组织到数据表文件中。
通过将表文件划分为若干个大小为8KB的页面来存储用户插入的每一行数据。但是用户创建的数据表中,极有可能存在大量的文本字段,比如TEXT、VARCHAR等。如果用户插入的某条数据其文本数据类型对应的数据超过了页(8KB)大小,PostgreSQL会如何处理?是选择将该数据存储在表底层的两个页(page)中,还是采取其他的方案来存储数据?
元组不能跨行
PostgreSQL官方文档中曾提到:PostgreSQL数据库不允许元组(行,记录)跨越多个页面(page)存储,所以,它不能直接存储非常大的字段值。对于大字段值,它将被压缩且(或)分解为多个物理行,该技术称为“TOAST”。
所以,如果某条记录中字段值的大小不能存储于一个页中,那么它并不会继续使用其他1个或n个页来存储这条数据,而是采用了其他的技术,比如TOAST或大对象数据存储机制。
注意,TOAST技术仅适用于那些可变长的数据类型,比如VARCHAR、TEXT、JSON和BYTEA等,而对于BLOB、CBLOG等数据类型,它们将使用另外一种方式(规则)来存储,即上面提到的“大对象存储机制”。在PostgreSQL数据库中,总共有两种方式用于存储大(文本)数据对象,分别是:TOAST机制和大对象机制。本文主要讲解TOAST机制,而对于“大对象存储机制”,后面将单独用一篇博文进行讲解。
TOAST技术
所谓TOAST(The Oversized-Attribute Storage Techniques),即超大属性存储技术。它是PostgreSQL的一种机制,用于处理大块数据以适应页面缓冲区。当待插入的数据(元组)超过TOAST_TUPLE_THRESHOLD(默认2KB)时候,PostgreSQL将压缩数据,以适应2KB的缓冲区大小。如果对大列(变长数据类型)项数据压缩没有产生更小的块(即小于2KB),那么该数据将会被分割成更小的块(chunks),然后创建一个TOAST表来存储和管理该元组。
/*
* If the new tuple is too big for storage or contains already toasted
* out-of-line attributes from some other relation, invoke the toaster.
*/
if (relation->rd_rel->relkind != RELKIND_RELATION &&
relation->rd_rel->relkind != RELKIND_MATVIEW)
{
/* toast table entries should never be recursively toasted */
Assert(!HeapTupleHasExternal(tup));
return tup;
}
else if (HeapTupleHasExternal(tup) || tup->t_len > TOAST_TUPLE_THRESHOLD)
//元组大小超过TOAST_TUPLE_THRESHOLD(2KB), 则创建TOAST表
return heap_toast_insert_or_update(relation, tup, NULL, options);
TOAST触发
TOAST机制是自动触发的,当它检测到元组大小超过TOAST_TUPLE_THRESHOLD时候,就会走TOAST逻辑处理段分支,然后根据用户的选择进行对应的插入或更新操作。当然,如上面所描述,只有一些变长数据类型(如:TEXT、JSON、BYTEA、VARCHAR等)才会触发TOAST机制,因为将一个不能产生较大字段值的数据类型(比如VARCHAR(1)、INT等)关联TOAST机制将得不偿失,其收益将远低于TOAST所带来的开销。TOAST机制背后将伴随着大量的逻辑业务判断处理、数据压缩以及TOAST表系列操作。
TOAST表存储策略
在PostgreSQL中,所有的数据库列(字段)都有与它们相关联的(默认)存储策略,这里共有四种不同的存储数据策略,它们分别是:PLAIN、EXTENDED、EXTERNAL和MAIN。各存储策略的解释如下:
(1) PLAIN
不允许压缩和线外存储。对于那些不能TOAST机制的数据类型,默认都是该策略,比如整数类型(INT,SMALLINT,BIGINT)、字符类型(CHAR)、布尔类型(BOOLEAN)等等。
(2) EXTENDED
允许线外存储和压缩。这是大多数可以使用TOAST机制的数据类型默认存储策略。它首先尝试压缩,如果这仍不能将数据放入页中,则使用线外存储。
(3) EXTERNAL
允许线外存储,但不允许压缩。该存储策略将使TEXT、BYTEA数据类型中的子串操作更快,其代价是牺牲存储空间。
(4) MAIN
允许压缩,但不能线外存储。然而在无法使列足够小以存入页时,它将会执行线外存储。所以它仍然是可以线外存储的,和EXTERNAL具有相似性。
存储策略PLAIN、EXTENDED、EXTERNAL和MAIN在PostgreSQL源码中分别被简写为p、e、x和m。通过storage_name函数,分别获取个存储策略所对应的名字。
#define TYPSTORAGE_PLAIN 'p' /* type not prepared for toasting */
#define TYPSTORAGE_EXTERNAL 'e' /* toastable, don't try to compress */
#define TYPSTORAGE_EXTENDED 'x' /* fully toastable */
#define TYPSTORAGE_MAIN 'm' /* like 'x' but try to store inline */
static const char * storage_name(char c)
{
switch (c)
{
case TYPSTORAGE_PLAIN:
return "PLAIN";
case TYPSTORAGE_EXTERNAL:
return "EXTERNAL";
case TYPSTORAGE_EXTENDED:
return "EXTENDED";
case TYPSTORAGE_MAIN:
return "MAIN";
default:
return "???";
}
}
使用psql工具登录postgres,然后创建数据表student,该表共有3个字段,分别是:id(主键, SERIAL类型)、name(姓名,VARCHAR类型)和age(年龄, INT类型)。
test=# CREATE TABLE student(id SERIAL PRIMARY KEY, name VARCHAR, age INT);
CREATE TABLE
在创建该表的时候,并没有显示地指定各字段的存储策略类型。当该表成功创建后,使用:\d+ student查看该表信息时,发现PostgreSQL将student表中的各字段补充了默认对应的存储策略类型。
该表中,字段id和age都是数字类型,所以默认是PLAIN;而字段name是VARCHAR,可变长数据类型,所以它支持TOAST策略,则对应的存储策略是EXTENDED,允许线外存储和压缩。
修改存储策略
可以使用ALTER TABLE table_name ALTER column_name SET STORAGE storage_name;
语句来修改表中各字段的存储策略规则。比如表student中,字段name的存储规则是EXTENDED,现使用ALTER TABLE语句来更改其存储规则为EXTERNAL;
test=# ALTER TABLE student ALTER name SET STORAGE EXTERNAL ;
ALTER TABLE
更改成功后,再次查看数据表student中的name字段存储策略时,已经成功地由EXTENDED改为了EXTERNAL。
不过值得注意的是,你无法将一个不支持TOAST机制的数据类型(列)的存储策略修改为其他。比如字段age是数据类型INT,其默认存储策略是PLAIN,若尝试将其修改为MAIN,则会报错提示。
TOAST表结构
如果一张表中存在可变长数据类型,那么该表将有一个与之关联的TOAST表。TOAST是一张单独的表,专用于存储大块数据的列。比如表student,其中字段name是可变长数据类型,则该字段存在一个与之对应的TOAST表,示意图如下:
我们知道,系统表pg_class中包括当前数据库里的所有数据表,其中每个数据表都在在pg_class中表示为一个元组,且每个数据表被分配一个OID作为该表的唯一标识。很显然,我们的TOAST表也在pg_class中,但是因为不知道TOAST表的名字,所以我们需要借助于表student,来间接获取。
SELECT relname FROM pg_class WHERE oid = (SELECT reltoastrelid FROM pg_class WHERE relname = 'student');
系统表pg_class中, 若该元组(某张表)中有变长数据类型,则字段reltoastrelid将关联该表所对应的TOAST表。因此通过reltoastrelid字段得到TOAST表的唯一OID即可获取到TOAST表名。
现在得到了TOAST表的表名是pg_toast_16436,我们通过TOAST表名反向查询其oid,得到其oid是16440,和表student中的字段reltoastrelid的值是相互对应的。
test=# SELECT oid FROM pg_class WHERE relname = 'pg_toast_16436';
oid
-------
16440
(1 row)
对于TOAST表的命名,其规则是:pg_toast_$(oid)。其中oid是该TOAST表所属表的oid值,比如数据表student在系统表pg_class中的oid是16436,则与之关联的TOAST的表名字是pg_toast_16436。
由于toast表位于pg_toast模式中,所以当要查询一个toast表时候,需要使用如下方式:
SELECT * FROM pg_toast.pg_toast_16436;
如下图所示,由于当前表student为空,所以与之关联的toast表也为空(0行记录)。
我们使用\d+命令来查看该toast( pg_toast.pg_toast_16436)表中的所有可见字段列表,得到该表共有3个字段,即chunk_id、chunk_seq和chunk_data。
字段chunk_id,线外存储时候,为该toast表所分配的oid;字段chunk_seq,块序列号,表明该块数据在toast表中的序列位置;字段chunk_data,存储的实际块数据。
之所以特意强调“可见字段”,那是因为数据表除了用户创建的字段外,还有许多字段是默认隐藏了(没有显示在终端)。比如xmin、cmin、xmax、cmax和ctid等等,关于这些隐藏字段的具体含义,在后面的MVCC(多版本并发控制)博文中进行详细介绍。
在将超过2KB大小的可变长数据存储到toast表中的时候,它会被分成最多包含TOAST_MAX_CHUNK_SIZE数据字节的块(chunk),每个块(chunk)都作为一个元组插入到toast表中。
小试牛刀
现在我们向数据表student中插入一条数据,其中name字段对应的值大小是39KB,这将远大于2KB,因此,它将触发TOAST机制,数据使用LZ压缩算法,并将压缩后的数据分成若干个大小不超过TOAST_MAX_CHUNK_SIZE的块,然后存储于toast表中。
这里的可变长数据是一个JSON报文(使用JSON工具压缩成一行)的字符串。仅列出部分信息,其余部分省略掉。
{"ipAddress":"10.244.0.133","protocolType":"HTTP", "dateTime":"2021-08-31T02:52:13Z", ......//省略}
现在向表student插入该数据,然后再查看pg_toast_16436。
test=# INSERT INTO student (id, name, age) VALUES (2, '{"ipAddress":"10.244.0.133", ......//省略}', 10);
test=# INSERT 0 1
可以看到,当这个超过2KB大小的元组尝试插入表student时,它触发了TOAST超大属性字段存储技术机制,并使用LZ压缩算法对源数据进行压缩和切分。现在该toast表中有6条记录,其中字段chunk_seq的值从0开始递增,一直到5。
字段chunk_data中的数据已经被压缩过了,如下图所示,其中chunk_id分配为16445。
TOAST数据压缩
PostgreSQL中TOAST机制的实现,内部采用了LZ压缩算法,对将要线外存储的数据进行压缩。对于LZ压缩算法的历史版本演变,以及LZ压缩算法内部原理,在LZ77压缩算法原理剖析一文中有非常详细的讲解。