暂无图片
暂无图片
2
暂无图片
暂无图片
暂无图片

PostgreSQL数据库中的TOAST机制

499

如何存储超大字段?

PostgreSQL中数据存储在堆表heap中,堆表一个页为8k,但是如果一个元组的大小非常大或者超过了页大小怎么办呢?你不能强行把这个超大的元组强行放进页中,因为会破坏页的结构,怎么办呢?只能是想办法放在堆表页外的其他地方,这个地方就是Toast表。对应的解决这一问题的相关实现被称为TOAST(The Oversized-Attribute Storage Technique)超尺寸字段存储技巧机制。

如果一个元组超过一定大小,那么就会将部分变长属性值存放在外部toast表中。但是数据页都是有一定大小限制的,toast表中也可能存在某个属性值的大小超过页大小的情况,怎么解决呢?通过分片的形式,将分片大小限制在页size的范围内,然后通过多个分片组合存储,通过多个页就可以存储一个比较大的属性值。那么这个阈值是多少呢?

#define TOAST_TUPLES_PER_PAGE 4 // 期望一个页中能够存储的toast处理后的元组数 #define TOAST_TUPLE_THRESHOLD MaximumBytesPerTuple(TOAST_TUPLES_PER_PAGE) #define TOAST_TUPLE_TARGET TOAST_TUPLE_THRESHOLD

也就是说当元组大小超过TOAST_TUPLE_THRESHOLD的时候就会触发toast处理,toast会尝试将元组压缩或存储在外部toast表中,使其不超过TOAST_TUPLE_TARGET字节。

那么什么时候创建toast表呢?toast表无需用户创建,对用户而言,其感知不到toast表的存在,toast表是在用户创建表时,如果创建的表的属性中有属性是可以toast的(比如text这种变长数据类型),那么将会为该表管理一个toast表,该toast表存储这些超大属性。对于大属性,PG引入了下列多种存储策略:

  • PLAIN:避免压缩和行外存储。只有那些不需要TOAST策略就能存放的数据类型允许选择(例如 int 类型),而对于text这类要求存储长度超过页大小的类型,是不允许采用此策略的。
  • EXTENDED:允许压缩和行外存储。一般会先压缩,如果还是太大,就会行外存储。
  • EXTERNAL:允许行外存储,但不许压缩。类似字符串这种会对数据的一部分进行操作的字段,采用此策略可能获得更高的性能,因为不需要读取出整行数据再解压。
  • MAIN:允许压缩,但不许行外存储。不过实际上,为了保证过大数据的存储,行外存储在其它方式(例如压缩)都无法满足需求的情况下,作为最后手段还是会被启动。因此理解为:尽量不使用行外存储更贴切。

通常,为了保证存储密度,PG一页(大小为8K字节)至少存储四个元组。因此理论上,一个元组大小阈值最大为:(8K字节 - 页面头部)/ 4。进行TOAST存储时,我们依次应用以下算法原则,并在该行不再超过阈值时立即停止:

  1. 首先,我们从“最长”属性到“最短”属性,通过“EXTERNAL”和“EXTENDED”策略来遍历属性。如果EXTENDED属性被压缩(如果有效的话),并且如果值本身超过页面的四分之一,它将立即进入TOAST表。External属性的处理方式相同,但未压缩。
  2. 如果在第一遍之后行版本仍不适合该页面,则将带有“EXTERNAL”和“EXTENDED”策略的其余属性传输到TOAST表。
  3. 如果这也没有帮助,可以尝试使用MAIN策略压缩属性,但将其保留在表的页中。
  4. 在步骤3之后,该行仍然不够短时,MAIN属性才能进入TOAST表。
    有时,更改某些列的策略可能很有用。例如,如果事先知道无法压缩列中的数据,则可以为其设置“EXTERNAL”策略,这样可以避免不必要的压缩尝试,从而节省时间。请注意,TOAST仅适用于表,不适用于索引。这对要索引的键的大小施加了限制。
