您现在的位置是: 网站首页 > 软件技术  > PostgreSQL 

PostgreSQL内核TOAST机制原理与实现

2025年12月14日 20:36 121人围观

简介TOAST(The Oversized-Attribute Storage Technique)是PostgreSQL的一种自动存储机制,用于处理大字段的存储问题,当表中某行数据的大小超过特定值的时,会以TOAST的形式存放。

TOAST(The Oversized-Attribute Storage Technique)是PostgreSQL的一种自动存储机制,用于处理大字段的存储问题,当表中某行数据的大小超过特定值的时,会以TOAST的形式存放。

引言

通常PostgreSQL的页面大小是固定的8KB,那么对于超过单行长度超过8KB的数据(比如1MB)应该怎么存储?一种办法时将这1MB的数据按照8KB分割,存储到1MB/8KB也就是128个页面。这种方法听起来可行,但实际上不行,主要有以下原因:

  • 事务与 MVCC 的简洁性​ 在 PostgreSQL 的 MVCC 实现中,更新一行是写入新版本,旧版本在页内或可被清理。如果一个行跨页存储,一次更新可能需要修改多个页,这会影响原子性、锁管理、回滚段(pg 没有回滚段,但有类似问题)的复杂性。跨页意味着行版本控制要跨页追踪,WAL 日志、崩溃恢复的复杂性大幅增加。
  • 页内行指针(Line Pointer)​ 每页开头有行指针数组,指向页内的行数据位置。行指针是定长的,只能指向本页内的 offset。跨页存储会破坏这个简单结构。
  • I/O 效率​ 即使只读一行中的一小部分字段,如果要加载多个页,会拖慢普通查询PostgreSQL 的设计是面向 OLTP 场景,希望单行较小,随机访问快。

因此从理论上来说,这种做法是可以的,但在实际上不行,而PostgreSQL采用TOAST(The Oversized-Attribute Storage Technique)​机制来实现大字段存储。

TOAST的存储原理

实际上PostgreSQL有两种大对象存储方法,即TOAST和LO(Large Object),本次只讨论TOAST方法。通常当一行总大小超过 2KB 左右时会自动触发 TOAST 机制,将大字段压缩或线外存储(out-of-line)。

所谓的线外存储,指的是该字段在本行中只留一个TOAST指针(约18字节),实际数据存在一个或多个 TOAST 表中,按chunk(通常 2KB)存储,跨多个页,但逻辑上仍属于这个字段。这样本行(heap tuple)仍然不超过 8KB,保持了行不跨页的约束,同时又支持大字段。

我们可以把用户定义的表叫做主表,而把TOAST存放的表叫TOAST表

TOAST表的结构

上面已经提到TOAST是以chunk的形式存储,实际上除了chunk以外TOAST表上还有一些其他字段。由于有些系统表本身包含TOAST表,因此可以直接如下SQL找一个TOAST表,并用\d查看TOAST表结构(注意的是TOAST表存放在pg_toast schema下,访问时要加上schema名);

select relname from pg_class where relname like '%toast%' and relkind='t' limit 1; 

可以看到,整个TOAST包含3个字段和一个索引,这3个字段含义如下:

  • chunk_id: TOAST对象的唯一标识符,一个chunk_id表示一条被切分的主表数据,比如1MB的数据会切成1MB/2KB条TOAST记录,这些TOAST记录的chunk_id相同,主表上存放的TOAST指针里面包含了chunk_id字段,在扫描某个大字段时,只需要把所有该chunk_id的所有数据扫描出来即可。
  • chunk_seq:当一个字段被TOAST存储时,如果被切成多条了TOAST,则通过chunk_seq的顺序组织数据,确保分片之间的有序。
  • chunk_data:实际存储的数据块,通常越2KB左右

同时可以看到TOAST表在(chunk_id, chunk_seq)建了联合主键索引,这是为了加速查找,首先定位到chunk_id,然后按照chunk_seq扫描该chunk_id下的所有数据块即可。

TOAST表的创建时机

前面我们一直再说超过2KB就会按照TOAST存储,那么什么样的表会创建TOAST表,比如create table t(a varchar(1000))会创建TOAST表吗?

数据存储策略

PostgreSQL中数据的存储类型分4类,不同存储类型对应了不同的存储策略

  • ‌PLAIN‌:不压缩、不线外存储(适用于固定长度类型,如整数)。‌
  • EXTENDED‌:允许压缩和线外存储(大多数可变长类型的默认策略)。‌
  • EXTERNAL‌:允许线外存储但不压缩(适合频繁读取的场景,如大文本字段)。‌
  • MAIN‌:允许压缩和可线外存储(在无法压缩时自动转为线外存储)。‌

