콘텐츠로 이동

(KO) PostgreSQL Visibility Map — 진공 건너뜀과 인덱스 전용 스캔을 가능하게 하는 페이지당 2비트 비트맵

목차

MVCC 기반 엔진은 튜플을 제자리에서 수정하지 않는다. 삭제는 이전 버전의 xmax 필드를 설정하고, 갱신은 새 버전을 삽입한 뒤 이전 버전에 xmax를 설정한다. 이전 버전의 바이트는 배경 정리 프로세스가 어떤 실행 중인 트랜잭션도 그 버전을 볼 수 없다고 판단할 때까지 페이지에 그대로 남는다. Database System Concepts(Silberschatz, 7판, §15.6 “Multiversion Concurrency Control”)는 이를 MVCC의 근본적인 트레이드오프로 규정한다. 읽기가 각자의 스냅샷을 보기 때문에 쓰기를 차단하지 않지만, 죽은 버전이 청소될 때까지 계속 쌓인다.

청소 담당 컴포넌트에게는 두 가지 질문이 생긴다.

  1. 어떤 페이지를 처리해야 하는가? 죽은 튜플이 없고 모든 살아 있는 튜플이 현재 및 미래의 모든 트랜잭션에 보이는 페이지라면 아무 작업도 필요 없다. 그 페이지를 다시 읽는 것은 I/O 낭비다.
  2. 인덱스 전용 스캔은 어떤 페이지를 신뢰할 수 있는가? 인덱스 전용 스캔(index-only scan)은 인덱스 리프 페이지에서 속성값을 직접 반환하고 힙을 건너뛸 수 있다. 단, 인덱스된 행이 쿼리의 스냅샷에 보임을 힙 가시성 정보 없이 확인할 수 있을 때만 그렇다.

두 질문은 같은 속성으로 귀결된다. 이 힙 페이지의 모든 튜플이 앞으로 실행될 모든 트랜잭션에 보이는가? 이 속성을 페이지 자체를 읽는 것보다 저렴하게 조회할 수 있는 간결한 페이지별 기록을 유지하는 것이 visibility map이 해결하는 문제다.

이론적 틀은 보수적 근사(conservative approximation) 다. 비트가 설정되어 있으면 그 속성이 보장된다. 비트가 꺼져 있으면 속성이 성립할 수도 있고 아닐 수도 있다. 거짓 음성(실제로는 깨끗한 페이지인데 비트가 꺼진 경우)은 불필요한 작업을 유발하지만 틀린 답을 내지 않는다. 거짓 양성(죽은 튜플이 있는데 비트가 켜진 경우)은 정확성 오류이므로, 설계는 의심스러울 때 항상 비트를 끄는 쪽을 택한다.

두 번째 비트인 all-frozen은 별개지만 연관된 요구사항을 다룬다. XID 순환 방지다. PostgreSQL은 32비트 트랜잭션 식별자(XID)를 사용하며, 약 21억 트랜잭션마다 순환한다. 순환 지점보다 2^31 이전에 삽입된 행은 현재 트랜잭션보다 더 새것으로 보여 사라진다. 이를 막기 위해 vacuum은 오래된 XID를 동결(freeze) 한다. 튜플의 xmin/xmax를 특수 값 FrozenTransactionId(XID 2)로 덮어 쓰거나 HEAP_XMIN_FROZEN 힌트 비트를 설정하면 이후 어떤 트랜잭션에서도 항상 보인다. 모든 튜플이 이미 동결된 페이지는 적극적 anti-wraparound VACUUM에서도 동결 작업이 필요 없다. all-frozen 비트가 바로 그 속성을 담는다.

힌트 비트: 튜플 단위 저렴한 근사

섹션 제목: “힌트 비트: 튜플 단위 저렴한 근사”

대부분의 MVCC 엔진은 힌트 비트를 튜플 또는 행 헤더에 직접 붙여 가시성 판단을 캐싱한다. 어떤 행을 삽입한 트랜잭션이 커밋됐음이 확인되면, 다음 백엔드는 튜플에 XMIN_COMMITTED 힌트 비트를 설정해 이후 백엔드들이 커밋 로그 조회를 건너뛰게 한다. 힌트 비트는 기회주의적이고 WAL에 기록되지 않는다. visibility map은 힌트 비트 위의 더 굵은 계층으로, 개별 튜플을 스캔하는 대신 읽기 측이 페이지 전체에 대한 비트 하나만 확인한다.

별도 파일에 저장하는 페이지 단위 비트맵

섹션 제목: “별도 파일에 저장하는 페이지 단위 비트맵”

페이지 단위 메타데이터를 힙 페이지 안에 저장하면, 그 메타데이터를 갱신할 때마다 힙 페이지를 읽고 더티 상태로 만들어야 한다. 일부 엔진은 그 대신 보조 비트맵을 별도 파일에 유지한다. PostgreSQL의 free-space map(FSM, 여유 공간 맵)도 같은 구조적 원칙을 따른다. 별도 fork에 페이지당 여유 공간을 근사치로 저장하면 삽입 연산이 힙을 전부 스캔하지 않고도 후보 페이지를 찾는다. visibility map도 같은 방식이다. 전용 저장소 fork, 힙 페이지당 하나의 기록, 어떤 힙 I/O도 시도하기 전에 먼저 조회한다.

크래시 안전성을 위한 2단계 핀 프로토콜

섹션 제목: “크래시 안전성을 위한 2단계 핀 프로토콜”