-- 创建表,含有text类型的属性,将会有一个关联的toast表。 postgres=# create table t3(a int, b text, c text); CREATE TABLE postgres=# \d+ t3 Table "public.t3" Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description --------+---------+-----------+----------+---------+----------+-------------+--------------+------------- a | integer | | | | plain | | | b | text | | | | extended | | | c | text | | | | extended | | | Access method: heap postgres=# \x Expanded display is on. postgres=# select * from pg_class where relname='t3'; -[ RECORD 1 ]-------+-------- oid | 20042 relname | t3 relnamespace | 2200 reltype | 20044 reloftype | 0 relowner | 10 relam | 2 relfilenode | 20042 reltablespace | 0 relpages | 0 reltuples | -1 relallvisible | 0 reltoastrelid | 20045 -- toast表 relhasindex | f relisshared | f relpersistence | p relkind | r relnatts | 3 -- 查看toast表信息 postgres=# select * from pg_class where oid=20045; -[ RECORD 1 ]-------+--------------- oid | 20045 relname | pg_toast_20042 -- toast表名 relnamespace | 99 -- pg_toast命名空间 reltype | 0 reloftype | 0 relowner | 10 relam | 2 relfilenode | 20045 -- 查看toast表结构 postgres=# \d+ pg_toast.pg_toast_20042 TOAST table "pg_toast.pg_toast_20042" Column | Type | Storage ------------+---------+--------- chunk_id | oid | plain -- 为整个toast数据分配的OID chunk_seq | integer | plain -- 序列号,存储该片段在整个TOAST数据中的位置 chunk_data | bytea | plain -- 存储该片段的实际数据 Owning table: "public.t3" Indexes: "pg_toast_20042_index" PRIMARY KEY, btree (chunk_id, chunk_seq) Access method: heap -- 设置字段b为external,不进行压缩,可存储在行外存储中 postgres=# alter table t3 alter b set storage external; ALTER TABLE postgres=# \d+ t3 Table "public.t3" Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Descripti on --------+---------+-----------+----------+---------+----------+-------------+--------------+---------- --- a | integer | | | | plain | | | b | text | | | | external | | | c | text | | | | extended | | | Access method: heap -- 元组大小没有超过阈值,没有触发toast机制,无需放入toast表中,放入toast表是有代价的,并不是extended策略就一定存储在外部 postgres=# insert into t3 values(1, '12345678','12345678'); INSERT 0 1 -- 查看toast表数据 postgres=# select * from pg_toast.pg_toast_20042; chunk_id | chunk_seq | chunk_data ----------+-----------+------------ (0 rows) -- 插入8k数据,超过了阈值,数据会被切片存储到toast表中 postgres=# insert into t3 values(2,repeat('12345678',1024)); INSERT 0 1 -- 查看toast表,发现数据被切片了。1996*+208=8192字节,数据被切片成了5片 postgres=# select chunk_id,chunk_seq,length(chunk_data),left(encode(chunk_data,'escape')::text,8) || '...' || right(encode(chunk_data,'escape')::text, 8) from pg_toast.pg_toast_20042; chunk_id | chunk_seq | length | ?column? ----------+-----------+--------+--------------------- 20048 | 0 | 1996 | 12345678...56781234 20048 | 1 | 1996 | 56781234...12345678 20048 | 2 | 1996 | 12345678...56781234 20048 | 3 | 1996 | 56781234...12345678 20048 | 4 | 208 | 12345678...12345678 (5 rows)

什么时候创建toast表

什么时候创建toast表呢?创建toast表的过程对用户来说是透明无感的,在创建表的时候,如果含有可toast的属性,那么就会在创建表后,创建toast表,并将toast表的oid信息对原表在pg_class.reltoastrelid字段进行更新。

