(KO) PostgreSQL TOAST — 대형 속성 저장, 압축, 역직렬화
목차
- 학술적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 가이드
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
학술적 배경
섹션 제목: “학술적 배경”8 KB 고정 크기 페이지는 PostgreSQL 스토리지의 원자 단위다. 힙 튜플은 반드시 단일 페이지 안에 들어야 한다. 상한은 MaxHeapTupleSize로, 페이지 헤더와 튜플 헤더 오버헤드를 빼면 약 8 KB다. 이 제약은 절대적이다. 페이지 I/O 모델이 버퍼 매니저에게 균일한 단위를 제공하고, 슬롯 페이지(slotted page) 레이아웃(Database Internals, Petrov, 3장 “File Formats” §“Slotted Pages”)이 레코드를 (페이지, 슬롯) 쌍으로 주소화하는데, 슬롯은 두 페이지에 걸칠 수 없기 때문이다.
실제 데이터는 이 제약을 자주 위반한다. 상품 설명을 담은 TEXT 컬럼, 문서를 담은 BYTEA 컬럼, 큰 객체를 담은 JSONB 컬럼은 모두 8 KB를 넘을 수 있다. 엔진은 페이지보다 큰 값을 저장하면서도 쿼리 레이어에게는 평범한 컬럼처럼 보이게 해야 한다.
Database System Concepts(Silberschatz, 7판, 13장 “Data Storage Structures”)에서 제시하는 표준 접근 두 가지는 다음과 같다.
-
대형 객체 / BLOB 저장. 엔진이 별도 파일이나 객체 저장소를 유지한다. 메인 튜플에는 핸들만 담는다. 읽기와 쓰기는 애플리케이션이 대형 객체 API를 직접 호출해 수행한다. Oracle
LOB컬럼과 PostgreSQL 자체의large_object서브시스템이 이 모델을 따른다. -
투명한 오버플로. 엔진이 쓰기와 읽기를 자동으로 가로채어 처리하므로 애플리케이션에는 임의 크기의 평범한 컬럼으로 보인다. 플래너, 실행기, 접근 방법은 불투명한
Datum포인터로 동작하고, 스토리지 레이어가 인라인 또는 보조 저장소 여부를 결정한 뒤 호출자에게 전달하기 전에 압축 해제나 재조합을 수행한다. TOAST가 이 두 번째 모델이다.
TOAST(PostgreSQL 개발팀이 “The Oversized-Attribute Storage Technique”의 약어로 명명했다. 때로는 “슬라이스 빵 이후 최고의 발명”이라는 유머로 풀이되기도 한다)는 다음 설계 제약 아래 두 번째 접근 방식을 구현한다.
- 투명성. SQL 변경이 필요 없다.
TEXT,BYTEA등 가변 길이(varlena) 컬럼은 선언된 타입의 저장 전략에 따라 자동으로 TOAST 대상이 된다. - 두 가지 크기 감소 전략. 엔진은 먼저 인라인 압축으로 메인 페이지에 datum을 유지하려 한다. 압축 후에도 임계값을 넘으면 외부 테이블로 이동시킨다.
- 임의 접근 슬라이스 조회.
substring(col, 1, 100)같이 일부만 필요한 호출자가 전체 datum을 가져와 압축 해제할 필요가 없어야 한다.
압축 측의 이론 기반은 범용 무손실 데이터 압축이다. PGLZ는 PostgreSQL 자체 LZ 계열 알고리즘이고, LZ4는 빠른 경로로 잘 알려진 알고리즘이다. 두 알고리즘 모두 표준 DBMS 교과서에는 나오지 않으며, TOAST 프레임워크 안에서의 공학적 선택이다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”varlena 헤더 문제
섹션 제목: “varlena 헤더 문제”고정 크기 페이지에 가변 길이 값을 저장하는 모든 엔진은 동일한 헤더 문제를 해결해야 한다. 컬럼 값이 자신의 길이를 직접 가지고 있어야 한다는 점이다. 페이지가 가변 길이 속성의 길이를 따로 저장하지 않기 때문이다. 이 길이 필드는 바이트를 소비하여 실제 데이터 공간을 줄인다. 보편적인 답은 길이와 플래그 비트를 함께 인코딩하는 소형 가변 길이 헤더다. 설계 긴장은 헤더 크기(작을수록 좋다)와 표현 가능한 크기 범위 사이에 있다.
PostgreSQL의 struct varlena는 2단계 방식을 쓴다.
- 4바이트 헤더(
varattrib_4b): 상위 2비트가 압축 여부와 외부 저장 여부를 인코딩하고, 나머지 비트가 헤더 포함 전체 길이를 담는다. 최대 약 1 GB까지 지원한다. - 1바이트 짧은 헤더(
varattrib_1b): 최상위 비트가1이면 “짧은 형식”을 뜻하고, 나머지 7비트가 헤더 포함 전체 길이를 담는다. 최대 126바이트까지 인라인으로 4바이트 세금 없이 저장할 수 있다.
짧은 헤더 최적화는 잘 알려진 기법이다. MySQL VARCHAR는 선언된 컬럼 길이에 따라 1바이트 또는 2바이트 길이 접두사를 쓴다. SQL Server도 유사한 2바이트 길이 접두사를 VARLEN 컬럼에 사용한다. 정확한 인코딩은 다르지만, 트레이드오프는 언제나 헤더 크기 대 표현 가능 범위다.
외부 저장 패턴
섹션 제목: “외부 저장 패턴”투명한 오버플로를 지원하는 엔진들은 일반적으로 세 곳 중 하나에 외부 데이터를 둔다.
- 같은 파일의 행 오버플로 페이지. SQL Server의 row-overflow 및 LOB 페이지, MySQL InnoDB의 off-page 컬럼이 이에 해당한다.
(value_id, chunk_seq)키를 가진 전용 보조 힙. Oracle LOB 세그먼트와 PostgreSQL TOAST 테이블이 이 방식이다.- 페이지 파일 외부의 파일. Oracle
BFILE이 해당한다. PostgreSQL의 대형 객체(large object)도 외부이지만 TOAST와는 별개의 메커니즘이다.
PostgreSQL TOAST는 방식 2를 선택한다. 토스트 가능한 컬럼이 하나라도 있는 릴레이션에는 연관된 pg_toast_<OID> 힙이 생성되며, 스키마는 (chunk_id OID, chunk_seq INT4, chunk_data BYTEA)다. 이렇게 하면 외부 데이터가 MVCC 메커니즘 안에 남는다. 조각들은 vacuum, 스냅샷, WAL의 대상이 되는 일반 힙 튜플이다. 동시에 레이아웃 간섭 없이 메인 힙과 분리된다.
스토리지 경로의 압축
섹션 제목: “스토리지 경로의 압축”외부화 전에 압축하는 것은 표준적인 최적화다. I/O를 줄이고, datum이 인라인으로 머물 가능성을 높이며, 자주 읽히는 datum의 역직렬화 비용을 절반으로 줄인다. 실질적인 문제는 비압축성을 언제 인정하느냐다. TOAST의 “2바이트 초과 절약” 기준(toast_compress_datum의 if (VARSIZE(tmp) < valsize - 2))은 전형적인 답이다. 헤더와 정렬 패딩을 고려하면 아무것도 절약하지 못하는 압축은 유지할 가치가 없다.
외부 datum을 위한 포인터 기반 간접 참조
섹션 제목: “외부 datum을 위한 포인터 기반 간접 참조”datum이 보조 저장소로 이동하면, 메인 튜플은 데이터 대신 작은 포인터(toast pointer 또는 indirect datum이라고도 한다)를 담는다. 포인터는 추가 토스팅을 유발하지 않을 만큼 작아야 하고, datum을 복구하기에 충분한 정보를 담아야 한다. 릴레이션 OID와 값 OID가 있으면 조각들을 재조합할 수 있다. 이 패턴은 투명한 오버플로를 지원하는 모든 엔진에 나타나며, 정확한 포인터 레이아웃은 엔진마다 다르다.
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”varlena 인코딩 체계
섹션 제목: “varlena 인코딩 체계”PostgreSQL의 모든 가변 길이 타입은 메모리와 디스크 표현 모두에서 struct varlena를 사용한다. 처음 1바이트 또는 4바이트가 헤더다. 정확한 해석은 첫 바이트의 상위 2비트에 따라 결정된다.
| 비트 패턴 | 인코딩 | 의미 |
|---|---|---|
1xxxxxxx | 1바이트 짧은 형식 | 전체 길이 = 하위 7비트; 데이터 인라인 |
00xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx | 4바이트 일반 | 전체 길이 = 하위 30비트; 데이터 인라인 |
10000000 (4바이트) | 4바이트 압축 | PGLZ 또는 LZ4 압축됨; 실제 크기는 tcinfo에 저장 |
00000001 (1바이트) | 1바이트 외부 태그 | 외부 포인터; 뒤에 VARTAG 바이트가 따름 |
외부 포인터의 VARTAG 바이트는 세 종류를 구분한다.
VARTAG_ONDISK = 18— 표준varatt_externalTOAST 포인터. 디스크에만 기록된다.VARTAG_INDIRECT = 1— 다른varlena를 가리키는 메모리 내 간접 포인터. 확장 객체 등 과도기적 메커니즘에서 사용된다.VARTAG_EXPANDED_RO / VARTAG_EXPANDED_RW = 2 / 3— 확장 객체 포인터. 예를 들어 메모리 내 배열이나 복합 타입의 expanded form이 이에 해당한다.
VARTAG_ONDISK만 디스크에 기록된다. 나머지는 쿼리 실행 중에만 존재하는 메모리 표현이다.
컬럼별 저장 전략
섹션 제목: “컬럼별 저장 전략”토스트 가능한 타입의 각 컬럼은 typstorage 플래그를 갖는다. pg_attribute.attstorage에 저장되며, 기본값은 pg_type.typstorage에서 가져온다.
| 플래그 | 상수 | 의미 |
|---|---|---|
'p' | TYPSTORAGE_PLAIN | 절대 토스팅하지 않는다. 인라인으로 있는 그대로 저장한다. 페이지를 넘치면 실패한다. |
'e' | TYPSTORAGE_EXTERNAL | 외부 저장은 허용하지만 인라인 압축은 하지 않는다. 이미 압축된 PNG 같은 대형 바이너리에 유용하다. |
'm' | TYPSTORAGE_MAIN | 인라인 유지를 선호한다. 인라인 압축을 시도하고, 최후 수단으로만 외부 저장한다. |
'x' | TYPSTORAGE_EXTENDED | 완전한 TOAST: 먼저 인라인 압축을 시도하고, 그래도 크면 외부 저장한다. TEXT, BYTEA, JSONB의 기본값이다. |
'm' 전략은 “인라인 유지”라는 약속의 가장 약한 형태다. 토스터는 TOAST_TUPLE_TARGET_MAIN(약 페이지 하나에 튜플 하나가 들어갈 크기)까지는 이 약속을 지키지만, 그것도 초과하면 결국 외부 저장한다.
토스팅 임계값과 4단계 알고리즘
섹션 제목: “토스팅 임계값과 4단계 알고리즘”튜플의 데이터 영역이 TOAST_TUPLE_TARGET(기본 8 KB 페이지 기준 약 2 KB)을 넘으면 토스터가 동작한다. 로직은 heaptoast.c의 heap_toast_insert_or_update에 있다. 적극성을 줄여 가며 속성들을 네 번 순회한다.
1단계 — EXTENDED 압축 및 매우 큰 EXTENDED/EXTERNAL 외부 저장.
attstorage = TYPSTORAGE_EXTENDED인 각 속성에 toast_tuple_try_compression을 호출한다. 압축 후에도 개별 속성이 maxDataLen보다 크면 즉시 toast_tuple_externalize로 외부로 밀어낸다. 압축이 되지 않는 EXTERNAL 속성은 TOASTCOL_INCOMPRESSIBLE로 표시하고 이후 압축 단계에서 건너뛴다.
2단계 — 남은 EXTENDED/EXTERNAL 외부 저장.
튜플이 여전히 maxDataLen을 넘고 toast 테이블이 존재하면(rel->rd_rel->reltoastrelid != InvalidOid), 남은 적격 속성을 외부로 밀어낸다.
3단계 — MAIN 압축.
튜플이 여전히 maxDataLen을 넘으면 TYPSTORAGE_MAIN 속성에 toast_tuple_try_compression을 적용한다. “인라인 선호” 컬럼에 대한 인라인 압축 최후 수단이다.
4단계 — MAIN 외부 저장.
목표값을 TOAST_TUPLE_TARGET_MAIN(약 페이지 하나에 꽉 차는 크기)으로 완화한다. 그래도 초과하면 MAIN 속성을 외부로 내보낸다.
진입점은 먼저 신규 튜플과 (UPDATE의 경우) 이전 튜플을 분해하고, ToastTupleContext를 초기화한 뒤, 헤더 오버헤드를 계산해 TOAST_TUPLE_TARGET을 데이터 크기 한계로 변환한다. 루프 조건은 반복마다 heap_compute_data_size를 다시 계산해 재평가된다는 점이 중요하다. 토스터는 미리 수립한 계획이 아니라 값 배열의 현재 상태(이미 압축·외부화됐을 수 있다)에 반응한다.
// heap_toast_insert_or_update — src/backend/access/heap/heaptoast.cheap_deform_tuple(newtup, tupleDesc, toast_values, toast_isnull);if (oldtup != NULL) heap_deform_tuple(oldtup, tupleDesc, toast_oldvalues, toast_oldisnull);/* ... fill ttc fields ... */toast_tuple_init(&ttc);
/* compute header overhead --- this should match heap_form_tuple() */hoff = SizeofHeapTupleHeader;if ((ttc.ttc_flags & TOAST_HAS_NULLS) != 0) hoff += BITMAPLEN(numAttrs);hoff = MAXALIGN(hoff);/* now convert to a limit on the tuple data size */maxDataLen = RelationGetToastTupleTarget(rel, TOAST_TUPLE_TARGET) - hoff;4단계는 같은 골격을 공유한다. toast_tuple_find_biggest_attribute로 가장 큰 적격 속성을 고른 뒤 처리하고 재검사한다. 이 선택자의 두 boolean 인수가 단계별 정책을 인코딩한다. for_compression은 아직 압축되지 않은 컬럼만 고려할지를, check_main은 MAIN 저장 컬럼을 이번 단계에서 대상으로 삼을지를 나타낸다.
// heap_toast_insert_or_update — src/backend/access/heap/heaptoast.c/* Round 1: compress EXTENDED; externalize a single huge value early */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;
if (TupleDescAttr(tupleDesc, biggest_attno)->attstorage == TYPSTORAGE_EXTENDED) toast_tuple_try_compression(&ttc, biggest_attno); else /* has attstorage EXTERNAL, ignore on subsequent compression passes */ toast_attr[biggest_attno].tai_colflags |= TOASTCOL_INCOMPRESSIBLE;
/* if it alone still busts the budget, push it out now */ if (toast_attr[biggest_attno].tai_size > maxDataLen && rel->rd_rel->reltoastrelid != InvalidOid) toast_tuple_externalize(&ttc, biggest_attno, options);}
/* Round 2: externalize remaining EXTENDED/EXTERNAL (needs a toast table) */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);}
/* Round 3: now take MAIN attributes into compression */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);}
/* Round 4: relax the budget to one tuple/page, then externalize MAIN */maxDataLen = TOAST_TUPLE_TARGET_MAIN - hoff;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);}1단계의 “매우 큰 값은 즉시 외부화” 분기는 의도적인 최적화다. 긴 TEXT/JSONB 컬럼 하나와 짧은 컬럼 여러 개가 있는 전형적인 경우, 거대 값을 즉시 외부로 밀어내면 애초에 문제가 아닌 짧은 컬럼들을 압축하는 CPU 낭비를 피할 수 있다.
어느 값이든 교체되면 TOAST_NEEDS_CHANGE가 설정되고, 함수는 heap_fill_tuple로 (지금은 더 작아진) toast_values 배열에서 새 HeapTuple을 재구성한다. 이 과정에서 t_hoff를 재계산하는 이유는, 이전 튜플이 저장된 이후 ALTER TABLE ADD COLUMN이 발생해 null 비트맵 너비가 바뀌어 있을 수 있기 때문이다.
TOAST 테이블에 datum 저장
섹션 제목: “TOAST 테이블에 datum 저장”toast_internals.c의 toast_save_datum은 toast 릴레이션을 열고, GetNewOidWithIndex로 새 valueid OID를 할당하며, datum을 TOAST_MAX_CHUNK_SIZE 바이트 조각으로 잘라 각각을 힙 튜플 (valueid, chunk_seq, chunk_data) 형태로 삽입한다. 모든 조각을 삽입한 뒤 18바이트 varatt_external 포인터를 만들어 반환한다.
온디스크 포인터는 네 필드로 구성된 varatt_external 구조체다. 핵심 미묘함은 va_extinfo에 있다. 이 필드는 저장된 페이로드 크기와 압축 방법을 하나의 uint32에 패킹한다. va_rawsize는 완전히 압축 해제된 크기를 담아 두어, 읽는 쪽이 압축 해제 없이 결과 버퍼를 미리 할당할 수 있게 한다.
// varatt_external — src/include/varatt.htypedef 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 */} varatt_external;toast_save_datum은 toast 릴레이션과 인덱스를 열고, 입력 datum의 형태에서 data_p / data_todo와 포인터 두 필드를 도출한다. 짧은 헤더 datum은 일반 헤더가 있는 것처럼 기록되고, 이미 인라인 압축된 datum은 압축 방법을 그대로 va_extinfo에 전달한다. 즉, datum은 압축된 채 저장되며 재압축되지 않는다.
// toast_save_datum — src/backend/access/common/toast_internals.cif (VARATT_IS_SHORT(dval)){ data_p = VARDATA_SHORT(dval); data_todo = VARSIZE_SHORT(dval) - VARHDRSZ_SHORT; toast_pointer.va_rawsize = data_todo + VARHDRSZ; /* as if not short */ toast_pointer.va_extinfo = data_todo;}else if (VARATT_IS_COMPRESSED(dval)){ data_p = VARDATA(dval); data_todo = VARSIZE(dval) - VARHDRSZ; toast_pointer.va_rawsize = VARDATA_COMPRESSED_GET_EXTSIZE(dval) + VARHDRSZ; VARATT_EXTERNAL_SET_SIZE_AND_COMPRESS_METHOD(toast_pointer, data_todo, VARDATA_COMPRESSED_GET_COMPRESS_METHOD(dval)); Assert(VARATT_EXTERNAL_IS_COMPRESSED(toast_pointer));}else{ data_p = VARDATA(dval); data_todo = VARSIZE(dval) - VARHDRSZ; toast_pointer.va_rawsize = VARSIZE(dval); toast_pointer.va_extinfo = data_todo;}valueid는 GetNewOidWithIndex로 생성한 새 OID다. 유일성은 toast 테이블 자체 인덱스에서 확인한다. CLUSTER/VACUUM FULL 재작성 중에는 rel->rd_toastoid가 설정되어 이전 값의 OID를 재사용하며, 새 toast 테이블에 이미 해당 값이 존재하면 data_todo = 0으로 조각 루프를 단락시킨다. 이것이 재작성이 공유 toast 값을 중복 저장하지 않는 방식이다.
조각 루프는 data_p를 TOAST_MAX_CHUNK_SIZE 바이트씩 잘라 (valueid, chunk_seq, chunk_data) 힙 튜플을 만들고 튜플과 인덱스 항목을 함께 삽입한다.
// toast_save_datum — src/backend/access/common/toast_internals.ct_values[0] = ObjectIdGetDatum(toast_pointer.va_valueid);t_values[2] = PointerGetDatum(&chunk_data);
while (data_todo > 0){ CHECK_FOR_INTERRUPTS(); chunk_size = Min(TOAST_MAX_CHUNK_SIZE, data_todo);
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);
/* index entry for each toast index (columns mirror the table) */ for (i = 0; i < num_indexes; i++) if (toastidxs[i]->rd_index->indisvalid) 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);
heap_freetuple(toasttup); data_todo -= chunk_size; data_p += chunk_size;}마지막으로 18바이트 외부 varlena를 만들어 toast_tuple_externalize에 반환하면, 그 함수가 ttc_values[attno]에 넣는다.
// toast_save_datum — src/backend/access/common/toast_internals.cresult = (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 테이블에는 항상 (chunk_id, chunk_seq) B-tree 인덱스가 있다. heap_fetch_toast_slice가 ScanKeyInit으로 조각을 순서대로 스캔할 때 이 인덱스를 사용한다.
toast_internals.c의 toast_compress_datum은 default_toast_compression GUC(기본값 pglz; lz4는 컴파일 시 USE_LZ4 필요)에 따라 pglz_compress_datum 또는 lz4_compress_datum으로 분기한다. 압축 방법 ID는 외부 datum의 경우 va_extinfo 상위 2비트에, 인라인 압축 datum의 경우 tcinfo에 저장된다.
// toast_compress_datum — src/backend/access/common/toast_internals.cswitch (cmethod) { case TOAST_PGLZ_COMPRESSION: tmp = pglz_compress_datum((const struct varlena *) value); cmid = TOAST_PGLZ_COMPRESSION_ID; break; case TOAST_LZ4_COMPRESSION: tmp = lz4_compress_datum((const struct varlena *) value); cmid = TOAST_LZ4_COMPRESSION_ID; break;}if (VARSIZE(tmp) < valsize - 2) { /* net savings; keep compressed form */ TOAST_COMPRESS_SET_SIZE_AND_COMPRESS_METHOD(tmp, valsize, cmid); return PointerGetDatum(tmp);} else { pfree(tmp); return PointerGetDatum(NULL); /* incompressible */}“2바이트 초과 절약” 검사는 헤더와 정렬 패딩 오버헤드를 고려했을 때 작은 datum이 오히려 커지는 것을 막는다.
PGLZ vs LZ4 — datum 수준 비교
섹션 제목: “PGLZ vs LZ4 — datum 수준 비교”두 압축기는 같은 계약을 공유한다. 일반 varlena를 받아 VARHDRSZ_COMPRESSED 접두사가 붙은 압축 varlena를 반환하거나, 실패 시 NULL을 반환한다. 단, 실패 판단 방식과 속도/압축률 트레이드오프가 다르다.
PGLZ는 PostgreSQL 내장 LZ77 계열 코더다. 입력이 너무 작거나 너무 크면 앞단에서 거부한다(PGLZ_strategy_default 경계). 핵심 pglz_compress에서 음수가 반환되면 “비압축성”으로 처리한다.
// pglz_compress_datum — src/backend/access/common/toast_compression.cvalsize = VARSIZE_ANY_EXHDR(value);if (valsize < PGLZ_strategy_default->min_input_size || valsize > PGLZ_strategy_default->max_input_size) return NULL;
tmp = (struct varlena *) palloc(PGLZ_MAX_OUTPUT(valsize) + VARHDRSZ_COMPRESSED);len = pglz_compress(VARDATA_ANY(value), valsize, (char *) tmp + VARHDRSZ_COMPRESSED, NULL);if (len < 0){ pfree(tmp); return NULL;}SET_VARSIZE_COMPRESSED(tmp, len + VARHDRSZ_COMPRESSED);return tmp;LZ4는 USE_LZ4 없이 빌드하면 스텁이 오류를 발생시킨다. 속도가 훨씬 빠르고 압축률은 일반적으로 낮다. LZ4_compressBound로 버퍼를 할당하고, 라이브러리 실패는 즉시 오류로 처리하며, “출력이 입력보다 크다”는 조건을 비압축성 신호로 쓴다.
// lz4_compress_datum — src/backend/access/common/toast_compression.cmax_size = LZ4_compressBound(valsize);tmp = (struct varlena *) palloc(max_size + VARHDRSZ_COMPRESSED);
len = LZ4_compress_default(VARDATA_ANY(value), (char *) tmp + VARHDRSZ_COMPRESSED, valsize, max_size);if (len <= 0) elog(ERROR, "lz4 compression failed");
/* data is incompressible so just free the memory and return NULL */if (len > valsize){ pfree(tmp); return NULL;}SET_VARSIZE_COMPRESSED(tmp, len + VARHDRSZ_COMPRESSED);return tmp;압축 datum의 처음 4바이트는 va_tcinfo 워드(toast_compress_header)다. 원본 페이로드 크기 30비트와 2비트 ToastCompressionId로 구성된다. toast_decompress_datum이 이 ID를 읽어 압축 해제기를 선택한다. 즉, 방법이 datum에 함께 저장되어 있어 ALTER TABLE ... ALTER COLUMN ... SET COMPRESSION 이후 테이블에 PGLZ와 LZ4 압축 값이 섞여 있어도 올바르게 처리된다.
// toast_decompress_datum — src/backend/access/common/detoast.ccmid = TOAST_COMPRESS_METHOD(attr);switch (cmid){ case TOAST_PGLZ_COMPRESSION_ID: return pglz_decompress_datum(attr); case TOAST_LZ4_COMPRESSION_ID: return lz4_decompress_datum(attr); default: elog(ERROR, "invalid compression method id %d", cmid); return NULL; /* keep compiler quiet */}슬라이스 경로의 비대칭성은 압축 해제의 임의 접근 가능 여부에서 비롯된다. PGLZ는 접두사 길이에서 멈출 수 있는 pglz_decompress_datum_slice를 제공한다. LZ4도 LZ4_decompress_safe_partial을 제공하지만, liblz4 ≥ 1.8.3이 필요하다. 따라서 lz4_decompress_datum_slice는 구버전 라이브러리에서 전체 압축 해제로 폴백한다.
// lz4_decompress_datum_slice — src/backend/access/common/toast_compression.c/* slice decompression not supported prior to 1.8.3 */if (LZ4_versionNumber() < 10803) return lz4_decompress_datum(value);
result = (struct varlena *) palloc(slicelength + VARHDRSZ);rawsize = LZ4_decompress_safe_partial((char *) value + VARHDRSZ_COMPRESSED, VARDATA(result), VARSIZE(value) - VARHDRSZ_COMPRESSED, slicelength, slicelength);역직렬화(detoasting)
섹션 제목: “역직렬화(detoasting)”detoast.c의 detoast_attr는 완전한 역직렬화 경로다. 필요하면 외부 저장소에서 가져오고, 필요하면 압축 해제하고, 필요하면 짧은 헤더를 확장한다. 결과는 항상 일반 4바이트 헤더 varlena다.
// detoast_attr — src/backend/access/common/detoast.cif (VARATT_IS_EXTERNAL_ONDISK(attr)){ /* externally stored --- fetch it back from there */ attr = toast_fetch_datum(attr); /* If it's compressed, decompress it */ if (VARATT_IS_COMPRESSED(attr)) { struct varlena *tmp = attr; attr = toast_decompress_datum(tmp); pfree(tmp); }}else if (VARATT_IS_EXTERNAL_INDIRECT(attr)){ /* in-memory indirect pointer --- dereference and recurse */ struct varatt_indirect redirect; VARATT_EXTERNAL_GET_POINTER(redirect, attr); attr = detoast_attr((struct varlena *) redirect.pointer); /* ... copy if it was already flat ... */}else if (VARATT_IS_EXTERNAL_EXPANDED(attr)) attr = detoast_external_attr(attr); /* flatten expanded object */else if (VARATT_IS_COMPRESSED(attr)) attr = toast_decompress_datum(attr); /* inline-compressed only */else if (VARATT_IS_SHORT(attr)){ /* short-header varlena --- convert to 4-byte header format */ Size data_size = VARSIZE_SHORT(attr) - VARHDRSZ_SHORT; struct varlena *new_attr = (struct varlena *) palloc(data_size + VARHDRSZ); SET_VARSIZE(new_attr, data_size + VARHDRSZ); memcpy(VARDATA(new_attr), VARDATA_SHORT(attr), data_size); attr = new_attr;}return attr;다섯 분기는 상호 배타적이며, 핫 패스 빈도 순으로 정렬되어 있다. 완전한 외부 온디스크 datum이 첫 번째고, 디스크에 나타나지 않는 두 메모리 과도기 형태(INDIRECT, EXPANDED)가 다음이며, 인라인 압축, 짧은 헤더 순으로 이어진다. 사후 조건은 언제나 호출자가 pfree할 수 있는 일반 4바이트 헤더 varlena다.
toast_fetch_datum이 재조합 엔진이다. 포인터에서 (정렬이 맞지 않을 수 있는) varatt_external을 복사하고, 저장된 크기에 맞게 결과 버퍼를 미리 할당하며, 압축 여부를 표시해 호출자의 VARATT_IS_COMPRESSED 검사가 동작하게 한 뒤 실제 조각 읽기를 테이블 AM에 위임한다.
// toast_fetch_datum — src/backend/access/common/detoast.cVARATT_EXTERNAL_GET_POINTER(toast_pointer, attr);attrsize = VARATT_EXTERNAL_GET_EXTSIZE(toast_pointer);
result = (struct varlena *) palloc(attrsize + VARHDRSZ);if (VARATT_EXTERNAL_IS_COMPRESSED(toast_pointer)) SET_VARSIZE_COMPRESSED(result, attrsize + VARHDRSZ);else SET_VARSIZE(result, attrsize + VARHDRSZ);
if (attrsize == 0) return result; /* shouldn't happen, but be safe */
toastrel = table_open(toast_pointer.va_toastrelid, AccessShareLock);table_relation_fetch_toast_slice(toastrel, toast_pointer.va_valueid, attrsize, 0, attrsize, result);table_close(toastrel, AccessShareLock);return result;table_relation_fetch_toast_slice는 테이블 AM 훅이고, 힙 구현이 heap_fetch_toast_slice다. 이 함수는 조각 범위를 계산하고, 스캔 키를 1~3개 만들어(valueid에 대한 동등 조건과 선택적으로 chunk_seq에 대한 동등 또는 범위 조건) toast 인덱스를 순서대로 탐색한다. 전체 조각을 읽을 때는 키 하나, 서브 범위를 읽을 때는 BTGreaterEqual / BTLessEqual 경계를 사용한다.
// heap_fetch_toast_slice — src/backend/access/heap/heaptoast.cstartchunk = sliceoffset / TOAST_MAX_CHUNK_SIZE;endchunk = (sliceoffset + slicelength - 1) / TOAST_MAX_CHUNK_SIZE;
ScanKeyInit(&toastkey[0], (AttrNumber) 1, BTEqualStrategyNumber, F_OIDEQ, ObjectIdGetDatum(valueid));
if (startchunk == 0 && endchunk == totalchunks - 1) nscankeys = 1; /* whole value */else if (startchunk == endchunk){ ScanKeyInit(&toastkey[1], (AttrNumber) 2, BTEqualStrategyNumber, F_INT4EQ, Int32GetDatum(startchunk)); nscankeys = 2; /* single chunk */}else{ ScanKeyInit(&toastkey[1], (AttrNumber) 2, BTGreaterEqualStrategyNumber, F_INT4GE, Int32GetDatum(startchunk)); ScanKeyInit(&toastkey[2], (AttrNumber) 2, BTLessEqualStrategyNumber, F_INT4LE, Int32GetDatum(endchunk)); nscankeys = 3; /* chunk range */}
toastscan = systable_beginscan_ordered(toastrel, toastidxs[validIndex], get_toast_snapshot(), nscankeys, toastkey);/* loop: copy each chunk's VARDATA into result, verifying curchunk == expectedchunk */조각별 루프는 curchunk == expectedchunk와 조각 크기가 위치에 맞는지 검증하여, 갭·중복·순서 오류가 있으면 ERRCODE_DATA_CORRUPTED를 발생시킨다. 읽기 시점에 toast 테이블 손상을 잡아내는 저비용 무결성 검사다.
슬라이스 변형 detoast_attr_slice는 부분 조회를 지원한다. 비압축 외부 datum의 경우 toast_fetch_datum_slice로 바로 이어지는 빠른 경로가 있다. 위에서 보여준 대로 조각 범위를 좁힌다. 압축된 외부 datum의 경우 요청한 비압축 접두사를 생성하기에 충분한 압축 바이트를 가져와야 한다. PGLZ는 이 한계를 구하는 pglz_maximum_compressed_size를 제공하지만 LZ4는 동등한 함수가 없어 슬라이스 경로도 전체 datum을 가져온다.
// detoast_attr_slice — src/backend/access/common/detoast.cif (VARATT_IS_EXTERNAL_ONDISK(attr)){ VARATT_EXTERNAL_GET_POINTER(toast_pointer, attr);
/* fast path for non-compressed external datums */ if (!VARATT_EXTERNAL_IS_COMPRESSED(toast_pointer)) return toast_fetch_datum_slice(attr, sliceoffset, slicelength);
/* compressed: fetch enough to decompress the requested prefix */ if (slicelimit >= 0) { int32 max_size = VARATT_EXTERNAL_GET_EXTSIZE(toast_pointer); if (VARATT_EXTERNAL_GET_COMPRESS_METHOD(toast_pointer) == TOAST_PGLZ_COMPRESSION_ID) max_size = pglz_maximum_compressed_size(slicelimit, max_size); /* (LZ4 has no such bound, so max_size stays = full size) */ preslice = toast_fetch_datum_slice(attr, 0, max_size); } else preslice = toast_fetch_datum(attr);}/* ... INDIRECT / EXPANDED / inline cases ... */
if (VARATT_IS_COMPRESSED(preslice)){ struct varlena *tmp = preslice; if (slicelimit >= 0) preslice = toast_decompress_datum_slice(tmp, slicelimit); else preslice = toast_decompress_datum(tmp); if (tmp != attr) pfree(tmp);}/* ... then copy [sliceoffset, sliceoffset+slicelength) out of preslice ... */바로 이것이 substring(big_text, 1, 100)이 비압축 외부 값에서는 첫 번째 조각만 접근하지만, LZ4 압축 값에서는 전체 datum 비용을 치르는 이유다. 접두사 한계는 압축기의 속성이지 TOAST의 속성이 아니다.
TOAST 스냅샷 의미론
섹션 제목: “TOAST 스냅샷 의미론”역직렬화는 TOAST 테이블을 읽기 위한 스냅샷이 필요하다. get_toast_snapshot(toast_internals.c)은 &SnapshotToastData를 반환한다. 이 특수 스냅샷은 TOAST 릴레이션에서 SnapshotSelf와 유사하게 커밋된 버전을 읽는다. 한 가지 안전 규칙을 강제한다. 역직렬화 시도 전에 현재 세션에 활성 스냅샷이 등록되어 있어야 한다는 것이다. 메인 테이블 조회와 역직렬화 사이에 vacuum이 TOAST 데이터를 제거하는 사태를 막기 위해서다.
// get_toast_snapshot — src/backend/access/common/toast_internals.cif (!HaveRegisteredOrActiveSnapshot()) elog(ERROR, "cannot fetch toast data without an active snapshot");return &SnapshotToastData;TOAST와 테이블 AM 인터페이스
섹션 제목: “TOAST와 테이블 AM 인터페이스”TOAST는 힙 접근 방법에 하드코딩되어 있지 않다. 테이블 AM 인터페이스(tableam.h)는 table_relation_fetch_toast_slice를 노출하며, 힙 AM이 이를 heap_fetch_toast_slice로 구현한다. 대형 값을 다른 방식으로 저장하는 커스텀 AM은 자체 구현을 제공할 수 있다. toast_internals.c와 detoast.c의 토스팅 결정 로직은 AM에 독립적이며, 조각 저장과 조회만 AM별로 다르다.
흐름 다이어그램
섹션 제목: “흐름 다이어그램”쓰기 경로 (INSERT/UPDATE):
flowchart TD
A[heap_insert / heap_update] --> B[heap_toast_insert_or_update]
B --> C{튜플 > TOAST_TUPLE_TARGET?}
C -- 아니오 --> Z[원본 튜플 반환]
C -- 예 --> D[1단계: EXTENDED 압축<br/>이후에도 크면 즉시 외부 저장]
D --> E[2단계: 남은 EXTENDED/EXTERNAL<br/>외부 저장]
E --> F[3단계: MAIN 압축]
F --> G[4단계: MAIN 외부 저장<br/>목표 = TOAST_TUPLE_TARGET_MAIN]
G --> H[교체된 Datum 값으로<br/>heap_form_tuple]
H --> Z2[새 튜플 반환]
D -->|toast_save_datum| T1[toast 릴레이션: 조각 INSERT<br/>valueid OID 할당<br/>Datum을 varatt_external 포인터로 교체]
E -->|toast_save_datum| T1
G -->|toast_save_datum| T1
읽기 경로 (역직렬화):
flowchart TD
R[실행기가 슬롯에서 Datum 읽기] --> V{varlena 태그?}
V -- 짧은 헤더 --> S[detoast_attr: 4바이트 헤더로 확장]
V -- 인라인 압축 --> IC[detoast_attr: 압축 해제<br/>pglz / lz4]
V -- VARTAG_ONDISK --> E1[toast_fetch_datum:<br/>pg_toast_N 열기<br/>systable_beginscan_ordered<br/>순서대로 조각 읽기]
E1 --> E2{조각 데이터가 압축됨?}
E2 -- 예 --> IC
E2 -- 아니오 --> Done[재조합된 varlena 반환]
IC --> Done
S --> Done
슬라이스 읽기 (부분 조회):
flowchart TD
P[detoast_attr_slice sliceoffset slicelength] --> Q{VARTAG_ONDISK?}
Q -- 비압축 --> QA[toast_fetch_datum_slice:<br/>좁은 조각 범위 스캔]
Q -- PGLZ 압축 --> QB[pglz_maximum_compressed_size<br/>최소 접두사 조각 가져오기]
Q -- LZ4 압축 --> QC[전체 조각 가져오기<br/>스트리밍 접두사 API 없음]
QA --> D2[재조합된 버퍼에서 슬라이스 복사]
QB --> D3[접두사 압축 해제 후 슬라이스]
QC --> D3
D2 --> Out[슬라이스 varlena 반환]
D3 --> Out
엔드 투 엔드 datum 생애주기 (EXTENDED 컬럼 하나):
하나의 대형 TEXT/JSONB 값이 심볼 경계를 넘어 토스터 결정부터 조각 저장까지, 그리고 읽기 시 재조합과 압축 해제까지 거치는 경로를 추적한다.
flowchart TD
subgraph WRITE [쓰기 경로]
W1[toast_tuple_try_compression] --> W2[toast_compress_datum]
W2 --> W3{pglz_compress_datum / lz4_compress_datum<br/>2바이트 초과 절약?}
W3 -- 아니오 --> W4[원본 varlena 유지]
W3 -- 예 --> W5[va_tcinfo 설정: 크기 + 방법 ID]
W4 --> W6[toast_tuple_externalize]
W5 --> W6
W6 --> W7[toast_save_datum]
W7 --> W8[TOAST_MAX_CHUNK_SIZE 조각으로 분할<br/>조각마다 heap_insert + index_insert]
W8 --> W9[varatt_external 구성<br/>va_rawsize / va_extinfo / va_valueid / va_toastrelid]
W9 --> W10[Datum을 VARTAG_ONDISK 포인터로 교체]
end
W10 -.디스크에 저장.-> R1
subgraph READ [읽기 경로]
R1[detoast_attr] --> R2{VARATT_IS_EXTERNAL_ONDISK?}
R2 -- 예 --> R3[toast_fetch_datum]
R3 --> R4[table_relation_fetch_toast_slice<br/>heap_fetch_toast_slice]
R4 --> R5[toast 인덱스에서 systable_beginscan_ordered<br/>get_toast_snapshot]
R5 --> R6{결과가 VARATT_IS_COMPRESSED?}
R6 -- 예 --> R7[toast_decompress_datum<br/>va_tcinfo 방법 ID로 pglz / lz4 선택]
R6 -- 아니오 --> R8[일반 varlena]
R7 --> R8
end
소스 코드 가이드
섹션 제목: “소스 코드 가이드”쓰기 측 심볼
섹션 제목: “쓰기 측 심볼”| 심볼 | 파일 | 역할 |
|---|---|---|
heap_toast_insert_or_update | access/heap/heaptoast.c | heap_insert / heap_update가 호출하는 진입점; 4단계 루프 구동 |
heap_toast_delete | access/heap/heaptoast.c | 메인 튜플 삭제 시 TOAST 행을 연쇄 삭제 |
toast_tuple_init | access/common/toast_helper.c | ToastTupleContext 초기화, 각 속성 분류 |
toast_tuple_find_biggest_attribute | access/common/toast_helper.c | 각 단계에서 가장 큰 적격 속성 선택 |
toast_tuple_try_compression | access/common/toast_helper.c | toast_compress_datum 호출; 압축되면 ttc_values의 값을 교체 |
toast_tuple_externalize | access/common/toast_helper.c | toast_save_datum 호출; 값을 varatt_external 포인터로 교체 |
toast_tuple_cleanup | access/common/toast_helper.c | 교체된 이전 외부 값 해제 |
toast_save_datum | access/common/toast_internals.c | toast 릴레이션 열기, valueid 할당, 조각 삽입, 포인터 반환 |
toast_delete_datum | access/common/toast_internals.c | 한 valueid의 모든 조각 삭제 |
toast_delete_external | access/common/toast_internals.c | 컬럼을 순회하며 외부 값에 toast_delete_datum 호출 |
toast_compress_datum | access/common/toast_internals.c | pglz_compress_datum 또는 lz4_compress_datum으로 분기 |
toast_open_indexes | access/common/toast_internals.c | toast 릴레이션의 모든 인덱스 열기; 유효한 것 반환 |
toast_close_indexes | access/common/toast_internals.c | toast 인덱스 닫기 및 배열 해제 |
읽기 측 심볼
섹션 제목: “읽기 측 심볼”| 심볼 | 파일 | 역할 |
|---|---|---|
detoast_attr | access/common/detoast.c | 완전한 역직렬화: 외부 조회 + 압축 해제 + 짧은 헤더 확장 |
detoast_external_attr | access/common/detoast.c | 외부 datum만 가져오기(여전히 압축되어 있을 수 있음) |
detoast_attr_slice | access/common/detoast.c | 부분 조회; 좁은 스캔 또는 접두사 압축 해제로 분기 |
toast_fetch_datum | access/common/detoast.c (정적) | varatt_external datum의 모든 조각 재조합 |
toast_fetch_datum_slice | access/common/detoast.c (정적) | varatt_external datum의 조각 범위 재조합 |
toast_decompress_datum | access/common/detoast.c (정적) | pglz_decompress_datum 또는 lz4_decompress_datum으로 분기 |
toast_decompress_datum_slice | access/common/detoast.c (정적) | datum의 접두사 길이만 압축 해제 |
heap_fetch_toast_slice | access/heap/heaptoast.c | AM측 조각 조회: toast 인덱스에서 systable_beginscan_ordered |
get_toast_snapshot | access/common/toast_internals.c | SnapshotToastData 반환; 활성 스냅샷 전제 조건 강제 |
toast_raw_datum_size | access/common/detoast.c | 완전히 역직렬화하지 않고 비압축 크기 반환 |
toast_datum_size | access/common/detoast.c | 물리적 저장 크기 반환 |
압축 심볼
섹션 제목: “압축 심볼”| 심볼 | 파일 | 역할 |
|---|---|---|
pglz_compress_datum | access/common/toast_compression.c | pglz_compress로 압축; 비압축성이면 NULL 반환 |
pglz_decompress_datum | access/common/toast_compression.c | PGLZ 전체 압축 해제 |
pglz_decompress_datum_slice | access/common/toast_compression.c | PGLZ 부분 압축 해제 |
lz4_compress_datum | access/common/toast_compression.c | LZ4 압축 (USE_LZ4 필요) |
lz4_decompress_datum | access/common/toast_compression.c | LZ4 전체 압축 해제 |
lz4_decompress_datum_slice | access/common/toast_compression.c | LZ4 부분 압축 해제 (liblz4 ≥ 1.8.3 필요) |
toast_get_compression_id | access/common/toast_compression.c | varlena에서 ToastCompressionId 추출 |
CompressionNameToMethod | access/common/toast_compression.c | "pglz" / "lz4" 문자열을 압축 방법 char로 변환 |
핵심 자료 구조
섹션 제목: “핵심 자료 구조”| 심볼 | 파일 | 역할 |
|---|---|---|
varatt_external | include/varatt.h | 18바이트 온디스크 TOAST 포인터: va_rawsize, va_extinfo, va_valueid, va_toastrelid |
varatt_indirect | include/varatt.h | 다른 varlena를 가리키는 메모리 내 간접 포인터 |
ToastAttrInfo | include/access/toast_helper.h | ToastTupleContext 내 속성별 분류 플래그와 현재 크기 |
ToastTupleContext | include/access/toast_helper.h | 4단계 루프 작업 컨텍스트: rel, values, isnull, oldvalues, attr 배열, 플래그 |
toast_compress_header | include/access/toast_internals.h | 인라인 압축 datum 헤더: vl_len_ + tcinfo(크기 + 방법 ID) |
| 상수 | 정의 위치 | 값 (8 KB 페이지 기준) |
|---|---|---|
TOAST_TUPLE_TARGET | heaptoast.h | 약 2 KB (≡ TOAST_TUPLE_THRESHOLD, 페이지당 4 튜플) |
TOAST_TUPLE_TARGET_MAIN | heaptoast.h | 약 8 KB (페이지당 1 튜플, 페이지 전체) |
TOAST_MAX_CHUNK_SIZE | heaptoast.h | 약 1996바이트 (페이지당 4 조각 마이너스 오버헤드) |
TOAST_POINTER_SIZE | detoast.h | 18바이트 (VARHDRSZ_EXTERNAL + sizeof(varatt_external)) |
TYPSTORAGE_PLAIN/EXTERNAL/MAIN/EXTENDED | pg_type.h | 'p', 'e', 'm', 'x' |
위치 힌트 (2026-06-05 기준, 커밋 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, 커밋 273fe94)”| 심볼 | 파일 | 대략적 행 |
|---|---|---|
heap_toast_insert_or_update | src/backend/access/heap/heaptoast.c | 96 |
heap_toast_delete | src/backend/access/heap/heaptoast.c | 43 |
heap_fetch_toast_slice | src/backend/access/heap/heaptoast.c | 626 |
toast_flatten_tuple | src/backend/access/heap/heaptoast.c | 350 |
toast_save_datum | src/backend/access/common/toast_internals.c | 119 |
toast_delete_datum | src/backend/access/common/toast_internals.c | 385 |
toast_compress_datum | src/backend/access/common/toast_internals.c | 46 |
toast_open_indexes | src/backend/access/common/toast_internals.c | 562 |
get_toast_snapshot | src/backend/access/common/toast_internals.c | 638 |
detoast_attr | src/backend/access/common/detoast.c | 116 |
detoast_external_attr | src/backend/access/common/detoast.c | 45 |
detoast_attr_slice | src/backend/access/common/detoast.c | 205 |
toast_fetch_datum | src/backend/access/common/detoast.c | 342 |
toast_fetch_datum_slice | src/backend/access/common/detoast.c | 395 |
toast_decompress_datum | src/backend/access/common/detoast.c | 470 |
toast_decompress_datum_slice | src/backend/access/common/detoast.c | 502 |
pglz_compress_datum | src/backend/access/common/toast_compression.c | 39 |
pglz_decompress_datum | src/backend/access/common/toast_compression.c | 81 |
pglz_decompress_datum_slice | src/backend/access/common/toast_compression.c | 108 |
lz4_compress_datum | src/backend/access/common/toast_compression.c | 138 |
lz4_decompress_datum | src/backend/access/common/toast_compression.c | 181 |
lz4_decompress_datum_slice | src/backend/access/common/toast_compression.c | 214 |
varatt_external | src/include/varatt.h | 32 |
TOAST_TUPLE_TARGET | src/include/access/heaptoast.h | 50 |
TOAST_MAX_CHUNK_SIZE | src/include/access/heaptoast.h | 84 |
TYPSTORAGE_EXTENDED | src/include/catalog/pg_type.h | 309 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”REL_18_STABLE, 커밋 273fe94 기준으로 검증됨.
확인됨:
heap_toast_insert_or_update의 4단계 루프 구조: 1~4단계,TYPSTORAGE_EXTENDED/TYPSTORAGE_MAIN구분,TOAST_TUPLE_TARGET/TOAST_TUPLE_TARGET_MAIN임계값이 설명과 일치한다.toast_save_datum의 조각 루프,GetNewOidWithIndex를 통한valueid할당,varatt_external포인터 구성 확인됨.detoast_attr의 ONDISK / COMPRESSED / SHORT 분기 구조 확인됨.detoast_attr_slice의 비압축 외부 datum 좁은 스캔 경로,pglz_maximum_compressed_size를 통한 PGLZ 접두사 처리, LZ4 전체 조회 폴백 확인됨.get_toast_snapshot의HaveRegisteredOrActiveSnapshot검사 확인됨.TOAST_MAX_CHUNK_SIZE가heaptoast.h에서EXTERN_TUPLE_MAX_SIZE - MAXALIGN(SizeofHeapTupleHeader) - sizeof(Oid) - sizeof(int32) - VARHDRSZ로 정의됨 확인됨.TOAST_POINTER_SIZE = VARHDRSZ_EXTERNAL + sizeof(varatt_external)이detoast.h에 있음 확인됨.- LZ4
lz4_decompress_datum_slice가LZ4_versionNumber() < 10803이면 전체 압축 해제로 폴백함 확인됨.
AM 인터페이스:
table_relation_fetch_toast_slice가 tableam 분기점이며, 힙 구현이heap_fetch_toast_slice임이src/backend/access/heap/heaptoast.c에서 확인됨.
미해결 / 범위 외:
toast_build_flattened_tuple/toast_flatten_tuple변형은 컨테이너 타입 및 CLUSTER/rewrite용 유틸리티 헬퍼다.toast_save_datum의 테이블 재작성 OID 보존 경로는 존재하지만 완전히 추적하지 않았다.large_object서브시스템(storage/large_object/)은 TOAST와 별개의 메커니즘이며 이 문서에서 다루지 않는다.- 컬럼별
default_toast_compression재정의(ALTER TABLE ... SET COMPRESSION)가toast_compress_datum의cmethod매개변수와 상호작용함을 확인했으나, 카탈로그 경로는 추적하지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”MySQL/InnoDB off-page 컬럼
섹션 제목: “MySQL/InnoDB off-page 컬럼”InnoDB는 큰 BLOB, TEXT, VARCHAR 값을 같은 .ibd 파일의 오버플로 페이지에 저장한다. COMPACT 행 형식 기준으로 인라인 부분이 약 20바이트로 줄어들면 나머지는 오버플로 페이지로 이동한다. 임계값은 약 40바이트다. PostgreSQL TOAST와 달리 InnoDB는 기본적으로 스토리지 레이어에서 개별 컬럼 값을 압축하지 않는다. 압축은 개별 컬럼 값이 아니라 B-tree 페이지에 적용되는 테이블 수준 옵션이다. 트레이드오프를 정리하면, InnoDB의 off-page 방식은 별도 힙을 피하지만, TOAST의 외부 저장 방식은 vacuum이 TOAST 행을 독립적으로 회수하게 하고 압축을 페이지 압축과 직교하게 유지한다.
Oracle LOB
섹션 제목: “Oracle LOB”Oracle LOB 컬럼은 메인 테이블 세그먼트 외부의 전용 LOB 세그먼트에 저장된다. 임계값(11g: 4 KB; 12c+: 설정 가능) 이하의 LOB은 행 안에 인라인으로 저장된다(“인라인 LOB”). Oracle BasicFiles는 extent 기반 저장 모델을 쓰고, SecureFiles(11g+)는 LOB 수준에서 중복 제거, 압축, 암호화를 추가한다. TOAST와의 의미론적 차이는 명확하다. Oracle LOB은 읽기/쓰기 커서 API를 가진 별도 SQL 타입이다. PostgreSQL TOAST는 완전히 투명하여 애플리케이션이 TEXT나 BYTEA를 평범하게 사용한다.
SQL Server 행 오버플로와 LOB 페이지
섹션 제목: “SQL Server 행 오버플로와 LOB 페이지”SQL Server는 VARCHAR(MAX), NVARCHAR(MAX), VARBINARY(MAX), TEXT/IMAGE를 8 KB 데이터 페이지와 별개의 LOB 페이지에 저장한다. 메인 행은 24바이트 포인터를 담는다. 더 작은 값이지만 8060바이트 행 제한을 넘는 경우에는 행 오버플로 페이지를 사용한다. TOAST 1단계에 해당하는 인라인 압축 동등물은 없다. 페이지 수준 압축(PAGE COMPRESSION)은 InnoDB의 페이지 압축과 유사한 테이블 옵션이다.
연구: 컬럼형 및 압축 저장
섹션 제목: “연구: 컬럼형 및 압축 저장”TOAST의 datum 단위 압축 모델은 컬럼형 저장 운동 이전에 설계됐다는 점이다. 컬럼형 저장은 개별 값이 아니라 컬럼 전체 실행(run-length encoding, 딕셔너리 인코딩, 델타 인코딩)을 압축한다. 컬럼형 압축 비율이 일반적으로 훨씬 높은 이유는 압축기가 타입과 분포가 같은 많은 값을 한꺼번에 보기 때문이다. DuckDB나 Apache Parquet 같은 엔진은 컬럼형 압축만 사용한다. Greenplum의 AO 테이블 같은 하이브리드 HTAP 엔진은 컬럼형 세그먼트에서 TOAST를 우회하고 블록 수준에서 대형 값을 처리한다.
PostgreSQL 12+의 플러그형 AM 표면(table_relation_fetch_toast_slice 분기)은 TOAST를 완전히 우회하려는 AM을 위한 확장 지점이다.
향후 과제: TOAST와 비동기 I/O (PG18)
섹션 제목: “향후 과제: TOAST와 비동기 I/O (PG18)”PostgreSQL 18은 시퀀셜 스캔 중 힙 페이지를 프리패치하는 비동기 I/O 레이어(storage/aio/)를 도입했다. heap_fetch_toast_slice를 통한 TOAST 조각 읽기(systable_beginscan_ordered 경유)는 현재 비동기 I/O를 우회하고 동기 ReadBuffer 호출을 사용한다. 잠재적인 향후 최적화로, 메인 테이블 인덱스 스캔이 외부 저장 포인터를 감지할 때 TOAST 조각을 미리 가져오는 방법이 있다. REL_18_STABLE에는 아직 구현되어 있지 않은 열린 공학 과제다.
src/backend/access/heap/heaptoast.c— 힙 특화 토스팅 진입점 및 조각 조회src/backend/access/common/toast_internals.c—toast_save_datum,toast_delete_datum,toast_compress_datum, 인덱스 헬퍼,get_toast_snapshotsrc/backend/access/common/detoast.c— 역직렬화, 부분 조회, 크기 보고src/backend/access/common/toast_compression.c— PGLZ와 LZ4 압축/압축 해제 분기src/include/access/heaptoast.h—TOAST_TUPLE_TARGET,TOAST_MAX_CHUNK_SIZE, 함수 선언src/include/access/toast_internals.h—toast_compress_header,TOAST_COMPRESS_*매크로, 함수 선언src/include/access/detoast.h—TOAST_POINTER_SIZEsrc/include/access/toast_helper.h—ToastAttrInfo,ToastTupleContext,TOAST_HAS_NULLS,TOAST_NEEDS_CHANGE,TOASTCOL_*플래그src/include/varatt.h—varatt_external,varatt_indirect,VARTAG_*,VARATT_IS_*매크로,VARHDRSZ_*src/include/catalog/pg_type.h—TYPSTORAGE_PLAIN/EXTERNAL/MAIN/EXTENDEDknowledge/code-analysis/postgres/postgres-heap-am.md— 힙 튜플 레이아웃, 슬롯 페이지, HOT 컨텍스트knowledge/code-analysis/postgres/postgres-page-layout.md—BLCKSZ, 페이지 헤더 레이아웃knowledge/code-analysis/postgres/postgres-mvcc-snapshots.md— 스냅샷 의미론knowledge/code-analysis/postgres/postgres-vacuum.md— vacuum이 죽은 TOAST 행 회수- Database System Concepts, Silberschatz 외, 7판, 13장 “Data Storage Structures”
- Database Internals, Alex Petrov, 3장 “File Formats”