콘텐츠로 이동

(KO) PostgreSQL Vacuum — 죽은 튜플 회수, 동결, XID 순환 방지

목차

MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 읽기 차단 없이 동시성을 확보하는 방법이다. 어떤 실행 중인 트랜잭션도 더 이상 볼 수 없을 때까지 이전 행 버전을 보존하는 방식으로 작동한다(Database Internals, Petrov, ch. 5 §“MVCC Versions and Cleanup”; Database System Concepts, 7e, ch. 15 §“Snapshot Isolation”). 그 대가로 삭제와 갱신 연산은 공간을 즉시 회수하지 않는다. 이전 버전을 죽은 상태로 표시(xmax 설정)하고 바이트를 페이지에 남겨 두기 때문이다. 배경 회수 프로세스가 없으면 꾸준히 갱신되는 테이블은 끝없이 커지고, 읽기는 살아 있는 버전을 찾기 위해 점점 길어지는 죽은 버전 체인을 훑어야 한다. 이 회수를 담당하는 프로세스를 가비지 컬렉터 (garbage collector) 또는 vacuum이라 부른다.

vacuum 문제는 두 개의 독립된 하위 문제로 나뉜다.

죽은 튜플 회수. 행 버전은 삭제 트랜잭션(xmax)이 커밋됐고 어떤 활성 트랜잭션의 스냅샷도 그 행을 볼 수 없을 때 죽은 상태가 된다. 가장 오래된 활성 스냅샷이 horizon(가시성 경계)을 결정한다. 그 horizon보다 이전에 삭제된 행 버전은 안전하게 제거할 수 있다. 구현의 핵심 어려움은 “제거해도 안전하다”는 판단이 전역 술어라는 점이다. vacuum은 자신의 백엔드뿐만 아니라 모든 활성 트랜잭션을 파악해야 하며, 그 전역 최솟값을 계산하는 작업 자체가 쓰기를 차단해서는 안 된다.

트랜잭션 ID(XID) 순환 방지. PostgreSQL은 32비트 XID를 사용한다. 순환 창은 현재 XID보다 2^31 트랜잭션(약 21억) 앞이다. 2^31 XID 이전에 삽입된 행은 현재 트랜잭션보다 더 새것으로 보여 보이지 않게 된다. 이를 막기 위해 vacuum은 오래된 XID를 동결 (freeze)한다. 튜플의 xmin/xmax를 특수 값 FrozenTransactionId(XID 2)로 덮어 쓰거나 HEAP_XMIN_FROZEN 힌트 비트를 설정하면, 이후 어떤 트랜잭션에서도 그 튜플은 항상 보인다. 정기적으로 동결하지 않으면 XID 공간이 순환할 때 데이터가 손상된다. 읽기 전용 테이블에도 VACUUM이 선택이 아닌 필수인 이유가 여기에 있다.

두 하위 문제 모두 visibility map(VM, 가시성 맵)과 맞물린다. VM은 관계별 페이지당 2비트 비트맵으로, 힙 접근 방법이 관리한다. all-visible(AV) 비트가 설정된 페이지에는 어떤 스냅샷도 볼 수 있는 죽은 튜플이 없다. vacuum은 죽은 튜플 수거 목적으로 이 페이지를 건너뛸 수 있다. all-frozen(AF) 비트까지 설정된 페이지에는 동결되지 않은 XID도 없다. vacuum은 동결 목적으로도 건너뛸 수 있다. VM은 안정된 테이블에서 반복 vacuum 비용을 낮추는 핵심 메커니즘이다. VM 비트 메커니즘은 postgres-heap-am.md에서 자세히 다룬다. 이 문서는 vacuum이 VM을 어떻게 구동하고 갱신하는지를 다룬다.

대부분의 MVCC 엔진에는 어떤 형태로든 배경 회수가 필요하다. PostgreSQL이 따르는 패턴은 PostgreSQL만의 것이 아니다. InnoDB의 purge 스레드, Oracle의 undo 세그먼트 재사용, SQL Server의 ghost-record 정리에서도 비슷한 형태를 볼 수 있다.

활성 XID 전역 스냅샷을 통한 horizon 계산

섹션 제목: “활성 XID 전역 스냅샷을 통한 horizon 계산”

회수 담당 컴포넌트는 실행 중인 트랜잭션 중 가장 오래된 XID를 알아야 한다. PostgreSQL에서 이는 OldestXmin이며, procarray(각 백엔드의 현재 XID와 스냅샷 horizon을 담은 PGPROC 항목의 공유 배열)에서 유도한다. OldestXmin보다 이전에 삭제된 튜플은 현재 및 미래의 모든 스냅샷에서 죽은 상태다. OldestXmin 계산은 procarray의 스냅샷 읽기다. 스핀락 획득 아래 공유 메모리를 읽는 것이지 완전한 트랜잭션이 아니다. 경쟁 설계로는 각 백엔드가 트랜잭션 시작/커밋 때마다 low-watermark 카운터를 유지하는 방식(스캔 불필요)과 여러 NewSQL 엔진이 사용하는 전역 epoch 메커니즘이 있다.

스캔 → 인덱스 삭제 → 힙 수거 3단계 루프

섹션 제목: “스캔 → 인덱스 삭제 → 힙 수거 3단계 루프”

인덱스에서 죽은 튜플을 정리하는 것은 힙에서 정리하는 것보다 비용이 크다. 각 인덱스마다 고유한 구조가 있기 때문이다. 거의 모든 MVCC 스토리지 엔진에서 공통적으로 나타나는 패턴은 다음과 같다.

  1. 힙 페이지 스캔. 죽은 항목을 찾아 TID(tuple identifier, 튜플 식별자)를 버퍼에 기록한다.
  2. 인덱스 일괄 삭제. 모든 인덱스를 한 번씩 순회하며 버퍼에 있는 TID에 해당하는 항목을 삭제한다. 비용이 큰 인덱스 ambulkdelete 패스다.
  3. 힙 항목 수거. 힙 페이지로 돌아와 죽은 line pointer를 LP_UNUSED로 표시해 미래 삽입에 사용할 슬롯을 확보하고, FSM(free-space map, 여유 공간 맵)을 갱신한다.

죽은 TID 버퍼는 단계 사이의 조율 지점이다. 힙 스캔이 완료되기 전에 버퍼가 가득 차면 엔진은 조기 플러시를 실행해야 한다. 현재 배치로 2~3단계를 수행하고 1단계를 재개하는 방식이다. 이 구조는 maintenance_work_mem(버퍼 크기)과 인덱스 패스 횟수 사이의 트레이드오프를 만든다.

배경 회수는 포그라운드 I/O와 자원을 경쟁한다. 큰 테이블의 제어되지 않은 vacuum은 I/O를 포화시키고 쿼리 지연을 늘린다. 표준 해법은 비용 기반 속도 제한 (cost-based rate limiting)이다. vacuum이 단위 시간당 수행하는 버퍼 읽기/쓰기 횟수를 세고, 비율이 임계치를 초과하면 슬립을 삽입한다. 이는 소프트 실시간 조정 수단이다. vacuum은 완료 시간을 희생하고 지연 여유를 확보한다.

