콘텐츠로 이동

(KO) PostgreSQL MVCC 스냅샷 — 가시성, procarray 조사, 격리 수준

목차

다중 버전 동시성 제어(MVCC, Multiversion Concurrency Control)란 각 행의 타임스탬프가 다른 여러 버전을 보존해 읽기와 쓰기가 서로를 차단하지 않도록 만드는 방법론이다. Database Internals(Petrov, 5장 §“Multiversion Concurrency Control”, 약 4004행)는 MVCC를 낙관적(OCC)·비관적 기법과 나란히 배치하면서, 그 정의적 특성을 “새 버전이 커밋되기 전까지 읽기 트랜잭션은 옛 버전을 계속 참조할 수 있다”고 표현한다. 조정의 단위가 상호 배제가 아니라 가시성이라는 점이다.

MVCC 위에 얹는 격리 수준이 스냅샷 격리(SI, Snapshot Isolation) 다. 트랜잭션은 특정 시점의 커밋된 상태를 개념적으로 촬영하고, 모든 읽기는 그 사진을 기준으로 수행된다. Database Internals(§“Isolation Levels”, 약 4138행; §“Snapshot Isolation”, 약 4179행)는 SI를 “트랜잭션이 데이터베이스 상태의 일관된 스냅샷을 볼 수 있는” 수준으로 설명한다. 자신의 쓰기와 스냅샷 시점 이전에 커밋된 모든 내용은 보이고, 스냅샷 이후에 커밋된 내용은 보이지 않는다. SI는 더티 읽기, 반복 불가 읽기, 그리고 스냅샷 범위 안의 팬텀을 차단한다.

데이터베이스가 이 개념을 정밀하게 정의해야 하는 이유가 있다. SQL 표준의 격리 수준 자체가 불명확하기 때문이다. Berenson et al. 1995, A Critique of ANSI SQL Isolation Levels(Microsoft TR-95-51)는 ANSI 현상(P1 더티 읽기, P2 반복 불가 읽기, P3 팬텀)이 모호하고, SI가 ANSI 격자에 깔끔히 들어맞지 않으며, ANSI 텍스트가 이름 붙이지 않은 이상 현상인 쓰기 왜곡(write skew) 을 SI가 허용한다고 지적한다. 쓰기 왜곡이란 두 트랜잭션이 각자 겹치는 집합을 읽고, 서로 다른 부분을 갱신하면서, 각자의 지역 불변식을 지키지만 결합된 효과는 불변식을 깨는 상황이다. PostgreSQL은 Berenson의 용어를 직접 채택한다. 소스 트리의 README-SSI가 이 논문을 현상 사전으로 인용한다. 쓰기 왜곡은 순수한 SI 엔진이 막을 수 없는 것이며, 별도의 SERIALIZABLE 메커니즘(SSI, 다른 문서에서 다룸)이 존재하는 이유다.

SI 모델로부터 모든 MVCC 엔진의 구현 방향을 결정하는 두 가지 선택이 나온다. PostgreSQL의 구체적인 답이 이 문서의 나머지를 채운다.

  1. 스냅샷을 어떻게 표현하고 가시성을 어떻게 결정할 것인가. 스냅샷 시점에 어떤 트랜잭션이 아직 진행 중이었는지를 포착해야 한다. 그 트랜잭션이 스냅샷 후 1마이크로초 뒤에 커밋하더라도 그것이 쓴 버전은 보이지 않아야 하기 때문이다. 진행 중인 트랜잭션 식별자 집합이 핵심 자료구조가 되고, 각 버전 스탬프는 삽입자와 삭제자가 누구인지 기록해야 한다.
  2. 스냅샷을 언제 취할 것인가. 문장마다 새 스냅샷을 취하면 READ COMMITTED가 되고, 트랜잭션 시작 시 한 번 취해 재사용하면 REPEATABLE READ / SI가 된다. MVCC 기계 자체는 동일하고, 오직 타이밍만 다르다. 이것이 격리 수준 조절 장치다.

세 번째 문제는 설계 선택이 아니라 구조적 숙명이다. 오래된 버전이 쌓이므로 엔진은 회수 수평선(reclamation horizon) 을 관리해야 한다. 어떤 살아 있는 트랜잭션도 더 이상 의존하지 않는 가장 오래된 스냅샷 시점이 수평선이다. 수평선 아래의 버전은 도달 불가능하므로 가비지 컬렉션 대상이 된다. 대조군은 OCC(Kung & Robinson 1981, dbms-papers/occ.md)다. OCC는 버전 스탬프 대신 커밋 시 검증을 수행하며, MVCC는 그 검증 비용 대신 다수 버전 보존과 회수 비용을 치른다.

교과서가 모델을 준다면, 이 절은 거의 모든 SI/MVCC 엔진이 어떤 형태로든 채택하는 공학적 관행을 나열한다. PostgreSQL, Oracle, InnoDB, SQL Server, CUBRID 모두 여기에 해당한다. 다음 절의 PostgreSQL 구체 선택은 이 공유 설계 공간 안의 한 조합으로 읽는 것이 맞다.

버전당 메타데이터: 삽입자와 삭제자 스탬프

섹션 제목: “버전당 메타데이터: 삽입자와 삭제자 스탬프”

모든 행 버전은 “이 버전이 내게 보이는가?”라는 질문에 중앙 조회 없이 답할 수 있는 충분한 정보를 담아야 한다. 최소 스탬프는 (삽입자, 삭제자) 쌍이다. 엔진은 오래된 버전을 살아 있는 행 옆에 제자리(in-place) 로 보관하거나(PostgreSQL: 힙에 모든 버전을, xmin/xmax 인라인으로), 별도 영역(out-of-place) 의 undo 공간에 역방향 포인터와 함께 보관한다(Oracle undo 세그먼트, InnoDB 롤백 세그먼트, CUBRID의 로그 상주 prev_version_lsa). 이 선택은 가비지 컬렉션 비용으로 이어진다. 제자리 저장은 읽기를 싸게 만들지만 전체 스캔 vacuum이 필요하고, 별도 저장은 힙을 압축하게 유지하지만 오래된 버전을 읽을 때 간접 참조 비용이 발생한다.

스냅샷은 진행 중 트랜잭션의 인구조사다

섹션 제목: “스냅샷은 진행 중 트랜잭션의 인구조사다”

스냅샷 취득 시 엔진은 어떤 트랜잭션이 아직 비행 중인지 기록한다. 두 가지 일반적인 인코딩이 있다.

  • 하한·상한 워터마크(xmin, xmax)와 명시적 진행 중 ID 목록. 하한 아래의 스탬프는 이미 커밋 또는 중단됐고, 상한 이상의 스탬프는 스냅샷 시점에 아직 시작되지 않아 확실히 보이지 않는다. [xmin, xmax) 구간의 스탬프만 목록에 대한 멤버십 검사가 필요하다. PostgreSQL이 이 방식을 택한다.
  • 최근 ID 슬라이딩 윈도우에 대한 비트 배열과 장기 실행 이상치를 위한 오버플로 목록(CUBRID의 mvcc_active_tran). 조회가 O(1)이지만 윈도우가 고정 크기 구조다.

두 인코딩 모두 목적은 같다. 일반적인 가시성 검사를 정수 비교 몇 번으로 끝내고, 실제로 동시 실행 중인 좁은 구간의 XID에 대해서만 멤버십 스캔으로 후퇴하는 것이다.

튜플 위에 가시성 결과 캐시하기

섹션 제목: “튜플 위에 가시성 결과 캐시하기”

