(KO) PostgreSQL XID 랩어라운드와 프리즈 — 트랜잭션 ID 할당, 한계 강제, 튜플 동결
목차
- 학술적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 가이드
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
학술적 배경
섹션 제목: “학술적 배경”PostgreSQL은 모든 힙 튜플 헤더(xmin, xmax)에 삽입되는 기본 버전 토큰으로
32비트 트랜잭션 식별자(XID) 를 사용한다. XID 공간은 2^32개(약 43억 개)의
값을 담지만, PostgreSQL은 이를 모듈러 산술(modular arithmetic) 로 다루며
절반 공간 비교자를 사용한다. XID b가 XID a보다 “더 새롭다”고 판단하는
조건은 b가 순방향으로 a로부터 2^31 이내에 있을 때다. 이로 인해 실질적인
순서 윈도우는 약 21억 트랜잭션으로 좁아진다.
이 구조의 귀결이 바로 XID 랩어라운드(XID wraparound) 라고 불리는
실시간 의무다. 데이터베이스가 오래된 튜플 버전을 프리즈(freeze)하지 않은 채
2^31개의 새 트랜잭션을 누적하면, xmin이 2^31 트랜잭션 이전에 할당된 튜플이
모듈러 비교자 상에서 현재 XID보다 더 새롭게 보인다. 그 튜플은 논리적으로
삭제된 것이 아님에도 모든 스냅샷에서 보이지 않게 되어 무음 데이터 손실이
발생한다. Database System Concepts(Silberschatz, 7판, §15.6 “Multiversion
Concurrency Control”)는 MVCC 버전 가시성을 스냅샷 기반 비교로 결정한다고
설명한다. 비교자의 도메인이 감싸면 가시성 판단 자체가 무너진다.
이 문제를 해결하려면 두 가지 하위 과제를 함께 처리해야 한다.
첫째, 튜플 프리즈(tuple freezing). xmin이 충분히 오래된 튜플 버전은
어떤 XID 비교와도 무관하게 미래 모든 트랜잭션에 가시적으로 재표시되어야 한다.
PostgreSQL은 두 가지 메커니즘을 제공한다. 튜플 헤더에 센티널 값
FrozenTransactionId(XID 2)를 저장하는 방법, 또는 t_infomask에
HEAP_XMIN_FROZEN 힌트 비트(HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID 조합)를
설정하는 방법이다. 두 형태 모두 가시성 검사를 “항상 가시적”으로 단락시킨다.
둘째, relfrozenxid 전진. 충분한 페이지의 튜플을 프리즈한 후 VACUUM은
pg_class.relfrozenxid를 해당 테이블에 남아 있는 가장 오래된 비동결 XID로
갱신한다. 클러스터 전체 최솟값인 pg_database.datfrozenxid가 전진하면
PostgreSQL은 살아 있는 튜플의 가시성 판단에 더 이상 필요 없는 오래된 CLOG
(커밋 로그) 페이지를 잘라낼 수 있다.
두 과제는 XID 할당 경로와도 연결된다. 새 트랜잭션이 식별자를 받기 전에
XID 공간이 부족한지 확인해야 하기 때문이다. 랩어라운드 방지는 VACUUM만의
문제가 아니라 GetNewTransactionId에서도 항시 작동하는 관심사다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”단조 증가 카운터와 주기적 GC 의무
섹션 제목: “단조 증가 카운터와 주기적 GC 의무”정수 버전 카운터를 사용하는 대부분의 MVCC 엔진은 동일한 구조적 문제에 직면한다. 스냅샷이 의미를 가지려면 카운터가 단조 증가해야 하지만 유한한 카운터는 결국 오버플로한다. 두 가지 설계 선택지가 있다.
- 카운터를 넓힌다. 64비트(또는 더 넓은) 정수를 사용해 현실적인 데이터베이스 수명 내에서 오버플로가 불가능하게 만든다. SQL Server는 64비트 버전 토큰을, 여러 NewSQL 엔진은 하이브리드 논리 클록을 사용한다.
- 오래된 버전을 프리즈한다. 32비트 카운터를 유지하되, 백그라운드 프로세스가 주기적으로 오래된 튜플을 “윈도우 너머”로 재표시한다. 그러면 비교 윈도우의 하한선이 상한선과 함께 전진한다. PostgreSQL(그리고 역사적으로 Firebird)은 이 방식을 선택했다.
프리즈 방식은 GC 의무(GC obligation) 를 만들어낸다. 백그라운드 유지 작업이
트랜잭션 속도를 따라잡는 한에서만 시스템이 안전하다. 이 의무는 테이블별 및
데이터베이스별 동결 XID 기준선(relfrozenxid, datfrozenxid)으로 구체화된다.
단계별 경고 및 중지 임계값
섹션 제목: “단계별 경고 및 중지 임계값”데이터 손실 직전에 단일 하드 스톱만 두는 것이 아니라 여러 경보 수준을 두는 설계가 합리적이다. 운영자가 반응할 시간을 주기 위해서다.
- VACUUM 강제 수준: 사용자에게 어떤 경고도 나타나기 전에 autovacuum을 강제한다.
- 경고 수준: 서버 로그에 ereport 메시지를 기록한다.
- 중지 수준: 기존 데이터를 보호하기 위해 새 XID 할당을 거부한다.
- 실제 랩 지점: 여기까지 도달하면 데이터 손상이 발생한다.
중지 수준과 랩 지점 사이의 간격은 DBA가 단일 사용자 모드에서 진단하고 비상 VACUUM을 실행하기에 충분할 만큼 넓어야 한다.
이론 ↔ PostgreSQL 대응표
섹션 제목: “이론 ↔ PostgreSQL 대응표”| 이론 개념 | PostgreSQL 엔티티 |
|---|---|
| 32비트 모듈러 XID 카운터 | TransactionId (uint32); 비교자: TransactionIdPrecedes |
| XID 동결 센티널 | FrozenTransactionId (XID 2); 또는 t_infomask의 HEAP_XMIN_FROZEN |
| GC 의무 하한선 (테이블별) | pg_class.relfrozenxid |
| GC 의무 하한선 (데이터베이스별) | pg_database.datfrozenxid |
| 단계별 경고 사다리 | TransamVariablesData의 xidVacLimit / xidWarnLimit / xidStopLimit / xidWrapLimit |
| 프리즈 기준선 (이보다 오래된 튜플은 반드시 프리즈) | VacuumCutoffs의 FreezeLimit |
| 튜플별 프리즈 계획 | HeapTupleFreeze 구조체 |
| 페이지 수준 프리즈 결정 | HeapPageFreeze.freeze_required |
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”XID 공간과 모듈러 비교자
섹션 제목: “XID 공간과 모듈러 비교자”TransactionId는 uint32다. 정상 범위 아래에는 세 개의 특수 값이 예약되어 있다.
// transam.h — src/include/access/transam.h#define InvalidTransactionId ((TransactionId) 0)#define BootstrapTransactionId ((TransactionId) 1)#define FrozenTransactionId ((TransactionId) 2)#define FirstNormalTransactionId ((TransactionId) 3)정상 XID에 대한 모든 산술은 이 특수 값들을 고려해야 한다.
TransactionIdIsNormal(xid)는 XID >= 3일 때만 true를 반환한다.
모듈러 “선행(precedes)” 관계는 절반 공간 규칙으로 정의된다.
// NormalTransactionIdPrecedes — src/include/access/transam.h#define NormalTransactionIdPrecedes(id1, id2) \ (AssertMacro(TransactionIdIsNormal(id1) && TransactionIdIsNormal(id2)), \ (int32) ((id1) - (id2)) < 0)부호 없는 뺄셈을 int32로 캐스팅하면 부호 있는 차이로 변환된다. 모듈러 의미에서
id1 < id2인 조건은 부호 있는 차이가 음수일 때, 즉 id1이 역방향으로 id2로부터
2^31 이내에 있을 때다. 모든 가시성 검사, 스냅샷 테스트, 한계 검사가 이 비교자를
사용한다.
TransamVariablesData: 공유 XID 상태
섹션 제목: “TransamVariablesData: 공유 XID 상태”전역 XID 카운터와 네 개의 랩어라운드 임계값은 모두 TransamVariablesData라는
단일 공유 메모리 구조체(클러스터당 하나)에 저장된다.
// TransamVariablesData — src/include/access/transam.htypedef struct TransamVariablesData{ /* OidGenLock으로 보호 */ Oid nextOid; /* 다음에 할당할 OID */ uint32 oidCount; /* XLOG 작업 전에 사용 가능한 OID 수 */
/* XidGenLock으로 보호 */ FullTransactionId nextXid; /* 다음에 할당할 XID (64비트: epoch+32비트) */
TransactionId oldestXid; /* 클러스터 전체 datfrozenxid 최솟값 */ TransactionId xidVacLimit; /* 여기서부터 autovacuum 강제 */ TransactionId xidWarnLimit; /* 여기서부터 경고 로그 */ TransactionId xidStopLimit; /* 여기서부터 XID 할당 거부 */ TransactionId xidWrapLimit; /* 데이터 손상이 발생하는 지점 */ Oid oldestXidDB; /* oldestXid를 소유한 데이터베이스 */
/* CommitTsLock으로 보호 */ TransactionId oldestCommitTsXid; TransactionId newestCommitTsXid;
/* ProcArrayLock으로 보호 */ FullTransactionId latestCompletedXid; uint64 xactCompletionCount;
/* XactTruncationLock으로 보호 */ TransactionId oldestClogXid; /* CLOG 조회가 안전한 가장 오래된 XID */} TransamVariablesData;카운터 nextXid는 FullTransactionId다. 이것은 32비트 epoch와 32비트 XID를
조합한 64비트 값으로, WAL 재생 중 epoch 전환을 감지하는 데 사용된다. 랩어라운드
계산에서는 XidFromFullTransactionId(nextXid)로 추출한 32비트 값만이 의미를
갖는다.
한계 사다리 설정: SetTransactionIdLimit
섹션 제목: “한계 사다리 설정: SetTransactionIdLimit”SetTransactionIdLimit은 pg_database.datfrozenxid가 전진할 때마다 호출된다
(vac_update_datfrozenxid가 성공한 VACUUM 후에 이를 호출한다). 이 함수는
oldest_datfrozenxid를 기반으로 네 임계값을 모두 재계산한다.
// SetTransactionIdLimit — src/backend/access/transam/varsup.cvoidSetTransactionIdLimit(TransactionId oldest_datfrozenxid, Oid oldest_datoid){ /* xidWrapLimit = oldest_datfrozenxid + 2^31 (XID 공간의 절반) */ xidWrapLimit = oldest_datfrozenxid + (MaxTransactionId >> 1); if (xidWrapLimit < FirstNormalTransactionId) xidWrapLimit += FirstNormalTransactionId;
/* 랩 지점 3M 전에 XID 할당 중지 */ xidStopLimit = xidWrapLimit - 3000000;
/* 랩 지점 40M 전에 경고 발행 */ xidWarnLimit = xidWrapLimit - 40000000;
/* oldest_datfrozenxid가 autovacuum_freeze_max_age만큼 오래되면 autovacuum 강제 */ xidVacLimit = oldest_datfrozenxid + autovacuum_freeze_max_age;
LWLockAcquire(XidGenLock, LW_EXCLUSIVE); TransamVariables->xidVacLimit = xidVacLimit; TransamVariables->xidWarnLimit = xidWarnLimit; TransamVariables->xidStopLimit = xidStopLimit; TransamVariables->xidWrapLimit = xidWrapLimit; TransamVariables->oldestXidDB = oldest_datoid; LWLockRelease(XidGenLock);}네 임계값이 단계별 응답 사다리를 정의한다.
| 임계값 | 랩 지점까지 기본 거리 | 동작 |
|---|---|---|
xidVacLimit | autovacuum_freeze_max_age 앞 (기본 2억) | 64K 트랜잭션마다 autovacuum 신호 강제 |
xidWarnLimit | 랩 지점 4천만 앞 | 데이터베이스 이름과 잔여 예산을 포함한 WARNING 로그 |
xidStopLimit | 랩 지점 3백만 앞 | 새 트랜잭션 XID 할당을 ERROR로 거부 |
xidWrapLimit | 0 (실제 랩 지점) | 메시지 생성에만 사용되는 참조값 |
그림 1 — XID 공간과 4단계 한계 사다리
flowchart LR
A["oldest_datfrozenxid\n(클러스터 하한선)"] --> B["xidVacLimit\n(autovacuum 강제)"]
B --> C["xidWarnLimit\n(경고 로그)"]
C --> D["xidStopLimit\n(XID 할당 거부)"]
D --> E["xidWrapLimit\n(데이터 손상)"]
E --> F["oldest_datfrozenxid\n+2^32 (여기서 랩)"]
style A fill:#aef,stroke:#333
style E fill:#faa,stroke:#333
그림 1 — 클러스터의 가장 오래된 동결 XID부터 랩 지점까지의 XID 수직선. 실질적인 안전 윈도우는 2^31(~21억) 트랜잭션이다. 네 임계값은 랩 지점으로부터 증가하는 거리에 위치한다.
GetNewTransactionId: XidGenLock 하에서 XID 할당
섹션 제목: “GetNewTransactionId: XidGenLock 하에서 XID 할당”XID가 필요한 모든 정상 트랜잭션은 GetNewTransactionId를 호출한다.
// GetNewTransactionId — src/backend/access/transam/varsup.cFullTransactionIdGetNewTransactionId(bool isSubXact){ LWLockAcquire(XidGenLock, LW_EXCLUSIVE);
full_xid = TransamVariables->nextXid; xid = XidFromFullTransactionId(full_xid);
/* 한계 사다리 검사 */ if (TransactionIdFollowsOrEquals(xid, TransamVariables->xidVacLimit)) { LWLockRelease(XidGenLock); /* ... 64K XID마다 autovacuum 런처에 신호 ... */ if (TransactionIdFollowsOrEquals(xid, xidStopLimit)) ereport(ERROR, ...); /* XID 할당 거부 */ else if (TransactionIdFollowsOrEquals(xid, xidWarnLimit)) ereport(WARNING, ...); LWLockAcquire(XidGenLock, LW_EXCLUSIVE); full_xid = TransamVariables->nextXid; xid = XidFromFullTransactionId(full_xid); }
/* 카운터 증가 전에 CLOG/subtrans/commit_ts 페이지 확장 */ ExtendCLOG(xid); ExtendCommitTs(xid); ExtendSUBTRANS(xid);
/* 이제 카운터 증가 */ FullTransactionIdAdvance(&TransamVariables->nextXid);
/* 잠금 해제 전에 ProcArray에 게시 */ MyProc->xid = xid; ProcGlobal->xids[MyProc->pgxactoff] = xid;
LWLockRelease(XidGenLock); return full_xid;}여기에는 세 가지 순서 제약이 내포되어 있다.
첫째, ExtendCLOG 후 FullTransactionIdAdvance다. ExtendCLOG가 실패하면
카운터는 증가하지 않으므로 다음 호출자가 재시도한다. XID가 CLOG 슬롯 없이
존재하는 상황을 막는다.
둘째, LWLockRelease 전에 MyProc->xid를 기록한다. 다른 백엔드가 스냅샷을
찍기 전에 procarray가 새 XID를 활성 상태로 인식한다. 이것은 OldestXmin 정확성에
필수적이다.
셋째, 한계 검사는 XidGenLock 내에서 수행하지만 ereport 호출 시에는
get_database_name 조회 중 잠금 보유를 피하기 위해 잠금을 잠시 해제한다.
vacuum_get_cutoffs: 프리즈 기준선 유도
섹션 제목: “vacuum_get_cutoffs: 프리즈 기준선 유도”모든 VACUUM 실행 전에 vacuum_get_cutoffs는 VacuumCutoffs에 저장될 네 가지
불변 XID 기준선을 계산한다. 이 문서에서 핵심은 FreezeLimit이다.
// vacuum_get_cutoffs (발췌) — src/backend/commands/vacuum.cboolvacuum_get_cutoffs(Relation rel, const VacuumParams *params, struct VacuumCutoffs *cutoffs){ int freeze_min_age = params->freeze_min_age; int freeze_table_age = params->freeze_table_age;
/* freeze_min_age를 autovacuum_freeze_max_age의 절반으로 제한 */ freeze_min_age = Min(freeze_min_age, autovacuum_freeze_max_age / 2);
/* FreezeLimit = nextXID - freeze_min_age (기본 5천만) */ cutoffs->FreezeLimit = nextXID - freeze_min_age;
/* 공격적 VACUUM 임계값: 테이블의 relfrozenxid가 너무 오래된 경우 */ /* freeze_table_age는 autovacuum_freeze_max_age의 0.95배로 제한 */ freeze_table_age = Min(freeze_table_age, autovacuum_freeze_max_age * 0.95); /* ... procarray에서 cutoffs->OldestXmin 설정 ... */}xmin이 FreezeLimit보다 오래된 튜플은 프리즈 후보다. LVRelState의
aggressive 플래그는 테이블의 relfrozenxid가 이미 freeze_table_age 트랜잭션
전보다 오래된 경우에 설정된다. 이 플래그가 설정되면 VACUUM은 all-visible 페이지를
건너뛰지 않고 모든 비동결 페이지를 방문해야 한다.
heap_prepare_freeze_tuple: 튜플별 프리즈 계획 수립
섹션 제목: “heap_prepare_freeze_tuple: 튜플별 프리즈 계획 수립”heap_prepare_freeze_tuple은 (pruneheap.c의 heap_page_prune_and_freeze를
경유해 lazy_scan_prune이 호출한다) 살아 있는 각 튜플의 프리즈 여부와
방법을 결정한다. 이 함수는 HeapTupleFreeze 계획을 반환한다.
// HeapTupleFreeze — src/include/access/heapam.htypedef struct HeapTupleFreeze{ TransactionId xmax; /* 새 xmax 값 (또는 변경 없음) */ uint16 t_infomask2; uint16 t_infomask; /* 프리즈 비트가 적용된 새 infomask */ uint8 frzflags; /* xvac 교체 플래그 */ uint8 checkflags; /* HEAP_FREEZE_CHECK_XMIN_COMMITTED 등 */ OffsetNumber offset; /* 튜플의 페이지 오프셋 */} HeapTupleFreeze;계획 수립 함수는 xmin, xmax(그리고 레거시 xvac 필드)를
cutoffs->FreezeLimit 및 cutoffs->MultiXactCutoff와 비교해 검사한다.
// heap_prepare_freeze_tuple (발췌) — src/backend/access/heap/heapam.cboolheap_prepare_freeze_tuple(HeapTupleHeader tuple, const struct VacuumCutoffs *cutoffs, HeapPageFreeze *pagefrz, HeapTupleFreeze *frz, bool *totally_frozen){ xid = HeapTupleHeaderGetXmin(tuple); if (TransactionIdIsNormal(xid)) { /* xmin이 relfrozenxid보다 오래되면 오류: 이미 프리즈됐어야 함 */ if (TransactionIdPrecedes(xid, cutoffs->relfrozenxid)) ereport(ERROR, ...);
/* xmin이 FreezeLimit보다 오래되면 프리즈 */ freeze_xmin = TransactionIdPrecedes(xid, cutoffs->OldestXmin); if (freeze_xmin) frz->checkflags |= HEAP_FREEZE_CHECK_XMIN_COMMITTED; } else xmin_already_frozen = true; /* 이미 프리즈됐거나 InvalidXID */
/* ... xmax / MultiXactId에 대한 유사한 로직 ... */
/* 페이지 프리즈가 필수인 경우(예: xvac 또는 MultiXact 기준선 초과) pagefrz->freeze_required = true로 설정 */
return (freeze_xmin || replace_xvac || replace_xmax || freeze_xmax);}HeapPageFreeze 구조체는 페이지가 반드시 프리즈되어야 하는지(필수 여부)와
프리즈 후 NewRelfrozenXid / NewRelminMxid가 어떤 값이 될지를 추적한다.
VACUUM의 최상위 추적기가 이를 사용해 정확하게 갱신된다.
heap_freeze_execute_prepared: 프리즈 계획 적용
섹션 제목: “heap_freeze_execute_prepared: 프리즈 계획 적용”페이지의 모든 살아 있는 튜플에 heap_prepare_freeze_tuple이 호출된 뒤
호출자가 페이지를 프리즈하기로 결정하면 heap_freeze_execute_prepared가 각
계획을 적용한다.
// heap_freeze_execute_prepared (발췌) — src/backend/access/heap/heapam.cvoidheap_freeze_execute_prepared(Relation rel, Buffer buffer, TransactionId FreezeLimit, HeapTupleFreeze *tuples, int ntuples){ for (i = 0; i < ntuples; i++) { HeapTupleFreeze *frz = tuples + i; /* ... checkflags가 요구하면 xmin 커밋 여부 확인 ... */ heap_execute_freeze_tuple(htup, frz); } /* 전체 페이지에 대해 단일 XLOG_HEAP2_FREEZE_PAGE WAL 레코드 기록 */ log_heap_freeze(rel, buffer, FreezeLimit, tuples, ntuples);}heap_execute_freeze_tuple(heapam.h의 인라인 함수)은 새 t_infomask 비트를
기록한다.
// heap_execute_freeze_tuple — src/include/access/heapam.h (인라인)static inline voidheap_execute_freeze_tuple(HeapTupleHeader tuple, HeapTupleFreeze *frz){ HeapTupleHeaderSetXmax(tuple, frz->xmax); tuple->t_infomask2 = frz->t_infomask2; tuple->t_infomask = frz->t_infomask; if (frz->frzflags & XLH_FREEZE_KILL_XVAC) HeapTupleHeaderSetXvac(tuple, InvalidTransactionId); /* HEAP_XMIN_FROZEN = HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID */}HEAP_XMIN_FROZEN 비트 조합이 기록되면 이후의 모든 가시성 검사는 XID 비교를
완전히 건너뛰고 “항상 가시적”을 반환한다.
그림 2 — 페이지별 프리즈 계획 수립과 적용
flowchart TD
A[lazy_scan_prune] --> B[heap_page_prune_and_freeze\npruneheap.c]
B --> C{살아 있는 각 튜플}
C --> D[heap_prepare_freeze_tuple\nHeapTupleFreeze 계획 수립]
D --> E{totally_frozen?}
E -- yes --> F[totally_frozen 카운트 증가]
E -- no --> G[프리즈 계획 배열에 추가]
C --> H{pagefrz.freeze_required\n또는 vacrel.aggressive?}
H -- yes --> I[heap_freeze_execute_prepared\n모든 계획 적용]
I --> J[heap_execute_freeze_tuple\nt_infomask에 HEAP_XMIN_FROZEN 기록]
I --> K[log_heap_freeze\nXLOG_HEAP2_FREEZE_PAGE WAL 레코드]
H -- no --> L[계획 폐기\n이번 사이클에서는 페이지 프리즈 없음]
그림 2 — 페이지별 프리즈 수명 주기. heap_prepare_freeze_tuple이 살아 있는 모든 튜플의 계획을 수립하고, heap_freeze_execute_prepared는 페이지 프리즈가 필수(mandatory freeze 또는 aggressive 모드)일 때만 계획을 적용한다. WAL 레코드는 페이지 전체를 단일 레코드로 커버한다.
relfrozenxid 전진과 CLOG 잘라내기
섹션 제목: “relfrozenxid 전진과 CLOG 잘라내기”heap_vacuum_rel 종료 시점에 VACUUM은 모든 스캔된 페이지에서 관찰한
가장 오래된 비동결 XID를 vacrel.NewRelfrozenXid에 기록한다.
vac_update_relstats는 이 값으로 pg_class.relfrozenxid를 전진시킨다.
// vac_update_relstats (발췌) — src/backend/commands/vacuum.cvoidvac_update_relstats(Relation relation, ..., TransactionId frozenxid, ...){ oldfrozenxid = pgcform->relfrozenxid; /* relfrozenxid는 절대 뒤로 가지 않는다 (저장된 값이 손상된 경우 예외) */ if (TransactionIdPrecedes(oldfrozenxid, frozenxid)) pgcform->relfrozenxid = frozenxid;}모든 릴레이션별 갱신이 끝나면 vac_update_datfrozenxid가 pg_class.relfrozenxid
전체를 스캔해 새 pg_database.datfrozenxid를 유도하고, SetTransactionIdLimit을
호출해 4단계 사다리를 전진시킨다. 마지막으로 vac_truncate_clog는
TransamVariables->oldestClogXid(AdvanceOldestClogXid가 유지)를 사용해
더 이상 필요 없는 CLOG 페이지를 잘라낸다.
그림 3 — 랩어라운드 방지 사이클 전체 흐름
flowchart TD
A[VACUUM / autovacuum] --> B[vacuum_get_cutoffs\nFreezeLimit = nextXID - freeze_min_age]
B --> C[lazy_scan_heap\naggressive 모드에서 모든 비동결 페이지 방문]
C --> D[heap_prepare_freeze_tuple\nFreezeLimit보다 오래된 각 튜플]
D --> E[heap_freeze_execute_prepared\nHEAP_XMIN_FROZEN 기록]
E --> F[vac_update_relstats\npg_class.relfrozenxid 전진]
F --> G[vac_update_datfrozenxid\npg_database.datfrozenxid 전진]
G --> H[SetTransactionIdLimit\nxidVacLimit/Warn/Stop/WrapLimit 재계산]
H --> I[vac_truncate_clog\n오래된 CLOG 페이지 제거]
H --> J[GetNewTransactionId\n각 XID 할당 시 새 한계 검사]
그림 3 — 랩어라운드 방지 사이클: VACUUM이 오래된 튜플을 프리즈하고 relfrozenxid/datfrozenxid를 전진시키면 4단계 사다리가 재설정되어 GetNewTransactionId가 갱신된 한계를 확인한다.
소스 코드 가이드
섹션 제목: “소스 코드 가이드”XID 할당 (varsup.c)
섹션 제목: “XID 할당 (varsup.c)”GetNewTransactionId—src/backend/access/transam/varsup.c— 다음 XID를 할당한다. 한계 사다리를 검사하고,nextXid를 증가시키기 전에 CLOG/subtrans/commit_ts를 확장한다. ProcArray에 게시한다.ReadNextFullTransactionId—varsup.c— 할당 없이nextXid를 읽는다 (스냅샷 로직과 모니터링에서 사용).SetTransactionIdLimit—varsup.c—oldest_datfrozenxid로부터 네 가지 한계를 재계산하고 저장한다.datfrozenxid가 전진한 후 호출된다.AdvanceNextFullTransactionIdPastXid—varsup.c— WAL 복구 중nextXid를 빠르게 전진시킨다.AdvanceOldestClogXid—varsup.c—XactTruncationLock하에서oldestClogXid를 전진시킨다. CLOG 잘라내기를 제어한다.TransamVariablesData—src/include/access/transam.h— 세 개의 서로 다른 LWLock으로 보호되는 필드를 가진 공유 구조체.
프리즈 기준선 계산 (vacuum.c)
섹션 제목: “프리즈 기준선 계산 (vacuum.c)”vacuum_get_cutoffs—src/backend/commands/vacuum.c—VacuumParams와 procarray로부터OldestXmin,FreezeLimit,MultiXactCutoff, aggressive 모드 임계값을 유도한다.vacuum_xid_failsafe_check—vacuum.c—relfrozenxid거리가vacuum_failsafe_age를 초과하는지 검사한다. failsafe가 작동해야 하면 true를 반환한다.vac_update_relstats—vacuum.c— VACUUM 종료 시pg_class.relfrozenxid와pg_class.relminmxid를 전진시킨다.vac_update_datfrozenxid—vacuum.c— 모든 릴레이션을 스캔해 새pg_database.datfrozenxid를 유도하고SetTransactionIdLimit을 호출한다.vac_truncate_clog—vacuum.c—oldestClogXid보다 오래된 CLOG 페이지를 잘라낸다.
튜플 프리즈 계획 수립과 적용 (heapam.c)
섹션 제목: “튜플 프리즈 계획 수립과 적용 (heapam.c)”heap_prepare_freeze_tuple—src/backend/access/heap/heapam.c— 기준선과 비교해xmin/xmax/xvac를 검사한다.HeapTupleFreeze계획을 구성하고HeapPageFreeze추적기를 갱신한다.heap_freeze_execute_prepared—heapam.c— 페이지의 모든 계획을 적용한다. 튜플별로heap_execute_freeze_tuple을 호출하고XLOG_HEAP2_FREEZE_PAGEWAL 레코드를 기록한다.heap_execute_freeze_tuple—src/include/access/heapam.h(인라인) — 새t_infomask/t_infomask2/xmax를 튜플 헤더에 기록한다.heap_tuple_should_freeze—heamam.c— 튜플이 프리즈 계획을 필요로 하는지 경량으로 검사한다. 페이지가 다른 이유로 프리즈가 필요한 경우 전체heap_prepare_freeze_tuple호출을 건너뛰는 데 사용된다.FreezeMultiXactId—heapam.c— 프리즈 중 MultiXact xmax 값을 처리한다. multi를 단일 XID 또는InvalidTransactionId로 축소할 수 있다.HeapTupleFreeze—src/include/access/heapam.h— 튜플별 프리즈 계획 (xmax, t_infomask, frzflags, checkflags, offset).HeapPageFreeze—heapam.h— 페이지 수준 프리즈 상태:freeze_required플래그와FreezePageRelfrozenXid/FreezePageRelminMxid추적기.
주요 상수 및 구조체
섹션 제목: “주요 상수 및 구조체”FrozenTransactionId—transam.h— XID 2, 레거시 동결 센티널.HEAP_XMIN_FROZEN—src/include/access/htup_details.h—t_infomask의(HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID)비트마스크. 가시성 검사가 “항상 가시적”을 반환하도록 단락시킨다.HEAP_FREEZE_CHECK_XMIN_COMMITTED—heapam.h—checkflags의 플래그.heap_freeze_execute_prepared가 동결 비트를 기록하기 전에 xmin이 커밋됐는지 확인하도록 요청한다.
위치 힌트 (2026-06-05 기준 / 커밋 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준 / 커밋 273fe94)”| 심볼 | 파일 | 행 |
|---|---|---|
TransamVariablesData | src/include/access/transam.h | 209 |
FrozenTransactionId | src/include/access/transam.h | 33 |
FirstNormalTransactionId | src/include/access/transam.h | 34 |
NormalTransactionIdPrecedes | src/include/access/transam.h | 147 |
GetNewTransactionId | src/backend/access/transam/varsup.c | 68 |
SetTransactionIdLimit | src/backend/access/transam/varsup.c | 372 |
AdvanceOldestClogXid | src/backend/access/transam/varsup.c | 352 |
vacuum_get_cutoffs | src/backend/commands/vacuum.c | 1116 |
vacuum_xid_failsafe_check | src/backend/commands/vacuum.c | 1284 |
vac_update_relstats | src/backend/commands/vacuum.c | 1442 |
HEAP_XMIN_FROZEN | src/include/access/htup_details.h | 206 |
HeapTupleFreeze | src/include/access/heapam.h | 139 |
HeapPageFreeze | src/include/access/heapam.h | 157 |
heap_prepare_freeze_tuple | src/backend/access/heap/heapam.c | 7063 |
heap_freeze_execute_prepared | src/backend/access/heap/heapam.c | 7389 |
heap_tuple_should_freeze | src/backend/access/heap/heapam.c | 7874 |
FreezeMultiXactId | src/backend/access/heap/heapam.c | 6713 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
HEAP_XMIN_FROZEN은HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID다.htup_details.h206행에서 확인:#define HEAP_XMIN_FROZEN (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID). 단일 비트가 아니라 비트 쌍이다. 357행의HeapTupleHeaderXminFrozen인라인이 이 조합을 정확히 검사한다. -
FrozenTransactionId(XID 2)는 레거시 센티널이고,HEAP_XMIN_FROZEN이 현재의 메커니즘이다. 이전 PostgreSQL 버전은xmin에 XID 2를 직접 저장했다. 현재 코드(7063행의heap_prepare_freeze_tuple에서 확인)는HEAP_XMIN_FROZENinfomask 비트를 사용하며 새로 동결된 튜플 헤더에 XID 2를 기록하지 않는다. XID 2는pg_upgrade로 올라온 PG9.4 이전 데이터베이스에서만 나타난다. -
SetTransactionIdLimit이 네 임계값의 유일한 기록자다.varsup.c에서xidVacLimit,xidWarnLimit,xidStopLimit,xidWrapLimit를TransamVariables에 기록하는 호출 지점은 하나뿐이다. MultiXact용인SetMultiXactIdLimit도 동일한 패턴으로 별도로 존재한다. -
xidStopLimit은 랩 지점에서 3,000,000 트랜잭션 앞에 있다.SetTransactionIdLimit약 407행에서 확인:xidStopLimit = xidWrapLimit - 3000000;. 단일 사용자 모드의 VACUUM이 실행될 공간을 남겨둔다. 이 값은 GUC가 아니라 컴파일 시 상수다. -
xidWarnLimit은 랩 지점에서 40,000,000 트랜잭션 앞에 있다. 약 400행에서 확인:xidWarnLimit = xidWrapLimit - 40000000;. 소스 주석은 이 값이 설정 불가능함을 명시한다. -
ExtendCLOG는XidGenLock내에서nextXid증가 전에 호출된다.varsup.c204–214행에서 확인. 이 순서는 의도적이다.ExtendCLOG가 실패하면 트랜잭션은 XID 슬롯을 소비하지 않고 ERROR를 받는다. XID가 “소실”되는 일이 없다. -
heap_prepare_freeze_tuple은xmin < relfrozenxid이면 손상을 감지한다. 약 7087행에서, 정상 xmin이cutoffs->relfrozenxid보다 앞서 있으면ereport(ERROR, errcode(ERRCODE_DATA_CORRUPTED), ...)를 호출한다. 이전 VACUUM에서 프리즈됐어야 할 비동결 튜플에 대한 하드 어서션이다. -
heap_freeze_execute_prepared는 튜플별이 아닌 페이지별로 한 번 호출된다.heap_freeze_execute_prepared말미의 단일log_heap_freeze호출이 몇 개의 튜플이 동결됐는지와 무관하게 페이지 전체를 커버하는 단일XLOG_HEAP2_FREEZE_PAGEWAL 레코드를 기록한다. WAL I/O를 일괄 처리한다.
미해결 질문
섹션 제목: “미해결 질문”-
HeapPageFreeze.FreezePageRelfrozenXid대 “no-freeze” 추적기.HeapPageFreeze에는 두 세트의NewRelfrozenXid추적기가 있다. “freeze” 경로용과 “no freeze” 경로용이다. 정확한 상호작용, 특히 “no-freeze” 추적기가 언제 뒤로 ratchet되는지는 완전히 추적하지 못했다. 조사 경로:heapam.h157–230행 주석 블록과heap_prepare_freeze_tuple이 두 추적기를 조건부로 갱신하는 부분을 따라가면 된다. -
vacuum_freeze_min_age의autovacuum_freeze_max_age / 2로의 제한.vacuum_get_cutoffs약 1200행의 코드는freeze_min_age를autovacuum_freeze_max_age / 2로 제한한다. FreezeLimit이 너무 보수적으로 설정되어 autovacuum의 강제 공격성이 따라잡지 못하는 상황을 방지한다는 근거를 주석에서 추론했지만, 커밋 히스토리로는 검증하지 않았다. 조사 경로:vacuum.c의 해당 제한 코드에git log -p를 실행하면 된다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”-
64비트 XID (자연적 예방). SQL Server(버전 토큰), CockroachDB(HLC 타임스탬프), YugabyteDB 등 많은 최신 시스템이 64비트 이상의 식별자를 사용해 오버플로가 실질적으로 불가능하게 만든다. 트레이드오프는 더 넓은 튜플 헤더다. PostgreSQL의 32비트
xmin/xmax는 각 4바이트인데, 64비트로 바꾸면 헤더 필드당 두 배가 된다. 행 밀도에 영향을 준다. PostgreSQL 개발 메일링 리스트에서 XID 확장 논의가 여러 차례 있었다. WAL 내부에서 사용하는 64비트FullTransactionId가 디딤돌이지만, 온디스크 헤더 확장은 더 큰 프로젝트다.postgres-xact.md에서FullTransactionIdepoch+XID 분할을 다룬다. -
InnoDB의 purge와 버전 체인. InnoDB는 오래된 버전을 기본 테이블스페이스가 아닌 별도의 언두 로그(롤백 세그먼트)에 보관해 랩어라운드 문제를 피한다. 버전 토큰(6바이트 trx_id)은 사실상 48비트로, 72경 트랜잭션 후에야 랩된다. 트레이드오프는 별도의 purge 메커니즘과 긴 트랜잭션 하에서의 언두 세그먼트 팽창이다. 인플레이스 대 언두 로그 MVCC 비교 분석은
postgres-heap-am.md의 자연스러운 동반 문서가 된다. -
VACUUM 의무와 긴 트랜잭션. 장기 실행 트랜잭션은
OldestXmin을 고정시켜relfrozenxid가 전진하는 것을 막는다. 4단계 사다리도 이동하지 못해 데이터베이스가xidStopLimit에 가까워진다. 모니터링 및 완화 전략(idle_in_transaction_session_timeout,old_snapshot_threshold,pg_terminate_backend)은postgres-mvcc-snapshots.md에서 다룬다. -
MultiXact 랩어라운드.
MultiXactId는 여러 트랜잭션이 튜플의xmax잠금을 공유할 때 사용하는 병렬 32비트 카운터다. 고유한 한계 사다리(SetMultiXactIdLimit,SetTransactionIdLimit의 대응 함수)와 고유한 프리즈 기준선(VacuumCutoffs의MultiXactCutoff)을 갖는다.heapam.c의FreezeMultiXactId가 튜플 프리즈 중 MultiXact 처리를 담당한다.postgres-multixact.md(계획됨)에서 자세히 다룬다. -
프리즈 메커니즘의 진화. PG9.4에서
HEAP_XMIN_FROZENinfomask 방식이 도입됐다(문자 그대로의 XID-2 기록 방식을 대체). PG16에서age(relfrozenxid)모니터링 함수와 긴급성 모델 개선이 추가됐다. PG17에서 dead-items 저장소가LVDeadItems평면 배열에서TidStore로 재설계됐다. 전체 진화 아크는postgres-evolution-vacuum-visibility.md(계획됨; 커버리지 맵 참조)에 속한다.
소비한 원본 소스
섹션 제목: “소비한 원본 소스”없음 (REL_18_STABLE, 커밋 273fe94의 PostgreSQL 소스 트리에서 직접 합성).
소스 코드 경로
섹션 제목: “소스 코드 경로”src/backend/access/transam/varsup.c— XID 할당, 한계 사다리 계산, CLOG 확장 순서.src/include/access/transam.h—TransamVariablesData, XID 상수,NormalTransactionIdPrecedes.src/backend/access/heap/heapam.c—heap_prepare_freeze_tuple,heap_freeze_execute_prepared,FreezeMultiXactId,heap_tuple_should_freeze.src/include/access/heapam.h—HeapTupleFreeze,HeapPageFreeze,heap_execute_freeze_tuple(인라인).src/include/access/htup_details.h—HEAP_XMIN_FROZEN및 관련 infomask 상수.src/backend/commands/vacuum.c—vacuum_get_cutoffs,vac_update_relstats,vac_update_datfrozenxid,vac_truncate_clog.src/include/commands/vacuum.h—VacuumCutoffs,VacuumParams.
교재 앵커
섹션 제목: “교재 앵커”- Database System Concepts, 7판 (Silberschatz 외), §15.6 “Multiversion Concurrency Control” — MVCC 가시성 프레이밍; 버전 체인과 스냅샷 술어.
- Database Internals (Petrov, 2019), 5장 §“MVCC Versions and Cleanup” — GC 의무와 기준선 전진.
이 트리의 관련 문서
섹션 제목: “이 트리의 관련 문서”postgres-vacuum.md— 프리즈 계획을 구동하는 3단계 VACUUM 루프;LVRelState,lazy_scan_prune,lazy_scan_heap.postgres-heap-am.md— 힙 튜플 레이아웃,t_infomask비트 정의, HOT 체인, 가시성 맵 비트 메커니즘.postgres-mvcc-snapshots.md—OldestXmin유도; 긴 트랜잭션이relfrozenxid전진을 막는 방식.postgres-xact.md— 트랜잭션 수명 주기;FullTransactionIdepoch+XID 분할; 커밋/어보트 경로.postgres-xlog-wal.md—XLOG_HEAP2_FREEZE_PAGEWAL 레코드 구조; ARIES steal/no-force 계약.postgres-multixact.md(계획됨) —MultiXactId랩어라운드와SetMultiXactIdLimit.postgres-evolution-vacuum-visibility.md(계획됨) — 메이저 릴리스에 걸친 프리즈 메커니즘 변화의 역사적 아크.dbms-papers/aries.md— ARIES 논문; 프리즈 WAL 레코드를 뒷받침하는 WAL 정확성.