이론 개념PostgreSQL 엔티티
MVCC 가비지 컬렉터vacuum() / heap_vacuum_rel()
전역 최고령 스냅샷 horizonVacuumCutoffsOldestXmin
XID 동결 horizonVacuumCutoffsFreezeLimit
죽은 TID 버퍼LVRelStateTidStore *dead_items
가시성 건너뜀 맵visibility map(VM) AV/AF 비트
1단계(힙 스캔)lazy_scan_heap()
2단계(인덱스 삭제)lazy_vacuum_all_indexes() / ambulkdelete
3단계(힙 수거)lazy_vacuum_heap_rel()
비용 스로틀 슬립vacuum_delay_point()
순환 방지aggressive vacuum + lazy_check_wraparound_failsafe()
병렬 인덱스 vacuumvacuumparallel.cParallelVacuumState

두 가지 명령 변형: lazy VACUUM과 VACUUM FULL

섹션 제목: “두 가지 명령 변형: lazy VACUUM과 VACUUM FULL”

PostgreSQL은 VACUUM 명령 이름을 공유하지만 근본적으로 다른 두 연산을 구분한다.

Lazy VACUUM(기본값, concurrent vacuum이라고도 함)은 제자리(in-place) 방식으로 동작한다. 페이지를 다시 쓰거나 테이블 배타 락을 잡지 않고 죽은 튜플 슬롯을 회수해 재사용 가능 상태로 만든다. lazy VACUUM 중에도 읽기와 쓰기는 정상적으로 계속된다. vacuumlazy.cheap_vacuum_rel()이 이를 구현하며, 이 문서가 다루는 대상이다.

VACUUM FULL은 전체 관계를 새 힙 파일로 다시 쓴다(CLUSTER 코드 경로 사용). 테이블에 배타 AccessExclusiveLock을 잡는다. 모든 여유 공간을 회수하지만 다른 접근을 모두 차단한다. 실제로는 거의 필요하지 않다. 일상적인 유지보수에는 lazy VACUUM으로 충분하다.

SQL 명령 VACUUMvacuum.cExecVacuum으로 진입한다. 옵션을 VacuumParams 구조체로 파싱하고 vacuum()을 호출한다. vacuum()은 대상 관계 목록을 확장하거나 일반 VACUUM의 경우 vacuum 가능한 모든 관계를 스캔하고, 관계마다 vacuum_rel()을 호출한다. 힙 AM의 경우 vacuum_rel()은 결국 table_relation_vacuum()을 거쳐 heap_vacuum_rel()을 호출한다.

// VacuumParams — src/include/commands/vacuum.h
typedef struct VacuumParams
{
bits32 options; /* VACOPT_* bitmask */
int freeze_min_age; /* min XID age before freeze, -1 = default */
int freeze_table_age; /* whole-table scan age threshold */
int multixact_freeze_min_age;
int multixact_freeze_table_age;
bool is_wraparound; /* force for-wraparound vacuum */
int log_min_duration;
VacOptValue index_cleanup; /* VACOPTVALUE_AUTO/ENABLED/DISABLED */
VacOptValue truncate;
Oid toast_parent;
double max_eager_freeze_failure_rate;
int nworkers; /* 0 = auto, -1 = no parallel */
} VacuumParams;

단 한 페이지도 스캔하기 전에 vacuum_get_cutoffs()가 vacuum 실행 전체에서 변하지 않는 네 개의 XID horizon을 계산한다.

// VacuumCutoffs — src/include/commands/vacuum.h
struct VacuumCutoffs
{
TransactionId relfrozenxid; /* current pg_class.relfrozenxid */
MultiXactId relminmxid;
TransactionId OldestXmin; /* tuples deleted before this are DEAD */
MultiXactId OldestMxact;
TransactionId FreezeLimit; /* XIDs older than this must be frozen */
MultiXactId MultiXactCutoff;
};

OldestXmin은 실행 중인 트랜잭션에서 가장 오래된 horizon을 찾기 위해 procarray를 스캔해 계산한다. xmaxOldestXmin 이전에 커밋된 튜플은 현재 및 미래의 모든 스냅샷에서 죽은 상태다.

FreezeLimitvacuum_freeze_min_age(기본 5000만)에서 유도한다. current_xid - vacuum_freeze_min_age보다 오래된 XID는 반드시 동결해야 한다. 동결은 저장된 XID를 FrozenTransactionId(XID 2)로 교체하거나 HEAP_XMIN_FROZEN 힌트 비트를 설정해, 해당 튜플을 영구적으로 가시 상태로 만든다.

LVRelState: 관계별 vacuum 작업 상태

섹션 제목: “LVRelState: 관계별 vacuum 작업 상태”

heap_vacuum_rel()은 이 관계의 vacuum이 지속되는 동안 살아 있는 LVRelState 하나를 힙에 할당한다. 모든 하위 함수에 전달되는 단일 중앙 상태 객체다.

// LVRelState (abridged) — src/backend/access/heap/vacuumlazy.c
typedef struct LVRelState
{
Relation rel;
Relation *indrels;
int nindexes;
BufferAccessStrategy bstrategy;
ParallelVacuumState *pvs; /* non-NULL if parallel */
bool aggressive; /* must scan every unfrozen tuple? */
bool skipwithvm; /* use VM to skip pages? */
struct VacuumCutoffs cutoffs;
GlobalVisState *vistest;
TransactionId NewRelfrozenXid; /* lowest unfrozen XID seen so far */
MultiXactId NewRelminMxid;
TidStore *dead_items; /* TIDs of LP_DEAD line pointers */
VacDeadItemsInfo *dead_items_info;
BlockNumber rel_pages;
BlockNumber scanned_pages;
int64 tuples_deleted;
int64 tuples_frozen;
int64 lpdead_items;
int64 live_tuples;
/* ... more counters ... */
} LVRelState;

aggressive 플래그는 vacuum이 relfrozenxid를 전진시키기 위해 모든 동결되지 않은 튜플을 방문해야 하는지 제어한다. 테이블의 relfrozenxidvacuum_freeze_table_age(기본값: current_xid보다 1억 5000만 트랜잭션 이전)에 가까워지면 aggressive vacuum이 시작된다. aggressive vacuum 없이는 테이블이 XID 순환 위험에 처한다.

lazy_scan_heap()이 전체 작업 루프를 구동한다. 관계의 ReadStream을 설정하고(PG18의 비동기 I/O 인프라) 블록을 순회한다. 블록 선택은 heap_vac_scan_next_block()이 담당하며, VM과 LVRelState 건너뜀 로직을 참조해 다음에 읽을 블록을 결정한다.

// lazy_scan_heap — src/backend/access/heap/vacuumlazy.c
static void
lazy_scan_heap(LVRelState *vacrel)
{
ReadStream *stream;
/* ... */
stream = read_stream_begin_relation(READ_STREAM_MAINTENANCE,
vacrel->bstrategy,
vacrel->rel,
MAIN_FORKNUM,
heap_vac_scan_next_block,
vacrel,
sizeof(uint8));
while (true)
{
/* Check TID store full → flush early via lazy_vacuum() */
if (vacrel->dead_items_info->num_items > 0 &&
TidStoreMemoryUsage(vacrel->dead_items) >
vacrel->dead_items_info->max_bytes)
{
lazy_vacuum(vacrel); /* phases 2+3 on current batch */
FreeSpaceMapVacuumRange(vacrel->rel, ...);
}
buf = read_stream_next_buffer(stream, &per_buffer_data);
if (!BufferIsValid(buf))
break;
/* Phase 1: prune + freeze this page */
/* ... cleanup lock, lazy_scan_prune() or lazy_scan_noprune() ... */
}
/* Final phase 2+3 after heap scan completes */
lazy_vacuum(vacrel);
}