순수한 MVCC 검사는 어떤 스탬프의 트랜잭션이 커밋됐는지 중단됐는지 알기 위해 커밋 로그(PostgreSQL의 clog/pg_xact)를 참조해야 한다. 같은 행을 매 스캔마다 참조하면 비용이 크다. 거의 모든 엔진이 쓰는 최적화가 힌트 비트(hint bit) 다. 가시성이 한 번 결정되면 엔진은 튜플 헤더의 남는 비트에 “이 xmin은 커밋됐다” / “이 xmin은 중단됐다”를 기록한다. 다음 읽기는 커밋 로그 조회를 건너뛴다. 여기서 내구성이 미묘한 문제가 된다. “커밋됐다”는 힌트는 커밋 레코드가 디스크에 안전하게 올라간 뒤에만 기록해야 한다. 그렇지 않으면 크래시 후 커밋 없이 비트만 살아남을 수 있기 때문이다.

살아 있는 스냅샷 중 가장 낮은 하한보다 오래된 버전은 도달 불가능하다. 전체적인 “가장 오래된 사용 중인 xmin” 하나가 수평선이 된다. 회수(PostgreSQL의 VACUUM, Oracle의 UNDO 트림, CUBRID의 vacuum_master)는 이 수평선으로 제한된다. 모든 SI 엔진이 동일한 구조적 비용을 안고 있다. 장시간 실행되는 트랜잭션 하나가 수평선을 고정시켜 수명이 짧은 수많은 트랜잭션이 완료됐음에도 회수를 막는다.

스냅샷 취득 타이밍이 격리 수준 조절 장치다

섹션 제목: “스냅샷 취득 타이밍이 격리 수준 조절 장치다”

대부분의 SI 엔진은 격리 수준에 걸쳐 동일한 기계를 재사용하고 스냅샷을 언제 취하는지만 달리한다.

  • Read Committed — 문장마다 새 스냅샷을 취해 각 문장이 최신 커밋 상태를 본다.
  • Repeatable Read / SI — 첫 사용 시 스냅샷 하나를 취해 트랜잭션 전체에서 재사용한다.
  • Serializable — SI만으로는 쓰기 왜곡을 막지 못한다. 두 가지 실용 답안이 있다. 서술자 잠금(predicate locking)과 의존성 순환 감지(PostgreSQL SSI), 또는 잠금 기반 직렬화로의 후퇴(CUBRID)다. 어느 경우든 이것은 스냅샷 기계 위에 얹은 별도 메커니즘이지 다른 스냅샷이 아니다.
이론 개념PostgreSQL 이름
버전당 타임스탬프TransactionId (XID) — 32비트, 순환; 수평선에는 FullTransactionId
행 안의 삽입자 / 삭제자 스탬프튜플 헤더 t_xmin / t_xmax (HeapTupleHeaderData 안)
스냅샷 시 포착한 진행 중 집합SnapshotData.xip[] (최상위) + subxip[] (서브트랜잭션)
스냅샷 하한 / 상한 워터마크SnapshotData.xmin / SnapshotData.xmax
인구조사 소스procarray (ProcGlobal->xids[], 백엔드당 슬롯 하나)
인구조사 루틴GetSnapshotData (procarray.c 안)
멤버십 검사XidInMVCCSnapshot (snapmgr.c 안)
가시성 판단자HeapTupleSatisfiesMVCC (heapam_visibility.c 안)
캐시된 결과infomask 힌트 비트 HEAP_XMIN_COMMITTED / _INVALID / _XMAX_*
회수 수평선RecentXmin, MyProc->xmin, GlobalVisState 경계
스냅샷 타이밍 정책GetTransactionSnapshot + IsolationUsesXactSnapshot()

procarray 내부 — PGPROC 슬롯 배치, XID 게시와 소거, 그룹 소거 최적화 — 는 postgres-procarray.md가 다룬다. 이 문서는 procarray를 GetSnapshotData가 읽는 테이블로 취급하고, 읽기 단계에서 멈춘다.

PostgreSQL은 교과서적인 제자리(in-place) MVCC 엔진이다. UPDATE는 행을 덮어쓰지 않고 새 튜플 버전을 삽입하면서 이전 버전의 t_xmax에 갱신 XID를 기록한다. 이전 버전은 vacuum이 회수할 때까지 힙 페이지에 남아 스냅샷 독자들에게 제공된다. 가시성을 구현하는 세 이동 부품이 있다.

  1. SnapshotData 구조체(snapshot.h) — 사진: xmin, xmax, 그리고 스냅샷 시점 진행 중이었던 XID의 xip[] 배열.
  2. GetSnapshotData (procarray.c) — 카메라: 공유 ProcArrayLock 아래 procarray를 한 번 순회해 구조체를 채운다.
  3. HeapTupleSatisfiesMVCC (heapam_visibility.c) — 판결자: 튜플과 스냅샷이 주어지면 가시/불가시를 결정하고, 스냅샷·커밋 로그·튜플 자체의 힌트 비트를 참조하며 결과를 infomask에 캐시한다.

스냅샷 매니저(snapmgr.c)는 이 모든 것을 수명과 타이밍 정책으로 감싼다. 어떤 스냅샷을 문장이 받는지, 얼마나 살아 있는지, 그리고 가장 오래된 xmin이 vacuum에 게시되는 방식을 담당한다.

// SnapshotData — src/include/utils/snapshot.h
typedef struct SnapshotData
{
SnapshotType snapshot_type; /* SNAPSHOT_MVCC for regular snapshots */
TransactionId xmin; /* all XID < xmin are visible to me */
TransactionId xmax; /* all XID >= xmax are invisible to me */
TransactionId *xip; /* in-progress top-level XIDs, [xmin,xmax) */
uint32 xcnt; /* # of XIDs in xip[] */
TransactionId *subxip; /* in-progress subxact XIDs */
int32 subxcnt;
bool suboverflowed; /* subxip[] incomplete -> use pg_subtrans */
bool takenDuringRecovery;
bool copied;
CommandId curcid; /* in my xact, CID < curcid are visible */
/* ... speculativeToken, vistest, refcounts, ph_node ... */
uint64 snapXactCompletionCount; /* lets static snapshots be reused */
} SnapshotData;

구조체의 형태가 핵심 아이디어를 담고 있다. xmin/xmax는 두 워터마크이고, xip[]는 엄격하게 [xmin, xmax) 안에 있는 진행 중 XID들의 명시적 집합이다. 헤더 주석의 불변식은 이렇다. “MVCC 스냅샷은 xmax 이상의 XID 효과를 볼 수 없다. 스냅샷에 나열된 XID들을 제외하고 그보다 오래된 모든 XID의 효과는 볼 수 있다.” 이 한 문장이 XidInMVCCSnapshot이 강제하는 계약이다.

SnapshotType은 일반적인 SNAPSHOT_MVCC를 특수 스냅샷들과 구분한다. SNAPSHOT_SELF, SNAPSHOT_ANY, SNAPSHOT_DIRTY, SNAPSHOT_TOAST, 논리적 디코딩용 SNAPSHOT_HISTORIC_MVCC, 프루닝용 SNAPSHOT_NON_VACUUMABLE이 있다. HeapTupleSatisfiesVisibility가 이 필드로 분기하며, 이 문서는 SNAPSHOT_MVCC 경로를 따른다. curcid는 트랜잭션 내 가시성을 처리한다. 자신의 트랜잭션이 이전 명령에서 쓴 튜플은 보이고, 현재 명령(CID >= curcid)이 쓴 것은 보이지 않는다.

스냅샷 구성 방식 — GetSnapshotData

섹션 제목: “스냅샷 구성 방식 — GetSnapshotData”

GetSnapshotData는 인구조사 루틴이다. 모든 백엔드가 현재 XID를 게시하는 공유 배열인 procarray를 순회하며 비행 중인 XID를 기록한다.

