콘텐츠로 이동

(KO) PostgreSQL procarray — PGPROC 슬롯 배치, XID 게시, 스냅샷 인구조사

목차

스냅샷 격리(SI, Snapshot Isolation) 엔진은 스냅샷 취득 시점에 “어떤 트랜잭션이 아직 진행 중인가”를 알아야 한다. 그 집합을 모르면 막 시작한 커밋이나 오래 실행 중인 갱신을 무엇으로 처리해야 할지 결정할 수 없다. Database Internals(Petrov, 5장)는 이 문제를 “active set” 포착이라 부른다. 어떤 XID가 스냅샷 경계 안에 있는지 없는지를 빠르게 판별하기 위해 엔진은 진행 중인 식별자의 집합을 어딘가에 게시해 두어야 한다.

멀티프로세스 공유 메모리 아키텍처에서 이 게시 공간은 자연스럽게 공유 프로세스 배열(process array) 형태가 된다. 각 백엔드는 자신의 슬롯에 현재 XID를 기록하고, 스냅샷 빌더는 그 배열을 순회해 인구조사(census)를 수행한다. 이 설계의 핵심 긴장은 두 가지다.

  1. 동시성 비용. 모든 스냅샷 취득이 배열 전체를 순회하면, 백엔드 수가 늘어날수록 비용이 선형으로 증가한다. 이는 고코어 환경에서 측정 가능한 병목이 된다. Yu et al., Staring into the Abyss: An Evaluation of Concurrency Control with One Thousand Cores(VLDB 2015)는 이 확장성 문제를 정량화했다. PostgreSQL 14의 “스냅샷 확장성(snapshot scalability)” 작업은 이에 대한 직접적인 응답이다.

  2. 경쟁 조건. XID가 할당되는 순간과 배열에 게시되는 순간, 그리고 소거되는 순간 사이에 미묘한 레이스가 존재한다. 잘못 배치된 읽기 한 번이 진행 중인 트랜잭션을 커밋된 것처럼 보이게 만들 수 있다. src/backend/access/transam/README는 이 레이스를 여러 페이지에 걸쳐 분석한다.

이 두 문제를 해결하는 방법이 procarray 구현의 핵심이다. postgres-mvcc-snapshots.md는 procarray를 읽는 소비자 관점에서 서술하며, 이 문서는 배열 자체의 내부 구조와 관리 방법을 다룬다.

MVCC 엔진 대부분은 백엔드당 하나의 고정 슬롯을 공유 메모리에 두고, 거기에 현재 XID와 관련 상태를 기록한다. 이 방식이 해시 테이블이나 동적 목록보다 선호되는 이유는 두 가지다. 슬롯의 위치가 고정돼 있어 인덱스 산술로 직접 접근할 수 있고, 잠금 범위를 배열 단위로 단순하게 설정할 수 있다. Oracle의 트랜잭션 테이블(Transaction Table), SQL Server의 버전 저장소 헤더 구조, CUBRID의 트랜잭션 목록 모두 이 관용구의 변형이다.

슬롯 구조체가 크면 스냅샷 순회 시 캐시 미스가 늘어난다. 이를 줄이기 위해 현대 DBMS는 자주 읽히는 필드를 별도의 밀집 배열(dense array)로 분리한다. 스냅샷 빌더는 거대한 슬롯 구조체가 아니라 XID 배열만 선형 순회한다. 이는 메모리 대역폭 절약과 캐시 친화적 접근을 동시에 얻는 방법이다.

XID 게시와 소거는 원자적이어야 한다. 스냅샷 빌더가 순회하는 동안 슬롯의 값이 부분적으로 바뀌면 안 되기 때문이다. 일반적인 방법은 두 가지다.

  • 공유/배타 잠금. 스냅샷 빌더는 공유 잠금, XID 소거는 배타 잠금. PostgreSQL의 ProcArrayLock이 이 방식이다.
  • 원자적 단어 쓰기. 단일 32비트 혹은 64비트 값은 잠금 없이 원자적으로 쓸 수 있다. PostgreSQL은 volatile uint32 캐스트(UINT32_ACCESS_ONCE)로 순회 측의 찢김 읽기를 방지하고, 쓰기 측은 여전히 잠금을 요구한다.

커밋 처리량이 높은 시스템에서 모든 커밋이 배타 잠금을 개별적으로 경합하면 큰 병목이 된다. 흔한 해법은 대기 중인 소거 요청을 하나의 잠금 획득으로 일괄 처리하는 그룹 소거(group clear) 패턴이다. 한 백엔드가 잠금을 획득하면 대기 목록에 있는 다른 백엔드들의 XID까지 함께 소거한다.

서브트랜잭션 XID는 최상위 XID와 별개로 존재한다. 모든 서브 XID를 배열 전체에 노출하면 공간이 폭발적으로 늘어난다. 실용적인 해법은 각 슬롯에 고정 크기의 캐시를 두고, 오버플로 시 외부 조회 테이블(pg_subtrans)로 위임하는 것이다.