청소 담당 컴포넌트가 페이지에 특정 속성(all-visible, all-frozen)이 있음을 표시할 때, 디스크에 내려간 상태가 크래시 후에도 유실되지 않아야 한다. 단순한 방법인 힙 페이지 갱신 후 보조 비트맵 갱신은 취약하다. 비트맵 페이지가 디스크에 먼저 내려가고 힙 페이지는 아직 내려가지 않은 상태에서 크래시가 나면, 비트맵 비트는 설정되어 있는데 힙 페이지 상태는 더럽다. 표준 해법은 두 갱신을 하나의 WAL 레코드에 함께 기록해 크래시 복구가 두 갱신을 원자적으로 복원하게 하는 것이다. PostgreSQL의 XLOG_HEAP2_VISIBLE 레코드가 정확히 이 역할을 한다.

지우기는 조용히, 설정은 WAL과 함께

섹션 제목: “지우기는 조용히, 설정은 WAL과 함께”

거의 모든 유사 시스템에서 대칭적인 비대칭이 나타난다. 비트를 끄는 것(이 페이지가 더러울 수 있다는 선언)은 항상 조용히 해도 안전하다. 결과는 거짓 음성이므로 불필요한 작업을 더 하게 될 뿐, 틀린 데이터를 반환하지 않는다. 비트를 켜는 것(이 페이지가 깨끗하다는 주장)은 내구성 보장이 필요하다. 거짓 양성은 독자가 힙 패치를 건너뛰고 오래된 데이터를 반환하게 만들 수 있기 때문이다. 로깅의 비대칭(설정 = WAL 기록, 지우기 = WAL 불필요하지만 힙 수정에 수반)은 이로부터 자연스럽게 나온다.

개념PostgreSQL 명칭
페이지당 “모든 죽은 튜플 회수됨” 비트VM fork의 VISIBILITYMAP_ALL_VISIBLE (0x01)
페이지당 “모든 튜플 동결됨” 비트VM fork의 VISIBILITYMAP_ALL_FROZEN (0x02)
VM 비트를 미러링하는 페이지 수준 힌트PageHeaderData.pd_flagsPD_ALL_VISIBLE 플래그
보조 비트맵 파일관계의 VISIBILITYMAP_FORKNUM (fork 2)
VM + 힙 LSN을 조율하는 WAL 레코드XLOG_HEAP2_VISIBLE / log_heap_visible
사용처: vacuum 건너뜀vacuumlazy.cvisibilitymap_get_status
사용처: 인덱스 전용 스캔 건너뜀nodeIndexonlyscan.cVM_ALL_VISIBLE 매크로

저장 구조: fork 2의 2비트 비트맵

섹션 제목: “저장 구조: fork 2의 2비트 비트맵”

각 관계는 최대 네 개의 온디스크 저장 fork를 가진다.

fork 0 MAIN_FORKNUM — 힙 데이터 페이지
fork 1 FSM_FORKNUM — free-space map
fork 2 VISIBILITYMAP_FORKNUM — visibility map ← 이 문서
fork 3 INIT_FORKNUM — unlogged 테이블 초기화 fork

visibility map fork는 간결한 비트맵을 저장한다. 힙 페이지당 2비트 를 표준 8 KB 버퍼 매니저 페이지에 패킹하며, 표준 PageHeaderData 외에 별도의 헤더는 없다. 힙 블록 N의 두 비트 위치는 세 개의 매크로로 계산한다.

// HEAPBLK_TO_MAPBLOCK / HEAPBLK_TO_MAPBYTE / HEAPBLK_TO_OFFSET — visibilitymap.c
#define MAPSIZE (BLCKSZ - MAXALIGN(SizeOfPageHeaderData))
#define HEAPBLOCKS_PER_BYTE (BITS_PER_BYTE / BITS_PER_HEAPBLOCK) /* 4 */
#define HEAPBLOCKS_PER_PAGE (MAPSIZE * HEAPBLOCKS_PER_BYTE)
#define HEAPBLK_TO_MAPBLOCK(x) ((x) / HEAPBLOCKS_PER_PAGE)
#define HEAPBLK_TO_MAPBYTE(x) (((x) % HEAPBLOCKS_PER_PAGE) / HEAPBLOCKS_PER_BYTE)
#define HEAPBLK_TO_OFFSET(x) (((x) % HEAPBLOCKS_PER_BYTE) * BITS_PER_HEAPBLOCK)

visibilitymapdefs.hBITS_PER_HEAPBLOCK은 2다. 따라서 1바이트가 힙 블록 4개를 담고, 8 KB VM 페이지 하나가 약 32,736개의 힙 페이지를 커버한다. 각 바이트 안의 비트 배치는 다음과 같다.

비트 0 (마스크 0x01): VISIBILITYMAP_ALL_VISIBLE
비트 1 (마스크 0x02): VISIBILITYMAP_ALL_FROZEN

두 플래그 상수는 다음과 같다.

visibilitymapdefs.h
#define VISIBILITYMAP_ALL_VISIBLE 0x01
#define VISIBILITYMAP_ALL_FROZEN 0x02
#define VISIBILITYMAP_VALID_BITS 0x03

all-frozen 비트는 all-visible 비트 없이 단독으로 설정할 수 없다. visibilitymap_set 내부의 assertion이 이를 강제한다. 동결된 페이지는 반드시 all-visible이기도 하다.

그림 1 — VM 페이지 안의 비트 배치

flowchart LR
    subgraph "VM 페이지의 1바이트"
        B7["bit 7\n블록 N+3\nfrozen"]
        B6["bit 6\n블록 N+3\nvisible"]
        B5["bit 5\n블록 N+2\nfrozen"]
        B4["bit 4\n블록 N+2\nvisible"]
        B3["bit 3\n블록 N+1\nfrozen"]
        B2["bit 2\n블록 N+1\nvisible"]
        B1["bit 1\n블록 N\nfrozen"]
        B0["bit 0\n블록 N\nvisible"]
    end