flowchart TD
  A["GetSnapshotData(snapshot)"] --> RU{"GetSnapshotDataReuse:\nxactCompletionCount\nunchanged?"}
  RU -- "yes" --> REUSE["reuse cached xip[]\nrelease lock, return"]
  RU -- "no" --> X["xmax = latestCompletedXid + 1\nxmin = xmax (seed)"]
  X --> LOOP["for each procarray slot:\nfetch other_xids[off]"]
  LOOP --> C1{"xid invalid\nor my own?"}
  C1 -- "skip" --> LOOP
  C1 -- "keep" --> C2{"xid >= xmax?"}
  C2 -- "skip (already running)" --> LOOP
  C2 -- "no" --> C3{"PROC_IN_VACUUM or\nPROC_IN_LOGICAL_DECODING?"}
  C3 -- "skip" --> LOOP
  C3 -- "no" --> ADD["xip[count++] = xid\nxmin = min(xmin, xid)\ncopy its subxids"]
  ADD --> LOOP
  LOOP --> FIN["set MyProc->xmin = xmin (if unset)\nrelease ProcArrayLock"]
  FIN --> GV["advance GlobalVis* bounds\nRecentXmin = xmin"]
  GV --> DONE["fill snapshot: xmin/xmax/xip/xcnt/..."]

그림 1 — GetSnapshotData 제어 흐름. 마지막 스냅샷 이후 어떤 트랜잭션도 완료하지 않았으면 재사용 빠른 경로가 단축한다. 그렇지 않으면 procarray를 한 번 순회해 xmax 아래의 모든 진행 중 XID를 수집하되, 자신의 XID와 vacuum, 논리 디코딩 백엔드는 건너뛴다.

워터마크가 먼저 설정된다. xmax는 가장 최근 완료된 XID보다 하나 크게 설정되고, xminxmax로 시드돼 스캔이 더 오래된 진행 중 XID를 발견할수록 낮아진다.

// GetSnapshotData — src/backend/storage/ipc/procarray.c (condensed)
LWLockAcquire(ProcArrayLock, LW_SHARED);
if (GetSnapshotDataReuse(snapshot)) /* nothing completed since last time */
{
LWLockRelease(ProcArrayLock);
return snapshot;
}
latest_completed = TransamVariables->latestCompletedXid;
/* xmax is always latestCompletedXid + 1 */
xmax = XidFromFullTransactionId(latest_completed);
TransactionIdAdvance(xmax);
xmin = xmax; /* seed; lowered in the loop */
/* take own xid into account, saves a check inside the loop */
if (TransactionIdIsNormal(myxid) && NormalTransactionIdPrecedes(myxid, xmin))
xmin = myxid;

루프는 모든 백엔드의 게시된 XID를 한 번 순회한다. 슬롯이 스냅샷에 기여하는지 판단하는 네 가지 필터가 있다.

// GetSnapshotData — the collection loop (condensed)
for (int pgxactoff = 0; pgxactoff < numProcs; pgxactoff++)
{
TransactionId xid = UINT32_ACCESS_ONCE(other_xids[pgxactoff]);
if (likely(xid == InvalidTransactionId)) /* no XID assigned: skip */
continue;
if (pgxactoff == mypgxactoff) /* never include my own XID */
continue;
if (!NormalTransactionIdPrecedes(xid, xmax)) /* >= xmax: treated running anyway */
continue;
statusFlags = allStatusFlags[pgxactoff];
if (statusFlags & (PROC_IN_LOGICAL_DECODING | PROC_IN_VACUUM))
continue; /* manages its own xmin */
if (NormalTransactionIdPrecedes(xid, xmin))
xmin = xid;
xip[count++] = xid; /* this XID was in progress */
/* ... copy the backend's cached subxids into subxip[], or set
* suboverflowed if its subxid cache spilled ... */
}

독자가 기억해야 할 세 가지 사실이 있다.

  • 백엔드는 자신의 XID를 스냅샷에 기록하지 않는다. 자신의 트랜잭션이 쓴 버전의 가시성은 가시성 판단자 안에서 TransactionIdIsCurrentTransactionIdcurcid가 별도로 처리한다. xip[] 멤버십 검사가 담당하지 않는다.
  • PROC_IN_VACUUM 백엔드는 제외된다. 지연 vacuum은 수평선을 고정하는 스냅샷을 보유하지 않는다. 따라서 크고 장기 실행되는 vacuum XID를 포함하지 않아야 수평선이 유지 보수 작업 때문에 끌어내려지지 않는다.
  • 서브XID는 오버플로할 수 있다. 각 PGPROC은 고정 개수의 서브트랜잭션 XID만 캐시한다(PGPROC_MAX_CACHED_SUBXIDS). 백엔드가 그 수보다 많은 서브트랜잭션을 실행했다면 캐시가 오버플로한 것이다. 스냅샷은 suboverflowed = true로 설정하고, XidInMVCCSnapshot은 서브트랜잭션 XID를 pg_subtrans에서 부모로 매핑한 뒤 멤버십을 검사한다.

잠금을 해제한 뒤 GetSnapshotData는 엔진 나머지가 읽는 두 가지 전역 장부를 게시한다.

// GetSnapshotData — after the loop (condensed)
if (!TransactionIdIsValid(MyProc->xmin))
MyProc->xmin = TransactionXmin = xmin; /* pin the horizon for this backend */
LWLockRelease(ProcArrayLock);
/* ... advance GlobalVisSharedRels/CatalogRels/DataRels/TempRels bounds ... */
RecentXmin = xmin;
snapshot->xmin = xmin;
snapshot->xmax = xmax;
snapshot->xcnt = count;
snapshot->subxcnt = subcount;
snapshot->suboverflowed = suboverflowed;
snapshot->snapXactCompletionCount = curXactCompletionCount;
snapshot->curcid = GetCurrentCommandId(false);

MyProc->xminvacuum 수평선이 계산되는 값이다. 이 백엔드가 스냅샷을 보유하는 한 xmin보다 젊은 버전은 회수될 수 없다. RecentXmin은 가장 최근 스냅샷의 하한을 백엔드 지역 캐시로 보관한다. GlobalVis* 경계는 프루닝과 vacuum이 사용하는 저렴한 근사 수평선이다.

GetSnapshotDatasnapXactCompletionCount — 커밋/중단마다 올라가는 공유 카운터인 TransamVariables->xactCompletionCount — 를 스냅샷에 기록한다는 점이 핵심 최적화다. 다음 호출 시 GetSnapshotDataReuse가 카운터를 비교해 완료된 트랜잭션이 없으면 이전에 계산한 xip[]가 동일하다는 것이 보장되므로 비용이 큰 procarray 스캔을 생략한다.

// GetSnapshotDataReuse — src/backend/storage/ipc/procarray.c (condensed)
static bool
GetSnapshotDataReuse(Snapshot snapshot)
{
Assert(LWLockHeldByMe(ProcArrayLock));
if (unlikely(snapshot->snapXactCompletionCount == 0))
return false;
curXactCompletionCount = TransamVariables->xactCompletionCount;
if (curXactCompletionCount != snapshot->snapXactCompletionCount)
return false;
/* unchanged: the recomputed snapshot would be identical -> reuse */
/* ... still must refresh MyProc->xmin and TransactionXmin ... */
return true;
}

이것이 PG 14에서 착지한 스냅샷 확장성 개선 작업의 성과다. 커밋 사이에 스냅샷을 많이 취하는 읽기 중심 워크로드는 실제 트랜잭션 완료 당 한 번만 전체 procarray 스캔을 수행한다.

가시성 결정 — HeapTupleSatisfiesMVCC

섹션 제목: “가시성 결정 — HeapTupleSatisfiesMVCC”

스냅샷이 주어지면 튜플당 판결이 HeapTupleSatisfiesMVCC다. 함수 전체가 튜플의 t_xmin(삽입자)과 t_xmax(삭제자)에 대한 결정 트리이며, 힌트 비트와 스냅샷 워터마크가 모든 단계에서 단락을 만든다. 골격은 다음과 같다.