그림 1 — 3단계 vacuum 루프

flowchart TD
    A[heap_vacuum_rel] --> B[vacuum_get_cutoffs]
    B --> C[dead_items_alloc]
    C --> D[lazy_scan_heap\n1단계: 힙 스캔]
    D --> E{TID 저장소 가득?}
    E -- 예 --> F[lazy_vacuum\n2+3단계]
    F --> D
    E -- 아니오 --> G{페이지 더 있음?}
    G -- 예 --> H[lazy_scan_prune / lazy_scan_noprune]
    H --> G
    G -- 아니오 --> I[lazy_vacuum\n최종 플러시]
    I --> J[lazy_truncate_heap?]
    J --> K[pg_class 통계 갱신]

그림 1 — heap_vacuum_rel / lazy_scan_heap의 외부 루프. 1단계(힙 스캔)는 페이지를 순회하며 죽은 항목 TID 저장소가 가득 찰 때 조기 플러시한다. 2+3단계(인덱스 + 힙 vacuum)는 배치가 찰 때마다, 그리고 힙 스캔 완료 후 한 번 더 실행된다.

1단계: lazy_scan_prune과 페이지 수준 정리

섹션 제목: “1단계: lazy_scan_prune과 페이지 수준 정리”

visibility map 건너뜀 검사를 통과한 각 블록에서 vacuum은 cleanup lock(LockBufferForCleanup)을 획득하고 lazy_scan_prune()을 호출한다. 이 락은 배타적이지만 페이지 처리가 끝나는 즉시 해제된다.

// lazy_scan_prune — src/backend/access/heap/vacuumlazy.c
static int
lazy_scan_prune(LVRelState *vacrel, Buffer buf, BlockNumber blkno,
Page page, Buffer vmbuffer,
bool all_visible_according_to_vm,
bool *has_lpdead_items, bool *vm_page_frozen)
{
PruneFreezeResult presult;
int prune_options = HEAP_PAGE_PRUNE_FREEZE;
if (vacrel->nindexes == 0)
prune_options |= HEAP_PAGE_PRUNE_MARK_UNUSED_NOW;
heap_page_prune_and_freeze(rel, buf, vacrel->vistest,
prune_options, &vacrel->cutoffs,
&presult, PRUNE_VACUUM_SCAN,
&vacrel->offnum,
&vacrel->NewRelfrozenXid,
&vacrel->NewRelminMxid);
if (presult.lpdead_items > 0)
dead_items_add(vacrel, blkno,
presult.deadoffsets, presult.lpdead_items);
/* update per-relation counters ... */
}

heap_page_prune_and_freeze()(pruneheap.c, 350행)는 cleanup lock 아래에서 하나의 페이지 패스로 개념적으로 구분되는 두 가지 일을 한다.

  1. HOT 체인 정리. 페이지의 모든 line pointer를 순회하며 HOT 갱신 체인(같은 논리적 행의 여러 버전을 t_ctid가 연결하는 체인) 안의 죽은 버전을 감지하고 체인을 축소한 뒤 죽은 line pointer를 LP_DEAD로 표시한다.

  2. 튜플 동결. cutoffs.FreezeLimit보다 오래된 xmin 또는 xmax XID를 가진 모든 살아 있는 튜플에 대해(cutoffs.MultiXactCutoff보다 오래된 MultiXact도 포함), 저장된 XID를 동결 sentinel로 교체하거나 HEAP_XMIN_FROZEN 힌트 비트를 설정한다.

죽은 line pointer는 vacrel->dead_items(maintenance_work_mem에서 할당된 TidStore)에 추가된다. 인덱스 패스가 해당 인덱스 항목을 제거할 수 있도록 하기 위해서다.

visibility map을 이용한 페이지 건너뜀

섹션 제목: “visibility map을 이용한 페이지 건너뜀”

heap_vac_scan_next_block()이 건너뜀 로직을 구현한다. 일반(non-aggressive) vacuum의 경우를 보면 다음과 같다.

  • all-visible 비트가 설정됐고 강제 스캔도 없는 페이지는 죽은 튜플 수거 목적으로 건너뛸 수 있다.
  • all-visible 비트와 all-frozen 비트 모두 설정된 페이지는 동결 목적으로도 건너뛸 수 있다.

aggressive vacuum에서는 all-frozen 비트를 그대로 존중한다. 완전히 동결된 페이지는 동결 작업이 불필요하다. 그러나 all-visible만 설정된 페이지는 relfrozenxid를 전진시키기 위해 동결이 필요한 XID가 있는지 확인하려고 방문해야 한다.

소형 임계값 SKIP_PAGES_THRESHOLD(32페이지)가 있다. 너무 짧은 클린 페이지 연속 구간을 건너뜀으로써 커널 read-ahead 효과를 잃지 않기 위한 장치다.

PG18은 eager freeze scanning(적극적 동결 스캔)을 도입했다. 일반 vacuum이 all-visible이지만 아직 all-frozen이 아닌 페이지를 선제적으로 방문해 동결함으로써, 다음 aggressive vacuum에 집중될 작업을 분산한다.

heap_vacuum_eager_scan_setup()LVRelState에 저장되는 eager 스캔 파라미터를 계산한다.

// heap_vacuum_eager_scan_setup — src/backend/access/heap/vacuumlazy.c
static void
heap_vacuum_eager_scan_setup(LVRelState *vacrel, VacuumParams *params)
{
/* cap on successes: MAX_EAGER_FREEZE_SUCCESS_RATE (0.2) of
* all-visible-but-not-all-frozen pages */
vacrel->eager_scan_remaining_successes =
(BlockNumber) (MAX_EAGER_FREEZE_SUCCESS_RATE *
(allvisible - allfrozen));
/* failure tolerance per EAGER_SCAN_REGION_SIZE (4096) block region */
vacrel->eager_scan_max_fails_per_region =
params->max_eager_freeze_failure_rate * EAGER_SCAN_REGION_SIZE;
}

성공 상한(MAX_EAGER_FREEZE_SUCCESS_RATE = 0.2, 적격 페이지의 최대 20%)은 하나의 일반 vacuum이 무한정 추가 동결 작업을 하지 못하도록 막는다. 영역별 실패 허용치는 최근 기록된 페이지가 많아 동결할 XID가 아직 없는 테이블 구간에서 I/O를 낭비하지 않도록 한다.

lazy_vacuum_all_indexes()는 모든 인덱스에 ambulkdelete()를 호출하며 vac_tid_reaped()를 콜백으로 전달한다. 이 콜백은 vacrel->dead_items에 있는 TID라면 true를 반환한다. 각 인덱스 AM은 자체 구조를 순회하며 일치하는 항목을 단일 패스로 삭제한다.