그림 1 — VM 페이지의 1바이트는 연속된 힙 블록 4개의 all-visible(하위 비트)과 all-frozen(상위 비트) 플래그를 저장한다. 블록 N은 비트 01을, 블록 N+1은 비트 23을 사용한다.

이중 표현: VM 비트와 PD_ALL_VISIBLE

섹션 제목: “이중 표현: VM 비트와 PD_ALL_VISIBLE”

all-visible 속성을 기록하는 곳은 visibility map만이 아니다. 힙 페이지 자체도 PageHeaderData.pd_flagsPD_ALL_VISIBLE 플래그를 갖는다.

// PageIsAllVisible / PageSetAllVisible / PageClearAllVisible — src/include/storage/bufpage.h
#define PD_ALL_VISIBLE 0x0004 /* all tuples on page are visible to everyone */

두 표현은 동기화 상태를 유지해야 한다. 힙 수정 연산이 VM 비트를 끌 때는 힙 변경과 같은 크리티컬 섹션 안에서, 같은 WAL 레코드에 PD_ALL_VISIBLE도 함께 끈다. VACUUM이 VM 비트를 켤 때는 힙 페이지에 PD_ALL_VISIBLE을 먼저 설정한 뒤 visibilitymap_set을 호출한다. 시스템이 유지하는 불변 조건은 다음과 같다.

VM 비트 설정됨 ⟹ PD_ALL_VISIBLE 설정됨.
VM 비트가 꺼져 있다고 해서 PD_ALL_VISIBLE도 꺼져 있다는 뜻은 아니다.

VM 비트가 디스크에 내려간 뒤 PD_ALL_VISIBLE이 힙 페이지에 기록되기 전에 크래시가 나면, XLOG_HEAP2_VISIBLE 레코드의 WAL 재실행이 힙 페이지에 PD_ALL_VISIBLE을 복원해 불변 조건을 지킨다.

VM 비트를 설정하는 작업은 두 단계로 나뉜다.

  1. VM 페이지 핀 획득 (visibilitymap_pin) — 대상 힙 블록을 커버하는 VM 페이지의 버퍼 매니저 핀을 획득한다. 디스크에서 VM 페이지를 읽어야 할 수도 있다. 힙 페이지를 잠근 채로 I/O를 하는 것이 금지되어 있으므로, 이 단계는 힙 페이지를 잠그기 전에 수행한다.
  2. 비트 설정 (visibilitymap_set) — 힙 페이지를 버퍼 잠금한 상태에서 비트를 설정하고 WAL 레코드를 내보낸다.
// visibilitymap_pin — src/backend/access/heap/visibilitymap.c
void
visibilitymap_pin(Relation rel, BlockNumber heapBlk, Buffer *vmbuf)
{
BlockNumber mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk);
if (BufferIsValid(*vmbuf))
{
if (BufferGetBlockNumber(*vmbuf) == mapBlock)
return; /* 이미 핀되어 있으면 재사용 */
ReleaseBuffer(*vmbuf);
}
*vmbuf = vm_readbuf(rel, mapBlock, true); /* 필요 시 VM fork 확장 */
}
// visibilitymap_set — src/backend/access/heap/visibilitymap.c
uint8
visibilitymap_set(Relation rel, BlockNumber heapBlk, Buffer heapBuf,
XLogRecPtr recptr, Buffer vmBuf, TransactionId cutoff_xid,
uint8 flags)
{
uint32 mapByte = HEAPBLK_TO_MAPBYTE(heapBlk);
uint8 mapOffset = HEAPBLK_TO_OFFSET(heapBlk);
uint8 *map;
uint8 status;
/* ... assertion 생략 ... */
page = BufferGetPage(vmBuf);
map = (uint8 *) PageGetContents(page);
LockBuffer(vmBuf, BUFFER_LOCK_EXCLUSIVE);
status = (map[mapByte] >> mapOffset) & VISIBILITYMAP_VALID_BITS;
if (flags != status)
{
START_CRIT_SECTION();
map[mapByte] |= (flags << mapOffset);
MarkBufferDirty(vmBuf);
if (RelationNeedsWAL(rel))
{
if (XLogRecPtrIsInvalid(recptr))
recptr = log_heap_visible(rel, heapBuf, vmBuf, cutoff_xid, flags);
if (XLogHintBitIsNeeded())
PageSetLSN(BufferGetPage(heapBuf), recptr);
PageSetLSN(page, recptr);
}
END_CRIT_SECTION();
}
LockBuffer(vmBuf, BUFFER_LOCK_UNLOCK);
return status; /* 이전 비트 상태를 호출자에게 반환 */
}

함수는 비트의 이전 상태를 반환한다. VACUUM은 이 값을 이용해 해당 페이지를 pg_class.relallvisible 카운트에 포함할지 결정한다.

비트 지우기: WAL 불필요, 힙 수정에 수반

섹션 제목: “비트 지우기: WAL 불필요, 힙 수정에 수반”

VM 비트를 끄는 것은 더 단순하고 WAL이 필요 없다.