flowchart TD
  S["HeapTupleSatisfiesMVCC(tuple, snapshot)"] --> XC{"XMIN_COMMITTED\nhint set?"}
  XC -- "no" --> INV{"XMIN_INVALID\nhint set?"}
  INV -- "yes" --> NV["return false\n(inserter aborted)"]
  INV -- "no" --> ME{"inserter is\ncurrent xact?"}
  ME -- "yes" --> MEC{"cmin >= curcid?"}
  MEC -- "yes" --> NV2["false\n(my later command)"]
  MEC -- "no" --> XMAXC["check xmax path"]
  ME -- "no" --> INSNAP{"XidInMVCCSnapshot\n(xmin)?"}
  INSNAP -- "yes" --> NV3["false\n(inserter still in progress)"]
  INSNAP -- "no" --> DIDC{"TransactionIdDidCommit\n(xmin)?"}
  DIDC -- "yes" --> SH1["SetHintBits(XMIN_COMMITTED)"]
  DIDC -- "no" --> SH2["SetHintBits(XMIN_INVALID)\nreturn false"]
  SH1 --> XMAXC
  XC -- "yes" --> FRZ{"frozen or not in\nsnapshot?"}
  FRZ --> XMAXC
  XMAXC --> XMAXI{"XMAX_INVALID\nor locked-only?"}
  XMAXI -- "yes" --> VIS["return true\n(visible)"]
  XMAXI -- "no" --> XMAXSNAP{"deleter committed\nand in snapshot?"}
  XMAXSNAP -- "still in progress" --> VIS
  XMAXSNAP -- "committed before snap" --> NV4["return false\n(deleted)"]

그림 2 — HeapTupleSatisfiesMVCC 결정 트리(골격). 삽입자(t_xmin)가 커밋됐으며 스냅샷에 보여야 튜플이 후보가 된다. 그 다음 삭제자(t_xmax)가 없거나, 중단됐거나, 스냅샷에 보이지 않아야 튜플이 최종적으로 가시다. 힌트 비트가 커밋/중단 분기를 단락시키고, SetHintBits가 새로 파악한 결과를 캐시한다.

삽입자 검사를 핵심 갈래로 압축하면 다음과 같다.

// HeapTupleSatisfiesMVCC — src/backend/access/heap/heapam_visibility.c (condensed)
if (!HeapTupleHeaderXminCommitted(tuple))
{
if (HeapTupleHeaderXminInvalid(tuple))
return false; /* inserter known aborted */
/* ... MOVED_OFF/MOVED_IN pre-9.0 upgrade arms elided ... */
else if (TransactionIdIsCurrentTransactionId(HeapTupleHeaderGetRawXmin(tuple)))
{
if (HeapTupleHeaderGetCmin(tuple) >= snapshot->curcid)
return false; /* inserted by a later command of my own xact */
/* ... then fall through to the xmax (delete) checks ... */
}
else if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
return false; /* inserter still in progress */
else if (TransactionIdDidCommit(HeapTupleHeaderGetRawXmin(tuple)))
SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
HeapTupleHeaderGetRawXmin(tuple)); /* cache: committed */
else
{
SetHintBits(tuple, buffer, HEAP_XMIN_INVALID, InvalidTransactionId);
return false; /* aborted/crashed; cache it */
}
}
else
{
/* xmin already hinted committed, but maybe not per our snapshot */
if (!HeapTupleHeaderXminFrozen(tuple) &&
XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
return false; /* treat as still in progress */
}
/* by here, the inserting transaction is committed and visible to us */

검사 순서는 성능 사다리다. 가장 싼 것이 먼저 온다. 힌트 비트(HeapTupleHeaderXminCommitted)는 모든 것을 생략하게 해준다. curcid 비교가 자신의 트랜잭션 튜플을 처리한다. XidInMVCCSnapshot이 스냅샷 멤버십 검사다. TransactionIdDidCommit은 힌트 비트가 아직 없을 때만 도달하는 비용이 큰 커밋 로그 조회이며, 그 결과는 즉시 SetHintBits로 캐시된다. 동결된 xmin(HeapTupleHeaderXminFrozen, committed와 invalid 비트가 모두 설정된 상태)은 무조건 가시이므로 스냅샷 검사조차 건너뛴다. 매우 오래된 튜플에 동결이 중요한 이유다.

삽입자가 커밋됐고 가시임이 확립되면 삭제자(t_xmax)를 대칭적 로직으로 검사한다. 유효하지 않거나 중단된 xmax, 또는 잠금 전용 xmax는 행이 살아 있음을 뜻한다(가시). 스냅샷에 없는 커밋된 xmax는 우리 스냅샷 이전에 행이 삭제됐음을 뜻한다(불가시). 스냅샷에 있는 커밋된 xmax는 삭제가 동시에 발생했으므로 여전히 행을 볼 수 있다.

스냅샷 멤버십 검사 — XidInMVCCSnapshot

섹션 제목: “스냅샷 멤버십 검사 — XidInMVCCSnapshot”

여기서 xmin/xmax 워터마크의 가치가 드러난다. 대다수 XID는 xip[]를 건드리지 않고 두 번의 비교만으로 결정된다.

// XidInMVCCSnapshot — src/backend/utils/time/snapmgr.c (condensed)
bool
XidInMVCCSnapshot(TransactionId xid, Snapshot snapshot)
{
/* Any xid < xmin is not in-progress (committed/aborted long ago) */
if (TransactionIdPrecedes(xid, snapshot->xmin))
return false;
/* Any xid >= xmax is in-progress (not started at snapshot time) */
if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
return true;
if (!snapshot->takenDuringRecovery)
{
if (!snapshot->suboverflowed)
{
if (pg_lfind32(xid, snapshot->subxip, snapshot->subxcnt))
return true; /* matched a known in-progress subxact */
/* fall through to search xip[] */
}
else
{
/* subxid cache overflowed: map to top-level via pg_subtrans */
xid = SubTransGetTopmostTransaction(xid);
if (TransactionIdPrecedes(xid, snapshot->xmin))
return false;
}
if (pg_lfind32(xid, snapshot->xip, snapshot->xcnt))
return true; /* matched a known in-progress top-level XID */
}
/* ... recovery branch: all XIDs live in subxip[] ... */
return false;
}

좁은 [xmin, xmax) 구간의 XID만 배열 스캔(pg_lfind32, SIMD 가속 선형 검색)에 도달한다. suboverflowed 분기는 고정 크기 서브XID 캐시의 비용이다. 스냅샷이 모든 서브트랜잭션을 포착하지 못했을 때 서브트랜잭션 XID는 pg_subtrans(SLRU)에서 부모 XID로 해석된 뒤에야 멤버십 검사가 가능하다. xip[]에는 최상위 XID만 보장되기 때문이다.

힌트 비트 — 안전하게 결과 캐시하기

섹션 제목: “힌트 비트 — 안전하게 결과 캐시하기”

SetHintBits는 방금 계산한 커밋/중단 결과를 튜플의 t_infomask에 기록한다. 다음 스캔에서 커밋 로그 조회를 완전히 건너뛰게 된다. 내구성 규칙이 이 과정의 핵심이다.

// SetHintBits — src/backend/access/heap/heapam_visibility.c
static inline void
SetHintBits(HeapTupleHeader tuple, Buffer buffer,
uint16 infomask, TransactionId xid)
{
if (TransactionIdIsValid(xid))
{
/* NB: xid must be known committed here! */
XLogRecPtr commitLSN = TransactionIdGetCommitLSN(xid);
if (BufferIsPermanent(buffer) && XLogNeedsFlush(commitLSN) &&
BufferGetLSNAtomic(buffer) < commitLSN)
{
/* commit not yet flushed and no LSN interlock: don't set hint */
return;
}
}
tuple->t_infomask |= infomask;
MarkBufferDirtyHint(buffer, true);
}

이 가드가 구현하는 것이 WAL 우선 힌트(WAL-before-hint) 규칙이다. “커밋됐다” 힌트 비트는 트랜잭션의 커밋 레코드가 이미 디스크에 플러시됐거나(!XLogNeedsFlush), 페이지 자체의 LSN이 이미 커밋 LSN을 넘어섰거나, 버퍼가 영구적이지 않은(임시/언로그드, 크래시 후 사라지는) 경우에만 설정할 수 있다. 그렇지 않으면 비트를 설정하지 않는다. 커밋이 내구화된 뒤 다음 방문에서 설정된다. 힌트 비트를 설정하면 페이지가 더러워지지만 MarkBufferDirtyHint(일반 MarkBufferDirty가 아님)로 처리하는 이유가 여기 있다. 비트는 커밋 로그에서 재구성 가능하므로 크래시로 손실돼도 무해하기 때문이다. 중단 힌트는 이 규칙을 적용하지 않는다. 중단은 즉시 기록해도 안전하다. HEAP_XMIN_INVALID 경우에 호출자가 InvalidTransactionId를 전달해 인터락을 건너뛰는 것이 그 이유다.

스냅샷 수명과 격리 수준 조절 장치 — snapmgr.c

섹션 제목: “스냅샷 수명과 격리 수준 조절 장치 — snapmgr.c”

누가 GetSnapshotData를 호출하고 결과가 얼마나 살아 있는지는 스냅샷 매니저의 담당이다. 쿼리의 진입점은 GetTransactionSnapshot이고, 격리 수준이 모든 것을 결정한다.

// GetTransactionSnapshot — src/backend/utils/time/snapmgr.c (condensed)
Snapshot
GetTransactionSnapshot(void)
{
/* ... historic-snapshot (logical decoding) arm elided ... */
if (!FirstSnapshotSet) /* first snapshot of this transaction */
{
InvalidateCatalogSnapshot();
if (IsolationUsesXactSnapshot()) /* REPEATABLE READ or SERIALIZABLE */
{
if (IsolationIsSerializable())
CurrentSnapshot = GetSerializableTransactionSnapshot(&CurrentSnapshotData);
else
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);
CurrentSnapshot = CopySnapshot(CurrentSnapshot); /* must outlive statement */
FirstXactSnapshot = CurrentSnapshot;
FirstXactSnapshot->regd_count++;
pairingheap_add(&RegisteredSnapshots, &FirstXactSnapshot->ph_node);
}
else
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);
FirstSnapshotSet = true;
return CurrentSnapshot;
}
if (IsolationUsesXactSnapshot()) /* RR/SR: reuse the transaction snapshot */
return CurrentSnapshot;
/* READ COMMITTED: a fresh snapshot for this statement */
InvalidateCatalogSnapshot();
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);
return CurrentSnapshot;
}