ParallelVacuumIsActive(vacrel)(즉 pvs != NULL)이면 인덱스는 ParallelVacuumState DSM 세그먼트(vacuumparallel.c)를 이용해 워커 프로세스에 분배된다. 각 워커는 배정받은 인덱스에 lazy_vacuum_one_index()를 호출한다. 리더는 공유 메모리 카운터와 ParallelVacuumKey DSM 키로 조율한다.

인덱스 vacuum 우회 최적화. lpdead_item_pagesrel_pages의 2%(BYPASS_THRESHOLD_PAGES) 미만이고 TID 저장소가 32 MB 안에 들어오면, lazy_vacuum()은 인덱스 및 힙 vacuum 단계 전체를 건너뛴다. 거의 깨끗한 테이블에서 무시할 만한 수의 죽은 항목을 위해 ambulkdelete 패스 전체를 수행하는 비용 급등을 피하는 방법이다. 임계치가 raw 죽은 항목 수가 아닌 LP_DEAD 항목이 하나라도 있는 페이지 수로 표현되는 이유가 있다. 페이지 수가 vacuum을 건너뛸 때 VM 비트가 설정되지 않은 채 남는 양의 대리 지표이기 때문이다. 설정되지 않은 VM 비트가 미래 스캔의 비용을 높인다. 또한 이 최적화는 num_index_scans == 0(단일 배치)일 때만 발동된다. 이전에 인덱스 패스를 이미 강제한 만큼 테이블이 컸다면, 저렴한 우회 가정이 더 이상 성립하지 않기 때문이다.

// lazy_vacuum (bypass decision) — src/backend/access/heap/vacuumlazy.c
threshold = (double) vacrel->rel_pages * BYPASS_THRESHOLD_PAGES;
bypass = (vacrel->lpdead_item_pages < threshold &&
TidStoreMemoryUsage(vacrel->dead_items) < 32 * 1024 * 1024);
/* ... */
if (bypass)
vacrel->do_index_vacuuming = false; /* but still do index cleanup */
else if (lazy_vacuum_all_indexes(vacrel))
lazy_vacuum_heap_rel(vacrel); /* full phase II then phase III */
else
Assert(VacuumFailsafeActive); /* round aborted by failsafe */

비대칭적 동작에 주목할 필요가 있다. 우회 경로도 이후에 인덱스 cleanup(amvacuumcleanup)은 실행한다. cleanup은 ambulkdelete가 실행되지 않을 때 비용이 저렴하고, 인덱스 AM이 자체 통계를 갱신하는 수단이기 때문이다. 건너뛰는 것은 비용이 큰 bulk-delete 패스뿐이다.

죽은 TID 저장소 크기와 인덱스 패스 횟수. vacrel->dead_items의 크기는 maintenance_work_mem(또는 autovacuum 아래에서는 autovacuum_work_mem)에서 유도된 dead_items_info->max_bytes로 제한된다. 저장소가 힙 스캔 중간에 한도에 도달하면 lazy_scan_heap()은 조기 플러시를 실행해야 한다. 부분 배치로 2+3단계를 수행하고 힙 스캔을 재개하는 방식이다. 조기 플러시가 한 번 발생할 때마다 모든 인덱스를 완전히 한 번 더 순회한다. 저장소를 넘칠 만큼 큰 테이블은 *(플러시 횟수 + 1)*번의 인덱스 스캔 비용을 지불한다. vacuum에 넉넉한 maintenance_work_mem을 주어야 하는 가장 중요한 이유가 여기 있다. 이는 힙 스캔 메모리가 아니라 인덱스 패스 횟수를 1회로 유지하는 버퍼다. PG17의 TidStore 재설계(라딕스 트리 기반, 기존 플랫 ItemPointerData 배열 대체)는 바이트당 유효 죽은 항목 밀도를 크게 높였다. 따라서 동일한 메모리 예산으로 두 번째 인덱스 패스를 강제하기 전에 훨씬 더 많은 죽은 튜플을 수용할 수 있게 됐다.

인덱스 항목이 제거된 후 lazy_vacuum_heap_rel()은 LP_DEAD 항목을 제공한 힙 페이지만을 두 번째로 순회한다(TID 저장소에 추적됨). 일반 배타 버퍼 락(cleanup lock이 아님)을 획득하고 lazy_vacuum_heap_page()를 호출한다. 이 함수는 LP_DEAD line pointer를 LP_UNUSED로 표시해 슬롯을 해제한다. 각 페이지 처리 후 해제된 공간을 FSM에 기록하고(FreeSpaceMapVacuum), 페이지가 완전히 깨끗해지면 VM all-visible 비트를 설정한다.

3단계가 2단계에 순서 의존적인 이유는 효율성이 아닌 정확성 때문이다. LP_DEAD line pointer를 LP_UNUSED로 바꾸면, 즉 미래 INSERT가 재사용할 수 있게 되면, 그 TID를 가리키는 모든 인덱스 항목이 먼저 제거돼 있어야 한다. 힙 슬롯을 재활용했는데 stale 인덱스 항목이 여전히 그 TID를 가리키고 있다면, 인덱스 스캔이 그 항목을 따라 논리적으로 전혀 관계없는 새 튜플에 도달할 수 있다. 죽은 TID TidStore가 두 패스를 연결하는 고리다. 2단계는 TidStore를 읽어 ambulkdelete를 구동하고, 3단계는 동일한 저장소를 순회해 수거할 힙 페이지와 오프셋을 찾는다. lazy_vacuum()이 항상 2단계를 완전히 끝낸 후(또는 failsafe 경로를 밟은 후)에야 lazy_vacuum_heap_rel()을 호출하는 이유가 여기에 있다.

그림 2 — 배치별 단계 파이프라인 (스캔+정리 → 죽은 TID 저장소 → 인덱스 vacuum → 힙 수거 → VM/동결)

flowchart TD
    P1["lazy_scan_prune<br/>heap_page_prune_and_freeze"] --> PR{"presult.lpdead_items > 0?"}
    PR -- 예 --> DS["dead_items_add<br/>TidStore *dead_items"]
    PR -- 아니오 --> VM
    DS --> VMSET{"페이지가 all-visible 됐는가?"}
    VMSET -- "예, all-frozen" --> VMF["visibilitymap_set<br/>ALL_VISIBLE | ALL_FROZEN"]
    VMSET -- "예, 미동결 XID 있음" --> VMV["visibilitymap_set<br/>ALL_VISIBLE"]
    VMSET -- 아니오 --> VM["NewRelfrozenXid / NewRelminMxid<br/>페이지별 추적"]
    VMF --> VM
    VMV --> VM
    VM --> FULL{"TidStore가 max_bytes 초과<br/>또는 스캔 완료?"}
    FULL -- 아니오 --> P1
    FULL -- 예 --> LV["lazy_vacuum"]
    LV --> BYP{"우회? lpdead_item_pages<br/>< BYPASS_THRESHOLD_PAGES<br/>and TidStore < 32MB"}
    BYP -- 예 --> RST["dead_items_reset<br/>인덱스+힙 vacuum 건너뜀"]
    BYP -- 아니오 --> P2["lazy_vacuum_all_indexes<br/>2단계: 인덱스별 ambulkdelete"]
    P2 --> OK{"인덱스 라운드 완료?"}
    OK -- "아니오, failsafe" --> FS["VacuumFailsafeActive<br/>do_index_vacuuming = false"]
    OK -- 예 --> P3["lazy_vacuum_heap_rel<br/>3단계: LP_DEAD to LP_UNUSED"]
    P3 --> RST