// visibilitymap_clear — src/backend/access/heap/visibilitymap.c
bool
visibilitymap_clear(Relation rel, BlockNumber heapBlk, Buffer vmbuf, uint8 flags)
{
int mapByte = HEAPBLK_TO_MAPBYTE(heapBlk);
int mapOffset = HEAPBLK_TO_OFFSET(heapBlk);
uint8 mask = flags << mapOffset;
char *map;
bool cleared = false;
/* all_frozen을 남긴 채 all_visible만 끄는 것은 금지 */
Assert(flags != VISIBILITYMAP_ALL_VISIBLE);
/* ... 버퍼 유효성 검사 ... */
LockBuffer(vmbuf, BUFFER_LOCK_EXCLUSIVE);
map = PageGetContents(BufferGetPage(vmbuf));
if (map[mapByte] & mask)
{
map[mapByte] &= ~mask;
MarkBufferDirty(vmbuf);
cleared = true;
}
LockBuffer(vmbuf, BUFFER_LOCK_UNLOCK);
return cleared;
}

heapam.c의 호출부 — heap_insert, heap_update, heap_delete, heap_lock_tuple — 는 힙 페이지 수정과 같은 크리티컬 섹션 안에서, 힙 버퍼 잠금을 유지한 채 visibilitymap_clear를 호출한다. 덕분에 힙 연산의 WAL 재실행이 VM 비트도 함께 끈다. VM 페이지의 더티 버퍼가 플러시되기 전에 크래시가 나도 안전하다.

비트 읽기: 잠금 없음, 호출자 책임

섹션 제목: “비트 읽기: 잠금 없음, 호출자 책임”
// visibilitymap_get_status — src/backend/access/heap/visibilitymap.c
uint8
visibilitymap_get_status(Relation rel, BlockNumber heapBlk, Buffer *vmbuf)
{
uint32 mapByte = HEAPBLK_TO_MAPBYTE(heapBlk);
uint8 mapOffset = HEAPBLK_TO_OFFSET(heapBlk);
char *map;
uint8 result;
/* ... 필요 시 VM 페이지 핀 ... */
map = PageGetContents(BufferGetPage(*vmbuf));
result = ((map[mapByte] >> mapOffset) & VISIBILITYMAP_VALID_BITS);
return result;
}

읽기는 VM 페이지에 아무 잠금 없이 수행된다. 소스 주석은 명확히 말한다. “다른 누군가가 읽은 직후 비트를 바꿀 수 있다.” 이는 의도적 설계다. VM 비트를 오래된 값으로 읽으면 보수적인 답(“not all-visible”)을 내놓으므로 안전하다. 경쟁 상태 처리는 호출자의 책임이며, VACUUM은 실제 행동 전에 잠금 아래에서 재확인한다.

편의 매크로 VM_ALL_VISIBLEVM_ALL_FROZEN은 이 함수를 감싼다.

// VM_ALL_VISIBLE / VM_ALL_FROZEN — src/include/access/visibilitymap.h
#define VM_ALL_VISIBLE(r, b, v) \
((visibilitymap_get_status((r), (b), (v)) & VISIBILITYMAP_ALL_VISIBLE) != 0)
#define VM_ALL_FROZEN(r, b, v) \
((visibilitymap_get_status((r), (b), (v)) & VISIBILITYMAP_ALL_FROZEN) != 0)

vacuumlazy.c의 내부 루프는 힙 페이지를 읽기 전에 visibilitymap_get_status를 호출한다. VISIBILITYMAP_ALL_VISIBLE이 설정된 페이지는 죽은 튜플 제거를 위한 읽기를 건너뛸 수 있다. VISIBILITYMAP_ALL_FROZEN이 설정된 페이지는 적극적(anti-wraparound) VACUUM에서도 건너뛸 수 있다.

// heap_vac_scan_next_block 내부 루프 — src/backend/access/heap/vacuumlazy.c
uint8 mapbits = visibilitymap_get_status(vacrel->rel,
next_unskippable_block,
&next_unskippable_vmbuffer);
next_unskippable_allvis = (mapbits & VISIBILITYMAP_ALL_VISIBLE) != 0;
if (!next_unskippable_allvis)
break; /* 이 블록은 반드시 처리해야 함 */
/* aggressive 모드에서는 동결 여부 추가 확인 ... */

VACUUM이 페이지의 모든 튜플이 가시적이고 동결되었다고 판단하면 적절한 플래그로 visibilitymap_set을 호출한다. 패스 시작과 끝에 visibilitymap_count를 호출해 pg_class.relallvisible을 갱신한다. 플래너는 이 값을 이용해 인덱스 전용 스캔의 이점을 추정한다.

그림 2 — VACUUM 페이지 건너뜀 판단 흐름

flowchart TD
    A["다음 힙 블록"] --> B{"VM: all-visible?"}
    B -->|"아니오"| C["힙 페이지 읽기\n죽은 튜플 처리"]
    B -->|"예"| D{"aggressive vacuum?"}
    D -->|"아니오"| E["블록 전체 건너뜀"]
    D -->|"예"| F{"VM: all-frozen?"}
    F -->|"예"| E
    F -->|"아니오"| G["힙 페이지 읽기\n오래된 XID 동결\n완료 시 all-frozen 설정"]
    C --> H{"페이지가 이제 all-visible?"}
    H -->|"예"| I["visibilitymap_set\nALL_VISIBLE\n또는 ALL_FROZEN"]
    H -->|"아니오"| A
    G --> A
    E --> A
    I --> A

그림 2 — VACUUM 내부 루프는 힙 I/O를 시도하기 전에 VM 비트를 확인한다. all-visible 페이지는 일반 모드에서 완전히 건너뛴다. all-frozen 페이지는 적극적 모드에서도 건너뛴다.

인덱스 전용 스캔이 VM을 사용하는 방식

섹션 제목: “인덱스 전용 스캔이 VM을 사용하는 방식”