매크로가 격리 수준으로 해석된다.

src/include/access/xact.h
#define XACT_READ_COMMITTED 1
#define XACT_REPEATABLE_READ 2
#define XACT_SERIALIZABLE 3
#define IsolationUsesXactSnapshot() (XactIsoLevel >= XACT_REPEATABLE_READ)
#define IsolationIsSerializable() (XactIsoLevel == XACT_SERIALIZABLE)

정리하면 다음과 같다.

  • READ COMMITTED(PostgreSQL이 READ UNCOMMITTED도 RC로 처리함)는 각 문장 시작 시 새 스냅샷을 취한다. 새 명령마다 그 시점까지 커밋된 모든 내용을 볼 수 있다.
  • REPEATABLE READ첫 사용 시 스냅샷 하나를 취하고, 문장 경계를 넘어 살아남도록 복사하고, 등록한 뒤, 트랜잭션 나머지 전체에서 동일한 스냅샷을 반환한다. PostgreSQL의 REPEATABLE READ는 완전한 스냅샷 격리이며 ANSI 최솟값보다 강하므로 팬텀도 막는다.
  • SERIALIZABLE은 동일한 트랜잭션 수명 스냅샷을 취하되 GetSerializableTransactionSnapshot으로 라우팅한다. 이 함수가 SSI 장부(서술자 잠금, 의존성 추적)를 등록한다. 스냅샷 자체는 동일한 SI 스냅샷이고, 직렬화 보장은 그 위에 얹힌다. 이 부분은 postgres-ssi-predicate-locking.md가 다룬다.
flowchart LR
  subgraph RC["READ COMMITTED"]
    direction TB
    S1["stmt 1"] --> G1["GetSnapshotData\n(fresh)"]
    S2["stmt 2"] --> G2["GetSnapshotData\n(fresh)"]
    S3["stmt 3"] --> G3["GetSnapshotData\n(fresh)"]
  end
  subgraph RR["REPEATABLE READ / SERIALIZABLE"]
    direction TB
    T1["stmt 1"] --> GG["GetSnapshotData\n(once, copied + registered)"]
    T2["stmt 2"] --> GG
    T3["stmt 3"] --> GG
  end

그림 3 — 격리 수준별 스냅샷 수명. READ COMMITTED는 문장마다 스냅샷을 재구성하므로 문장 사이의 커밋을 볼 수 있다. REPEATABLE READ와 SERIALIZABLE은 첫 사용 시 한 번 스냅샷을 취하고 트랜잭션 전체에서 재사용한다.

별도의 CatalogSnapshot(역시 MVCC)이 시스템 카탈로그 스캔에 쓰이며 카탈로그 변경이 발생할 때마다 무효화된다. 긴 REPEATABLE READ 트랜잭션 안에서도 백엔드의 카탈로그 뷰가 최신 상태를 유지하는 이유다. InvalidateCatalogSnapshot은 각 GetTransactionSnapshot 진입 시 호출돼 카탈로그 스냅샷이 트랜잭션 스냅샷보다 오래되지 않도록 유지한다. GetLatestSnapshot은 RI 무결성 검사처럼 RR/SR 모드에서도 즉시 최신 스냅샷이 필요한 드문 코드를 위해 SecondarySnapshotData에 빌드한다.

등록된 모든 스냅샷은 MyProc->xmin을 고정한다. 스냅샷이 해제되면 SnapshotResetXmin이 아직 등록된 스냅샷 중 가장 낮은 xmin으로 백엔드의 하한을 재계산하고, 남은 것이 없으면 InvalidTransactionId로 내린다.

// SnapshotResetXmin — src/backend/utils/time/snapmgr.c
static void
SnapshotResetXmin(void)
{
if (ActiveSnapshot != NULL)
return; /* something still active: keep xmin */
if (pairingheap_is_empty(&RegisteredSnapshots))
{
MyProc->xmin = TransactionXmin = InvalidTransactionId; /* release horizon */
return;
}
/* otherwise lower MyProc->xmin to the oldest registered snapshot's xmin */
minSnapshot = pairingheap_container(SnapshotData, ph_node,
pairingheap_first(&RegisteredSnapshots));
if (TransactionIdPrecedes(MyProc->xmin, minSnapshot->xmin))
MyProc->xmin = TransactionXmin = minSnapshot->xmin;
}

RegisteredSnapshotsxmin으로 정렬된 페어링 힙이므로 가장 오래된 것이 힙 루트에 위치해 O(1)으로 접근할 수 있다. 전역 vacuum 수평선은 이후 모든 백엔드의 MyProc->xmin 중 최솟값으로, ComputeXidHorizons가 계산하며 아래에서 설명하는 GlobalVisState 경계로 노출된다. vacuum 메커니즘 자체는 postgres-vacuum.md가 다룬다. 여기서는 “수평선이 무엇이고 누가 고정하는가”에서 멈춘다.

프루닝 결정마다 정확한 “가장 오래된 XID”를 계산하려면 procarray를 계속 다시 스캔해야 한다. 대신 PostgreSQL은 관계 클래스별로 근사 경계 쌍을 관리하며 지연 갱신한다.

// GlobalVisState — src/backend/storage/ipc/procarray.c
struct GlobalVisState
{
/* XIDs >= are considered running by some backend */
FullTransactionId definitely_needed;
/* XIDs < are not considered to be running by any backend */
FullTransactionId maybe_needed;
};

튜플의 삭제 XID가 maybe_needed 아래면 확실히 제거 가능하고, definitely_needed 이상이면 확실히 여전히 필요하다. 두 경계 사이의 XID는 모호해 ComputeXidHorizons를 통한 정밀 재계산을 유발한다.