그림 2 — TID 배치에 대한 단계 전체 흐름. 1단계(lazy_scan_prune)는 페이지를 정리하고 동결 스탬프를 찍은 뒤 죽은 TID를 TidStore에 공급하고, VM all-visible / all-frozen 비트를 기회적으로 설정하며 NewRelfrozenXid를 추적한다. 저장소가 가득 차거나 힙 스캔이 끝나면 lazy_vacuum()이 저렴하게 우회하거나, 2단계(ambulkdelete) 후 3단계(lazy_vacuum_heap_rel)를 실행하거나, wraparound failsafe를 발동한다. 저장소는 배치마다 리셋된다.

lazy_scan_prune()의 VM 갱신 부분은 방금 정리된 페이지가 미래 건너뜀을 위한 all-visible(경우에 따라 all-frozen) 지위를 획득하는 지점이다.

// lazy_scan_prune (VM-set tail) — src/backend/access/heap/vacuumlazy.c
/* page became all-visible: set ALL_VISIBLE, plus ALL_FROZEN if applicable */
PageSetAllVisible(page);
MarkBufferDirty(buf);
old_vmbits = visibilitymap_set(vacrel->rel, blkno, buf,
InvalidXLogRecPtr,
vmbuffer, presult.vm_conflict_horizon,
flags);
if ((old_vmbits & VISIBILITYMAP_ALL_VISIBLE) == 0)
{
vacrel->vm_new_visible_pages++;
if (presult.all_frozen)
{
vacrel->vm_new_visible_frozen_pages++;
*vm_page_frozen = true;
}
}

heap_page_prune_and_freeze()가 반환하는 presult.vm_conflict_horizon은 복구가 사용하는 스냅샷 충돌 horizon이다. VM 설정 레코드를 재생하는 hot-standby 복제본은 해당 horizon 이전의 스냅샷을 가진 쿼리를 취소해야 한다. 페이지가 all-visible로 선언되려 하기 때문이다. 이 메커니즘이 스탠바이에서의 VM 구동 index-only scan이 스탠바이 자체 독자가 여전히 볼 수 있어야 하는 행을 반환하지 못하도록 막는다.

// lazy_vacuum_heap_rel — src/backend/access/heap/vacuumlazy.c
static void
lazy_vacuum_heap_rel(LVRelState *vacrel)
{
TidStoreIter *iter = TidStoreBeginIterate(vacrel->dead_items);
stream = read_stream_begin_relation(
READ_STREAM_MAINTENANCE | READ_STREAM_USE_BATCHING,
vacrel->bstrategy, vacrel->rel, MAIN_FORKNUM,
vacuum_reap_lp_read_stream_next, iter,
sizeof(TidStoreIterResult));
while (true) {
buf = read_stream_next_buffer(stream, (void **) &iter_result);
if (!BufferIsValid(buf)) break;
LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
lazy_vacuum_heap_page(vacrel, blkno, buf,
offsets, num_offsets, vmbuffer);
/* update FSM with freed space */
}
}

lazy_check_wraparound_failsafe()는 스캔 중 FAILSAFE_EVERY_PAGES(약 512K 페이지, 8 KB/페이지 기준 4 GB)마다 호출된다. 내부적으로 vacuum_xid_failsafe_check()를 호출해 현재 XID와 테이블의 relfrozenxid 사이의 거리를 계산한다. 거리가 vacuum_failsafe_age(기본 16억)를 초과하면 failsafe가 발동된다.

// lazy_check_wraparound_failsafe — src/backend/access/heap/vacuumlazy.c
if (unlikely(vacuum_xid_failsafe_check(&vacrel->cutoffs)))
{
VacuumFailsafeActive = true;
vacrel->bstrategy = NULL; /* use all shared_buffers */
vacrel->do_index_vacuuming = false;
vacrel->do_index_cleanup = false;
vacrel->do_rel_truncate = false;
VacuumCostActive = false; /* disable cost delay */
ereport(WARNING, ...);
return true;
}

failsafe는 인덱스 정리가 불완전해질 수 있음을 감수하고 힙 동결 스캔의 진행을 보장한다. 이는 최후의 수단이다. 정상 경로는 autovacuum이 충분히 자주 실행돼 모든 테이블이 failsafe 임계치 아래를 유지하는 것이다.

vacuum_delay_point(false)는 각 페이지 반복의 시작마다 호출된다. 현재 vacuum 비용(버퍼 읽기 + 더티 버퍼, vacuum_cost_page_hit, _miss, _dirty로 가중치 부여)을 계산하고, 누적 잔액이 vacuum_cost_limit을 초과하면 vacuum_cost_delay 밀리초 동안 슬립한다. 병렬 vacuum의 경우 워커는 parallel_vacuum_worker_delay_ns로 비용을 보고하고 리더가 집계한다. VacuumSharedCostBalanceVacuumActiveNWorkers 원자 카운터가 워커 간 비례 지연을 조율한다.

세 단계가 모두 완료된 후 should_attempt_truncation()이 관계 끝의 빈 페이지를 잘라낼지 결정한다. lazy_truncate_heap()AccessExclusiveLock을 획득하고, 후미 페이지가 비어 있음을 역방향 스캔으로 확인한 뒤, smgrtruncate()를 호출해 물리 파일을 줄인다. 잘라내기 경로는 배타 락이 필요하므로 동시 읽기에 의해 차단될 수 있다. VACUUM_TRUNCATE_LOCK_TIMEOUT(5000 ms) 타임아웃으로 재시도해 무한 차단을 방지한다.

heap_vacuum_rel() 끝에서 vac_update_relstats()pg_class.relpages, pg_class.reltuples, pg_class.relfrozenxid, pg_class.relminmxid를 갱신한다. relfrozenxid는 이번 vacuum 패스에서 실제로 발견된 최솟값인 vacrel.NewRelfrozenXid로 전진한다. relfrozenxid를 전진시키면 vac_truncate_clog()가 오래된 CLOG 페이지를 폐기할 수 있어 pg_xact/ 디스크 공간이 확보된다.

params->nworkers >= 0이고 테이블에 병렬 임계치를 넘는 인덱스가 두 개 이상 있으면, vacuum은 vacuumparallel.c의 표준 ParallelContext / DSM 장치를 사용해 병렬 워커를 포크한다. 병렬 처리 단위는 인덱스 하나당 워커 하나다. 1단계(힙 스캔)와 3단계(힙 수거)는 항상 리더에서 실행된다. 인덱스 패스(2단계 ambulkdelete와 최종 amvacuumcleanup)만 팬아웃된다.

parallel_vacuum_init()이 DSM 세그먼트에 할당하는 PVShared 구조체는 리더와 워커 사이의 조율 레코드다.