인덱스 전용 스캔(IOS)은 힙을 패치하지 않고 인덱스 리프에서 속성값을 반환한다. 단, 힙 튜플이 스캔의 스냅샷에 보임을 알 수 있을 때만 가능하다. VM이 없다면 실행기는 가시성을 확인하기 위해 모든 인덱스 항목마다 힙 페이지를 패치해야 한다. VM이 대부분의 패치를 없앤다.

// IndexOnlyNext — src/backend/executor/nodeIndexonlyscan.c
if (!VM_ALL_VISIBLE(scandesc->heapRelation,
ItemPointerGetBlockNumber(tid),
&node->ioss_VMBuffer))
{
/* 힙을 방문해 가시성을 확인해야 함 */
InstrCountTuples2(node, 1);
if (!index_fetch_heap(scandesc, node->ioss_TableSlot))
continue; /* 보이는 튜플 없음, 다음 인덱스 항목으로 */
/* ... */
}

VM_ALL_VISIBLE이 참을 반환하면 실행기는 모든 트랜잭션에서 튜플이 보인다고 신뢰하고 힙 패치를 완전히 건너뛴다. VACUUM이 대부분의 페이지에 VM 비트를 설정한 크고 거의 정적인 테이블에서, IOS는 힙 읽기 없이 전적으로 인덱스에서 쿼리에 답할 수 있다.

그림 3 — 인덱스 전용 스캔 가시성 판단

flowchart TD
    IDX["인덱스 리프 항목\n(tid, 속성값)"] --> VM{"VM_ALL_VISIBLE\n(tid.block)?"}
    VM -->|"true"| RET["인덱스에서 속성값 반환\n(힙 패치 없음)"]
    VM -->|"false"| HEAP["힙 튜플 패치\nMVCC 가시성 확인"]
    HEAP --> VIS{"스냅샷에 튜플이\n보이는가?"}
    VIS -->|"예"| RET2["힙에서 속성값 반환"]
    VIS -->|"아니오"| SKIP["건너뜀 — 다음\n인덱스 항목으로"]

그림 3 — VM 비트가 설정되어 있으면 인덱스 전용 스캔은 힙을 건드리지 않고 인덱스에서 직접 데이터를 반환한다. 비트가 꺼져 있으면 힙 가시성 확인으로 폴백한다.

visibilitymap_set이 WAL 레코드를 내보낼 때 heapam.clog_heap_visible을 호출한다.

// log_heap_visible — src/backend/access/heap/heapam.c
XLogRecPtr
log_heap_visible(Relation rel, Buffer heap_buffer, Buffer vm_buffer,
TransactionId snapshotConflictHorizon, uint8 vmflags)
{
xl_heap_visible xlrec;
xlrec.snapshotConflictHorizon = snapshotConflictHorizon;
xlrec.flags = vmflags;
if (RelationIsAccessibleInLogicalDecoding(rel))
xlrec.flags |= VISIBILITYMAP_XLOG_CATALOG_REL;
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfHeapVisible);
XLogRegisterBuffer(0, vm_buffer, 0);
flags = REGBUF_STANDARD;
if (!XLogHintBitIsNeeded())
flags |= REGBUF_NO_IMAGE;
XLogRegisterBuffer(1, heap_buffer, flags);
recptr = XLogInsert(RM_HEAP2_ID, XLOG_HEAP2_VISIBLE);
return recptr;
}

레코드는 VM 버퍼와 힙 버퍼 모두를 등록한다. 재실행 시 heapam_xlog.cheap_xlog_visible은 힙 페이지에 PD_ALL_VISIBLE을 설정하고 VM 비트도 설정한다. snapshotConflictHorizon 필드는 Hot Standby에서 복구 충돌을 해결하는 데 쓰인다. 스탠바이의 인덱스 전용 스캔이 snapshotConflictHorizon보다 오래된 xmin horizon을 갖는다면, 새로 설정된 all-visible 비트를 근거로 틀린 결과를 반환하기 전에 해당 스캔이 취소된다.

추가 플래그 VISIBILITYMAP_XLOG_CATALOG_REL은 사용자 카탈로그 테이블에 설정된다. 스탠바이의 논리 디코딩이 행 이미지를 재구성하려면 카탈로그 페이지의 가시성 변경을 추적해야 하기 때문이다.

힙 관계가 커지면 VM fork도 함께 커져야 할 수 있다. 내부 함수 vm_readbuf는 요청된 VM 블록 번호가 캐시된 크기를 초과하면 vm_extend를 호출한다.

// vm_extend — src/backend/access/heap/visibilitymap.c
static Buffer
vm_extend(Relation rel, BlockNumber vm_nblocks)
{
Buffer buf;
buf = ExtendBufferedRelTo(BMR_REL(rel), VISIBILITYMAP_FORKNUM, NULL,
EB_CREATE_FORK_IF_NEEDED | EB_CLEAR_SIZE_CACHE,
vm_nblocks, RBM_ZERO_ON_ERROR);
CacheInvalidateSmgr(RelationGetSmgr(rel)->smgr_rlocator);
return buf;
}

EB_CREATE_FORK_IF_NEEDED 플래그는 처음 사용 시 VM fork를 생성한다. CacheInvalidateSmgr은 공유 무효화 메시지를 브로드캐스트해 다른 백엔드들이 캐시한 smgr 참조를 닫게 한다. 파일 크기 상태가 낡아지는 것을 막기 위해서다.