이론 개념PostgreSQL 이름
프로세스 당 슬롯 구조체PGPROC (src/include/storage/proc.h)
슬롯 디렉터리 헤더ProcArrayStruct (procarray.c 내부)
전역 슬롯 테이블PROC_HDR / ProcGlobal (proc.h)
밀집 XID 배열ProcGlobal->xids[]
밀집 상태 플래그 배열ProcGlobal->statusFlags[]
밀집 서브XID 상태 배열ProcGlobal->subxidStates[]
슬롯 접근 잠금ProcArrayLock (LWLock)
그룹 소거 목록ProcGlobal->procArrayGroupFirst (원자 연결 목록)
서브XID 캐시PGPROC.subxids.xids[], 최대 PGPROC_MAX_CACHED_SUBXIDS = 64
오버플로 위임 테이블pg_subtrans (SLRU, SubTransGetTopmostTransaction)
핫 스탠바이 대역 배열KnownAssignedXids[] + KnownAssignedXidsValid[]

procarray는 두 계층으로 구성된다. 인덱스 헤더인 ProcArrayStruct와 실제 슬롯 데이터를 담는 PROC_HDR(= ProcGlobal)이다.

// ProcArrayStruct — src/backend/storage/ipc/procarray.c
typedef struct ProcArrayStruct
{
int numProcs; /* 현재 유효한 procarray 항목 수 */
int maxProcs; /* pgprocnos[] 할당 크기 */
/* Hot Standby 처리 — KnownAssignedXids 관련 */
int maxKnownAssignedXids;
int numKnownAssignedXids;
int tailKnownAssignedXids;
int headKnownAssignedXids;
TransactionId lastOverflowedXid;
/* 복제 슬롯 horizon */
TransactionId replication_slot_xmin;
TransactionId replication_slot_catalog_xmin;
/* PROCARRAY_MAXPROCS 길이의 인덱스 배열 */
int pgprocnos[FLEXIBLE_ARRAY_MEMBER];
} ProcArrayStruct;

pgprocnos[]ProcGlobal->allProcs[]에 대한 인덱스다. 슬롯이 추가·삭제될 때 이 배열이 유지된다는 점이다. 슬롯 자체는 allProcs[]에 고정 위치로 존재하고, pgprocnos[]는 현재 활성인 슬롯의 pgxactoffProcNumber 매핑을 담는다.

PROC_HDR은 그보다 상위의 전역 구조체다.

// PROC_HDR (= ProcGlobal) — src/include/storage/proc.h
typedef struct PROC_HDR
{
PGPROC *allProcs; /* 전체 PGPROC 배열 */
TransactionId *xids; /* PGPROC.xid 미러 — 밀집 배열 */
XidCacheStatus *subxidStates; /* PGPROC.subxidStatus 미러 */
uint8 *statusFlags; /* PGPROC.statusFlags 미러 */
uint32 allProcCount;
dlist_head freeProcs;
/* ... autovacFreeProcs, bgworkerFreeProcs, walsenderFreeProcs ... */
pg_atomic_uint32 procArrayGroupFirst; /* 그룹 XID 소거 목록 헤드 */
pg_atomic_uint32 clogGroupFirst; /* clog 그룹 업데이트 목록 헤드 */
/* ... walwriterProc, checkpointerProc, spins_per_delay ... */
} PROC_HDR;

밀집 배열(xids[], subxidStates[], statusFlags[])이 핵심이다. GetSnapshotDataallProcs[]를 직접 방문하지 않고 이 배열만 선형 순회한다. 결과적으로 스냅샷 빌더가 접근하는 메모리는 소수의 캐시라인으로 압축된다.

flowchart TD
    PA["ProcArrayStruct\nnumProcs / pgprocnos[]"]
    PG["PROC_HDR (ProcGlobal)\nallProcs[] / xids[]\nsubxidStates[] / statusFlags[]"]
    P0["PGPROC[0]\nxid / xmin / vxid\nsubxids / statusFlags"]
    P1["PGPROC[1]"]
    PN["PGPROC[N]"]

    PA -- "pgprocnos[i] → 인덱스" --> PG
    PG -- "allProcs[pgprocnos[i]]" --> P0
    PG -- "allProcs[pgprocnos[i]]" --> P1
    PG -- "allProcs[pgprocnos[i]]" --> PN
    PG -- "xids[pgxactoff]" --> P0
    PG -- "xids[pgxactoff]" --> P1

Figure 1 — ProcArrayStruct와 PROC_HDR의 관계. ProcArrayStruct는 활성 슬롯의 인덱스 배열(pgprocnos[])을 담고, PROC_HDR은 실제 PGPROC 배열과 XID·상태를 담는 밀집 미러 배열을 함께 보유한다.

각 백엔드 슬롯의 핵심 필드는 다음과 같다.

// PGPROC — src/include/storage/proc.h (관련 필드 발췌)
struct PGPROC
{
dlist_node links;
PGSemaphore sem; /* 대기용 세마포어 */
Latch procLatch;
TransactionId xid; /* 현재 최상위 트랜잭션 XID;
* ProcGlobal->xids[pgxactoff]에 미러 */
TransactionId xmin; /* 스냅샷 취득 시 최소 실행 XID;
* vacuum 수평선 계산에 사용 */
int pid; /* 백엔드 PID; 0이면 준비된 2PC */
int pgxactoff; /* ProcGlobal 밀집 배열의 오프셋 */
struct {
ProcNumber procNumber;
LocalTransactionId lxid;
} vxid; /* 가상 트랜잭션 ID */
Oid databaseId;
Oid roleId;
uint8 statusFlags; /* PROC_IN_VACUUM 등;
* ProcGlobal->statusFlags[pgxactoff]에 미러 */
XidCacheStatus subxidStatus; /* 서브XID 캐시 개수와 오버플로 여부;
* ProcGlobal->subxidStates[pgxactoff]에 미러 */
struct XidCache {
TransactionId xids[PGPROC_MAX_CACHED_SUBXIDS]; /* 최대 64개 */
} subxids;
/* LWLock / 조건 변수 / LOCK 대기 필드 ... */
};