// PVShared — src/backend/commands/vacuumparallel.c
typedef struct PVShared
{
Oid relid;
int elevel;
int64 queryid;
double reltuples;
bool estimated_count;
int maintenance_work_mem_worker;
int ring_nbuffers;
pg_atomic_uint32 cost_balance; /* shared vacuum cost balance */
pg_atomic_uint32 active_nworkers; /* workers currently delaying */
pg_atomic_uint32 idx; /* next index to claim */
dsa_handle dead_items_dsa_handle;
dsa_pointer dead_items_handle; /* shared TidStore */
VacDeadItemsInfo dead_items_info;
} PVShared;

세 개의 필드가 설계의 핵심을 담고 있다. dead_items_handle / dead_items_dsa_handle은 DSA 내 공유 TidStore를 가리킨다. 모든 워커가 리더의 힙 스캔이 생성한 동일한 죽은 TID 집합을 읽는다. 죽은 항목 버퍼는 워커별로 복사되지 않는다. idx는 원자 claim 카운터다. 각 워커(그리고 워커로 합류하는 리더)가 pg_atomic_fetch_add_u32(&shared->idx, 1)을 실행해 다음 처리할 인덱스를 차지하는 방식이다. 인덱스 배열에 대한 락-프리 work-stealing 큐다. cost_balance / active_nworkers는 전체 워커 풀이 각자 독립적으로가 아니라 함께 vacuum_cost_limit을 준수하는 공유 비용 기반 지연을 구현한다.

팬아웃 자체는 parallel_vacuum_process_all_indexes()에서 일어난다. 이 함수는 인덱스 vacuum 라운드마다 한 번, cleanup 때 한 번 호출된다. 현재 단계에 필요한 워커 수를 계산하고 워커를 실행한 뒤, 리더가 동일한 work-stealing 루프에 합류한다.

// parallel_vacuum_process_all_indexes — src/backend/commands/vacuumparallel.c
/* The leader process will participate */
nworkers--;
nworkers = Min(nworkers, pvs->pcxt->nworkers);
/* ... mark each index's parallel_workers_can_process ... */
pg_atomic_write_u32(&(pvs->shared->idx), 0);
if (nworkers > 0)
{
pg_atomic_write_u32(&(pvs->shared->cost_balance), VacuumCostBalance);
pg_atomic_write_u32(&(pvs->shared->active_nworkers), 0);
ReinitializeParallelWorkers(pvs->pcxt, nworkers);
LaunchParallelWorkers(pvs->pcxt);
/* ... enable shared cost balance for leader ... */
}
/* leader vacuums parallel-unsafe indexes itself, then joins the safe loop */
parallel_vacuum_process_unsafe_indexes(pvs);
parallel_vacuum_process_safe_indexes(pvs);

각 참가자(워커는 parallel_vacuum_main() 경유, 리더는 인라인)는 parallel_vacuum_process_safe_indexes()에서 동일한 claim 루프를 실행한다.

// parallel_vacuum_process_safe_indexes — src/backend/commands/vacuumparallel.c
for (;;)
{
int idx = pg_atomic_fetch_add_u32(&(pvs->shared->idx), 1);
if (idx >= pvs->nindexes)
break; /* all indexes claimed */
indstats = &(pvs->indstats[idx]);
if (!indstats->parallel_workers_can_process)
continue; /* leader handles unsafe ones */
parallel_vacuum_process_one_index(pvs, pvs->indrels[idx], indstats);
}

워커에 할당되지 않는 인덱스 범주가 두 가지 있다. parallel-unsafe 인덱스(해당 AM이 워커에서 bulk-delete를 지원하지 않는다고 선언한 경우, 예: VACUUM_OPTION_PARALLEL_BULKDEL 미지원)와 워커 할당 가치가 없을 만큼 작은 인덱스다. parallel_vacuum_process_unsafe_indexes()가 이를 safe 루프에 합류하기 전에 리더에서 처리한다. 리더가 워커가 작업 중일 때 유휴 상태가 되지 않도록 하는 구조다.

그림 3 — 병렬 인덱스 vacuum 워커 팬아웃

flowchart TD
    A["lazy_vacuum_all_indexes<br/>ParallelVacuumIsActive?"] -- 예 --> B["parallel_vacuum_process_all_indexes"]
    A -- 아니오 --> S["리더 단독 루프:<br/>인덱스별 lazy_vacuum_one_index"]
    B --> C["nworkers 계산<br/>nworkers-- (리더 참여)"]
    C --> D["LaunchParallelWorkers<br/>공유 cost_balance 설정"]
    D --> E["리더: process_unsafe_indexes<br/>(parallel-unsafe + 소형 인덱스)"]
    D --> W1["worker 1<br/>parallel_vacuum_main"]
    D --> W2["worker 2<br/>parallel_vacuum_main"]
    D --> Wn["worker N<br/>parallel_vacuum_main"]
    E --> F["리더 합류:<br/>process_safe_indexes"]
    W1 --> Q["원자 claim:<br/>idx = fetch_add(shared.idx, 1)"]
    W2 --> Q
    Wn --> Q
    F --> Q
    Q --> ONE["parallel_vacuum_process_one_index<br/>ambulkdelete / amvacuumcleanup"]
    ONE --> Q
    Q -- "idx >= nindexes" --> WAIT["WaitForParallelWorkersToFinish<br/>InstrAccumParallelQuery"]
    WAIT --> CARRY["공유 비용 잔액을<br/>리더 힙 스캔으로 반영"]