CreateStmt *cstmt = (CreateStmt *) stmt; Datum toast_options; /* 创建表 */ address = DefineRelation(cstmt, RELKIND_RELATION, InvalidOid, NULL, queryString); /* parse and validate reloptions for the toast table */ toast_options = transformRelOptions((Datum) 0,cstmt->options,"toast",validnsps,true,false); (void) heap_reloptions(RELKIND_TOASTVALUE, toast_options, true); /* 创建toast表,并与主表关联,更新主表的pg_class.reltoastrelid */ NewRelationCreateToastTable(address.objectId,toast_options);

toast表结构

在建表时,如果属性是可以toast的,则会为该表创建关联的toast表,并创建一个索引,表名为pg_toast_<reloid>

/* Create the toast table and its index */ snprintf(toast_relname, sizeof(toast_relname), "pg_toast_%u", relOid); snprintf(toast_idxname, sizeof(toast_idxname), "pg_toast_%u_index", relOid);

该表需要存储超大属性值,那么在存储与查询时,怎么知道当前元组的属性值在toast表中的那个位置呢?这就需要我们设计toast表结构:

postgres=# \d+ pg_toast.pg_toast_20042 TOAST table "pg_toast.pg_toast_20042" Column | Type | Storage ------------+---------+--------- chunk_id | oid | plain -- 为整个toast数据分配的OID chunk_seq | integer | plain -- 序列号,存储该片段在整个TOAST数据中的位置 chunk_data | bytea | plain -- 存储该片段的实际数据 Owning table: "public.t3" Indexes: "pg_toast_20042_index" PRIMARY KEY, btree (chunk_id, chunk_seq) Access method: heap

我们通过chunk_id来辨识是属于哪个元组的数据,每个被存储在toast的数据都会被分配一个OID,通过这个OID可以在toast表中找到属于该toast数据的所有片段,进而可以重组该数据。前面讲过为了解决超大数据可能会超过页大小的问题,采用分片存储,将原有的超大数据切分为多个片段chunk,通过chunk_seq为分片分配顺序,将分片数据保存在chunk_data中。这里还有一个问题,那就是分片大小?分片的大小为TOAST_MAX_CHUNK_SIZE字节,每个分片都作为独立的元组存储在toast表中。

#define EXTERN_TUPLES_PER_PAGE 4 /* toast表中一个页中切片的数量,这个值是可以被修改的 */ #define EXTERN_TUPLE_MAX_SIZE MaximumBytesPerTuple(EXTERN_TUPLES_PER_PAGE) /* 每个切片的最大大小 */ #define TOAST_MAX_CHUNK_SIZE \ (EXTERN_TUPLE_MAX_SIZE - \ MAXALIGN(SizeofHeapTupleHeader) - \ sizeof(Oid) - \ sizeof(int32) - \ VARHDRSZ)

那么具体源码实现中,通过varatt_external来表示toast指针。也就是通过这个来定位到toast表数据。该结构体描述了一个存储在toast表中的数据的元信息:

typedef struct varatt_external { int32 va_rawsize; /* 原数据大小(包含头部) */ uint32 va_extinfo; /* 外存储的数据大小,不包含头部和压缩方法 */ Oid va_valueid; /* 在toast表中的唯一OID */ Oid va_toastrelid; /* 存储该值的toast表的relid */ } varatt_external;

总结访问toast表的流程:首先从表的toast属性中获取toast指针,然后通过toast指针找到toast表,再通过toast数据的oid在toast表中找到所有的分片并按序号拼装起来得到完整的toast数据。

元组插入/更新时的TOAST操作

在插入或者更新时,对于含有大字段的列,可能会进行toast处理,在插入时,会判断元组大小tup->t_len是否超过了阈值TOAST_TUPLE_THRESHOLD,如果超过了,则触发TOAST处理,否则不处理。