pgxactoff 필드가 핵심 연결고리다. 이 값이 있어야 ProcGlobal->xids[pgxactoff] 같은 밀집 배열에 자신의 미러 항목을 O(1)로 찾을 수 있다. 슬롯이 추가·삭제되면 pgxactoff가 바뀔 수 있으므로, 이 값을 안전하게 읽으려면 ProcArrayLock 또는 XidGenLock을 잡아야 한다.

statusFlags의 주요 비트는 다음과 같다.

비트 상수의미
PROC_IN_VACUUMlazy vacuum 실행 중
PROC_IN_LOGICAL_DECODING논리 디코딩 중 (xmin 별도 관리)
PROC_IN_SAFE_IC안전한 카탈로그 임시 커밋 중
PROC_VACUUM_FOR_WRAPAROUNDwraparound 방지 vacuum

GetSnapshotDataPROC_IN_VACUUM | PROC_IN_LOGICAL_DECODING 슬롯을 건너뛰는 이유는 이들이 스냅샷 수평선을 별도로 관리하기 때문이다. 이들의 XID를 snapshot에 포함하면 long-running vacuum이 전체 xmin을 오랫동안 낮게 묶어두는 문제가 생긴다.

백엔드가 첫 번째 쓰기 트랜잭션을 시작하면 GetNewTransactionId(xact.c)가 새 XID를 할당하고, 이를 MyProc->xidProcGlobal->xids[MyProc->pgxactoff]에 동시에 기록한다. 이 두 곳의 동기화가 안전한 이유는 XidGenLock을 잡고 수행하기 때문이다.

트랜잭션이 커밋 또는 롤백될 때 ProcArrayEndTransaction이 호출된다.

// ProcArrayEndTransaction — src/backend/storage/ipc/procarray.c (요약)
void
ProcArrayEndTransaction(PGPROC *proc, TransactionId latestXid)
{
if (TransactionIdIsValid(latestXid))
{
/* XID가 있는 경우: ProcArrayLock 배타 잠금 필요 */
if (LWLockConditionalAcquire(ProcArrayLock, LW_EXCLUSIVE))
{
ProcArrayEndTransactionInternal(proc, latestXid);
LWLockRelease(ProcArrayLock);
}
else
ProcArrayGroupClearXid(proc, latestXid); /* 그룹 소거 경로 */
}
else
{
/* XID가 없는 경우: 잠금 없이 로컬 필드만 정리 */
proc->vxid.lxid = InvalidLocalTransactionId;
proc->xmin = InvalidTransactionId;
/* ... */
}
}

XID가 있는 트랜잭션의 소거는 ProcArrayEndTransactionInternal에서 이루어진다.

// ProcArrayEndTransactionInternal — src/backend/storage/ipc/procarray.c (요약)
static inline void
ProcArrayEndTransactionInternal(PGPROC *proc, TransactionId latestXid)
{
int pgxactoff = proc->pgxactoff;
Assert(LWLockHeldByMeInMode(ProcArrayLock, LW_EXCLUSIVE));
ProcGlobal->xids[pgxactoff] = InvalidTransactionId; /* 밀집 배열 소거 */
proc->xid = InvalidTransactionId; /* PGPROC 소거 */
proc->xmin = InvalidTransactionId;
proc->vxid.lxid = InvalidLocalTransactionId;
proc->delayChkptFlags = 0;
/* 서브XID 캐시도 함께 소거 */
if (proc->subxidStatus.count > 0 || proc->subxidStatus.overflowed)
{
ProcGlobal->subxidStates[pgxactoff].count = 0;
ProcGlobal->subxidStates[pgxactoff].overflowed = false;
proc->subxidStatus.count = 0;
proc->subxidStatus.overflowed = false;
}
MaintainLatestCompletedXid(latestXid); /* latestCompletedXid 전진 */
TransamVariables->xactCompletionCount++; /* 스냅샷 재사용 카운터 증가 */
}

xactCompletionCount 증가가 핵심이다. 이 카운터는 GetSnapshotDataReuse가 “마지막 스냅샷 이후 완료된 트랜잭션이 있는가”를 단 한 번의 비교로 판단하는 근거가 된다.

커밋 처리량이 높은 환경에서 ProcArrayLock 배타 잠금을 개별적으로 경합하면 큰 직렬화 병목이 된다. ProcArrayGroupClearXid는 이 문제를 그룹 처리로 해결한다.

sequenceDiagram
    participant B1 as 백엔드 1 (리더)
    participant B2 as 백엔드 2
    participant B3 as 백엔드 3
    participant Lock as ProcArrayLock

    B2 ->> B2: 잠금 실패 → 그룹 목록 등록
    B3 ->> B3: 잠금 실패 → 그룹 목록 등록
    B1 ->> Lock: 배타 잠금 획득
    B1 ->> B1: 자신의 XID 소거
    B1 ->> B2: B2의 XID 소거 (대신)
    B1 ->> B3: B3의 XID 소거 (대신)
    B1 ->> Lock: 잠금 해제
    B1 ->> B2: 세마포어로 깨우기
    B1 ->> B3: 세마포어로 깨우기