对于这4种存储策略来说,MAIN策略是比较特殊的存在,MAIN的特点如下: 1. 优先压缩(与EXTENDED相同) 2. 即使压缩后仍大于TOAST阈值,也尽量尝试存储在主表行内 3. 只有实在放不下(超过页面容量)时才使用TOAST 4. 介于PLAIN和EXTENDED之间的折中方案

MAIN之所以叫MAIN,是有主存的含义,意思就是尽量放在页面内,MAIN先选择压缩,压如果压缩后的MAIN的数据仍然大于2KB,只要能在页内存的下就可以存储,不需要TOAST,因此如果数据访问频繁,且数据介于2KB~8KB,则可以使用MAIN。

由此可见,对于大字段并不是暴力的按照TOAST切分存储,有些存储可能优先选择压缩,毕竟对大字段先压缩后再TOAST也能减少TOAST分分片,对空间都是有利的。通常我们创建的varchar、text等类型都是EXTENDED的,也就是先压缩在存储。

我们可以在创建表时可以指定text b storage main来执行存储策略,或者通过alter table t2 alter column b set storage main;来修改存储策略。 我们分别用EXTENDED和MAIN创建两个表:

然后给两个表分别插入一条2500的随机字符,之所以随机是为了避免压缩,因为字符有规律重复后压缩率会比较高,只有完全随机字符串才能避免被压缩。 我们用pageinspect来查看元组在页面上的存储长度

CREATE EXTENSION IF NOT EXISTS pageinspect; 
SELECT lp_len FROM heap_page_items(get_raw_page('t1', 0)); 
SELECT lp_len FROM heap_page_items(get_raw_page('t2', 0)); 

可以看到,t2表的b字段是MAIN类型,且插入了长度为2500的字符串而并未触发TOAST。

创建TOAST时机

前面我们提到varchar(1000)会不会创建TOAST,我们实际看一下:

可以看到,虽然我们只创建varchar(1000),但仍然创建了TOAST,这是为什么?其实这和计算列的长度有关,具体在函数heapam_relation_needs_toast_table中实现的,该函数计算了表的总列宽用于判断是否需要创建TOAST。

static bool heapam_relation_needs_toast_table(Relation rel) 
{ 
    int32       data_length = 0; 
    bool        maxlength_unknown = false; 
    bool        has_toastable_attrs = false; 
    int32       tuple_length; 

   // 循环判断所有列 
    for (i = 0; i < tupdesc->natts; i++) 
    { 
        Form_pg_attribute att = TupleDescAttr(tupdesc, i); 

        if (att->attisdropped) 
            continue; 
        // 对齐长度 
        data_length = att_align_nominal(data_length, att->attalign); 
        // 固定长度则直接累加 
        if (att->attlen > 0) 
        { 
            /* Fixed-length types are never toastable */ 
            data_length += att->attlen; 
        } 
        else 
        { 
           // 边长类型则计算最大长度,varchar(1000)的1000存放在atttypmod字段 
            int32       maxlen = type_maximum_size(att->atttypid, 
                                                   att->atttypmod); 
            // 未定义的长度,如 text、blob等蕾西,maxlen返回-1 
            if (maxlen < 0) 
                maxlength_unknown = true; 
            else 
                data_length += maxlen; 
            // 除了PLAIN存储策略,其他的存储策略都可能可以TOAST 
            if (att->attstorage != TYPSTORAGE_PLAIN) 
                has_toastable_attrs = true; 
        } 
    } 
    // 都是PLAIN类型,不需要TOAST 
    if (!has_toastable_attrs) 
        return false;           /* nothing to toast? */ 
    // 由未定义长度的类型,创建TOAST 
    if (maxlength_unknown) 
        return true;            /* any unlimited-length attrs? */ 
    // 计算tuple的长度(包括bitmap) 
    tuple_length = MAXALIGN(SizeofHeapTupleHeader + 
                            BITMAPLEN(tupdesc->natts)) + 
        MAXALIGN(data_length); 
    // 如果tuple长度大于TOAST阈值,返回true 
    return (tuple_length > TOAST_TUPLE_THRESHOLD); 
} 

整个计算长度的过程中,关键在于type_maximum_size函数,这个函数又调用了pg_encoding_max_lengthpg_encoding_max_length函数又调用了关键的结构const pg_wchar_tbl pg_wchar_table[]pg_wchar_table中定义了各种编码的最大编码字节。