// GlobalVisTestIsRemovableFullXid — src/backend/storage/ipc/procarray.c (condensed)
if (FullTransactionIdPrecedes(fxid, state->maybe_needed))
return true; /* below floor: removable */
if (FullTransactionIdFollowsOrEquals(fxid, state->definitely_needed))
return false; /* above ceiling: keep */
/* ambiguous band: recompute exact horizon, then recheck */
if (GlobalVisTestShouldUpdate(state))
{
GlobalVisUpdate();
return FullTransactionIdPrecedes(fxid, state->maybe_needed);
}
else
return false;

인스턴스가 네 개 존재한다. GlobalVisSharedRels, GlobalVisCatalogRels, GlobalVisDataRels, GlobalVisTempRels다. 일반 사용자 테이블은 공유 카탈로그보다 공격적인 수평선을 쓸 수 있고, 임시 테이블의 오래된 버전은 다른 세션에 전혀 보이지 않으므로 수평선이 현재 백엔드의 XID만으로 결정된다. GlobalVisTestFor(rel)이 적절한 인스턴스를 선택한다. 이 경계들이 FullTransactionId(64비트, 순환 없음)인 것은 바로 수평선 산술이 32비트 XID 순환으로 혼동되지 않도록 하기 위해서다. vacuum 동결이 존재하는 이유와 같다. 이것이 postgres-vacuum.mdpostgres-heap-am.md(HOT 프루닝)의 접점이며, 두 문서 모두 GlobalVisTestIsRemovableXid를 소비한다.

삽입자가 커밋됐고 가시임이 확립되면, HeapTupleSatisfiesMVCCt_xmax를 검사해 행이 우리 스냅샷 이전에 삭제됐는지 판단한다. 구조는 삽입자 사다리를 그대로 반영하지만 판결이 반전된다. 커밋됐고 가시인 삭제자는 행을 불가시로 만든다.

// HeapTupleSatisfiesMVCC — deleter (t_xmax) path, src/backend/access/heap/heapam_visibility.c (condensed)
if (tuple->t_infomask & HEAP_XMAX_INVALID) /* no deleter, or deleter aborted */
return true; /* visible: the row is live */
if (HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask))
return true; /* xmax is a lock, not a delete */
/* ... HEAP_XMAX_IS_MULTI (multixact) arm: resolve update xid, recurse ... */
if (!(tuple->t_infomask & HEAP_XMAX_COMMITTED))
{
if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmax(tuple), snapshot))
return true; /* deleter still in progress -> visible */
if (!TransactionIdDidCommit(HeapTupleHeaderGetRawXmax(tuple)))
{
SetHintBits(tuple, buffer, HEAP_XMAX_INVALID, InvalidTransactionId);
return true; /* deleter aborted -> visible, cache it */
}
SetHintBits(tuple, buffer, HEAP_XMAX_COMMITTED,
HeapTupleHeaderGetRawXmax(tuple)); /* deleter committed -> cache */
}
else
{
if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmax(tuple), snapshot))
return true; /* committed but invisible to us -> visible */
}
return false; /* deleter committed and visible -> deleted */

대칭이 핵심이다. t_xmax가 없거나 중단됐거나 잠금 전용이면 행은 살아 있다. 스냅샷에 있는 삭제자는 우리에게 삭제가 보이지 않으므로 여전히 행을 볼 수 있다. 스냅샷 이전에 커밋된 삭제자만 행을 불가시로 만든다. HEAP_XMAX_IS_MULTI 갈래는 t_xmax가 여러 잠금자와 하나의 갱신자를 묶은 MultiXactId인 경우를 처리한다. HeapTupleGetUpdateXid가 실제 삭제 XID를 추출한 뒤 동일한 커밋/스냅샷 내 검사가 실행된다. SetHintBits는 삭제자 결과(HEAP_XMAX_COMMITTED / HEAP_XMAX_INVALID)를 삽입자 측과 동일한 WAL 우선 힌트 규칙 아래 캐시한다.

가시성 기계는 네 파일에 걸쳐 있다. 호출 흐름은 GetTransactionSnapshotGetSnapshotData → (스캔마다) HeapTupleSatisfiesVisibilityHeapTupleSatisfiesMVCCXidInMVCCSnapshot / SetHintBits 이고, snapmgr.c가 수명을, procarray.c가 수평선을 소유한다. procarray 내부(PGPROC 슬롯 배치, XID 게시, 그룹 소거)는 postgres-procarray.md가 다루며, 이 목록은 GetSnapshotData가 게시된 배열을 읽는 지점에서 멈춘다.

스냅샷 구조체와 스탬프 (snapshot.h, htup_details.h)

섹션 제목: “스냅샷 구조체와 스탬프 (snapshot.h, htup_details.h)”
  • SnapshotData (구조체 태그, snapshot.h) — 사진. xmin/xmax 워터마크, xip[]/xcnt 최상위 진행 중 집합, subxip[]/subxcnt + suboverflowed 서브트랜잭션, curcid 트랜잭션 내 가시성, takenDuringRecovery 복구 시 배치 선택, vistest 관련 GlobalVisState 포인터, snapXactCompletionCount 재사용 가능 여부.
  • SnapshotType (열거형, snapshot.h) — SNAPSHOT_MVCC가 공통 경로이며, HeapTupleSatisfiesVisibility가 이것을 기준으로 HeapTupleSatisfiesMVCC, HeapTupleSatisfiesSelf, HeapTupleSatisfiesDirty 등으로 분기한다.
  • HEAP_XMIN_COMMITTED / HEAP_XMIN_INVALID / HEAP_XMIN_FROZEN / HEAP_XMAX_COMMITTED / HEAP_XMAX_INVALID (htup_details.h의 매크로) — infomask 힌트 비트. HEAP_XMIN_FROZEN(HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID) — 두 비트가 모두 설정된 상태가 “동결됨”을 뜻하며 HeapTupleHeaderXminFrozen이 읽는다.
  • GetSnapshotData — procarray 인구조사. xmax = latestCompletedXid + 1을 설정하고, xmin = xmax로 시드한 뒤, ProcGlobal->xids[]를 한 번 순회해 xmax 아래의 모든 진행 중 XID를 수집한다. 유효하지 않은 슬롯, 자신의 백엔드, PROC_IN_VACUUM/PROC_IN_LOGICAL_DECODING 슬롯은 건너뛴다. MyProc->xmin, TransactionXmin, RecentXmin을 게시하고 GlobalVis* 경계를 전진시킨다.
  • GetSnapshotDataReuse — 빠른 경로. 스냅샷에 저장된 snapXactCompletionCountTransamVariables->xactCompletionCount와 비교한다. 같으면 마지막 스냅샷 이후 완료된 트랜잭션이 없으므로 캐시된 xip[]가 동일하며 스캔을 생략한다. MyProc->xmincurcid는 여전히 갱신한다.
  • ComputeXidHorizons — 정밀 수평선 재계산. procarray를 순회해 관계 클래스별 정확한 가장 오래된 필요 XID를 생성한다. 모호한 XID가 근사 경계 사이에 있을 때 GlobalVisUpdate가 지연 호출한다.
  • GlobalVisState (구조체 태그), GlobalVisTestFor / GlobalVisTestIsRemovableXid / GlobalVisTestIsRemovableFullXid / GlobalVisUpdate — 프루닝과 vacuum이 소비하는 클래스별 근사 수평선(definitely_needed / maybe_needed). 인스턴스 네 개: GlobalVisSharedRels, GlobalVisCatalogRels, GlobalVisDataRels, GlobalVisTempRels.
  • HeapTupleSatisfiesVisibility — 분기자. snapshot->snapshot_type으로 분기해 적절한 판단자로 전달한다.
  • HeapTupleSatisfiesMVCCSNAPSHOT_MVCC 판단자. 삽입자 사다리(t_xmin): 힌트 비트 → 자신의 트랜잭션에는 curcidXidInMVCCSnapshotTransactionIdDidCommit → 캐시. 이후 반전된 판결로 삭제자 사다리(t_xmax). HeapTupleGetUpdateXid를 통한 Multixact 갈래.
  • SetHintBits (그리고 내보내진 HeapTupleSetHintBits) — WAL 우선 힌트 규칙(XLogNeedsFlush + BufferGetLSNAtomic 인터락) 아래 커밋/중단 결과를 t_infomask에 캐시하고 MarkBufferDirtyHint를 호출한다.
  • XidInMVCCSnapshot — 멤버십 검사. 두 번의 워터마크 비교가 대다수 XID를 결정하고, [xmin, xmax) 구간만 subxip[] 이후 xip[]pg_lfind32 스캔에 도달한다. suboverflowedSubTransGetTopmostTransaction을 통한 pg_subtrans 조회를 강제한다.
  • GetTransactionSnapshot — 쿼리당 진입점. 첫 호출: InvalidateCatalogSnapshot, 이후 등록된 트랜잭션 수명 스냅샷(RR/SR, 복사 + RegisteredSnapshots 추가) 또는 새 CurrentSnapshotData(RC). 이후 호출: RR/SR은 재사용, RC는 새 스냅샷.
  • GetLatestSnapshot — RI 검사처럼 RR/SR에서도 최신 상태가 필요한 코드를 위해 SecondarySnapshotData에 즉시 최신 스냅샷을 빌드한다.
  • GetCatalogSnapshot / InvalidateCatalogSnapshot — 시스템 카탈로그 스캔용 별도 MVCC 스냅샷. 트랜잭션 스냅샷보다 오래되지 않도록 유지하고 카탈로그 변경 시 무효화한다.
  • CopySnapshotCurrentSnapshotData를 깊은 복사해 트랜잭션 수명 스냅샷이 문장 경계를 넘어 살아남도록 한다.
  • SnapshotResetXminMyProc->xmin을 등록된 스냅샷 중 가장 오래된 것의 xmin(RegisteredSnapshots 페어링 힙 루트)으로 재계산하거나, 남은 것이 없으면 InvalidTransactionId로 내려 백엔드의 vacuum 수평선 보유를 해제한다.
  • XACT_READ_COMMITTED / XACT_REPEATABLE_READ / XACT_SERIALIZABLE, IsolationUsesXactSnapshot()(>= XACT_REPEATABLE_READ), IsolationIsSerializable()(== XACT_SERIALIZABLE) — GetTransactionSnapshot이 스냅샷 수명을 선택하기 위해 읽는 조절 장치.