Figure 2 — 그룹 XID 소거 패턴. 백엔드 1이 ProcArrayLock을 획득하면, 잠금을 기다리는 대신 그룹 목록에 등록된 백엔드 2·3의 XID까지 일괄 소거한다. 각 백엔드는 세마포어로 깨어나 직접 잠금을 경합하지 않는다.

ProcGlobal->procArrayGroupFirst는 원자 연결 목록의 헤드다. 잠금 획득에 실패한 백엔드가 자신의 pgprocno를 CAS로 목록 앞에 추가하고, 최초 획득자가 목록을 순회하며 대기자 전원의 XID를 소거한다.

백엔드가 시작할 때 ProcArrayAddProcArrayLockXidGenLock을 모두 배타적으로 잡고 슬롯을 배열에 삽입한다. 두 잠금이 모두 필요한 이유는 GetSnapshotData(ProcArrayLock 공유)와 GetNewTransactionId(XidGenLock 배타)가 동시에 밀집 배열을 읽기 때문이다.

삽입은 포인터 주소 순으로 정렬된 위치를 찾아 memmove로 자리를 만든다. 이후 영향을 받는 모든 슬롯의 pgxactoff를 갱신한다.

// ProcArrayAdd (핵심 부분만) — src/backend/storage/ipc/procarray.c
LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
LWLockAcquire(XidGenLock, LW_EXCLUSIVE);
/* ... 정렬 위치 index 탐색 ... */
memmove(&arrayP->pgprocnos[index + 1], &arrayP->pgprocnos[index],
movecount * sizeof(*arrayP->pgprocnos));
memmove(&ProcGlobal->xids[index + 1], &ProcGlobal->xids[index],
movecount * sizeof(*ProcGlobal->xids));
memmove(&ProcGlobal->subxidStates[index + 1], &ProcGlobal->subxidStates[index],
movecount * sizeof(*ProcGlobal->subxidStates));
memmove(&ProcGlobal->statusFlags[index + 1], &ProcGlobal->statusFlags[index],
movecount * sizeof(*ProcGlobal->statusFlags));
arrayP->pgprocnos[index] = GetNumberFromPGProc(proc);
proc->pgxactoff = index;
ProcGlobal->xids[index] = proc->xid;
/* ... pgxactoff 재조정 ... */
LWLockRelease(XidGenLock); /* 역순 해제 */
LWLockRelease(ProcArrayLock);

ProcArrayRemove는 역방향으로 동작한다. 2PC 준비된 트랜잭션을 배열에서 제거할 때도 이 함수를 호출하며, 이 경우 latestXid를 전달해 MaintainLatestCompletedXid를 직접 실행한다.

스냅샷 빌더가 실제로 수행하는 작업은 다음과 같다. ProcArrayLock 공유 잠금을 잡고, 먼저 재사용 가능 여부를 확인한다. 재사용이 불가능하면 밀집 배열을 한 번 순회한다.

// GetSnapshotData 핵심 순회 — src/backend/storage/ipc/procarray.c (요약)
for (int pgxactoff = 0; pgxactoff < numProcs; pgxactoff++)
{
TransactionId xid = UINT32_ACCESS_ONCE(other_xids[pgxactoff]);
uint8 statusFlags;
if (likely(xid == InvalidTransactionId)) /* XID 없는 슬롯: 건너뜀 */
continue;
if (pgxactoff == mypgxactoff) /* 자신의 XID: 루프 밖에서 처리 */
continue;
if (!NormalTransactionIdPrecedes(xid, xmax)) /* xmax 이상: 건너뜀 */
continue;
statusFlags = allStatusFlags[pgxactoff];
if (statusFlags & (PROC_IN_LOGICAL_DECODING | PROC_IN_VACUUM))
continue; /* 독립적 xmin 관리: 건너뜀 */
if (NormalTransactionIdPrecedes(xid, xmin))
xmin = xid;
xip[count++] = xid;
/* 서브XID 캐시 복사 또는 suboverflowed 플래그 설정 */
}

UINT32_ACCESS_ONCEvolatile uint32 캐스트로 컴파일러가 읽기를 최적화해 분리하거나 합치는 것을 막는다. 실제 원자성 보장은 잠금이 제공한다. 이 패턴이 필요한 이유는 transam/README에 서술돼 있다. XID 할당 측(GetNewTransactionId)은 XidGenLock을 잡고 두 위치에 차례로 쓰는데, 읽기 측이 찢김 읽기를 하면 아직 기록되지 않은 쪽의 값을 볼 수 있기 때문이다.

순회 후 잠금을 해제하고 GlobalVis* 경계를 갱신한다.

// GetSnapshotData 잠금 해제 후 — src/backend/storage/ipc/procarray.c (요약)
if (!TransactionIdIsValid(MyProc->xmin))
MyProc->xmin = TransactionXmin = xmin; /* 수평선 핀 */
LWLockRelease(ProcArrayLock);
/* GlobalVisSharedRels / CatalogRels / DataRels / TempRels 경계 갱신 */
RecentXmin = xmin;
snapshot->xmin = xmin;
snapshot->xmax = xmax;
snapshot->xcnt = count;
snapshot->snapXactCompletionCount = curXactCompletionCount;