관계가 트런케이션될 때 visibilitymap_prepare_truncate는 살아남은 마지막 VM 페이지에서 뒤쪽 비트들을 지우고 새로운 VM 블록 수를 반환한다. 새 힙 크기가 VM 페이지 경계에 정확히 맞으면 비트를 지울 필요가 없다. 실제 smgrtruncate 호출은 호출자(TRUNCATECLUSTER 코드 경로)의 책임이다.

// visibilitymap_count — src/backend/access/heap/visibilitymap.c
void
visibilitymap_count(Relation rel, BlockNumber *all_visible, BlockNumber *all_frozen)
{
for (mapBlock = 0;; mapBlock++)
{
mapBuffer = vm_readbuf(rel, mapBlock, false);
if (!BufferIsValid(mapBuffer)) break;
map = (uint64 *) PageGetContents(BufferGetPage(mapBuffer));
nvisible += pg_popcount_masked((const char *) map, MAPSIZE, VISIBLE_MASK8);
if (all_frozen)
nfrozen += pg_popcount_masked((const char *) map, MAPSIZE, FROZEN_MASK8);
ReleaseBuffer(mapBuffer);
}
*all_visible = nvisible;
if (all_frozen) *all_frozen = nfrozen;
}

VISIBLE_MASK8(0x55)는 all-visible 비트(각 2비트 쌍의 하위 비트)를 선택하고, FROZEN_MASK8(0xAA)는 all-frozen 비트를 선택한다. pg_popcount_masked는 마스크와 AND한 뒤 설정된 비트 수를 센다. VACUUM은 패스 시작과 끝에 이 함수를 호출해 pg_class.relallvisible을 갱신한다. 쿼리 플래너는 이 값을 이용해 인덱스 전용 스캔이 건너뛸 수 있는 페이지 비율을 추정한다.

  • visibilitymap_clear — 페이지 하나의 지정된 비트를 끈다. 비트가 실제로 꺼지면 true를 반환한다. 호출자는 올바른 VM 페이지의 버퍼 핀을 보유해야 하고 힙 페이지는 버퍼 잠금 상태여야 한다. WAL은 여기서 내보내지 않는다. 힙 수정 WAL 레코드도 재실행 시 VM 비트를 끈다는 전제 아래 정확성이 보장된다.

  • visibilitymap_pinheapBlk를 커버하는 VM 페이지의 버퍼 매니저 핀을 획득(또는 재사용)한다. visibilitymap_set 전에, 힙 페이지를 잠그기 전에 호출해야 한다. VM 페이지가 아직 없으면 VM fork를 확장한다.

  • visibilitymap_pin_ok — 이전에 핀된 버퍼가 여전히 heapBlk를 커버하는지 확인한다. 같은 VM 페이지에 대한 여러 연산을 배치할 때 불필요한 재핀을 피하는 데 쓰인다.

  • visibilitymap_set — 이전에 핀된 VM 페이지에 하나 또는 두 비트를 설정한다. 호출자는 힙 페이지에 PD_ALL_VISIBLE을 설정하고 힙 버퍼를 잠근 상태여야 한다. WAL이 필요할 때 XLOG_HEAP2_VISIBLE을 내보낸다. 이전 비트 상태를 반환한다.

  • visibilitymap_get_statusheapBlk의 2비트 상태를 반환한다. 잠금 없음. 단일 바이트 읽기(지원되는 모든 아키텍처에서 원자적). 호출자는 오래된 읽기 가능성을 처리해야 한다.

  • visibilitymap_count — 전체 VM fork를 스캔해 all-visible 및 all-frozen 페이지 수를 센다. 잠금 없음, 근사값. VACUUM이 pg_class.relallvisible을 갱신하는 데 사용한다.

  • visibilitymap_prepare_truncate — 관계 트런케이션 준비: 살아남은 마지막 VM 페이지에서 뒤쪽 비트들을 지우고 새 VM 블록 수를 반환한다. VM fork 트런케이션이 불필요하면 InvalidBlockNumber를 반환한다.

  • visibilitymap_truncation_length — 순수 계산: 제안된 힙 트런케이션 길이를 받아 올바른 VM 트런케이션 길이를 반환한다. 부작용 없음.

  • vm_readbufRBM_ZERO_ON_ERRORReadBufferExtended를 사용해 VM 페이지를 읽거나 확장한다. 새 페이지는 PageInit으로 초기화한다. smgr_cached_nblocks[VISIBILITYMAP_FORKNUM]에 VM fork 블록 수를 캐시해 반복적인 smgrnblocks 호출을 피한다.

  • vm_extendExtendBufferedRelTo로 VM fork를 최소 vm_nblocks 블록까지 확장한다. 확장 후 CacheInvalidateSmgr 메시지를 보낸다.

  • vacuumlazy.c — 메인 힙 스캔 전에 visibilitymap_pin, 건너뜀 판단 루프에서 블록별로 visibilitymap_get_status, all-visible/all-frozen 페이지 확인 후 visibilitymap_set, VACUUM 패스 시작과 끝에 visibilitymap_count를 호출한다.

  • heapam.cheap_insert, heap_update, heap_delete, heap_lock_tuple 등은 VM 비트가 설정된 페이지를 수정할 때 (사전 핀된 버퍼와 함께) visibilitymap_clear를 호출한다.

  • nodeIndexonlyscan.cIndexOnlyNext는 모든 후보 인덱스 항목마다 VM_ALL_VISIBLE을 호출해 힙 패치를 건너뛸지 결정한다.

위치 힌트 표 (커밋 273fe94, 2026-06-05)