위치 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
SnapshotData (구조체)src/include/utils/snapshot.h138
SnapshotData.xmin / xmaxsrc/include/utils/snapshot.h153 / 154
SnapshotData.xip / xcntsrc/include/utils/snapshot.h164 / 165
SnapshotData.snapXactCompletionCountsrc/include/utils/snapshot.h209
HEAP_XMIN_COMMITTED / _INVALID / _FROZENsrc/include/access/htup_details.h204 / 205 / 206
HEAP_XMAX_COMMITTED / _INVALIDsrc/include/access/htup_details.h207 / 208
XACT_READ_COMMITTEDXACT_SERIALIZABLEsrc/include/access/xact.h37–39
IsolationUsesXactSnapshot / IsolationIsSerializablesrc/include/access/xact.h51 / 52
GetSnapshotDatasrc/backend/storage/ipc/procarray.c2175
GetSnapshotDataReusesrc/backend/storage/ipc/procarray.c2095
ComputeXidHorizonssrc/backend/storage/ipc/procarray.c1735
GlobalVisState (구조체)src/backend/storage/ipc/procarray.c167
GlobalVisTestForsrc/backend/storage/ipc/procarray.c4107
GlobalVisUpdatesrc/backend/storage/ipc/procarray.c4205
GlobalVisTestIsRemovableFullXidsrc/backend/storage/ipc/procarray.c4222
GlobalVisTestIsRemovableXidsrc/backend/storage/ipc/procarray.c4263
HeapTupleSatisfiesMVCCsrc/backend/access/heap/heapam_visibility.c960
HeapTupleSatisfiesVisibilitysrc/backend/access/heap/heapam_visibility.c1776
SetHintBitssrc/backend/access/heap/heapam_visibility.c114
GetTransactionSnapshotsrc/backend/utils/time/snapmgr.c271
GetLatestSnapshotsrc/backend/utils/time/snapmgr.c353
GetCatalogSnapshotsrc/backend/utils/time/snapmgr.c384
InvalidateCatalogSnapshotsrc/backend/utils/time/snapmgr.c454
CopySnapshotsrc/backend/utils/time/snapmgr.c606
SnapshotResetXminsrc/backend/utils/time/snapmgr.c935
XidInMVCCSnapshotsrc/backend/utils/time/snapmgr.c1870

REL_18_STABLE 브랜치의 커밋 273fe94를 기준으로 확인했다. 이 문서의 모든 코드 발췌는 위 위치 힌트 표에 인용된 줄에서 압축한 것이며, 생략 줄은 // ...으로 표시하고 인용된 주석은 원문 그대로 보존했다.

  • xmax는 항상 스캔 전에 latestCompletedXid + 1로 계산된다. GetSnapshotData(procarray.c, 2247–2249행) 확인: xmaxTransamVariables->latestCompletedXid에서 읽은 뒤 TransactionIdAdvance(xmax)가 적용된다. xminxmax로 시드되고 루프 안에서만 낮아지므로, 진행 중인 트랜잭션이 없는 스냅샷은 xmin == xmax가 된다.

  • 백엔드 자신의 XID는 값이 아닌 pgxactoff 위치로 제외된다. 수집 루프(procarray.c, 2293행) 확인: if (pgxactoff == mypgxactoff) continue;. 자신의 쓰기 가시성은 HeapTupleSatisfiesMVCC 안의 TransactionIdIsCurrentTransactionId + curcid가 처리하며 xip[] 멤버십 검사는 담당하지 않는다.

  • PROC_IN_VACUUMPROC_IN_LOGICAL_DECODING 백엔드는 건너뛴다. 루프의 statusFlags 필터(procarray.c, 약 2300행 이후) 확인: 지연 vacuum은 수평선을 고정하는 스냅샷을 보유하지 않으므로 XID를 수집하지 않는다. 장기 실행 VACUUM이 전역 xmin을 끌어내리지 않는 이유다.

  • 재사용 빠른 경로는 오직 xactCompletionCount로만 제어된다. GetSnapshotDataReuse(procarray.c, 2101–2106행) 확인: 저장된 snapXactCompletionCount가 0이 아니고 현재 전역 카운터와 같으면 스냅샷을 재사용하고 procarray 스캔을 생략한다. 인트리 주석(2108–2127행)은 transam/README를 인용하며, ProcArrayLock이 잡혀 있는 동안 실행 중 XID 집합은 변하지 않고 카운터는 완료 시에만 잠금 아래에서 올라간다는 불변식을 설명한다.

  • XidInMVCCSnapshot은 대다수 XID를 두 번의 비교로 결정한다. snapmgr.c(1880–1885행) 확인: xid < xmin → false, xid >= xmax → true. [xmin, xmax) 구간만 subxip[] 이후 xip[]pg_lfind32에 도달한다. suboverflowed 분기(1908–1923행)는 SubTransGetTopmostTransaction으로 서브트랜잭션을 부모로 매핑한 뒤 배열 스캔을 수행한다.

  • 힌트 비트 쓰기는 WAL 우선 힌트 인터락을 따른다. SetHintBits(heapam_visibility.c, 117–131행) 확인: 유효한 xid를 가진 “커밋됐다” 힌트는 BufferIsPermanent && XLogNeedsFlush(commitLSN) && BufferGetLSNAtomic(buffer) < commitLSN일 때 억제된다. 더러운 표시는 MarkBufferDirtyHint(일반 MarkBufferDirty가 아님)로 처리하는데, 비트가 clog에서 재구성 가능해 크래시로 손실돼도 무해하기 때문이다. 중단/유효하지 않은 힌트는 InvalidTransactionId를 전달해 인터락을 완전히 건너뛴다.

  • 격리 수준이 RC와 RR 스냅샷 수명의 유일한 차이다. GetTransactionSnapshot(snapmgr.c, 315–344행) 확인: RR/SR(IsolationUsesXactSnapshot())은 한 번 빌드하고 CopySnapshot하며 RegisteredSnapshots에 등록한 뒤 이후 동일한 스냅샷을 반환한다. RC는 매 호출마다 새 CurrentSnapshotData를 빌드한다. SERIALIZABLE은 첫 빌드를 GetSerializableTransactionSnapshot으로 라우팅하는 점만 다르다.

  • GlobalVisState 경계는 FullTransactionId다. procarray.c(167–173행) 확인: definitely_neededmaybe_needed 모두 64비트 FullTransactionId이므로 수평선 산술이 32비트 XID 순환에 면역이다. GlobalVisTestIsRemovableFullXid(4222–4254행)는 maybe_needed 아래면 제거 가능, definitely_needed 이상이면 유지, 모호한 구간은 GlobalVisUpdate로 재계산한다.

  1. REL_18 수집 루프의 정확한 statusFlags 마스크 상수. 본문은 PROC_IN_VACUUM | PROC_IN_LOGICAL_DECODING을 언급하지만, 이 문서는 인용된 줄에서 압축한 필터를 인용한 것이지 리터럴 마스크 표현식이 아니다. 루프를 확장하려는 독자는 procarray.c 약 2300행 주변을 다시 읽고 추가 PROC_* 플래그가 스냅샷 제외에 참여하는지 확인해야 한다. 조사 경로: GetSnapshotData에서 statusFlags &를 grep하고 proc.hPROC_* 정의와 대조한다.

  2. 재사용 빠른 경로와 curcid 전진의 상호작용. GetSnapshotDataReuse는 재사용 시에도 snapshot->curcid = GetCurrentCommandId(false)를 갱신한다(2134행). 따라서 재사용된 스냅샷도 같은 트랜잭션의 이후 명령을 볼 수 있다. 호출자 중 갱신되지 않은 curcid에 의존하는 경우가 있는지는 검증되지 않았다. 경로 자체는 안전해 보이지만 모든 GetSnapshotData 호출자를 완전히 추적하지는 않았다.

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

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”