단일 XID가 진행 중인지 확인하는 TransactionIdIsInProgress는 여러 단계의 빠른 탈출 경로를 가진다.

flowchart TD
    A["TransactionIdIsInProgress(xid)"] --> R1{"xid < RecentXmin?"}
    R1 -- "yes" --> FALSE1["false\n오래됐으므로 완료됨"]
    R1 -- "no" --> R2{"캐시에 '완료'로 기록됨?"}
    R2 -- "yes" --> FALSE2["false"]
    R2 -- "no" --> R3{"자신의 트랜잭션?"}
    R3 -- "yes" --> TRUE1["true"]
    R3 -- "no" --> LOCK["ProcArrayLock 공유 잠금"]
    LOCK --> R4{"xid > latestCompletedXid?"}
    R4 -- "yes" --> TRUE2["true\n아직 미완"]
    R4 -- "no" --> SCAN["배열 순회:\n최상위 XID 일치 검사,\n서브XID 캐시 검사"]
    SCAN --> R5{"발견?"}
    R5 -- "yes" --> TRUE3["true"]
    R5 -- "no" --> FALSE3["false\n(pg_subtrans 추가 조회 가능)"]

Figure 3 — TransactionIdIsInProgress 탈출 경로. RecentXmin 비교, 개인 캐시 조회, 자신의 트랜잭션 확인을 거쳐 공유 배열 순회에 도달한다. 비용이 높은 경로에는 가능한 한 늦게 도달하도록 설계됐다.

GetSnapshotDataGlobalVis* 경계를 근사치로 유지한다. 정확한 수평선이 필요할 때는 ComputeXidHorizons가 호출된다. 이 함수는 ProcArrayLock 공유 잠금 아래 전체 배열을 순회하며 네 가지 수평선을 계산한다.

수평선대상특이사항
shared_oldest_nonremovable공유 카탈로그 테이블모든 DB의 백엔드를 고려
data_oldest_nonremovable일반 사용자 테이블현재 DB의 백엔드만 고려; 복제 슬롯 xmin 포함
catalog_oldest_nonremovable카탈로그 테이블복제 슬롯 catalog_xmin 포함
temp_oldest_nonremovable임시 테이블현재 세션의 xid만 고려

GlobalVisState 구조체는 이 계산 결과를 두 경계로 표현한다.

// GlobalVisState — src/backend/storage/ipc/procarray.c
struct GlobalVisState
{
/* 이 값 이상의 XID를 참조하는 백엔드가 있음 — 확실히 필요 */
FullTransactionId definitely_needed;
/* 이 값 미만의 XID를 참조하는 백엔드가 없음 — 확실히 제거 가능 */
FullTransactionId maybe_needed;
};

두 경계 사이에 놓인 XID는 모호하다. 이 경우 GlobalVisTestShouldUpdate가 재계산 여부를 결정하고, GlobalVisUpdateComputeXidHorizons를 호출해 경계를 갱신한다. 잦은 재계산을 막기 위해 속도 제한 로직(transactionEndsCounter, 시간 기반 쿨다운)도 있다.

FullTransactionId(64비트)를 쓰는 이유가 있다. 32비트 XID는 약 40억 번 커밋 후 순환하는데, 수평선 산술이 32비트로 이루어지면 순환 경계 근처에서 대소 비교가 반전될 수 있다. 64비트 값은 이 문제를 구조적으로 차단한다.

스탠바이 서버는 기본 서버의 PGPROC 배열에 직접 접근할 수 없다. 대신 WAL 스트림에서 도착하는 XID 정보를 KnownAssignedXids[]KnownAssignedXidsValid[] 배열에 기록한다. 이 배열은 기본 서버의 진행 중 XID 집합을 스탠바이가 추적하는 방법이다.

배열은 XID 순으로 정렬된 슬롯 배열이다. 삭제 시 실제로 압축하지 않고 해당 슬롯의 Valid 비트만 내린 뒤 주기적으로 KnownAssignedXidsCompress를 호출해 공백을 제거한다. 이진 탐색으로 O(log N) 조회를 지원하면서 빈번한 압축 비용을 피하는 방식이다.

스탠바이가 스냅샷을 구성할 때 GetSnapshotData는 정상 경로 대신 KnownAssignedXidsGetAndSetXmin을 호출해 subxip[]에 모든 XID를 채운다. 스탠바이에서는 최상위 XID와 서브 XID를 구분하기 어렵기 때문에 모두 subxip[]에 넣는 설계 선택이다.

flowchart LR
    subgraph Primary["기본 서버"]
        WR["WAL 레코드\nxid 할당/완료"]
    end
    subgraph Standby["스탠바이 서버"]
        SU["Startup 프로세스\nKnownAssignedXidsAdd/Remove"]
        KA["KnownAssignedXids[]\nKnownAssignedXidsValid[]"]
        GSD["GetSnapshotData\n(KnownAssignedXidsGetAndSetXmin)"]
    end

    WR -- "WAL 스트림" --> SU
    SU --> KA
    KA --> GSD

Figure 4 — 핫 스탠바이 XID 추적. Startup 프로세스가 WAL 스트림을 읽어 KnownAssignedXids 배열을 관리하고, GetSnapshotData는 정상 procarray 대신 이 배열을 참조해 스탠바이 스냅샷을 구성한다.