섹션 제목: “위치 힌트 표 (커밋 273fe94, 2026-06-05)”
심볼파일
BITS_PER_HEAPBLOCKsrc/include/access/visibilitymapdefs.h17
VISIBILITYMAP_ALL_VISIBLEsrc/include/access/visibilitymapdefs.h20
VISIBILITYMAP_ALL_FROZENsrc/include/access/visibilitymapdefs.h21
VISIBILITYMAP_VALID_BITSsrc/include/access/visibilitymapdefs.h22
MAPSIZEsrc/backend/access/heap/visibilitymap.c113
HEAPBLOCKS_PER_PAGEsrc/backend/access/heap/visibilitymap.c117
HEAPBLK_TO_MAPBLOCKsrc/backend/access/heap/visibilitymap.c120
HEAPBLK_TO_MAPBYTEsrc/backend/access/heap/visibilitymap.c122
HEAPBLK_TO_OFFSETsrc/backend/access/heap/visibilitymap.c123
VISIBLE_MASK8src/backend/access/heap/visibilitymap.c126
FROZEN_MASK8src/backend/access/heap/visibilitymap.c127
visibilitymap_clearsrc/backend/access/heap/visibilitymap.c145
visibilitymap_pinsrc/backend/access/heap/visibilitymap.c193
visibilitymap_pin_oksrc/backend/access/heap/visibilitymap.c217
visibilitymap_setsrc/backend/access/heap/visibilitymap.c242
visibilitymap_get_statussrc/backend/access/heap/visibilitymap.c329
visibilitymap_countsrc/backend/access/heap/visibilitymap.c382
visibilitymap_prepare_truncatesrc/backend/access/heap/visibilitymap.c428
visibilitymap_truncation_lengthsrc/backend/access/heap/visibilitymap.c506
vm_readbufsrc/backend/access/heap/visibilitymap.c524
vm_extendsrc/backend/access/heap/visibilitymap.c619
PD_ALL_VISIBLEsrc/include/storage/bufpage.h190
PageIsAllVisiblesrc/include/storage/bufpage.h431
VM_ALL_VISIBLEsrc/include/access/visibilitymap.h24
VM_ALL_FROZENsrc/include/access/visibilitymap.h27
log_heap_visiblesrc/backend/access/heap/heapam.c8813
heap_xlog_visiblesrc/backend/access/heap/heapam_xlog.c182
VISIBILITYMAP_FORKNUMsrc/include/common/relpath.h61
  • VM은 힙 페이지당 정확히 2비트를 저장하며, 표준 PageHeaderData 외에 별도 헤더가 없다. visibilitymap.cMAPSIZE, HEAPBLOCKS_PER_BYTE, HEAPBLOCKS_PER_PAGE 읽기로 확인. MAPSIZE = BLCKSZ - MAXALIGN(SizeOfPageHeaderData), HEAPBLOCKS_PER_BYTE = BITS_PER_BYTE / BITS_PER_HEAPBLOCK = 4.

  • all-visible 비트 설정은 관계에 WAL이 필요할 때 항상 WAL을 내보내며, 비트 끄기는 자체 WAL 레코드를 내보내지 않는다. visibilitymap_set(XLOG_HEAP2_VISIBLE 내보냄)과 visibilitymap_clear(MarkBufferDirty만, XLogInsert 없음)를 읽어 확인. 비트 끄기의 정확성 논거는 소스 주석에 있다. 호출자가 힙 변경 WAL 재실행 시에도 비트를 끈다는 보장이 전제된다.

  • all-frozen은 all-visible 없이 설정할 수 없다. visibilitymap_setAssert(flags != VISIBILITYMAP_ALL_FROZEN)visibilitymap_clearAssert(flags != VISIBILITYMAP_ALL_VISIBLE)(all-frozen이 여전히 설정된 상태에서) 두 assertion 지점을 읽어 확인.

  • visibilitymap_get_status는 잠금을 획득하지 않는다. 함수 본문을 읽어 LockBuffer 호출이 없음을 확인. 소스 주석이 호출자에게 경쟁 상태를 명시적으로 경고한다. 바이트 읽기가 원자적인 아키텍처(PostgreSQL이 지원하는 모든 플랫폼)에서는 경쟁 상태가 안전하다. 최악의 경우 보수적인 오래된 읽기가 발생할 뿐이다.

  • xl_heap_visiblesnapshotConflictHorizon은 Hot Standby에서 인덱스 전용 스캔을 취소하는 데 쓰인다. heap_xlog_visible(heapam_xlog.c:182)에서 InHotStandby가 참일 때 ResolveRecoveryConflictWithSnapshot이 호출됨을 확인.

  • VISIBILITYMAP_XLOG_CATALOG_REL은 WAL 레코드에만 설정되고 VM 비트맵 온디스크 기록에는 들어가지 않는다. log_heap_visible에서 xlrec.flags에 OR되지만 온디스크 비트 쓰기에는 전달되지 않음을 확인. visibilitymapdefs.h의 상수 주석도 VISIBILITYMAP_XLOG_* 상수는 visibilitymap_set에 전달해서는 안 된다고 명시한다.

  • visibilitymap_countVISIBLE_MASK8=0x55FROZEN_MASK8=0xAApg_popcount_masked를 사용한다. visibilitymap.c 373~375줄에서 확인. 마스크들이 각 2비트 쌍의 하위·상위 비트를 올바르게 분리한다.

  1. VM fork의 지연 초기화. vm_readbuf는 VM 페이지가 손상되거나 없는 경우에도 실패하지 않고 RBM_ZERO_ON_ERROR를 전달한다. 손상된 VM 페이지는 자동으로 모두 0(모든 비트 꺼짐)으로 초기화되어 보수적으로 안전한 상태를 유지한다. 이러한 조용한 초기화를 탐지하거나 보고하는 메커니즘이 있는지는 확인하지 않았다. 조사 경로: ReadBufferExtendedReadBuffer_common에서 RBM_ZERO_ON_ERROR 경로를 추적한다.

  2. 크래시 복구 후 pg_class.relallvisible 정확성. VACUUM은 패스 후 visibilitymap_count를 호출해 relallvisible을 갱신한다. VACUUM이 완료된 뒤 카탈로그 갱신이 커밋되기 전에 서버가 크래시하면, 복구 후 relallvisible이 실제보다 낮을 수 있다. 플래너가 이를 재확인하는지 아니면 카탈로그 값을 그냥 신뢰하는지는 이번 검토에서 확인하지 않았다. 조사 경로: costsize.c에서 플래너가 relallvisible을 사용하는 방식과 낡은 카운트에 대한 방어 코드를 추적한다.

