데이터베이스의 저장 단위, 페이지
일단 TOAST를 이해하기에 앞서 데이터베이스의 단위 중 페이지에 대하여 이해해야 합니다. 페이지는 데이터베이스에서 고정 크기의 블록이며(일반적으로 8KB), 이 페이지에는 하나 이상의 행을 포함할 수 있습니다. 그래서 페이지의 헤더에는 페이지에 있는 행의 정보들을 포함하게 됩니다. 여기서 각 페이지는 물리적으로는 다른 공간에 있기 때문에 하나의 행을 여러 페이지에 걸쳐서 저장하지 못합니다. 그렇다면 8KB를 넘는 큰 데이터를 가진 행을 저장하는 기술이 필요합니다. 이번에는 PostgreSQL에서 한 컬럼에 큰 데이터를 저장하는 기술인 TOAST에 대하여 이야기해보려 합니다.
Reference
TOAST(The Oversized-Attributed Storage Technique)
TOAST는 대용량 데이터를 효율적으로 저장하고 사용하기 위해서 PostgreSQL에서 사용되는 기술입니다. 앞서 말했듯이 하나의 페이지에 페이지보다 큰 행을 저장할 수 없기 때문에 PostgreSQL은 TOAST 메커니즘을 이용합니다. TOAST의 동작은 행을 압축하여 저장하거나 데이터를 여러 물리적인 행으로 분할하고 압축합니다. 데이터가 TOAST_TUPLE_THRESHOLD (보통 2KB)보다 큰 경우에 행 압축을 먼저 시도합니다. 만약 압축한 행이 TOAST_TUPLE_THRESHOLD 를 초과하면 큰 필드에 대하여 기존의 테이블이 아닌 별개의 TOAST 테이블에 분할하여 저장합니다. 기존의 테이블에는 TOAST 테이블에 분할된 행들을 가르키는 TOAST 포인터로 대체됩니다. 이렇게 메인 테이블이 아닌 다른 곳에 저장하는 방식을 out-of-line이라고 합니다.
데이터 타입과 TOAST
TOAST가 적용되는지 여부는 데이터 타입에 따라 결정됩니다. 데이터 타입 중 가변길이(variable-length)가 가능한 text나 varchar, bytea와 같은 데이터 타입을 TOAST-able (TOAST 적용 가능)하다고 표현합니다. 고정적인 길이를 가진 integer, boolean과 같은 데이터 타입에 대해서는 TOAST를 적용할 필요가 없기 때문에 오버헤드를 발생시키지 않도록 TOAST를 적용하지 않습니다.
TOAST 테이블
Reference
- https://github.com/postgres/postgres/blob/master/src/backend/catalog/toasting.c
- https://github.com/postgres/postgres/blob/master/src/backend/access/common/toast_internals.c
- https://github.com/postgres/postgres/blob/master/src/include/access/heaptoast.h
테이블의 컬럼 중 하나라도 TOAST가 가능한 테이블은 TOAST 테이블을 생성합니다. 카탈로그에 해당 테이블은 pg_class.reltoastrelid
정보를 통해 TOAST 테이블의 OID를 확인할 수 있습니다.
TOAST 테이블은 pg_toast.pg_toast_테이블OID
이름으로 생성되고, 메인 테이블의 큰 컬럼 데이터를 여러 chunk라는 단위로 단일 또는 여러 행에 걸쳐 저장됩니다.
TOAST 테이블은 3개의 컬럼을 가집니다.
- chunk_id : 특정 TOASTed 값을 식별하는 OID
- chunk_seq : 같은 chunk id를 가진 행끼리의 순차적인 번호
- chunk_data : chunk가 가진 실제 데이터
데이터인 chunk_data는 TOAST_MAX_CHUNK_SIZE
크기(byte)로 나눠지게 됩니다. 한 페이지에 4개의 행을 사용하며, 각 행마다 헤더(TOAST 헤더)를 제외한 값을 가집니다.(블록사이즈가 8KB라면 chunk의 사이즈는 대략 2000바이트에 가깝습니다.)
#define EXTERN_TUPLES_PER_PAGE 4 /* tweak only this */
#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)
이해를 돕기 위해 예제로 text 데이터 타입이 있는 test_toast 테이블을 만들어보겠습니다.
다음과 같이 PostgreSQL은 메인 테이블의 OID와 같이 TOAST 테이블의 OID를 카탈로그에 저장합니다. TOAST 테이블에 chunk에 대한 id, seq, data로 이루어져있고, 현재 메인 테이블에 chunk로 분할할 데이터가 없어 구조만 있는 상태인 것을 볼 수 있습니다.
postgres=# CREATE TABLE test_toast (
id serial primary key,
long_text text
);postgres=# SELECT relname, relfilenode, reltoastrelid FROM pg_class
WHERE relname = ‘test_toast’;
-[ RECORD 1 ]-+———–
relname | test_toast
relfilenode | 26417
reltoastrelid | 26421postgres=# d pg_toast.pg_toast_26417
TOAST table “pg_toast.pg_toast_26417”
Column | Type
————+———
chunk_id | oid
chunk_seq | integer
chunk_data | bytea
Owning table: “public.test_toast”
Indexes:
“pg_toast_26417_index” PRIMARY KEY, btree (chunk_id, chunk_seq)postgres=# SELECT * from pg_toast.pg_toast_26417;
(0 rows)
그럼 ‘long_text’ 컬럼에 하나의 큰 값을 넣어 봅시다. TOAST 테이블에 여러 개의 chunk로 나누어져 데이터가 저장된 것을 확인할 수 있습니다. 그리고 기존의 메인 테이블인 test_toast 테이블은 TOAST 포인터 정보를 가지게 됩니다. raw 데이터로 조회하면 대부분 0으로 TOAST 포인터의 값을 제외한 나머지 데이터가 대부분 비어있는 것을 확인할 수 있습니다.(해당 함수는 contrib extension인 pageinspect를 설치해야합니다.)
postgres=# INSERT INTO test_toast (long_text)
VALUES (
repeat(‘A’, 1000000)
);postgres=# select chunk_id, chunk_seq from pg_toast.pg_toast_26417;
chunk_id | chunk_seq
———-+———–
16403 | 0
16403 | 1
16403 | 2
16403 | 3
16403 | 4
16403 | 5postgres=# select * from get_raw_page(‘public.test_toast’, 0);
get_raw_page
————————————
x00000000903f930b000000002400701f0020042000000000d
09f5c00a09f5c00709f5c000000000000000000000000000000
000000000000000000000000000000000(생략)
(1 row)
TOAST 포인터 및 인덱스
Reference
메인 테이블의 큰 열 데이터는 TOAST 테이블에 나누어진 chunk 데이터를 포인팅하기 위해 TOAST 포인터를 가지고 있습니다.
TOAST 포인터의 구조는 varatt.h에서 확인할 수 있습니다.
typedef struct varatt_external
{
int32 va_rawsize; /* Original data size (includes header) */
uint32 va_extinfo; / External saved size (without header) and
* compression method */
Oid va_valueid; / Unique ID of value within TOAST table */
Oid va_toastrelid; / RelID of TOAST table containing it */
}
TOAST 포인터는 조회할 TOAST 테이블의 OID와 chunk의 특정값(chunk_id)의 OID를 저장하고 있습니다. 그리고 TOAST 포인터는 원래 압축되지 않은 데이터의 길이(va_rawsize), 물리적 저장크기(압축이 적용된 경우 길이) 및 사용된 압축 방법(va_extinfo)도 저장합니다.
그래서 TOAST 테이블의 인덱스는 pg_toast.pg_toast_테이블OID_index
라는 이름으로 chunk_id 및 chunk_seq를 사용한 index로 자동으로 생성됩니다.
postgres=# d pg_toast.pg_toast_26417_index
Index “pg_toast.pg_toast_26417_index”
Column | Type | Key? | Definition
———–+———+——+————
chunk_id | oid | yes | chunk_id
chunk_seq | integer | yes | chunk_seq
primary key, btree, for table “pg_toast.pg_toast_26417”
그래서 TOAST된 데이터를 접근하는 순서는 다음과 같습니다.
- 메인 테이블의 TOAST 포인터를 찾습니다.
- 해당 TOAST 포인터가 가르키는 TOAST 테이블의 chunk_id로 인덱스를 사용하여 접근합니다.
- TOAST 테이블에 chunk_id에 맞는 데이터를 읽어 메모리에 로드합니다.