typedef struct 
{ 
    mb2wchar_with_len_converter mb2wchar_with_len;  /* convert a multibyte 
                                                     * string to a wchar */ 
    wchar2mb_with_len_converter wchar2mb_with_len;  /* convert a wchar string 
                                                     * to a multibyte */ 
    mblen_converter mblen;      /* get byte length of a char */ 
    mbdisplaylen_converter dsplen;  /* get display width of a char */ 
    mbchar_verifier mbverifychar;   /* verify multibyte character */ 
    mbstr_verifier mbverifystr; /* verify multibyte string */ 
    int         maxmblen;       /* max bytes for a char in this encoding */ 
} pg_wchar_tbl; 

可以看到,UTF-8单字符最大需要4字节编码,通过\l可以看到我们的库就是UTF-8编码。因此当我们创建varchar(1000)时,实际存储是按照1000*4长度来计算长度的,这超过了TOAST的阈值,因此胡创建TOAST表。

我们可以在建库的时候指定Encoding为SQL_ASCII,一个字符按照1字节编码,创建varchar(1000)则不会创建TOAST。

create database db1 template template0 encoding 'SQL_ASCII'; 

而只有当我们创建超过2000长度的varchar时才会创建TOAST,如下所示:

因此大家平常所说的长度超过2000就创建TOAST这句话并完全准确,这里的2000指的是字节数,varchar(1000)定义的是字符数,当考虑到字符编码时,字符数和字节数并不相等。

TOAST POINTER

TOAST POINTER对用户是完全透明的,是存储引擎内部使用的结构,但是通过了解TOAST POINTER有助于更深入了解TOAST内幕。首先我们要知道的是,当我们把数据使用TOAST存储时,主表上存的是什么?其实主表上存的就是TOAST POINTER。

在学习TOAST POINTER之前,我们先学习下Postgres中对变长字符串的表示。和绝大部分系统一样,Postgres使用了一个标准头来记录字符串的长度,不同是随着字符串的长度不同,这个头也不相同,不过Postgres抽象了结构体varattrib_1b,如下所示,但是具体的存储需要根据va_header的高或低2bit来判断。

在小端模式下定义如下:

xxxxxx00 4-byte length word, aligned, uncompressed data (up to 1G) 
xxxxxx10 4-byte length word, aligned, *compressed* data (up to 1G) 
00000001 1-byte length word, unaligned, TOAST pointer 
xxxxxxx1 1-byte length word, unaligned, uncompressed data (up to 126b) 

我们把1字节的头叫做短头,4字节的头叫做长头

  • 小于126字节的短串;用create table t3(a text)来创建一张表,插入一条'1234567890'的字符串,通过pageinspect来解析,结果如下:

解析首字节0x17

postgres=# select 0x17::bit(8); 
   bit     
---------- 
 00010111 
(1 row) 

可见最低bit是1,因此

typedef struct 
{ 
    uint8       va_header; 
    char        va_data[FLEXIBLE_ARRAY_MEMBER]; /* Data begins here */ 
} varattrib_1b; 
┌────────────────────────────┐ 
│      0X80 + length         │ ← 1字节首字节标 
├────────────────────────────┤ 
│         va_rawsize         │ ← 4字节 (原始数据大小)  
├────────────────────────────┤ 
│         va_extinfo         │ ← 4字节 (扩展信息) 
├────────────────────────────┤ 
│          va_valueid        │ ← 4字节 (TOAST对象ID)  
├────────────────────────────┤ 
│        va_toastrelid       │ ← 4字节 (TOAST表OID)    
└────────────────────────────┘ 

TOAST插入流程

TOAST的插入函数入口是heap_toast_insert_or_update,这个函数很长,总体来说是在处理3种可以TOAST的数据,使得最终能够在页面存的下,大概可以分为4个步骤:

  • 阶段1: 处理EXTENDED列,尝试压缩 → 如果太大 → 立即TOAST存储
  • 阶段2: 处理EXTERNAL列,不压缩,如果太大 → TOAST存储
  • 阶段3: 处理MAIN列,尝试压缩 → 希望变小后能行内存储
  • 阶段4: 处理压缩后的MAIN列,压缩后仍太大 → TOAST存储

详细的流程如下所示,其实就是按照存储策略优先级,依次处理3中数据,注意的是最后只剩下MAIN时,即使超过了2KB,也不一定会触发TOAST存储,MAIN类型的数据压缩后优先在页内存储,如果压缩后实在存不下(压缩后大于8KB),此时才会把MAIN以TOAST存储。

TOAST表的可见性

整体来说,TOAST不太需要判断可见性,TOAST的可见性完全是跟着主表走的,正常情况下都是先扫描出主表上的TOAST POINTER后再扫描TOAST表。

在返回客户端时,printtup函数会调用text_to_cstring函数来做TOAST数据的还原,通过pg_detoast_datum_packed->detoast_attr最终解析

detoast_attr中通过索引扫描TOAST表,调用HeapTupleSatisfiesToast判断可见性