(KO) PostgreSQL 페이지 레이아웃 — 슬롯 페이지 구조, ItemId 간접 참조, 체크섬 메커니즘
목차
이론적 배경
섹션 제목: “이론적 배경”디스크 기반 관계형 데이터베이스는 모두 같은 물리적 문제에 직면한다. 디스크는 고정 크기 단위(섹터, OS 페이지)로만 읽고 쓸 수 있는 반면, 엔진이 다루는 튜플은 가변 길이이고 바이트 오프셋이 아닌 논리 주소로 O(1)에 찾아야 한다. Database Internals(Petrov, ch. 3 §“Page Structure”)는 이를 두 단계 주소 지정 문제로 정리한다. 엔진은 디스크를 고정 크기 페이지(PostgreSQL에서는 block이라고도 한다)로 나누고, 페이지 내 가변 길이 레코드는 간접 참조 계층 — 논리 슬롯 번호를 페이지 내 바이트 오프셋에 매핑하는 디렉터리 — 으로 관리한다.
두 가지 설계 축이 이로부터 자연스럽게 따라온다.
-
아이템을 어떻게 주소로 지칭할 것인가? 바이트 오프셋을 직접 저장하면 페이지를 압축하는 순간 모든 참조가 깨진다. 논리 슬롯 번호(디렉터리 인덱스)를 쓰면 슬롯 번호만 유효한 한 페이지 내 바이트를 자유롭게 재배치할 수 있다. PostgreSQL은 논리 슬롯 번호를 선택했고, 이를 line pointer 또는 item identifier라고 부른다.
-
페이지 내 자유 공간을 어떻게 관리할 것인가? 아이템 디렉터리를 한쪽 끝에서 늘려 가고 아이템 데이터를 반대쪽 끝에서 채워 가다 중간의 자유 공간 갭에서 만나게 하는 방식이 가장 단순하다. 이것이 슬롯 페이지(slotted page) 레이아웃으로, Database System Concepts(Silberschatz 외, ch. 13 §“File Organization”)에 기술된 표준 방식이다. 별도의 프리 리스트 오버헤드가 없다는 점이 장점이다.
힙 페이지에서는 세 번째 축이 중요하다. MVCC(다중 버전 동시성 제어)를 위해 온페이지 튜플 헤더에 트랜잭션 가시성 정보(t_xmin, t_xmax, t_ctid, t_infomask)가 담겨야 한다는 점이다. InnoDB는 undo 포인터만 헤더에 두고 이전 버전을 별도 undo 영역에서 재구성하지만, PostgreSQL은 버전 체인을 힙에 직접 인라인하는 방식을 택했다.
네 번째 관심사는 *조용한 데이터 손상(silent data corruption)*이다. 스토리지 비트 플립이나 불량 HBA는 구조적으로 유효해 보이지만 잘못된 데이터를 담은 페이지를 만들 수 있다. 쓰기 시점에 계산해 읽기 시점에 검증하는 페이지 체크섬이 이 유형의 오류를 탐지한다. PostgreSQL은 9.3 버전에서 선택적 페이지 체크섬을 도입했고, PostgreSQL 18 기준으로는 오프라인 클러스터에서 pg_checksums로 활성화·비활성화할 수 있다.
DBMS 공통 설계
섹션 제목: “DBMS 공통 설계”슬롯 페이지 레이아웃은 대부분의 디스크 기반 DBMS에서 거의 동일한 형태로 수렴했다. PostgreSQL의 구체적인 선택을 읽기 전에, 공통적으로 쓰이는 공학 관례를 먼저 짚어 두면 이후 내용을 하나의 설계 공간 안에서 파악할 수 있다.
헤더 → 라인 포인터 배열 → 자유 공간 갭 → 아이템 영역
섹션 제목: “헤더 → 라인 포인터 배열 → 자유 공간 갭 → 아이템 영역”슬롯 페이지는 네 개의 연속 영역으로 구성된다.
- 고정 크기 헤더 — 페이지 수준 메타데이터. LSN 또는 시퀀스 번호(WAL용), 플래그, 세 가변 영역을 구분하는 세 오프셋(
lower,upper,special)이 여기 있다. - 라인 포인터 배열 — 아이템이 삽입될수록 앞쪽(높은 오프셋 방향)으로 늘어난다. 각 슬롯은 소형 고정 크기 구조체로, 아이템 바디의 바이트 오프셋·길이·상태 플래그를 담는다.
- 자유 공간 갭 —
pd_lower와pd_upper사이의 미사용 영역. 새 라인 포인터 슬롯 하나와 새 아이템 바디 하나를 모두 담기 어려울 때 페이지가 꽉 찬 것으로 본다. - 아이템 영역 —
pd_upper에서 뒤쪽(낮은 오프셋 방향)으로 채워진다.MAXALIGN패딩을 적용해 정렬을 유지한다.
이 레이아웃에서 한 가지 핵심 불변식이 있다. 라인 포인터 슬롯은 한 번 할당되면 이동하지 않는다(LP_UNUSED 표시 후에만 재사용 가능). 아이템 바디는 PageRepairFragmentation이 한데 모을 수 있다. 외부 참조 — 인덱스에 저장된 ItemPointer — 는 (블록, 슬롯 번호) 쌍을 담으므로 바이트 위치가 바뀌어도 무효화되지 않는다.
액세스 메서드 메타데이터를 위한 스페셜 공간
섹션 제목: “액세스 메서드 메타데이터를 위한 스페셜 공간”인덱스는 아이템 스트림과 별도로 페이지별 메타데이터가 필요하다. 트리 레벨, 형제 포인터, 사이클 탐지 마커 등이 그것이다. 관례는 페이지 끝에 **스페셜 공간(special space)**을 마련하는 것이며, PageInit 시점에 크기가 확정된다. 힙 페이지는 스페셜 공간이 없다. 인덱스 AM은 각자의 opaque 구조체(BTPageOpaqueData 등)를 여기에 기록한다.
TID: 페이지를 넘나드는 논리 주소
섹션 제목: “TID: 페이지를 넘나드는 논리 주소”*튜플 식별자(TID)*는 한 페이지의 콘텐츠가 다른(또는 같은) 페이지의 튜플을 가리키는 지속 논리 주소다. (블록 번호, 라인 포인터 오프셋 번호) 쌍을 담는다. 인덱스는 TID를 힙 튜플을 가리키는 페이로드로 저장하고, 힙은 t_ctid 필드에 TID를 써서 MVCC 버전 체인을 형성한다. TID는 최소한의 크로스 페이지 참조 단위다. 인덱스 리프에 넣기에 충분히 작고, 튜플 압축에도 안정적이며, 버퍼 매니저 조회 한 번으로 해결된다.
페이지 체크섬: 행이 아닌 블록 단위
섹션 제목: “페이지 체크섬: 행이 아닌 블록 단위”스토리지 무결성을 위한 표준 기술은 페이지 헤더에 16비트 체크섬을 저장하고, 쓰기 시점에 계산해 읽기 시점에 검증하는 것이다. 행 단위로 계산하면 모든 읽기·쓰기에서 모든 행 헤더를 건드려야 해 비용이 지나치다. 블록 전체를 한 번에 다루면 블록 I/O당 계산 한 번으로 끝난다. 체크섬에 블록 번호를 섞으면 블록 위치 이탈(잘못된 섹터에 쓰인 페이지)도 감지된다.
이론 ↔ PostgreSQL 매핑
섹션 제목: “이론 ↔ PostgreSQL 매핑”| 개념 | PostgreSQL 이름 |
|---|---|
| 페이지 / 블록 | Page (PageData) — BLCKSZ 바이트(기본 8192) |
| 페이지 헤더 | PageHeaderData (bufpage.h) |
| 자유 공간 하한 경계 | pd_lower |
| 자유 공간 상한 경계 | pd_upper |
| 스페셜 공간 경계 | pd_special |
| 마지막 변경의 WAL 위치 | pd_lsn (PageXLogRecPtr) |
| 라인 포인터 / 슬롯 | ItemIdData (itemid.h) |
| 라인 포인터 상태 | LP_UNUSED / LP_NORMAL / LP_REDIRECT / LP_DEAD |
| 튜플 식별자(TID) | ItemPointerData (itemptr.h) |
| TID의 블록 번호 부분 | ip_blkid (BlockIdData) |
| TID의 오프셋 번호 부분 | ip_posid (OffsetNumber) |
| 페이지 체크섬 | pd_checksum; pg_checksum_page로 계산 |
| 페이지 초기화 | PageInit |
| 페이지 검증 | PageIsVerified |
| 아이템 삽입 | PageAddItemExtended |
| 튜플 압축 | PageRepairFragmentation |
PostgreSQL의 접근법
섹션 제목: “PostgreSQL의 접근법”페이지 헤더: PageHeaderData
섹션 제목: “페이지 헤더: PageHeaderData”PostgreSQL의 모든 페이지 — 힙 페이지, 인덱스 페이지, FSM 페이지, VM 페이지 — 는 동일한 24바이트 PageHeaderData로 시작한다.
// PageHeaderData — src/include/storage/bufpage.htypedef struct PageHeaderData{ PageXLogRecPtr pd_lsn; /* WAL position of last change */ uint16 pd_checksum; /* page checksum (optional) */ uint16 pd_flags; /* PD_HAS_FREE_LINES | PD_PAGE_FULL | PD_ALL_VISIBLE */ LocationIndex pd_lower; /* offset to start of free space */ LocationIndex pd_upper; /* offset to end of free space */ LocationIndex pd_special; /* offset to start of special space */ uint16 pd_pagesize_version; /* high byte = page size; low byte = layout version */ TransactionId pd_prune_xid; /* oldest prunable XID on this page, or 0 */ ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* line pointer array */} PageHeaderData;SizeOfPageHeaderData는 offsetof(PageHeaderData, pd_linp)로 정의되며, 현재 모든 플랫폼에서 24바이트다. 라인 포인터 배열은 이 고정 헤더 직후부터 시작한다.
pd_lsn 은 버퍼 매니저에서 가장 중요한 필드다. FlushBuffer가 더티 페이지를 디스크에 기록하기 직전, XLogFlush(PageGetLSN(page))를 호출해 이 페이지의 마지막 변경 LSN까지 WAL이 안정 스토리지에 기록됐음을 보장한다. 이것이 PostgreSQL이 WAL-before-data 규칙을 물리적으로 집행하는 방식이다.
pd_pagesize_version 은 페이지 크기와 레이아웃 버전을 하나의 uint16에 압축한다. 상위 바이트는 페이지 크기를 256 단위로 인코딩하고(BLCKSZ = 8192 → 0x2000), 하위 바이트는 레이아웃 버전 번호로 현재 PG_PAGE_LAYOUT_VERSION = 4다(PostgreSQL 8.3 이후 불변). PageGetPageSize는 0xFF00 마스크로 크기를, PageGetPageLayoutVersion은 0x00FF 마스크로 버전을 추출한다.
pd_flags 는 세 개의 힌트 비트를 담는다.
PD_HAS_FREE_LINES (0x0001)—pd_lower앞에LP_UNUSED슬롯이 하나 이상 있다. WAL에 기록되지 않는 힌트이며, 틀릴 경우 라인 포인터 배열을 선형 탐색하는 폴백이 작동한다.PageAddItemExtended와PageRepairFragmentation이 이 비트를 설정·초기화한다.PD_PAGE_FULL (0x0002)—UPDATE가 새 튜플 버전을 넣을 공간을 찾지 못했다. 프루닝이 필요하다는 힌트다. WAL에 기록되지 않는다.PD_ALL_VISIBLE (0x0004)— 이 페이지의 모든 튜플이 모든 트랜잭션에 가시적이다(dead 버전이 없다). 가시성 맵이 이 비트를 기반으로 인덱스 전용 스캔에서 튜플별 가시성 검사를 생략한다.
pd_prune_xid 는 프루닝 결정을 돕는 힌트 필드다. 트랜잭션 xid가 튜플을 삭제하면 PageSetPrunable(page, xid)가 이 페이지에서 지금까지 본 것 중 가장 오래된 XID로 pd_prune_xid를 갱신한다. VACUUM과 힙 프루너는 이 필드를 확인해 이 페이지에 제거 가능한 dead 버전이 있는지 판단한다.
라인 포인터: ItemIdData
섹션 제목: “라인 포인터: ItemIdData”pd_linp[] 배열은 바이트 오프셋 SizeOfPageHeaderData부터 앞으로 자란다. 각 슬롯은 세 비트 필드로 이루어진 32비트 워드다.
// ItemIdData — src/include/storage/itemid.htypedef struct ItemIdData{ unsigned lp_off:15, /* byte offset to item body, from page start */ lp_flags:2, /* state: LP_UNUSED/LP_NORMAL/LP_REDIRECT/LP_DEAD */ lp_len:15; /* byte length of item body */} ItemIdData;15비트 필드가 오프셋과 길이를 최대 32767로 제한하는 것이 PostgreSQL 페이지 크기 상한이 32 KB(BLCKSZ ≤ 32768)인 이유다. 네 가지 라인 포인터 상태가 존재한다.
| 상태 | 값 | 의미 |
|---|---|---|
LP_UNUSED | 0 | 빈 슬롯. lp_len은 항상 0. 재사용 가능. |
LP_NORMAL | 1 | 활성 아이템. lp_off와 lp_len 유효. |
LP_REDIRECT | 2 | HOT 리다이렉트. lp_off는 바이트 오프셋이 아닌 다음 슬롯의 오프셋 번호. lp_len은 0. |
LP_DEAD | 3 | Dead 상태. 튜플 바디가 남아 있을 수도, 없을 수도 있다. 힙 프루닝이 설정하며, VACUUM이 LP_UNUSED로 전환한다. |
LP_REDIRECT 상태는 Heap Only Tuple(HOT) 의 구현체다. UPDATE가 인덱스 열을 변경하지 않을 때, 새 튜플 버전은 같은 페이지에 삽입되고 새 인덱스 항목은 만들어지지 않는다. 기존 라인 포인터는 LP_REDIRECT로 전환되고, lp_off에는 새 버전의 슬롯 번호가 들어간다. 인덱스 스캔은 힙 페이지 내에서 리다이렉트 체인을 따라가므로 인덱스를 재진입하지 않는다. 빈번히 갱신되는 테이블의 인덱스 크기를 안정적으로 유지한다는 점이 핵심이다.
PageGetMaxOffsetNumber는 할당된 라인 포인터 수를 pd_lower에서 직접 계산한다.
// PageGetMaxOffsetNumber — src/include/storage/bufpage.hstatic inline OffsetNumberPageGetMaxOffsetNumber(const PageData *page){ const PageHeaderData *pageheader = (const PageHeaderData *) page; if (pageheader->pd_lower <= SizeOfPageHeaderData) return 0; else return (pageheader->pd_lower - SizeOfPageHeaderData) / sizeof(ItemIdData);}PageGetItemId(page, offsetNumber)는 pd_linp[offsetNumber - 1]에 대한 포인터를 반환한다. 오프셋 번호는 관례상 1부터 시작한다.
튜플 식별자: ItemPointerData
섹션 제목: “튜플 식별자: ItemPointerData”ItemPointerData(a/k/a TID)는 힙 튜플 하나를 가리키는 6바이트 크로스 페이지 주소다.
// ItemPointerData — src/include/storage/itemptr.htypedef struct ItemPointerData{ BlockIdData ip_blkid; /* block number (4 bytes: hi+lo uint16 pair) */ OffsetNumber ip_posid; /* line-pointer offset number (1-based) */} ItemPointerData;BlockIdData는 역사적 정렬 이유로 블록 번호를 두 개의 uint16(bi_hi, bi_lo)으로 나눠 저장한다. BlockIdGetBlockNumber가 이를 재조립한다. 최대 블록 번호는 2^32 - 1이며, BLCKSZ = 8192 기준 릴레이션당 최대 32 TB에 해당한다.
TID는 두 가지 역할을 맡는다. 인덱스에서는 각 리프 항목이 힙 튜플을 가리키는 페이로드로 TID를 저장한다. 힙에서는 HeapTupleHeaderData.t_ctid가 이 튜플의 최신 버전 TID를 담는다. 더 새로운 버전이 없으면 t_ctid는 자기 자신을 가리키고, 업데이트됐다면 새 버전을 가리킨다. MVCC 가시성 로직(heapam_visibility.c)은 주어진 스냅샷에 맞는 버전을 찾기 위해 t_ctid 체인을 따라간다.
아이템 영역: 튜플 바디는 pd_upper에서 아래로
섹션 제목: “아이템 영역: 튜플 바디는 pd_upper에서 아래로”// PageAddItemExtended (축약) — src/backend/storage/page/bufpage.cOffsetNumberPageAddItemExtended(Page page, Item item, Size size, OffsetNumber offsetNumber, int flags){ PageHeader phdr = (PageHeader) page; // ... 축약: 오프셋 검증, 빈 슬롯 찾기 또는 배열 확장 ... alignedSize = MAXALIGN(size); upper = (int) phdr->pd_upper - (int) alignedSize; if (lower > upper) return InvalidOffsetNumber; /* page full */
ItemIdSetNormal(itemId, upper, size); /* record byte offset + length */ memcpy((char *) page + upper, item, size); phdr->pd_lower = (LocationIndex) lower; phdr->pd_upper = (LocationIndex) upper; return offsetNumber;}삽입마다 pd_upper는 MAXALIGN(size)만큼 낮아지고 pd_lower는 sizeof(ItemIdData)만큼 높아진다. 이 두 값을 모두 반영했을 때 lower > upper면 페이지가 꽉 찬 것이다. MAXALIGN은 튜플 바디의 시작이 8바이트(또는 플랫폼 지정) 경계에 맞도록 보장한다. 이는 튜플 내 숫자형 필드에 대한 제로 카피 접근에 필수적이다.
단편화와 압축: PageRepairFragmentation
섹션 제목: “단편화와 압축: PageRepairFragmentation”VACUUM이나 힙 프루닝이 dead 튜플을 LP_UNUSED 또는 LP_DEAD로 표시하고 바디를 지우면 아이템 영역에 구멍이 생긴다. pd_upper 포인터는 압축 패스 없이는 되돌릴 수 없다. PageRepairFragmentation은 살아있는 아이템 바디를 모두 수집해 compactify_tuples로 pd_special 쪽으로 밀어붙이고 pd_upper를 재설정한다. 라인 포인터 슬롯 자체는 이동하지 않으며, lp_off 값만 새 위치로 갱신된다. pd_linp[] 배열 끝에 연속으로 쌓인 LP_UNUSED 슬롯도 잘라내어 pd_lower를 줄인다.
// PageRepairFragmentation (축약) — src/backend/storage/page/bufpage.cvoidPageRepairFragmentation(Page page){ // ... 축약: 살아있는 아이템을 itemidbase[]에 수집, 손상 여부 점검 ... compactify_tuples(itemidbase, nstorage, page, presorted); // truncate trailing unused line pointers from pd_linp[] if (finalusedlp != nline) ((PageHeader) page)->pd_lower -= (sizeof(ItemIdData) * nunusedend); // set PD_HAS_FREE_LINES hint}presorted 패스트 경로(아이템이 lp_off 내림차순, 즉 삽입 순서로 정렬된 경우)는 memmove 한 번으로 처리한다. 정렬되지 않은 경우는 임시 버퍼를 경유해 복사한다. 호출자는 버퍼에 대한 배타적 cleanup 락을 보유해야 한다.
페이지 초기화: PageInit
섹션 제목: “페이지 초기화: PageInit”// PageInit — src/backend/storage/page/bufpage.cvoidPageInit(Page page, Size pageSize, Size specialSize){ PageHeader p = (PageHeader) page; specialSize = MAXALIGN(specialSize); MemSet(p, 0, pageSize); /* zero the whole page first */ p->pd_flags = 0; p->pd_lower = SizeOfPageHeaderData; /* no line pointers yet */ p->pd_upper = pageSize - specialSize; /* item area ceiling */ p->pd_special = pageSize - specialSize; /* special-space floor */ PageSetPageSizeAndVersion(page, pageSize, PG_PAGE_LAYOUT_VERSION); /* pd_prune_xid zeroed by MemSet */}초기화 직후 pd_lower == pd_upper가 성립한다. PageIsEmpty는 pd_lower <= SizeOfPageHeaderData를 확인한다.
페이지 검증: PageIsVerified
섹션 제목: “페이지 검증: PageIsVerified”버퍼 매니저가 디스크에서 페이지를 읽어 들일 때, 공유 풀에 넣기 전에 PageIsVerified를 호출한다.
// PageIsVerified (축약) — src/backend/storage/page/bufpage.cboolPageIsVerified(PageData *page, BlockNumber blkno, int flags, bool *checksum_failure_p){ if (!PageIsNew(page)) { if (DataChecksumsEnabled()) { checksum = pg_checksum_page(page, blkno); if (checksum != p->pd_checksum) checksum_failure = true; } if ((p->pd_flags & ~PD_VALID_FLAG_BITS) == 0 && p->pd_lower <= p->pd_upper && p->pd_upper <= p->pd_special && p->pd_special <= BLCKSZ && p->pd_special == MAXALIGN(p->pd_special)) header_sane = true; if (header_sane && !checksum_failure) return true; } /* all-zeros page is acceptable (crashed extension of relation) */ if (pg_memory_is_all_zeros((size_t *) page, BLCKSZ)) return true; return false;}체크섬 검사(활성화된 경우)와 네 오프셋 필드에 대한 구조 건전성 검사가 순서대로 실행된다. 전체 0 페이지는 무조건 통과된다. 이는 백엔드가 릴레이션을 확장한 후 새 블록에 대한 WAL을 기록하기 전에 크래시하면 이런 페이지가 생길 수 있기 때문이다. VACUUM이 나중에 정리한다. 이 함수는 오류 보고 방식을 제어하는 PIV_LOG_WARNING, PIV_LOG_LOG, PIV_IGNORE_CHECKSUM_FAILURE 플래그를 받는다.
체크섬: 블록 번호를 섞은 16비트 폴드-앤-XOR
섹션 제목: “체크섬: 블록 번호를 섞은 16비트 폴드-앤-XOR”pd_checksum 필드에는 pg_checksum_page(checksum.h에 선언, checksum.c에 구현)가 계산한 16비트 값이 들어간다. 알고리즘은 8 KB 페이지를 32바이트 청크로 처리하며 블록 번호를 혼합한 뒤 결과를 16비트로 접는다. 블록 번호를 섞는 것이 중요하다. 같은 페이지 바이트라도 블록 0과 블록 1000에서 서로 다른 체크섬이 나오기 때문에 잘못된 위치에 쓰인 블록도 감지할 수 있다.
PageSetChecksumCopy(버퍼 플러시에 사용)는 체크섬 계산 전에 페이지 사본을 만들어 동시 힌트 비트 갱신이 최종 값에 영향을 미치지 않도록 한다. PageSetChecksumInplace(pg_checksums 등 오프라인 경로에 사용)는 페이지에 직접 쓴다. storage/page/README는 불변식을 명시한다. 체크섬은 페이지가 공유 풀에 들어오거나 나갈 때만 유효하며, 힌트 비트 업데이트로 수시로 수정될 수 있는 상주 상태에서는 유효하지 않다는 것이다.
두 설정 함수 모두 동일한 두 가지 단락 조건을 공유한다. PageIsNew인 페이지(전혀 초기화되지 않은, 전체 0)이거나 !DataChecksumsEnabled()인 클러스터는 체크섬 계산을 건너뛴다.
// PageSetChecksumCopy — src/backend/storage/page/bufpage.cchar *PageSetChecksumCopy(Page page, BlockNumber blkno){ static char *pageCopy = NULL; if (PageIsNew(page) || !DataChecksumsEnabled()) return page; /* nothing to do */ if (pageCopy == NULL) pageCopy = MemoryContextAllocAligned(TopMemoryContext, BLCKSZ, PG_IO_ALIGN_SIZE, 0); memcpy(pageCopy, page, BLCKSZ); ((PageHeader) pageCopy)->pd_checksum = pg_checksum_page(pageCopy, blkno); return pageCopy;}정적 pageCopy는 백엔드당 한 번 PG_IO_ALIGN_SIZE에 맞춰 할당되고, 이후 모든 플러시에서 재사용된다. 호출자는 반환된 포인터를 즉시 기록해야 하며, 보관해서는 안 된다. PageSetChecksumInplace는 복사 없이 pg_checksum_page(page, blkno) 결과를 pd_checksum에 직접 쓴다.
체크섬 알고리즘: 병렬 FNV-1a 폴드
섹션 제목: “체크섬 알고리즘: 병렬 FNV-1a 폴드”pg_checksum_page는 src/backend/storage/page/checksum.c에 있지만, 그 파일은 storage/checksum_impl.h를 #include하는 한 줄짜리 셸이다. 이 간접 참조 덕분에 외부 도구(pg_checksums, pg_upgrade, pg_basebackup)가 동일한 코드를 헤더 하나 포함으로 재사용할 수 있다. 알고리즘은 컴파일러가 자동 SIMD 최적화를 적용할 수 있도록 설계된 FNV-1a(Fowler/Noll/Vo) 변형이다. 워킹 셋이 OS 캐시에는 들어가지만 공유 버퍼에는 들어가지 않는 읽기 집중 워크로드에서 체크섬이 지배적인 비용이 되므로, 속도가 설계의 핵심 동인이다.
페이지는 N_SUMS = 32개 열로 이루어진 2차원 uint32 배열로 다뤄진다. 각 열은 별도의 난수 기반 상수(seed)로 독립적으로 폴드된다. SIMD 유닛이 32개 레인을 병렬로 처리할 수 있는 이유가 여기에 있다.
// checksum constants and one fold round — src/include/storage/checksum_impl.h#define N_SUMS 32#define FNV_PRIME 16777619typedef union { PageHeaderData phdr; uint32 data[BLCKSZ / (sizeof(uint32) * N_SUMS)][N_SUMS];} PGChecksummablePage;#define CHECKSUM_COMP(checksum, value) \do { \ uint32 __tmp = (checksum) ^ (value); \ (checksum) = __tmp * FNV_PRIME ^ (__tmp >> 17); \} while (0)>> 17 XOR 시프트는 PostgreSQL이 순수 FNV-1a에 추가한 부분이다. 순수 FNV는 상위 입력 비트가 상위 출력 비트에만 영향을 주는 약점이 있는데, 시프트가 상위 비트를 하위로 접어 넣어 이를 보완한다. pg_checksum_block은 checksumBaseOffsets[]로 32개 레인을 초기화하고 모든 행에 CHECKSUM_COMP를 적용한다. 이후 마지막 행을 충분히 혼합하기 위해 0으로 두 번 더 라운드를 돌린 뒤, 32개 부분 합을 XOR로 접어 단일 32비트 결과를 만든다.
// pg_checksum_block (축약) — src/include/storage/checksum_impl.hstatic uint32pg_checksum_block(const PGChecksummablePage *page){ uint32 sums[N_SUMS]; uint32 result = 0, i, j; memcpy(sums, checksumBaseOffsets, sizeof(checksumBaseOffsets)); for (i = 0; i < (BLCKSZ / (sizeof(uint32) * N_SUMS)); i++) for (j = 0; j < N_SUMS; j++) CHECKSUM_COMP(sums[j], page->data[i][j]); for (i = 0; i < 2; i++) /* two zero rounds to mix last row */ for (j = 0; j < N_SUMS; j++) CHECKSUM_COMP(sums[j], 0); for (i = 0; i < N_SUMS; i++) result ^= sums[i]; return result;}pg_checksum_page는 블록 해시를 두 가지 페이지 고유 처리로 감싼다. 먼저 pd_checksum을 임시로 0으로 만들어 저장된 값이 재계산에 영향을 주지 않도록 한다(복원은 호출자의 몫). 블록 해시 이후 blkno를 XOR로 섞는다. 이것이 잘못된 섹터에 쓰인 페이지가 검증에 실패하게 만드는 장치다. 최종적으로 (checksum % 65535) + 1로 16비트에 사상한다. + 1은 결과가 절대 0이 되지 않도록 보장하며, 0을 “체크섬 미기록” 센티널로 예약한다.
// pg_checksum_page (축약) — src/include/storage/checksum_impl.huint16pg_checksum_page(char *page, BlockNumber blkno){ PGChecksummablePage *cpage = (PGChecksummablePage *) page; uint16 save_checksum; uint32 checksum; save_checksum = cpage->phdr.pd_checksum; cpage->phdr.pd_checksum = 0; /* exclude stored checksum */ checksum = pg_checksum_block(cpage); cpage->phdr.pd_checksum = save_checksum; checksum ^= blkno; /* detect transposed pages */ return (uint16) ((checksum % 65535) + 1);}PGChecksummablePage 유니온은 PageHeaderData를 uint32 그리드에 오버레이한다. pd_lsn, pd_flags, 네 오프셋 필드를 포함한 헤더 전체가 체크섬에 포함되지만, pd_checksum 필드 자체는 임시 0 처리로 제외된다. pg_checksum_block 내부의 Assert(sizeof(PGChecksummablePage) == BLCKSZ)는 그리드가 블록을 정확히 타일링함을 보장한다. 지원되는 모든 BLCKSZ(1~32 KB, 256 이상의 2의 거듭제곱)는 4 * N_SUMS = 128의 배수 조건을 충족한다.
체크섬 읽기/쓰기 흐름
섹션 제목: “체크섬 읽기/쓰기 흐름”flowchart TD
subgraph WRITE["쓰기 경로 — 공유 풀을 나가는 페이지"]
W0["FlushBuffer / SyncOneBuffer"]
W1{"PageIsNew 또는<br/>!DataChecksumsEnabled?"}
W2["페이지 그대로 반환<br/>(체크섬 없음)"]
W3["PageSetChecksumCopy:<br/>정렬된 pageCopy에 memcpy"]
W4["pg_checksum_page(pageCopy, blkno)"]
W5["사본에 pd_checksum 기록"]
W6["smgrwrite()로 디스크에 쓰기"]
W0 --> W1
W1 -->|yes| W2
W1 -->|no| W3 --> W4 --> W5 --> W6
end
subgraph CKSUM["pg_checksum_page 내부"]
C1["pd_checksum 저장 후 필드 = 0으로 설정"]
C2["pg_checksum_block:<br/>32개 병렬 FNV-1a 레인<br/>uint32 그리드 전체 처리"]
C3["두 번의 0 라운드 + XOR 폴드로 uint32 도출"]
C4["checksum ^= blkno"]
C5["pd_checksum 복원<br/>(checksum % 65535) + 1 반환"]
C1 --> C2 --> C3 --> C4 --> C5
end
subgraph READ["읽기 경로 — 공유 풀에 들어오는 페이지"]
R0["smgrread()로 디스크에서 블록 읽기"]
R1["PageIsVerified(page, blkno, ...)"]
R2{"DataChecksumsEnabled?"}
R3["computed = pg_checksum_page(page, blkno)"]
R4{"computed == pd_checksum?"}
R5["헤더 건전성:<br/>pd_lower<=pd_upper<=pd_special<=BLCKSZ"]
R6["버퍼 풀에 페이지 수용"]
R7{"페이지 전체가 0인가?"}
R8["수용 (릴레이션 확장 중 크래시)"]
R9["체크섬 실패: ERROR / WARNING<br/>PIV_* 플래그에 따라"]
R0 --> R1 --> R2
R2 -->|yes| R3 --> R4
R2 -->|no| R5
R4 -->|yes| R5
R4 -->|no| R7
R5 --> R6
R7 -->|yes| R8
R7 -->|no| R9
end
W4 -.calls.-> C1
R3 -.calls.-> C1
그림 2 — 체크섬 생애주기. 쓰기 경로는 페이지가 공유 풀을 떠날 때 사본에 체크섬을 계산하고, 읽기 경로는 페이지가 들어올 때 재계산해 비교한다. 두 경로 모두 같은 pg_checksum_page(중앙)를 호출하며, 이 함수는 blkno를 XOR로 섞으므로 잘못된 섹터에 쓰인 페이지는 검증에 실패한다. 전체 0 페이지는 릴레이션 확장 중 크래시가 정상적으로 남기는 블록이므로 실패 분기를 우회한다.
페이지 레이아웃 다이어그램
섹션 제목: “페이지 레이아웃 다이어그램”flowchart TD
A["PageHeaderData<br/>(24바이트 고정)"]
B["pd_linp[0]..pd_linp[N-1]<br/>ItemIdData 배열<br/>↓ pd_upper 방향으로 증가"]
C["자유 공간 갭<br/>(pd_lower .. pd_upper)"]
D["아이템 바디<br/>(튜플 / 인덱스 항목)<br/>pd_upper에서 ↑ 방향으로 채움"]
E["스페셜 공간<br/>(힙은 0바이트,<br/>인덱스 AM은 opaque 구조체)"]
A --> B --> C --> D --> E
그림 1 — PostgreSQL 페이지의 네 영역. pd_lower는 라인 포인터 배열의 다음 빈 슬롯을, pd_upper는 아이템 영역의 최상단을 가리킨다. 삽입마다 pd_lower는 올라가고 pd_upper는 내려간다. pd_special은 PageInit 시점에 고정된다.
다음 다이어그램은 같은 페이지를 바이트 주소 맵으로 표현한다. 두 포인터가 서로를 향해 증가한다는 구조가 명확히 드러난다. pd_lower는 라인 포인터 배열의 고수위 표시(SizeOfPageHeaderData에서 위로 증가), pd_upper는 아이템 영역의 저수위 표시(pd_special에서 아래로 증가)이며, 그 사이 갭이 유일한 자유 공간이다. pd_lower + sizeof(ItemIdData) > pd_upper - MAXALIGN(size)이면 페이지가 꽉 찬 것이다.
flowchart TB
subgraph PAGE["한 BLCKSZ 페이지 (기본 8192 바이트), 바이트 오프셋 0이 최상단"]
direction TB
H["offset 0 .. SizeOfPageHeaderData (24)<br/>PageHeaderData<br/>pd_lsn | pd_checksum | pd_flags<br/>pd_lower | pd_upper | pd_special<br/>pd_pagesize_version | pd_prune_xid"]
LP["pd_linp[0] (offnum 1)<br/>pd_linp[1] (offnum 2)<br/>...<br/>pd_linp[N-1] (offnum N)<br/>ItemIdData 배열 — 슬롯이 아래로 증가 ↓<br/>각 슬롯 = lp_off:15 lp_flags:2 lp_len:15"]
FREE["자유 공간<br/>PageGetFreeSpace =<br/>pd_upper - pd_lower - sizeof(ItemIdData)"]
ITEM["tuple body N (가장 최신, 낮은 오프셋)<br/>...<br/>tuple body 1 (가장 오래된, 높은 오프셋)<br/>아이템 바디가 위로 증가 ↑, MAXALIGN 패딩"]
SP["스페셜 공간<br/>힙: 0바이트 (pd_special == BLCKSZ)<br/>btree: BTPageOpaqueData 등"]
H --> LP --> FREE --> ITEM --> SP
end
PL["pd_lower<br/>= 24 + N * sizeof(ItemIdData)"]
PU["pd_upper<br/>= 가장 최신 튜플 바디 상단"]
PS["pd_special<br/>= BLCKSZ - MAXALIGN(specialSize)"]
PL -.points at start of.-> FREE
PU -.points at end of.-> FREE
PS -.points at start of.-> SP
그림 1b — 슬롯 페이지의 바이트 맵. 라인 포인터 배열과 아이템 영역이 반대 방향에서 서로를 향해 증가한다. PageGetItemId는 1-기반 오프셋 번호로 배열을 인덱싱하고, PageGetItem은 슬롯의 lp_off를 따라 아이템 영역으로 접근한다. 인덱스 리프에 저장된 ItemPointerData TID는 (blkno, 오프셋 번호) 쌍을 담는다. 오프셋 번호가 배열 인덱스이기 때문에 PageRepairFragmentation이 lp_off를 재기록해도 외부 TID는 무효화되지 않는다.
힙 튜플 헤더 레이아웃
섹션 제목: “힙 튜플 헤더 레이아웃”힙 튜플 바디는 HeapTupleHeaderData로 시작한다.
// HeapTupleHeaderData — src/include/access/htup_details.hstruct HeapTupleHeaderData{ union { HeapTupleFields t_heap; /* xmin, xmax, cid/xvac */ DatumTupleFields t_datum; /* used when stored as a composite datum */ } t_choice; ItemPointerData t_ctid; /* TID of this or newer tuple version */ uint16 t_infomask2; /* attribute count + flags */ uint16 t_infomask; /* HEAP_XMIN_COMMITTED, HEAP_XMAX_INVALID, … */ uint8 t_hoff; /* offset to user data (past nulls bitmap) */ bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; /* nulls bitmap (optional) */ /* user data follows at offset t_hoff */};HeapTupleFields는 t_xmin(삽입 트랜잭션), t_xmax(삭제·락 트랜잭션), t_cid/t_xvac를 담는다. t_infomask의 HEAP_XMIN_COMMITTED·HEAP_XMAX_INVALID 같은 비트는 힌트 비트다. 한 번 설정되면 커밋 로그(CLOG) 조회를 건너뛰고 튜플 헤더에서 커밋 상태를 바로 읽을 수 있다. 힌트 비트 설정은 WAL 레코드 없이 페이지를 더티 상태로 만든다. 단, 체크섬이 활성화되고 페이지가 그 외에는 clean 상태일 때는 예외다. 이 경우 MarkBufferDirtyHint가 전체 페이지 이미지를 WAL에 기록해야 한다는 점을 storage/page/README가 명시한다.
소스 탐방
섹션 제목: “소스 탐방”페이지 헤더와 레이아웃 기본 요소
섹션 제목: “페이지 헤더와 레이아웃 기본 요소”PageHeaderData(bufpage.h) — 24바이트 고정 헤더. 네 오프셋 필드(pd_lsn,pd_lower,pd_upper,pd_special) 모두 여기 있다.PageXLogRecPtr(bufpage.h) —XLogRecPtr의 역사적 두uint32표현.PageXLogRecPtrGet/PageXLogRecPtrSet으로 변환.PG_PAGE_LAYOUT_VERSION(bufpage.h) — 컴파일 타임 상수, 값 4.pd_pagesize_version하위 바이트에 압축.SizeOfPageHeaderData(bufpage.h) —offsetof(PageHeaderData, pd_linp), 24바이트.PageInit후 초기pd_lower.PageInit(bufpage.c) — 페이지를 0으로 초기화하고pd_lower,pd_upper,pd_special,pd_pagesize_version을 설정.PageIsNew(bufpage.h) —pd_upper == 0;PageInit호출 전 상태.PageIsEmpty(bufpage.h) —pd_lower <= SizeOfPageHeaderData; 슬롯이 하나도 없는 상태.
라인 포인터(ItemId) 계층
섹션 제목: “라인 포인터(ItemId) 계층”ItemIdData(itemid.h) — 32비트 패킹 구조체:lp_off:15,lp_flags:2,lp_len:15.LP_UNUSED / LP_NORMAL / LP_REDIRECT / LP_DEAD(itemid.h) — 네 가지 라인 포인터 상태.LP_REDIRECT가 HOT 체인의 피벗.PageGetItemId(page, offnum)(bufpage.h) —&pd_linp[offnum-1]반환.PageGetMaxOffsetNumber(bufpage.h) —(pd_lower - SizeOfPageHeaderData) / sizeof(ItemIdData).PageGetItem(page, itemId)(bufpage.h) —(char *)page + lp_off.ItemIdSetNormal / ItemIdSetRedirect / ItemIdSetDead / ItemIdSetUnused(itemid.h) — 네 가지 상태 전환.
아이템 삽입과 공간 관리
섹션 제목: “아이템 삽입과 공간 관리”PageAddItemExtended(bufpage.c) — 아이템 삽입. 오프셋 검증, 슬롯 선택,pd_upper감소,pd_lower증가, 바이트 복사.PageGetFreeSpace(bufpage.c) —pd_upper - pd_lower - sizeof(ItemIdData). 이 값 미만이면 0 반환.PageGetHeapFreeSpace(bufpage.c) —PageGetFreeSpace와 유사하지만MaxHeapTuplesPerPage도 강제한다.PageRepairFragmentation(bufpage.c) — 전체 defrag. 살아있는 아이템 바디를 한데 모으고, 끝의LP_UNUSED슬롯을 잘라내고,pd_upper를 재설정.PageTruncateLinePointerArray(bufpage.c) — VACUUM 2차 패스에서 호출. 아이템 바디 이동 없이 끝의LP_UNUSED항목만 제거.compactify_tuples(static,bufpage.c) —PageRepairFragmentation의 내부 루프. 정렬된 경우memmove패스트 경로, 그렇지 않으면 임시 버퍼 경유.
TID 계층
섹션 제목: “TID 계층”ItemPointerData(itemptr.h) — 6바이트 TID:ip_blkid+ip_posid.BlockIdData(block.h) —bi_hi+bi_louint16 쌍.BlockIdGetBlockNumber로 재조립.ItemPointerSet / ItemPointerGet{BlockNumber,OffsetNumber}(itemptr.h) — TID 접근자.ItemPointerIsValid(itemptr.h) —ip_posid != 0.
검증과 체크섬
섹션 제목: “검증과 체크섬”PageIsVerified(bufpage.c) — 디스크에서 읽어온 페이지가 버퍼 풀에 들어오기 전의 게이트. 헤더 건전성 + 선택적 체크섬 검사.pg_checksum_page(checksum.h선언;checksum_impl.h구현) — 블록 번호를 매개변수로 하는BLCKSZ바이트 16비트 체크섬.pd_checksum을 임시로 0으로 만들고,blkno를 XOR로 섞고,(checksum % 65535) + 1을 반환.pg_checksum_block(static,checksum_impl.h) — FNV-1a 내부 루프.checksumBaseOffsets[]로 초기화한 32개 부분 합, 두 번의 0 라운드, XOR 폴드로 단일uint32도출.CHECKSUM_COMP/FNV_PRIME/N_SUMS/PGChecksummablePage(checksum_impl.h) — 폴드 매크로, 소수 승수16777619, 레인 수32,PageHeaderData를 그리드에 오버레이하는 유니온.checksum.c(bufpage/page) —checksum_impl.h를#include하는 한 줄짜리 셸. 외부 도구가 동일한 코드를 재사용하기 위한 장치.PageSetChecksumCopy(bufpage.c) — 로컬 사본 생성 후 체크섬 계산, 사본 포인터 반환. 버퍼 플러시 경로에서 사용.PageSetChecksumInplace(bufpage.c) — 페이지에 직접 체크섬 기록.pg_checksums등 오프라인 경로 사용.DataChecksumsEnabled()(bufmgr.h) — GUC 제어 술어. 검증 및 체크섬 설정 경로 모두에서 참조.
위치 힌트 (2026-06-05 기준, 커밋 273fe94, REL_18_STABLE)
섹션 제목: “위치 힌트 (2026-06-05 기준, 커밋 273fe94, REL_18_STABLE)”| 심볼 | 파일 | 줄 번호 |
|---|---|---|
PageHeaderData | src/include/storage/bufpage.h | 159 |
SizeOfPageHeaderData | src/include/storage/bufpage.h | 218 |
PG_PAGE_LAYOUT_VERSION | src/include/storage/bufpage.h | 207 |
PD_HAS_FREE_LINES | src/include/storage/bufpage.h | 188 |
PD_PAGE_FULL | src/include/storage/bufpage.h | 189 |
PD_ALL_VISIBLE | src/include/storage/bufpage.h | 190 |
PageGetMaxOffsetNumber | src/include/storage/bufpage.h | 372 |
PageGetItemId | src/include/storage/bufpage.h | 245 |
PageGetItem | src/include/storage/bufpage.h | 354 |
PageGetLSN / PageSetLSN | src/include/storage/bufpage.h | 386–394 |
ItemIdData | src/include/storage/itemid.h | 25 |
LP_UNUSED / LP_NORMAL / LP_REDIRECT / LP_DEAD | src/include/storage/itemid.h | 38–41 |
ItemPointerData | src/include/storage/itemptr.h | 36 |
PageInit | src/backend/storage/page/bufpage.c | 42 |
PageIsVerified | src/backend/storage/page/bufpage.c | 94 |
PageAddItemExtended | src/backend/storage/page/bufpage.c | 193 |
PageRepairFragmentation | src/backend/storage/page/bufpage.c | 698 |
PageTruncateLinePointerArray | src/backend/storage/page/bufpage.c | 834 |
PageGetFreeSpace | src/backend/storage/page/bufpage.c | 906 |
PageSetChecksumCopy | src/backend/storage/page/bufpage.c | 1509 |
PageSetChecksumInplace | src/backend/storage/page/bufpage.c | 1541 |
pg_checksum_page (선언) | src/include/storage/checksum.h | 22 |
pg_checksum_page (구현) | src/include/storage/checksum_impl.h | 187 |
pg_checksum_block | src/include/storage/checksum_impl.h | 146 |
N_SUMS | src/include/storage/checksum_impl.h | 106 |
FNV_PRIME | src/include/storage/checksum_impl.h | 108 |
checksumBaseOffsets | src/include/storage/checksum_impl.h | 121 |
CHECKSUM_COMP | src/include/storage/checksum_impl.h | 135 |
PGChecksummablePage | src/include/storage/checksum_impl.h | 111 |
HeapTupleHeaderData | src/include/access/htup_details.h | 153 |
SizeofHeapTupleHeader | src/include/access/htup_details.h | 185 |
HEAP_XMIN_COMMITTED | src/include/access/htup_details.h | 204 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
PageHeaderData는 현재 모든 플랫폼에서 24바이트다. 커밋 273fe94의bufpage.h에서SizeOfPageHeaderData = offsetof(PageHeaderData, pd_linp)로 확인. 구조체는pd_prune_xid(오프셋 20,TransactionId=uint32)에서 끝나고 플렉시블 배열pd_linp[]가 오프셋 24에서 시작한다. -
페이지 레이아웃 버전은 4이며 PostgreSQL 8.3 이후 변경된 적 없다.
bufpage.h의PG_PAGE_LAYOUT_VERSION = 4로 확인. 파일 내 주석이 버전 0~4의 역사를 8.3 종료로 기술한다. -
LP_REDIRECT의lp_off는 바이트 오프셋이 아닌 슬롯 번호를 담는다.itemid.h의ItemIdGetRedirect(itemId)와heapam.c의 HOT 체인 추적 로직으로 확인. 호출자는ItemIdGetRedirect로OffsetNumber를 얻은 뒤 그 번호로 다시PageGetItemId를 호출한다. -
PageRepairFragmentation은 힙 전용이며, 인덱스 AM은PageIndexMultiDelete를 사용한다.bufpage.c684줄 주석(“This routine is usable for heap pages only, but see PageIndexMultiDelete”)으로 확인. -
전체 0 페이지는
PageIsVerified를 무조건 통과한다.bufpage.c142줄에서pg_memory_is_all_zeros검사가 체크섬·건전성 경로 이후에 실행되며 0 블록에true를 반환한다. 주석은 릴레이션 확장 중 크래시한 백엔드가 남기는 0 페이지 시나리오를 동기로 설명한다. -
버퍼 플러시 시 체크섬은 동시 힌트 비트 쓰기를 제외하기 위해 사본에 계산된다.
bufpage.c1509줄의PageSetChecksumCopy와 “many or even most pages in shared buffers have invalid page checksums”라는 README 문구로 확인. -
pd_prune_xid는 인덱스 페이지에서 사용되지 않는다.bufpage.h126줄 주석(“It is currently unused in index pages”)으로 확인. -
pg_checksum_page는>> 17상위 비트 믹서를 추가한 병렬 FNV-1a 폴드다.checksum.c가#include하는checksum_impl.h에서 직접 확인.N_SUMS = 32개 레인,FNV_PRIME = 16777619,checksumBaseOffsets[]의 레인별 시드, 두 번의 trailing 0 라운드, XOR 폴드.pg_checksum_page는pd_checksum을 임시로 0으로 만들고,blkno를 XOR로 섞고,(checksum % 65535) + 1을 반환한다. “변형 FNV”라는 구 문서 표현의 정확한 의미는^ ((hash ^ value) >> 17)항이다.
미해결 사항
섹션 제목: “미해결 사항”-
MaxHeapTuplesPerPage도출 방식. 이 상수는 힙 페이지의 라인 포인터 배열 상한을 제한한다(PageAddItemExtended의PAI_IS_HEAP플래그).BLCKSZ와MinHeapTupleSize로부터 계산되는 정확한 공식은htup.h에 있으며 이번 패스에서 확인하지 않았다. -
체크섬 비활성화 시
pd_checksum필드.bufpage.h주석은 “체크섬의 유효 값으로 0을 쓸 수 있다”고 하고, 9.3 이전 데이터베이스는 과거 timelineid 필드가 있던 바이트 오프셋에 0이 아닌 값이 남아 있을 수 있다고 설명한다. 모든 업그레이드 경로에서PageIsVerified가 이 모호함을 올바르게 처리하는지는 이번 분석만으로 확인되지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프런티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”-
InnoDB의 페이지 포맷 — InnoDB는 16 KB 고정 페이지에
FIL_PAGE_LSN헤더 필드와 LSN·체크섬을 담은 별도 페이지 트레일러를 사용한다. InnoDB 체크섬은 헤더 필드를 제외한 바디만 덮으며, 트레일러 체크섬은 PostgreSQL의 단일 필드 방식과 달리 찢어진 페이지(torn page) 탐지가 가능하다. 두 방식의 torn-page 탐지 전략을 비교하면 PostgreSQL 설계 선택의 배경이 분명해진다. -
가변 페이지 크기 실험 — PostgreSQL의
BLCKSZ는 컴파일 타임 상수다.--with-blocksize옵션으로 1~32 KB 중 하나를 선택할 수 있다. 컬럼 지향 또는 HTAP 워크로드를 위한 가변 페이지 크기 연구는 페이지 추상화 자체를 확장하거나 대체해야 하는지 묻는다.table access methodAPI(postgres-table-am.md)가bufpage.c를 건드리지 않고 이를 가능하게 하는 확장 지점이다. -
“페이지 너머” 스토리지 모델 — 컬럼 스토어(cstore_fdw 전신, Citus columnar, DuckDB)는 슬롯 페이지 모델 자체를 버리고 압축된 컬럼 청크를 쓴다. PostgreSQL의 Table AM API(PG12+)는 이런 모델을 코어에 플러그인하기 위한 첫걸음이다. Stonebraker 외의 “The End of an Architectural Era”(VLDB 2007)가 이 방향의 이론 기반이다.
-
HOT 체인과 라인 포인터 간접 참조 —
LP_REDIRECT메커니즘은 PostgreSQL 8.3에서 도입됐다(Heap Only Tuples). HOT 설계 문서(src/backend/access/heap/README.HOT)를 읽고 MySQL의 undo 세그먼트 기반 인플레이스 업데이트 방식과 비교하면postgres-heap-am.md의 좋은 후속 노트가 될 것이다. -
WAL 전체 페이지 이미지와 찢어진 페이지 보호 —
storage/page/README는 WAL의 전체 페이지 쓰기(full-page writes)가 찢어진 페이지를 막는다고 명시한다. 체크섬, 전체 페이지 쓰기, 힌트 비트 업데이트의 상호작용은postgres-xlog-wal.md에서 다룰 만한 주제다.
소비한 raw 파일
섹션 제목: “소비한 raw 파일”(없음 — 소스 트리에서 직접 합성)
소스 코드 경로 (REL_18_STABLE, 커밋 273fe94)
섹션 제목: “소스 코드 경로 (REL_18_STABLE, 커밋 273fe94)”src/backend/storage/page/bufpage.csrc/backend/storage/page/checksum.csrc/backend/storage/page/itemptr.csrc/backend/storage/page/READMEsrc/include/storage/bufpage.hsrc/include/storage/itemid.hsrc/include/storage/itemptr.hsrc/include/storage/checksum.hsrc/include/storage/checksum_impl.hsrc/backend/storage/page/checksum.csrc/include/access/htup_details.hsrc/include/access/htup.h
교과서 참고문헌
섹션 제목: “교과서 참고문헌”- Petrov, Database Internals (2019), ch. 3 §“Page Structure”
- Silberschatz 외, Database System Concepts (7판), ch. 13 §“File Organization” (슬롯 페이지 레이아웃)
관련 지식 베이스 문서
섹션 제목: “관련 지식 베이스 문서”knowledge/code-analysis/postgres/postgres-buffer-manager.md— 페이지가 공유 풀에 들어오고 나가는 방식, WAL-before-flush 집행knowledge/code-analysis/postgres/postgres-heap-am.md— 힙 튜플 레이아웃, HOT 체인 메커니즘, 힙 프루닝knowledge/code-analysis/postgres/postgres-mvcc-snapshots.md—t_xmin/t_xmax/t_infomask힌트 비트가 가시성 결정을 이끄는 방식knowledge/code-analysis/postgres/postgres-xlog-wal.md— 전체 페이지 쓰기, 체크섬과 WAL의 상호작용