그림 3 — 인덱스 팬아웃. 공유 TidStore(dead_items_handle 경유)는 모든 참가자가 읽는다. shared.idx는 원자 카운터로 워커와 리더가 fetch-and-add로 인덱스를 차지하는 락-프리 work-stealing 방식이다. 리더는 parallel-unsafe 및 소형 인덱스를 직접 처리한 후 safe 루프에 합류하므로 유휴 상태가 없다. 라운드 후 공유 비용 잔액은 힙 스캔 재개 전 리더의 VacuumCostBalance에 반영된다.

  • ExecVacuumsrc/backend/commands/vacuum.cVacuumStmt 파싱, VacuumParams 빌드, vacuum() 호출.
  • vacuumvacuum.c — 관계 목록 확장; 관계별 루프.
  • vacuum_relvacuum.c — 관계 열기, 락 획득, table_relation_vacuum()heap_vacuum_rel() 호출.
  • vacuum_get_cutoffsvacuum.cVacuumCutoffs 계산: OldestXmin, OldestMxact, FreezeLimit, MultiXactCutoff.
  • heap_vacuum_relvacuumlazy.cLVRelState 할당, lazy_scan_heap 호출, 통계 마무리.
  • heap_vacuum_eager_scan_setupvacuumlazy.c — eager 동결 파라미터(eager_scan_remaining_successes 등) 계산.
  • lazy_scan_heapvacuumlazy.cReadStream을 사용한 페이지 외부 루프; TID 저장소가 가득 찰 때 lazy_vacuum() 실행.
  • heap_vac_scan_next_blockvacuumlazy.c — ReadStream 콜백; VM을 참조해 all-visible / all-frozen 페이지 건너뜀.
  • find_next_unskippable_blockvacuumlazy.c — 건너뛸 수 있는 페이지를 지나 vacrel->next_unskippable_block 전진.
  • lazy_scan_prunevacuumlazy.c — 페이지별: heap_page_prune_and_freeze 호출, LP_DEAD 오프셋 수집.
  • lazy_scan_noprunevacuumlazy.c — cleanup lock 없이 페이지 처리: 기존 LP_DEAD 항목을 수정 없이 계산.
  • heap_page_prune_and_freezepruneheap.c — HOT 체인 정리, 튜플 동결, PruneFreezeResult 반환.
  • lazy_vacuumvacuumlazy.c — 우회 vs 전체 2+3단계 결정.
  • lazy_vacuum_all_indexesvacuumlazy.c — 각 인덱스에 ambulkdelete 호출(병렬 워커를 통할 수도 있음).
  • lazy_vacuum_one_indexvacuumlazy.c — 단일 인덱스에 index_bulk_delete 호출.
  • lazy_vacuum_heap_relvacuumlazy.c — LP_DEAD 페이지에 대한 두 번째 힙 패스; lazy_vacuum_heap_page 호출.
  • lazy_vacuum_heap_pagevacuumlazy.c — LP_DEAD 오프셋을 LP_UNUSED로 표시; FSM 및 VM 갱신.
  • lazy_check_wraparound_failsafevacuumlazy.crelfrozenxid가 위험할 만큼 오래됐으면 failsafe 발동.
  • vacuum_xid_failsafe_checkvacuum.c — XID 거리 계산; failsafe 임계치 초과 시 true 반환.
  • lazy_cleanup_all_indexesvacuumlazy.c — 최종 vacuum 패스 후 각 인덱스에 ambulkcleanup 호출.
  • lazy_truncate_heapvacuumlazy.c — 후미 빈 페이지 잘라내기.
  • vac_update_relstatsvacuum.cpg_class 행 갱신.
  • vac_truncate_clogvacuum.c — CLOG 잘라내기 horizon 전진.
  • parallel_vacuum_initvacuumparallel.c — DSM 설정, 워커 포크.
  • PVSharedvacuumparallel.c — 리더 + 워커가 공유하는 DSM 구조체.
  • vacuum_delay_pointvacuum.c — 비용 기반 슬립; 병렬 모드에서 VacuumSharedCostBalance 읽기.

위치 힌트 (2026-06-05 / 커밋 273fe94 기준)

섹션 제목: “위치 힌트 (2026-06-05 / 커밋 273fe94 기준)”
심볼파일
ExecVacuumsrc/backend/commands/vacuum.c162
vacuum_get_cutoffssrc/backend/commands/vacuum.c1116
vacuum_xid_failsafe_checksrc/backend/commands/vacuum.c1284
vacuum_relsrc/backend/commands/vacuum.c2018
VacuumParamssrc/include/commands/vacuum.h217
VacuumCutoffssrc/include/commands/vacuum.h253
VacDeadItemsInfosrc/include/commands/vacuum.h292
LVRelStatesrc/backend/access/heap/vacuumlazy.c259
SKIP_PAGES_THRESHOLDsrc/backend/access/heap/vacuumlazy.c209
MAX_EAGER_FREEZE_SUCCESS_RATEsrc/backend/access/heap/vacuumlazy.c241
EAGER_SCAN_REGION_SIZEsrc/backend/access/heap/vacuumlazy.c250
BYPASS_THRESHOLD_PAGESsrc/backend/access/heap/vacuumlazy.c187
FAILSAFE_EVERY_PAGESsrc/backend/access/heap/vacuumlazy.c193
heap_vacuum_relsrc/backend/access/heap/vacuumlazy.c615
heap_vacuum_eager_scan_setupsrc/backend/access/heap/vacuumlazy.c488
lazy_scan_heapsrc/backend/access/heap/vacuumlazy.c1200
lazy_scan_prunesrc/backend/access/heap/vacuumlazy.c1944
lazy_vacuumsrc/backend/access/heap/vacuumlazy.c2450
lazy_vacuum_all_indexessrc/backend/access/heap/vacuumlazy.c2575
lazy_vacuum_heap_relsrc/backend/access/heap/vacuumlazy.c2720
lazy_cleanup_all_indexessrc/backend/access/heap/vacuumlazy.c3003
lazy_vacuum_one_indexsrc/backend/access/heap/vacuumlazy.c3071
lazy_check_wraparound_failsafesrc/backend/access/heap/vacuumlazy.c2950
heap_page_prune_and_freezesrc/backend/access/heap/pruneheap.c350
PVSharedsrc/backend/commands/vacuumparallel.c57
parallel_vacuum_initsrc/backend/commands/vacuumparallel.c243
parallel_vacuum_process_all_indexessrc/backend/commands/vacuumparallel.c611
parallel_vacuum_process_safe_indexessrc/backend/commands/vacuumparallel.c774
parallel_vacuum_process_unsafe_indexessrc/backend/commands/vacuumparallel.c828
parallel_vacuum_process_one_indexsrc/backend/commands/vacuumparallel.c865
parallel_vacuum_mainsrc/backend/commands/vacuumparallel.c989
  • LVRelStateheap_vacuum_rel()에서 할당되고 종료 시 해제된다. 617행에서 vacrel = (LVRelState *) palloc0(sizeof(LVRelState))로 확인됐다. palloc0이므로 모든 카운터가 자동으로 0으로 시작된다.

  • 죽은 항목은 플랫 배열이 아닌 TidStore에 저장된다. REL_18 기준으로 vacrel->dead_itemsTidStore *다. 이전 릴리스의 LVDeadItems 플랫 배열을 대체한다. TidStoremaintenance_work_mem이 초과되면 디스크로 spill할 수 있다. 이전 설계는 max_items 카운트에서 플러시를 트리거했다. 이 변경은 PG17에 도입됐다. 위치 힌트 테이블은 PG18 인터페이스를 기준으로 한다.

  • SKIP_PAGES_THRESHOLD = 32는 컴파일 타임 상수다. vacuumlazy.c 209행에 정의돼 있다. GUC가 아니므로 런타임에 변경할 수 없다. 51행 주석은 이 상수가 대부분 깨끗한 테이블에서도 커널 readahead 효과를 유지하기 위해 존재한다고 설명한다.

  • Eager 동결 스캔은 vacuum_max_eager_freeze_failure_rate로 제어된다. 이 GUC(기본값이 0이 아님)는 PG18에 추가됐다. eager 스캔 기능 전체의 게이트 역할을 한다. 0으로 설정하면 heap_vacuum_eager_scan_setup()이 즉시 반환해 eager 스캔이 활성화되지 않는다(507~508행에서 확인).

  • Wraparound failsafe는 비용 지연을 비활성화한다. 2990행에서 failsafe 발동 시 VacuumCostActive = falseVacuumCostBalance = 0이 무조건 설정된다. 동결 완료가 I/O 공정성보다 중요하기 때문에 의도적인 동작이다.

  • 병렬 vacuum은 힙 스캔이 아닌 인덱스 단계에만 사용된다. vacuumparallel.c 확인 결과 parallel_vacuum_init()lazy_scan_heap()이나 lazy_vacuum_heap_rel()이 아닌 lazy_vacuum_all_indexes()lazy_cleanup_all_indexes()에서 호출된다.

  • vacuum.cvacuum_rel()은 데이터베이스 전체의 relfrozenxid를 전진시킨다. 관계별 vacuum 후 vac_update_datfrozenxid()가 호출돼(VACOPT_SKIP_DATABASE_STATS가 설정된 경우 제외) pg_database.datfrozenxid를 갱신한다. 동결 추적은 두 수준으로 이루어진다. 테이블 수준은 pg_class.relfrozenxid, 데이터베이스 수준은 pg_database.datfrozenxid다. vac_truncate_clog()가 얼마나 많은 CLOG를 폐기할 수 있는지 결정하는 데 이 값을 사용한다.

  1. maintenance_work_mem 압박 아래서의 TidStore 디스크 spill 경로. 코드 주석에 TidStore가 디스크로 spill할 수 있다고 나온다. PG17의 플랫 배열에서 TidStore로 재설계한 근거로 언급된 내용이다. tidstore.c 구현은 이번 검증 패스에서 완전히 추적하지 못했다. 조사 경로: src/backend/access/common/tidstore.c를 읽어 spill 경로 확인 및 회귀 테스트에서 실행 여부 파악.

  2. GlobalVisState *vistest vs VacuumCutoffs.OldestXmin. 둘 다 “어떤 튜플이 죽었는가”를 표현하지만, vistest는 페이지의 각 튜플에 GlobalVisibility를 반영하는 래퍼다. heap_page_prune_and_freeze() 맥락에서 vacrel->vistestvacrel->cutoffs.OldestXmin의 정확한 관계는 GlobalVisState 구조체 정의까지 추적하지 못했다. 조사 경로: src/include/access/heapam.hGlobalVisTestIsRemovableXid() 읽기.

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

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”
  • InnoDB의 purge 스레드. InnoDB는 전용 배경 purge 스레드(MySQL 5.7부터는 purge 스레드 풀)로 MVCC 가비지 컬렉션을 수행한다. PostgreSQL과의 핵심 차이는 InnoDB가 이전 행 버전을 기본 테이블 파일이 아닌 별도의 undo log(롤백 세그먼트)에 보관한다는 점이다. Purge는 LP_UNUSED로의 인-플레이스 슬롯 전환이 아니라 undo 세그먼트를 회수한다. InnoDB의 회수는 인덱스 패스가 불필요하다. 기본 클러스터드 인덱스에 이미 새 버전이 있기 때문이다. 그러나 undo 세그먼트는 긴 트랜잭션 아래서 크게 증가할 수 있다. undo 기반 vs 인-플레이스 MVCC 회수의 비교 분석은 이 문서와 postgres-heap-am.md의 자연스러운 후속 자료가 될 것이다.

  • VACUUM FULL vs CLUSTER vs pg_repack. PostgreSQL의 lazy VACUUM은 bloat(공간 낭비)을 남긴다. 페이지 내의 해제된 LP_UNUSED 슬롯과 잘라내기 후 후미의 빈 페이지가 그것이다. VACUUM FULL / CLUSTER는 관계를 다시 쓰지만 배타 락이 필요하다. pg_repack 확장(이 코어 전용 트리의 범위 밖)은 트리거 유지 shadow 테이블과 짧은 락 전환을 사용해 온라인 재팩을 수행한다. 기술은 해당 소스 문서에 설명돼 있다.

  • 긴 트랜잭션과 vacuum 차단. 장시간 실행 트랜잭션은 스냅샷의 OldestXmin horizon을 고정시킨다. 그 결과 vacuum이 죽은 튜플 horizon을 전진시키지 못한다. pg_stat_activity에서 볼 수 있는 “긴 트랜잭션이 vacuum을 차단하는” 문제다. 모니터링과 완화 전략(idle-in-transaction 타임아웃, old_snapshot_threshold GUC)은 postgres-mvcc-snapshots.md에서 다룬다.

  • XID 순환의 역사. PG16은 정수형 age(relfrozenxid) 모니터링 함수와 개선된 autovacuum 긴급도 모델을 도입했다. PG17은 TidStore 재설계를 도입했다. 메이저 릴리스에 걸친 동결 메커니즘의 전체 발전 과정은 postgres-evolution-vacuum-visibility.md(계획 중; coverage map 참고)에서 다룰 예정이다.

  • PostgreSQL의 ARIES 계보. vacuum의 steal/no-force 정책을 안전하게 만드는 WAL 기반 충돌 복구는 postgres-xlog-wal.md에서 다루며 ARIES 논문(dbms-papers/aries.md)에 기반한다. vacuum과 WAL의 상호작용은 간접적이다. 동결은 WAL 레코드를 기록하고(heap_page_prune_and_freeze()XLogInsert 호출), LP_UNUSED 전환도 WAL을 생성한다. 이 로그 레코드들 덕분에 복구된 재생이 vacuum이 크래시 전에 생성한 동결/수거 상태와 일관성을 유지할 수 있다.