WAL 발신자(walsender)가 체크포인트 시 GetRunningTransactionData를 호출해 현재 진행 중인 트랜잭션 목록을 RunningTransactions 구조체로 반환한다. 이 정보는 WAL RUNNING_XACTS 레코드로 기록돼 스탠바이가 KnownAssignedXids를 정리(prune)하는 근거가 된다.

GetSnapshotData와 달리 이 함수는 PROC_IN_VACUUM 슬롯을 건너뛰지 않는다. 스탠바이의 KnownAssignedXids 정리를 위한 보수적인 목록이 필요하기 때문이다. ProcArrayLockXidGenLock을 모두 공유 잠금으로 잡고 반환한다. 잠금 해제는 호출자 책임이다.

ProcArrayStruct와 초기화 (procarray.c)

섹션 제목: “ProcArrayStruct와 초기화 (procarray.c)”
  • ProcArrayStruct (struct) — 배열 헤더. numProcs/maxProcs, pgprocnos[], KnownAssignedXids 카운터, 복제 슬롯 xmin 필드를 담는다.
  • ProcArrayShmemSize — 핫 스탠바이 모드 여부에 따라 TOTAL_MAX_CACHED_SUBXIDS 크기의 KnownAssignedXids* 공간을 추가해 요구 크기를 반환한다.
  • ProcArrayShmemInitShmemInitStruct로 공유 메모리를 할당·초기화한다. xactCompletionCount를 1로 시작하는 점이 눈에 띈다.
  • ProcArrayAddProcArrayLock + XidGenLock 배타 잠금 아래 슬롯을 정렬 삽입. 네 개 밀집 배열을 memmove로 시프트하고 pgxactoff를 재조정한다.
  • ProcArrayRemove — 역방향으로 슬롯 제거. 2PC 완료 시 latestXid를 전달해 MaintainLatestCompletedXid 직접 호출.
  • ProcArrayEndTransaction — 커밋/롤백 시 XID 소거 진입점. XID가 있으면 잠금을 시도하고, 실패 시 ProcArrayGroupClearXid로 위임.
  • ProcArrayEndTransactionInternal — 실제 소거 로직. 밀집 배열과 PGPROC 양쪽을 소거하고 서브XID 캐시를 비우며 xactCompletionCount를 증가시킨다.
  • ProcArrayGroupClearXid — CAS 기반 원자 연결 목록으로 그룹 소거 등록. 리더가 procArrayGroupFirst 목록을 순회하며 대기자 XID를 일괄 소거한다.
  • TransactionIdIsInProgress — 단일 XID 확인. 5단계 빠른 탈출 (RecentXmin / 개인 캐시 / 자신 / latestCompletedXid / 배열 순회)로 비용을 최소화한다.
  • GetSnapshotData — procarray 인구조사 루틴. mvcc-snapshots.md에서 소비자 관점으로 상세히 다룬다.
  • GetSnapshotDataReusesnapXactCompletionCount 비교 기반 재사용 경로.
  • GetRunningTransactionData — WAL RUNNING_XACTS 레코드용 진행 중 XID 목록 반환. ProcArrayLock + XidGenLock 공유 잠금 유지한 채 반환한다.
  • GetOldestActiveTransactionId — vacuum 등이 쓰는 단순한 oldest active XID 조회.
  • ComputeXidHorizons — 네 가지 수평선(shared_oldest_nonremovable, data_oldest_nonremovable, catalog_oldest_nonremovable, temp_oldest_nonremovable)의 정밀 계산. GlobalVisUpdate가 필요 시 호출한다.
  • GlobalVisState (struct) — definitely_needed / maybe_neededFullTransactionId 경계.
  • GlobalVisUpdate / GlobalVisUpdateApply — 근사 경계 갱신 진입점. 속도 제한 후 ComputeXidHorizons 호출.
  • GlobalVisTestIsRemovableFullXid / GlobalVisTestIsRemovableXid — 삭제 XID가 제거 가능한지 빠르게 판단. 경계 밖이면 O(1), 경계 안이면 재계산.
  • GlobalVisTestFor — 릴레이션 종류에 따라 네 인스턴스 중 하나(GlobalVisSharedRels / GlobalVisCatalogRels / GlobalVisDataRels / GlobalVisTempRels) 반환.
  • KnownAssignedXidsAdd / KnownAssignedXidsRemove / KnownAssignedXidsRemoveTree — WAL 적용 시 Startup 프로세스가 호출하는 추가·제거 루틴.
  • KnownAssignedXidsCompress — 유효 항목을 배열 앞으로 압축. KAX_NO_SPACE이거나 휴리스틱 조건 충족 시 수행.
  • KnownAssignedXidsGetAndSetXmin — 스탠바이 GetSnapshotData에서 호출. subxip[]를 채우고 xmin을 설정한다.
  • KnownAssignedXidsSearch — 이진 탐색으로 XID 존재 여부 확인 혹은 제거.
  • PGPROC (struct) — 슬롯 구조체. xid, xmin, pgxactoff, vxid, statusFlags, subxidStatus, subxids.
  • PROC_HDR (struct) — 전역 헤더. allProcs[], xids[], subxidStates[], statusFlags[] 밀집 배열, 그룹 소거 원자 목록.
  • PGPROC_MAX_CACHED_SUBXIDS — 서브XID 캐시 크기 상수 = 64.
  • PROC_IN_VACUUM / PROC_IN_LOGICAL_DECODING / PROC_IN_SAFE_IC / PROC_VACUUM_FOR_WRAPAROUNDstatusFlags 비트 상수.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