PostgreSQL 너머 — 비교 설계와 연구 프론티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”
  • InnoDB의 change buffer와 page cleaner. InnoDB는 undo 로그와 배경 page-cleaner 스레드로 페이지 청결도를 암묵적으로 추적한다. 명시적인 가시성 비트맵은 없다. PostgreSQL의 페이지당 명시적 2비트 기록과의 대비가 시사적이다. PostgreSQL은 모든 쓰기에서 VM을 유지하는 비용을 치르고, VACUUM과 IOS에서 O(1) 페이지 청결도 조회를 얻는다. 워크로드 유형(쓰기 집중형 vs 읽기 집중형 vs vacuum 민감형)별 직접 비용 비교가 이 트레이드오프를 더 선명하게 드러낼 수 있다.

  • MySQL 8 InnoDB의 persistent statistics와 innodb_stats_persistent. InnoDB도 배경 정리 패스 후 플래너에게 가시적인 통계를 노출한다. 각 시스템이 배경 정리 후 플래너 가시 통계를 갱신하는 방법을 교차 참조하면, PostgreSQL의 VM → relallvisible → IOS 비용 모델을 명확히 할 수 있다.

  • VACUUM의 eager scanning 모드 (PG18). PostgreSQL 18은 곧 수정될 가능성이 높은 페이지의 all-visible 비트를 선제적으로 해제했다가 VACUUM 후 재설정하는 eager scanning 서브모드를 추가했다. 핫 테이블에서 IOS 가용성 지연을 줄이는 효과가 있다. visibilitymap_get_statusheap_vac_scan_next_block의 건너뜀 로직과의 상호작용은 이 문서에서 설명한 핵심 VM 메커니즘의 흥미로운 확장이다. 전체 VACUUM 패스 설명은 postgres-vacuum.md를 참고한다.

  • 술어 잠금과 SSI. visibility map은 모든 트랜잭션에 대한 튜플 가시성을 보장하지만, SSI는 rw-안티의존성 사이클을 탐지하기 위해 더 세밀한 읽기 술어 추적이 필요하다. VM을 SSI 술어 확인의 지름길로 사용할 수는 없다. 상호작용은 postgres-ssi-predicate-locking.md(미작성)에서 다룬다.

  • BRIN 인덱스와 visibility map. Block Range Index(BRIN)는 VM과 유사하게 블록 범위별 최솟값·최댓값 통계를 간결한 페이지당 메타데이터에 저장한다. VM과 BRIN 메타데이터 모두 VACUUM이 갱신한다. brin_summarize_range가 VM 비트를 활용해 깨끗한 페이지의 요약을 건너뛸 수 있는지는 이번 검토에서 확인하지 않았다.

(없음 — 소스 트리에서 직접 합성)

  • Silberschatz, Korth, Sudarshan. Database System Concepts, 7판, §15.6 “Multiversion Concurrency Control”; §18.3 “Multiple Granularity”.
  • Petrov, Alex. Database Internals, 5장 §“MVCC Versions and Cleanup”.

소스 코드 경로 (REL_18_STABLE, 커밋 273fe94)

섹션 제목: “소스 코드 경로 (REL_18_STABLE, 커밋 273fe94)”
  • src/backend/access/heap/visibilitymap.c — 전체 구현
  • src/include/access/visibilitymap.h — 공개 API와 VM_ALL_* 매크로
  • src/include/access/visibilitymapdefs.h — 비트 플래그 상수
  • src/include/storage/bufpage.hPD_ALL_VISIBLE, PageIsAllVisible
  • src/include/common/relpath.hVISIBILITYMAP_FORKNUM 열거형
  • src/backend/access/heap/heapam.clog_heap_visible
  • src/backend/access/heap/heapam_xlog.cheap_xlog_visible 재실행
  • src/backend/access/heap/vacuumlazy.c — VM 구동 페이지 건너뜀
  • src/backend/executor/nodeIndexonlyscan.c — IOS의 VM_ALL_VISIBLE
  • postgres-heap-am.md — 힙 튜플 레이아웃, PD_ALL_VISIBLE 생명주기, HOT
  • postgres-vacuum.md — 전체 VACUUM 패스, LVRelState, wraparound failsafe
  • postgres-mvcc-snapshots.md — 스냅샷 획득, xmin/xmax 가시성
  • postgres-xid-wraparound-freeze.md — 동결 메커니즘, FrozenTransactionId
  • postgres-buffer-manager.mdReadBufferExtended, RBM_ZERO_ON_ERROR
  • postgres-xlog-wal.md — WAL 삽입, XLogRegisterBuffer
  • postgres-smgr-md.mdsmgrexists, smgrnblocks, VISIBILITYMAP_FORKNUM