없음(이 문서는 REL_18_STABLE, 커밋 273fe94의 PostgreSQL 소스 트리에서 직접 합성됐다).

  • src/backend/commands/vacuum.c — 진입점, 파라미터 파싱, cutoff 계산, CLOG 잘라내기, 통계 갱신.
  • src/backend/commands/vacuumparallel.c — 병렬 인덱스 vacuum 설정, 워커 프로토콜, PVShared DSM 레이아웃.
  • src/backend/access/heap/vacuumlazy.cheap_vacuum_rel, lazy_scan_heap, 모든 단계별 함수, wraparound failsafe, eager 스캔.
  • src/include/commands/vacuum.hVacuumParams, VacuumCutoffs, VacDeadItemsInfo, VacOptValue, VACOPT_* 플래그.
  • src/backend/access/heap/pruneheap.cheap_page_prune_and_freeze(), HOT 정리, 페이지별 동결 로직.
  • src/backend/access/heap/visibilitymap.clazy_vacuum_heap_pagelazy_scan_prune에서 호출하는 VM 비트 설정/해제 연산.
  • src/backend/storage/freespace/freespace.c — 힙 수거 후 FSM 갱신.
  • Database Internals (Petrov, 2019), ch. 5 §“MVCC Versions and Cleanup” — MVCC 가비지 컬렉션 프레임.
  • Database System Concepts, 7e (Silberschatz et al.), ch. 15 §“Snapshot Isolation” — 죽은 버전 가시성의 이론적 프레임.
  • postgres-heap-am.md — 힙 튜플 레이아웃, HOT 갱신 체인, LP_* 상태, visibility map 비트 메커니즘.
  • postgres-mvcc-snapshots.md — procarray에서의 OldestXmin 유도, 스냅샷 horizon, 긴 트랜잭션의 vacuum 차단.
  • postgres-xlog-wal.md — 정리와 동결이 발행하는 WAL 레코드; ARIES steal/no-force 계약.
  • postgres-xact.md — 트랜잭션 생명주기, relfrozenxid 의미론.
  • postgres-autovacuum.md (계획 중) — heap_vacuum_rel()을 자동으로 구동하는 autovacuum 데몬.
  • postgres-xid-wraparound-freeze.md (계획 중) — 동결 메커니즘과 varsup.c XID 할당 심층 분석.
  • dbms-papers/aries.md — vacuum이 의존하는 WAL과 steal/no-force 정책의 기반 ARIES 복구 논문.