ProcArrayStruct (struct)src/backend/storage/ipc/procarray.c71
GlobalVisState (struct)src/backend/storage/ipc/procarray.c167
ProcArrayShmemSizesrc/backend/storage/ipc/procarray.c376
ProcArrayShmemInitsrc/backend/storage/ipc/procarray.c414
ProcArrayAddsrc/backend/storage/ipc/procarray.c468
ProcArrayRemovesrc/backend/storage/ipc/procarray.c565
ProcArrayEndTransactionsrc/backend/storage/ipc/procarray.c667
ProcArrayEndTransactionInternalsrc/backend/storage/ipc/procarray.c731
ProcArrayGroupClearXidsrc/backend/storage/ipc/procarray.c~800
TransactionIdIsInProgresssrc/backend/storage/ipc/procarray.c1402
ComputeXidHorizonssrc/backend/storage/ipc/procarray.c1735
GetSnapshotDataReusesrc/backend/storage/ipc/procarray.c2095
GetSnapshotDatasrc/backend/storage/ipc/procarray.c2175
GetRunningTransactionDatasrc/backend/storage/ipc/procarray.c2689
GlobalVisUpdateApplysrc/backend/storage/ipc/procarray.c4166
GlobalVisUpdatesrc/backend/storage/ipc/procarray.c4205
GlobalVisTestIsRemovableFullXidsrc/backend/storage/ipc/procarray.c4222
GlobalVisTestForsrc/backend/storage/ipc/procarray.c4107
KnownAssignedXidsCompresssrc/backend/storage/ipc/procarray.c4665
KnownAssignedXidsAddsrc/backend/storage/ipc/procarray.c4782
KnownAssignedXidsSearchsrc/backend/storage/ipc/procarray.c4886
KnownAssignedXidsGetAndSetXminsrc/backend/storage/ipc/procarray.c5127
PGPROC (struct)src/include/storage/proc.h148
PROC_HDR (struct)src/include/storage/proc.h383
PGPROC_MAX_CACHED_SUBXIDSsrc/include/storage/proc.h39
PROC_IN_VACUUMsrc/include/storage/proc.h58
PROC_IN_LOGICAL_DECODINGsrc/include/storage/proc.h64

커밋 273fe94, 브랜치 REL_18_STABLE을 기준으로 확인했다.

  • ProcGlobal->xids[]PGPROC.xid의 밀집 미러 배열이다. PROC_HDR 주석(proc.h, 330–382행)에 명시돼 있다. ProcArrayAdd 삽입 시 ProcGlobal->xids[index] = proc->xid로 초기화하고, ProcArrayEndTransactionInternal에서 ProcGlobal->xids[pgxactoff] = InvalidTransactionId로 소거한다. 두 위치를 동기화하는 것이 불변식이다.

  • ProcArrayLockXidGenLock이 모두 필요한 이유는 서로 다른 소비자가 있기 때문이다. GetSnapshotDataProcArrayLock 공유 잠금만 잡고 밀집 배열을 읽는다. GetNewTransactionIdXidGenLock 배타 잠금 아래 xids[]를 쓴다. ProcArrayAdd/Remove는 두 잠금을 모두 배타적으로 잡아 삽입·삭제 중 어느 소비자와도 충돌이 없음을 보장한다(proc.h 주석, 330–345행 확인).

  • xactCompletionCountProcArrayEndTransactionInternal에서만 증가한다. procarray.c 782행 확인. GetSnapshotDataReuse는 이 카운터의 변화 여부만 비교하므로, 커밋·롤백이 없으면 어떤 백엔드가 스냅샷을 아무리 많이 요청해도 재사용이 유효하다.

  • ProcArrayGroupClearXid의 목록 헤드는 원자 uint32다. ProcGlobal->procArrayGroupFirst(proc.h ~410행)가 원자 타입이다. CAS 루프로 삽입하고, 리더가 목록을 순회해 일괄 소거한다. 잠금이 없으므로 스핀 대기가 발생하지 않는다.

  • KnownAssignedXids 배열 크기는 TOTAL_MAX_CACHED_SUBXIDS로 동일하다. ProcArrayShmemSize 주석(~398행)에 “모든 주 구조체가 동일한 크기로 할당돼야 한다”고 명시돼 있다. 스탠바이 스냅샷 구성 시 복사 작업의 편의를 위해서다.

  • GlobalVisState의 두 경계는 FullTransactionId(64비트)다. procarray.c 167–174행 확인. 32비트 XID 순환이 수평선 산술에 영향을 주지 않도록 설계된 것이다.

  • ComputeXidHorizonsPROC_IN_VACUUM 슬롯도 수평선 계산에 포함한다. GetSnapshotData의 스냅샷 수집 루프와 달리, ComputeXidHorizons는 vacuum 슬롯을 oldest_considered_running에는 포함하지만 관계 분류별 nonremovable 계산에서는 건너뛴다(procarray.c ~1840행). 이 미묘한 차이가 의도적임을 procarray.c의 주석이 설명한다.

  1. UINT32_ACCESS_ONCE가 충분한 조건. volatile uint32 캐스트로 찢김 읽기를 막는다는 보장이 모든 아키텍처에서 성립하는지는 transam/README와 C 표준의 엄밀한 분석이 필요하다. 현재 코드는 이것이 충분하다고 가정하지만, 공식적인 메모리 모델 증명은 없다. 조사 경로: transam/README의 “XID assignment” 절 및 GetNewTransactionId의 배리어 주석 추적.

  2. 그룹 소거 시 대기자 깨우기 순서. ProcArrayGroupClearXid가 리더로 승격된 백엔드가 목록을 순회하며 세마포어로 대기자를 깨운다. 이 순서가 FIFO를 보장하는지, 아니면 CAS 삽입 순서에 의존하는지는 소스를 직접 추적해야 한다. 기아(starvation) 가능성과 연관된다.

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

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