포인터만 제시하며 분석하지 않는다. 각 항목은 후속 문서나 형제 문서의 시작점이다. 이 절의 깊이는 의도적으로 얕게 유지한다.

  • Berenson 비평이 현상 사전이다. Berenson, Bernstein, Gray, Melton, O’Neil & O’Neil, A Critique of ANSI SQL Isolation Levels(SIGMOD 1995 / MSR TR-95-51)는 PostgreSQL의 스냅샷 기계가 RR에서 더티/반복 불가/팬텀 읽기를 막으면서도 쓰기 왜곡을 혼자서는 막지 못하는 이유를 설명한다. 이 문서의 자연스러운 다음 읽을 문서는 postgres-ssi-predicate-locking.md다. 동일한 SI 스냅샷 위에 Cahill et al.의 Serializable Snapshot Isolation(SIGMOD 2008)과 Ports & Grittner(VLDB 2012)의 rw-안티의존성 순환 감지를 얹는다.

  • OCC가 대조군이다. Kung & Robinson, On Optimistic Methods for Concurrency Control(TODS 1981; dbms-papers/occ.md)는 버전 스탬프 대신 커밋 시 검증을 수행한다. MVCC는 그 검증 비용 대신 이 문서의 vacuum 수평선 기계가 관리하는 다수 버전 보존 비용을 치른다.

  • 제자리 대 별도 장소 버전 저장. PostgreSQL은 모든 버전을 힙에 보관한다(저렴한 읽기, 전체 스캔 VACUUM). Oracle/InnoDB/CUBRID는 역방향 포인터를 가진 undo에 오래된 버전을 보관한다(압축된 힙, 간접 참조된 오래된 버전 읽기). cubrid-mvcc.md가 별도 장소 방식의 형제 문서다. 두 설계의 대칭 비용(PostgreSQL의 bloat/HOT 대 CUBRID의 vacuum 읽기 증폭)은 같은 설계 선택의 두 끝에서 본 것이다.

  • 높은 코어 수에서의 스냅샷 확장성. PG 14의 스냅샷 확장성 개선(snapXactCompletionCount 재사용 경로와 XID를 밀집 ProcGlobal->xids[] 배열로 분리)은 Yu et al., Staring into the Abyss(VLDB 2015)가 1000 코어에서 CC 프로토콜 간 측정한 procarray 스캔 비용을 목표로 한다. procarray 측 세부 사항은 postgres-procarray.md가 다룬다.

  • 인메모리 MVCC 재설계. Hekaton, HyPer, Cicada는 중앙 procarray 스캔 대신 캐시 친화적 인메모리 버전 체인과 타임스탬프 할당을 사용한다. Wu et al., An Empirical Evaluation of In-Memory MVCC(VLDB 2017)가 이 공간을 조사한다. MVCC 자체에 내재된 비용과 PostgreSQL처럼 디스크 상주 MVCC에 내재된 비용을 구분하는 데 유용하다.

  • 64비트 XID 수평선과 순환. GlobalVisStateFullTransactionId 경계는 32비트 XID 순환을 완전히 없애는 방향으로 나아가는 디딤돌이다. 동결/순환 메커니즘은 postgres-vacuum.md가 소유하며, 수평선이 무엇을 제어하는지에 대한 자연스러운 다음 읽을 문서다.

  • 없음. 이 문서는 REL_18 소스 트리에서 직접 합성됐다(sources: [] 프론트매터).

소스 코드 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)

섹션 제목: “소스 코드 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)”
  • src/include/utils/snapshot.hSnapshotData, SnapshotType.
  • src/include/access/htup_details.hHEAP_XMIN_* / HEAP_XMAX_* 힌트 비트 매크로, HeapTupleHeaderXmin* 접근자.
  • src/include/access/xact.h — 격리 수준 상수와 IsolationUsesXactSnapshot / IsolationIsSerializable 매크로.
  • src/backend/storage/ipc/procarray.cGetSnapshotData, GetSnapshotDataReuse, ComputeXidHorizons, GlobalVisStateGlobalVisTest* 계열.
  • src/backend/access/heap/heapam_visibility.cHeapTupleSatisfiesMVCC, HeapTupleSatisfiesVisibility, SetHintBits.
  • src/backend/utils/time/snapmgr.cGetTransactionSnapshot, GetLatestSnapshot, GetCatalogSnapshot, InvalidateCatalogSnapshot, CopySnapshot, SnapshotResetXmin, XidInMVCCSnapshot.
  • Petrov, Database Internals(O’Reilly 2019), 5장 — MVCC, 격리 수준, 스냅샷 격리. knowledge/research/dbms-general/database-internals.md에 캡처됨.
  • Berenson, Bernstein, Gray, Melton, O’Neil & O’Neil, A Critique of ANSI SQL Isolation Levels(SIGMOD 1995 / Microsoft TR-95-51).
  • Kung & Robinson, On Optimistic Methods for Concurrency Control(ACM TODS 1981). knowledge/research/dbms-papers/occ.md 참조.
  • Cahill, Röhm & Fekete, Serializable Isolation for Snapshot Databases(SIGMOD 2008); Ports & Grittner, Serializable Snapshot Isolation in PostgreSQL(VLDB 2012). postgres-ssi-predicate-locking.md가 다룬다.
  • postgres-procarray.md — PGPROC 슬롯 배치, XID 게시, 그룹 소거: GetSnapshotData가 읽는 내부.
  • postgres-heap-am.md — 힙 튜플 헤더, HOT, 프루닝에서 GlobalVisTestIsRemovableXid의 소비자.
  • postgres-vacuum.md — vacuum 수평선이 제어하는 것; 동결과 순환.
  • postgres-ssi-predicate-locking.md — SI 스냅샷 위의 SERIALIZABLE / 쓰기 왜곡 방지.