TOAST의 전략
Reference
- https://www.postgresql.org/docs/current/storage-toast.html
- https://github.com/postgres/postgres/blob/master/src/include/access/heaptoast.h
TOAST는 TOAST-able
열을 디스크에 저장하기 위해 4가지 전략을 사용합니다.
- PLAIN : 압축또는 외부저장을 방지합니다. 또한 가변길이(variable-length) 데이터 타입에 대하여 TOAST 사용을 비활성화 합니다. TOAST가 되지 않는 데이터 타입에 대해 사용하는 유일한 전략입니다.
- EXTENDED : TOAST가 가능한 데이터 타입에 대해 기본적으로 사용되는 전략입니다. 압축 및 외부 저장을 모두 허용합니다. 이것은 대부분의 TOAST-able 데이터 타입의 기본값입니다. 압축이 먼저 시도되고 행이 페이지의 1/4보다 크다면 외부 저장(out-of-line)을 시도합니다. EXTENDED는 MAIN 전략과 달리 메인 테이블의 하나의 행 데이터 페이지의 1/4로 줄이려고 합니다.
- EXTERNAL : 외부저장은 하지만 압축은 허용하지 않습니다. EXTERNAL을 사용하면 압축되지 않은 경우 out-of-line의 값의 필수 부분만 가져오도록 최적화되어 있기 때문에 wide text 및 bytea 열에서 부분 문자열에 대한 작업이 빨라집니다.(대신 압축을 하지 않아 디스크 공간이 커집니다.)
- MAIN : 압축을 허용합니다. 실제로는 페이지에 맞도록 행을 작게 만드려고 하지만 행을 페이지에 맞게 충분히 작게 만들 수 없는 경우에는 TOAST 테이블에 저장합니다. EXTENDED와 비슷한 전략으로 보이지만 우선순위 측면에서 차이가 있습니다. MAIN의 경우에는 열에 대해 압축을 시도하고 해당 행이 충분히 하나의 페이지에 존재할 수 있다면 TOAST 테이블로 이동시키지 않습니다. 압축된 해당 행이 더 이상 하나의 행으로 존재할 수 없을 때 TOAST 테이블로 이동시킵니다.
해당 전략은 ALTER문(ALTER TALBE ... SET STORAGE
)으로 수정할 수도 있습니다. 만약 EXTENDED 였던 데이터 타입이 있었다면 기존의 TOAST 포인터는 유지되지만, 새롭게 생성되는 8KB이상의 데이터는 저장할 수 없어 행 데이터가 크다고 오류가 발생합니다.
postgres=# d+ test_toast
Table “public.test_toast”
Column | Type | … | Storage | …
—————+———+ … +———-+ …
id | integer | … | plain | …
long_text | text | … | extended | …
Indexes:
“test_toast_pkey” PRIMARY KEY, btree (id)
Access method: heappostgres=# ALTER TABLE test_toast ALTER COLUMN long_text SET STORAGE PLAIN;
ALTER TABLEpostgres=# INSERT INTO test_toast (long_text)
VALUES (
repeat(‘A’, 1000000)
);
ERROR: row is too big: size 1000032, maximum size 8160
또한 TOAST_TUPLE_TARGET을 조정하여 테이블에 대한 전략을 조절할 수도 있습니다. TOAST_TUPLE_TARGET 은 TOAST 테이블에서 압축을 적용하는 기준이 되는 크기를 설정할 수 있습니다. 블록사이즈에 비례하여(일반적으로 2KB) 설정되며 해당 값보다 높은 데이터를 압축하도록 합니다. ALTER문으로 특정 테이블의 압축 기준을 설정하거나, 압축하지 않도록 할 수 있습니다. (ALTER TABLE … SET (toast_tuple_target = N))
TOAST 성능
TOAST 테이블의 성능을 비교해보겠습니다. TOAST를 적용하지 않을 데이터를 가진 테이블과 TOAST를 적용할 데이터를 가질 테이블을 생성합니다.
전략에서 언급한 TOAST_TUPLE_TARGET
을 설정하여 5000바이트가 넘지 않으면 TOAST가 적용되지 않도록 설정합니다.
postgres=# create table disable_toast_table(id serial, short_text text);
CREATE TABLE
postgres=# create table enable_toast_table(id serial, long_text text);
CREATE TABLE
postgres=# ALTER TABLE disable_toast_table SET (toast_tuple_target = 5000);
ALTER TABLE
그리고 각 테이블에는 4KB의 무작위 문자열을 10000개 INSERT합니다. 두 테이블 모두 40MB에 가까운 데이터를 INSER합니다.
postgres=# INSERT INTO disable_toast_table (short_text)
SELECT random_string(4096)
FROM generate_series(1, 10000);postgres=# INSERT INTO enable_toast_table (long_text)
SELECT random_string(4096)
FROM generate_series(1,10000);
각 테이블의 데이터를 조회하였을 때 TOAST된 테이블이 실행 시간이 몇배나 차이나는 것을 확인할 수 있습니다.
postgres=# EXPLAIN ANALYZE SELECT * FROM disable_toast_table where id=9000;
QUERY PLAN
——————————————————————————————————————————————————
Seq Scan on disable_toast_table (cost=0.00..10325.00 rows=1 width=4104) (actual time=8.608..8.915 rows=1 loops=1)
Filter: (id = 9000)
Rows Removed by Filter: 9999
Planning Time: 0.081 ms
Execution Time: 8.934 ms
(5 rows)postgres=# EXPLAIN ANALYZE SELECT * FROM enable_toast_table where id=9000;
QUERY PLAN
——————————————————————————————————————————————————-
Seq Scan on enable_toast_table (cost=0.00..389.00 rows=1 width=22) (actual time=0.368..0.408 rows=1 loops=1)
Filter: (id = 9000)
Rows Removed by Filter: 9999
Planning Time: 0.029 ms
Execution Time: 0.416 ms
(5 rows)
이것은 TOAST를 이용하여 데이터를 저장하면 데이터들이 out-of-line, 즉 외부의 TOAST 테이블에 존재하여 실제로 테이블을 스캔할 때 모든 데이터를 읽지 않게 되어 실행 시간이 줄어들게 됩니다. 플랜에서 Sequential 스캔을 실행할 때 한 행을 비교하는 width(한 행의 메모리의 사용량)이 작아 빠르게 스캔할 수 있습니다.
지금까지 PostgreSQL의 TOAST에 관해 알아보았습니다
‘PostgreSQL의 Virtual File Descriptor’를 바로 이어서 확인해보세요!