각 항목은 후속 문서의 시작점이다. 분석보다는 방향 제시에 집중한다.

  • 스냅샷 확장성 연구. Yu et al., Staring into the Abyss: An Evaluation of Concurrency Control with One Thousand Cores(VLDB 2015)는 1,000 코어 환경에서 procarray 같은 집중 공유 자료구조가 CC 프로토콜 전반의 병목이 됨을 보인다. PostgreSQL 14의 xactCompletionCount 재사용 경로와 밀집 배열 분리는 이 논문이 지적한 문제에 대한 직접적인 응답이다.

  • MVCC를 위한 타임스탬프 할당. Hekaton(Diaconu et al., SIGMOD 2013)과 HyPer(Neumann et al., VLDB 2015)는 중앙 procarray 순회 없이 단조 증가 타임스탬프로 스냅샷을 구성한다. “누가 실행 중인가”를 배열에서 읽는 대신 “현재 타임스탬프보다 오래된 커밋 XID인가”를 비교만 한다. 디스크 기반 MVCC(PostgreSQL)와 인메모리 MVCC(Hekaton)의 스냅샷 구성 비용 구조적 차이를 이해하는 출발점이다.

  • OCC와 능동적 트랜잭션 집합. Kung & Robinson 1981(dbms-papers/occ.md)의 OCC는 커밋 시 충돌 검증을 위해 진행 중 트랜잭션 집합을 유지한다. 개념은 비슷하지만, OCC는 읽기 집합 비교를 위한 것이고 PostgreSQL procarray는 가시성 결정을 위한 것이다. 두 쓰임의 차이를 비교하면 “진행 중 집합이 무엇을 위해 존재하는가”를 명확히 볼 수 있다.

  • 복제 슬롯과 수평선 핀. ProcArrayStructreplication_slot_xminreplication_slot_catalog_xminComputeXidHorizons에 입력된다. 복제 슬롯이 xmin 수평선을 고정하는 메커니즘의 전체 그림은 postgres-xlog-wal.md와 walsender 관련 문서에서 다룬다.

  • 핫 스탠바이의 충돌 처리. PGPROC.recoveryConflictPending은 스탠바이에서 기본 서버의 트랜잭션과 쿼리 충돌이 발생할 때 신호를 보내는 경로다. KnownAssignedXids와 함께 스탠바이 쿼리 취소의 전체 메커니즘을 이해하려면 procsignal.chot_standby_feedback 설정을 함께 봐야 한다.

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

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

섹션 제목: “소스 코드 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)”
  • src/backend/storage/ipc/procarray.cProcArrayStruct, ProcArrayAdd, ProcArrayRemove, ProcArrayEndTransaction, ProcArrayGroupClearXid, TransactionIdIsInProgress, ComputeXidHorizons, GetSnapshotData, GetSnapshotDataReuse, GetRunningTransactionData, GlobalVisState, GlobalVis* 함수군, KnownAssignedXids* 함수군.
  • src/include/storage/proc.hPGPROC, PROC_HDR, PGPROC_MAX_CACHED_SUBXIDS, PROC_IN_VACUUM, PROC_IN_LOGICAL_DECODING 등 상수.
  • src/include/storage/procarray.h — 외부 공개 함수 선언.
  • src/backend/access/transam/README — XID 할당·게시의 레이스 조건 및 잠금 프로토콜 설명.
  • Petrov, Database Internals (O’Reilly 2019), 5장 — MVCC, 능동적 집합, 스냅샷 격리. knowledge/research/dbms-general/database-internals.md에 캡처.
  • Yu, Pavlo, Meijer & Petrov, Staring into the Abyss: An Evaluation of Concurrency Control with One Thousand Cores(VLDB 2015) — 고코어 환경의 CC 병목 계량화.
  • Kung & Robinson, On Optimistic Methods for Concurrency Control(ACM TODS 1981). knowledge/research/dbms-papers/occ.md 참조.
  • postgres-mvcc-snapshots.mdGetSnapshotData의 소비자 관점; SnapshotData 구조체, HeapTupleSatisfiesMVCC, XidInMVCCSnapshot, 격리 수준 타이밍.
  • postgres-xact.md — 트랜잭션 수명주기, GetNewTransactionId, xactCompletionCount 게시 쪽.
  • postgres-vacuum.mdGlobalVisState 수평선의 소비자; vacuum 회수 메커니즘.
  • postgres-xlog-wal.md — WAL RUNNING_XACTS 레코드, 복제 슬롯 xmin 핀.