// 如果元组大小超过了阈值或者包含了来自其他关系已经进行过toast处理的外部属性,则进行toast处理 static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, int options) { // ... 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) return heap_toast_insert_or_update(relation, tup, NULL, options); else return tup; }

调用栈如下:

heap_toast_insert_or_update(Relation rel, HeapTuple newtup, HeapTuple oldtup, int options) (src\backend\access\heap\heaptoast.c:98) heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, int options) (src\backend\access\heap\heapam.c:2323) heap_insert(Relation relation, HeapTuple tup, CommandId cid, int options, BulkInsertState bistate) (src\backend\access\heap\heapam.c:2117) heapam_tuple_insert(Relation relation, TupleTableSlot * slot, CommandId cid, int options, BulkInsertState bistate) (src\backend\access\heap\heapam_handler.c:252) table_tuple_insert(Relation rel, TupleTableSlot * slot, CommandId cid, int options, struct BulkInsertStateData * bistate) (src\include\access\tableam.h:1374) ExecInsert(ModifyTableState * mtstate, ResultRelInfo * resultRelInfo, TupleTableSlot * slot, TupleTableSlot * planSlot, EState * estate, _Bool canSetTag) (src\backend\executor\nodeModifyTable.c:1031)

我们详细看一下heap_toast_insert_or_update函数的实现逻辑:

HeapTuple heap_toast_insert_or_update(Relation rel, HeapTuple newtup, HeapTuple oldtup, int options) { HeapTuple result_tuple; TupleDesc tupleDesc; int numAttrs; bool toast_isnull[MaxHeapAttributeNumber]; Datum toast_values[MaxHeapAttributeNumber]; ToastTupleContext ttc; // 将元组数据提取到values数组中 heap_deform_tuple(newtup, tupleDesc, toast_values, toast_isnull); // 初始化ToastTupleContext toast_tuple_init(&ttc); /* * 压缩和线外存储直到元组长度符合要求 * 1. 对EXTENDED的属性进行压缩,将非常大的EXTENED或EXTERNAL的属性进行线外存储 * 2. 将EXTENDED或EXTERNAL的属性进行线外存储 * 3. 对MAIN的属性进行压缩 * 4. 如果还不满足则将MAIN的属性进行线外存储 */ // 计算元组的大小,如果元组大小大于阈值,就一直进行,直到其他条件不满足 // 1. 对EXTENDED的属性进行压缩,将非常大的EXTENED或EXTERNAL的属性进行线外存储 while (heap_compute_data_size(tupleDesc, toast_values, toast_isnull) > maxDataLen) { // 找到最大的属性 int biggest_attno = toast_tuple_find_biggest_attribute(&ttc, true, false); if (biggest_attno < 0) break; /* 如果属性为EXTENDED,则先尝试进行压缩 */ if (TupleDescAttr(tupleDesc, biggest_attno)->attstorage == TYPSTORAGE_EXTENDED) toast_tuple_try_compression(&ttc, biggest_attno); else { /* 如果是EXTERNAL, 则不进行压缩,跳过压缩 */ toast_attr[biggest_attno].tai_colflags |= TOASTCOL_INCOMPRESSIBLE; } /* 如果该属性(压缩后,如有)本身就超过了阈值,则立即进行线外存储 * 这样可以避免在只有一个长字段和几个短字段的元组时,对其他字段进行无意义的压缩 */ if (toast_attr[biggest_attno].tai_size > maxDataLen && rel->rd_rel->reltoastrelid != InvalidOid) toast_tuple_externalize(&ttc, biggest_attno, options); } /* 2. 对EXTENDED或EXTERNAL的属性进行线外存储 */ while (heap_compute_data_size(tupleDesc,toast_values, toast_isnull) > maxDataLen && rel->rd_rel->reltoastrelid != InvalidOid) { int biggest_attno = toast_tuple_find_biggest_attribute(&ttc, false, false); if (biggest_attno < 0) break; toast_tuple_externalize(&ttc, biggest_attno, options); } /* 3. 对MAIN属性进行压缩 */ while (heap_compute_data_size(tupleDesc, toast_values, toast_isnull) > maxDataLen) { int biggest_attno = toast_tuple_find_biggest_attribute(&ttc, true, true); if (biggest_attno < 0) break; toast_tuple_try_compression(&ttc, biggest_attno); } /* 4. 如果还没有满足要求,则对MAIN进行线外存储 */ while (heap_compute_data_size(tupleDesc, toast_values, toast_isnull) > maxDataLen && rel->rd_rel->reltoastrelid != InvalidOid) { int biggest_attno = toast_tuple_find_biggest_attribute(&ttc, false, true); if (biggest_attno < 0) break; toast_tuple_externalize(&ttc, biggest_attno, options); } // 如果需要进行线外存储,则构造新的tuple if ((ttc.ttc_flags & TOAST_NEEDS_CHANGE) != 0) { // ... heap_fill_tuple(tupleDesc,toast_values,toast_isnull,(char *) new_data + new_header_len,new_data_len,&(new_data->t_infomask),((ttc.ttc_flags & TOAST_HAS_NULLS) != 0) ?new_data->t_bits : NULL); } else result_tuple = newtup; toast_tuple_cleanup(&ttc); return result_tuple; }

那么具体的怎么将线外存储的数据存储到toast表中的呢?我们看一下toast_tuple_externalize函数的实现:

void toast_tuple_externalize(ToastTupleContext *ttc, int attribute, int options) { Datum *value = &ttc->ttc_values[attribute]; Datum old_value = *value; ToastAttrInfo *attr = &ttc->ttc_attr[attribute]; attr->tai_colflags |= TOASTCOL_IGNORE; *value = toast_save_datum(ttc->ttc_rel, old_value, attr->tai_oldexternal, options); if ((attr->tai_colflags & TOASTCOL_NEEDS_FREE) != 0) pfree(DatumGetPointer(old_value)); attr->tai_colflags |= TOASTCOL_NEEDS_FREE; ttc->ttc_flags |= (TOAST_NEEDS_CHANGE | TOAST_NEEDS_FREE); }

其实现具体在toast_save_datum函数,将一个大字段分割成片(chunk),插入到toast表,并返回一个外部存储指针作为新的字段值

Datum toast_save_datum(Relation rel, Datum value, struct varlena *oldexternal, int options) { // ... struct varatt_external toast_pointer; union { struct varlena hdr; /* this is to make the union big enough for a chunk: */ char data[TOAST_MAX_CHUNK_SIZE + VARHDRSZ]; /* ensure union is aligned well enough: */ int32 align_it; } chunk_data; /* 打开toast表以及索引 */ toastrel = table_open(rel->rd_rel->reltoastrelid, RowExclusiveLock); validIndex = toast_open_indexes(toastrel, RowExclusiveLock, &toastidxs, &num_indexes); // ... /* 设置value OID, */ if (!OidIsValid(rel->rd_toastoid)) { /* normal case: just choose an unused OID */ toast_pointer.va_valueid = GetNewOidWithIndex(toastrel, RelationGetRelid(toastidxs[validIndex]), (AttrNumber) 1); } else { // ... if (toast_pointer.va_valueid == InvalidOid) { // 找到一个未使用的不与已存在的value OID冲突的OID do { toast_pointer.va_valueid = GetNewOidWithIndex(toastrel, RelationGetRelid(toastidxs[validIndex]), (AttrNumber) 1); } while (toastid_valueid_exists(rel->rd_toastoid, toast_pointer.va_valueid)); } } /* Initialize constant parts of the tuple data */ t_values[0] = ObjectIdGetDatum(toast_pointer.va_valueid); t_values[2] = PointerGetDatum(&chunk_data); /* 分片chunk插入toast表中,一个分片chunk就是toast表的一个tuple */ while (data_todo > 0) { int i; /* Calculate the size of this chunk */ chunk_size = Min(TOAST_MAX_CHUNK_SIZE, data_todo); /* 构造一个分片chunk tuple */ t_values[1] = Int32GetDatum(chunk_seq++); SET_VARSIZE(&chunk_data, chunk_size + VARHDRSZ); memcpy(VARDATA(&chunk_data), data_p, chunk_size); toasttup = heap_form_tuple(toasttupDesc, t_values, t_isnull); heap_insert(toastrel, toasttup, mycid, options, NULL); /* 插入toast表 */ // 插入索引 for (i = 0; i < num_indexes; i++) { /* Only index relations marked as ready can be updated */ if (toastidxs[i]->rd_index->indisready) index_insert(toastidxs[i], t_values, t_isnull, &(toasttup->t_self), toastrel, toastidxs[i]->rd_index->indisunique ? UNIQUE_CHECK_YES : UNIQUE_CHECK_NO, false, NULL); } /* Free memory */ heap_freetuple(toasttup); /* 移动到下一个分片chunk */ data_todo -= chunk_size; data_p += chunk_size; } /* 关闭索引以及toast表 */ toast_close_indexes(toastidxs, num_indexes, NoLock); table_close(toastrel, NoLock); /* 构造toast指针,返回 */ result = (struct varlena *) palloc(TOAST_POINTER_SIZE); SET_VARTAG_EXTERNAL(result, VARTAG_ONDISK); memcpy(VARDATA_EXTERNAL(result), &toast_pointer, sizeof(toast_pointer)); return PointerGetDatum(result); }

读取toast数据

在查询涉及被toast过的数据时,需要读取toast数据并进行恢复(解压缩、将分片进行组装为完整数据)。读取的过程其实就是插入的逆过程,根据toast属性中存储的toast指针,从toast表中读取所有分片,再根据序号将分片组装为完整数据,再判断是否为经过压缩的数据,如果是则需要进行解压缩。
具体可分析detoast_attr函数。这里不在详细进行分析。

toast_decompress_datum(struct varlena * attr) (src\backend\access\common\detoast.c:485) detoast_attr(struct varlena * attr) (src\backend\access\common\detoast.c:173) pg_detoast_datum_packed(struct varlena * datum) (src\backend\utils\fmgr\fmgr.c:1757) text_to_cstring(const text * t) (src\backend\utils\adt\varlena.c:225) textout(FunctionCallInfo fcinfo) (src\backend\utils\adt\varlena.c:574) FunctionCall1Coll(FmgrInfo * flinfo, Oid collation, Datum arg1) (src\backend\utils\fmgr\fmgr.c:1138) OutputFunctionCall(FmgrInfo * flinfo, Datum val) (src\backend\utils\fmgr\fmgr.c:1575) printtup(TupleTableSlot * slot, DestReceiver * self) (src\backend\access\common\printtup.c:357) ExecutePlan(QueryDesc * queryDesc, CmdType operation, _Bool sendTuples, uint64 numberTuples, ScanDirection direction, DestReceiver * dest) (src\backend\executor\execMain.c:1586) standard_ExecutorRun(QueryDesc * queryDesc, ScanDirection direction, uint64 count, _Bool execute_once) (src\backend\executor\execMain.c:360) ExecutorRun(QueryDesc * queryDesc, ScanDirection direction, uint64 count, _Bool execute_once) (src\backend\executor\execMain.c:309) PortalRunSelect(Portal portal, _Bool forward, long count, DestReceiver * dest) (src\backend\tcop\pquery.c:919) PortalRun(Portal portal, long count, _Bool isTopLevel, _Bool run_once, DestReceiver * dest, DestReceiver * altdest, QueryCompletion * qc) (src\backend\tcop\pquery.c:763) exec_simple_query(const char * query_string) (src\backend\tcop\postgres.c:1217)
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

文章被以下合辑收录

评论