(KO) PostgreSQL procarray — PGPROC 슬롯 배치, XID 게시, 스냅샷 인구조사
목차
- 학술적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 가이드
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
학술적 배경
섹션 제목: “학술적 배경”스냅샷 격리(SI, Snapshot Isolation) 엔진은 스냅샷 취득 시점에 “어떤 트랜잭션이 아직 진행 중인가”를 알아야 한다. 그 집합을 모르면 막 시작한 커밋이나 오래 실행 중인 갱신을 무엇으로 처리해야 할지 결정할 수 없다. Database Internals(Petrov, 5장)는 이 문제를 “active set” 포착이라 부른다. 어떤 XID가 스냅샷 경계 안에 있는지 없는지를 빠르게 판별하기 위해 엔진은 진행 중인 식별자의 집합을 어딘가에 게시해 두어야 한다.
멀티프로세스 공유 메모리 아키텍처에서 이 게시 공간은 자연스럽게 공유 프로세스 배열(process array) 형태가 된다. 각 백엔드는 자신의 슬롯에 현재 XID를 기록하고, 스냅샷 빌더는 그 배열을 순회해 인구조사(census)를 수행한다. 이 설계의 핵심 긴장은 두 가지다.
-
동시성 비용. 모든 스냅샷 취득이 배열 전체를 순회하면, 백엔드 수가 늘어날수록 비용이 선형으로 증가한다. 이는 고코어 환경에서 측정 가능한 병목이 된다. Yu et al., Staring into the Abyss: An Evaluation of Concurrency Control with One Thousand Cores(VLDB 2015)는 이 확장성 문제를 정량화했다. PostgreSQL 14의 “스냅샷 확장성(snapshot scalability)” 작업은 이에 대한 직접적인 응답이다.
-
경쟁 조건. XID가 할당되는 순간과 배열에 게시되는 순간, 그리고 소거되는 순간 사이에 미묘한 레이스가 존재한다. 잘못 배치된 읽기 한 번이 진행 중인 트랜잭션을 커밋된 것처럼 보이게 만들 수 있다.
src/backend/access/transam/README는 이 레이스를 여러 페이지에 걸쳐 분석한다.
이 두 문제를 해결하는 방법이 procarray 구현의 핵심이다. postgres-mvcc-snapshots.md는 procarray를 읽는 소비자 관점에서 서술하며, 이 문서는 배열 자체의 내부 구조와 관리 방법을 다룬다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”프로세스 당 슬롯 테이블
섹션 제목: “프로세스 당 슬롯 테이블”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 대응표
섹션 제목: “이론 ↔ PostgreSQL 대응표”| 이론 개념 | 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[] |
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”공유 메모리 레이아웃
섹션 제목: “공유 메모리 레이아웃”procarray는 두 계층으로 구성된다. 인덱스 헤더인 ProcArrayStruct와 실제 슬롯 데이터를 담는 PROC_HDR(= ProcGlobal)이다.
// ProcArrayStruct — src/backend/storage/ipc/procarray.ctypedef 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[]는 현재 활성인 슬롯의 pgxactoff → ProcNumber 매핑을 담는다.
PROC_HDR은 그보다 상위의 전역 구조체다.
// PROC_HDR (= ProcGlobal) — src/include/storage/proc.htypedef 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[])이 핵심이다. GetSnapshotData는 allProcs[]를 직접 방문하지 않고 이 배열만 선형 순회한다. 결과적으로 스냅샷 빌더가 접근하는 메모리는 소수의 캐시라인으로 압축된다.
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 구조체
섹션 제목: “PGPROC 구조체”각 백엔드 슬롯의 핵심 필드는 다음과 같다.
// 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_VACUUM | lazy vacuum 실행 중 |
PROC_IN_LOGICAL_DECODING | 논리 디코딩 중 (xmin 별도 관리) |
PROC_IN_SAFE_IC | 안전한 카탈로그 임시 커밋 중 |
PROC_VACUUM_FOR_WRAPAROUND | wraparound 방지 vacuum |
GetSnapshotData가 PROC_IN_VACUUM | PROC_IN_LOGICAL_DECODING 슬롯을 건너뛰는 이유는 이들이 스냅샷 수평선을 별도로 관리하기 때문이다. 이들의 XID를 snapshot에 포함하면 long-running vacuum이 전체 xmin을 오랫동안 낮게 묶어두는 문제가 생긴다.
XID 게시와 소거
섹션 제목: “XID 게시와 소거”백엔드가 첫 번째 쓰기 트랜잭션을 시작하면 GetNewTransactionId(xact.c)가 새 XID를 할당하고, 이를 MyProc->xid와 ProcGlobal->xids[MyProc->pgxactoff]에 동시에 기록한다. 이 두 곳의 동기화가 안전한 이유는 XidGenLock을 잡고 수행하기 때문이다.
트랜잭션이 커밋 또는 롤백될 때 ProcArrayEndTransaction이 호출된다.
// ProcArrayEndTransaction — src/backend/storage/ipc/procarray.c (요약)voidProcArrayEndTransaction(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 voidProcArrayEndTransactionInternal(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가 “마지막 스냅샷 이후 완료된 트랜잭션이 있는가”를 단 한 번의 비교로 판단하는 근거가 된다.
그룹 XID 소거
섹션 제목: “그룹 XID 소거”커밋 처리량이 높은 환경에서 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를 소거한다.
ProcArrayAdd와 ProcArrayRemove
섹션 제목: “ProcArrayAdd와 ProcArrayRemove”백엔드가 시작할 때 ProcArrayAdd가 ProcArrayLock과 XidGenLock을 모두 배타적으로 잡고 슬롯을 배열에 삽입한다. 두 잠금이 모두 필요한 이유는 GetSnapshotData(ProcArrayLock 공유)와 GetNewTransactionId(XidGenLock 배타)가 동시에 밀집 배열을 읽기 때문이다.
삽입은 포인터 주소 순으로 정렬된 위치를 찾아 memmove로 자리를 만든다. 이후 영향을 받는 모든 슬롯의 pgxactoff를 갱신한다.
// ProcArrayAdd (핵심 부분만) — src/backend/storage/ipc/procarray.cLWLockAcquire(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를 직접 실행한다.
GetSnapshotData의 procarray 순회
섹션 제목: “GetSnapshotData의 procarray 순회”스냅샷 빌더가 실제로 수행하는 작업은 다음과 같다. 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_ONCE는 volatile 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;TransactionIdIsInProgress
섹션 제목: “TransactionIdIsInProgress”단일 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 비교, 개인 캐시 조회, 자신의 트랜잭션 확인을 거쳐 공유 배열 순회에 도달한다. 비용이 높은 경로에는 가능한 한 늦게 도달하도록 설계됐다.
ComputeXidHorizons와 GlobalVisState
섹션 제목: “ComputeXidHorizons와 GlobalVisState”GetSnapshotData는 GlobalVis* 경계를 근사치로 유지한다. 정확한 수평선이 필요할 때는 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.cstruct GlobalVisState{ /* 이 값 이상의 XID를 참조하는 백엔드가 있음 — 확실히 필요 */ FullTransactionId definitely_needed; /* 이 값 미만의 XID를 참조하는 백엔드가 없음 — 확실히 제거 가능 */ FullTransactionId maybe_needed;};두 경계 사이에 놓인 XID는 모호하다. 이 경우 GlobalVisTestShouldUpdate가 재계산 여부를 결정하고, GlobalVisUpdate가 ComputeXidHorizons를 호출해 경계를 갱신한다. 잦은 재계산을 막기 위해 속도 제한 로직(transactionEndsCounter, 시간 기반 쿨다운)도 있다.
FullTransactionId(64비트)를 쓰는 이유가 있다. 32비트 XID는 약 40억 번 커밋 후 순환하는데, 수평선 산술이 32비트로 이루어지면 순환 경계 근처에서 대소 비교가 반전될 수 있다. 64비트 값은 이 문제를 구조적으로 차단한다.
핫 스탠바이 — KnownAssignedXids
섹션 제목: “핫 스탠바이 — KnownAssignedXids”스탠바이 서버는 기본 서버의 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 대신 이 배열을 참조해 스탠바이 스냅샷을 구성한다.
GetRunningTransactionData
섹션 제목: “GetRunningTransactionData”WAL 발신자(walsender)가 체크포인트 시 GetRunningTransactionData를 호출해 현재 진행 중인 트랜잭션 목록을 RunningTransactions 구조체로 반환한다. 이 정보는 WAL RUNNING_XACTS 레코드로 기록돼 스탠바이가 KnownAssignedXids를 정리(prune)하는 근거가 된다.
GetSnapshotData와 달리 이 함수는 PROC_IN_VACUUM 슬롯을 건너뛰지 않는다. 스탠바이의 KnownAssignedXids 정리를 위한 보수적인 목록이 필요하기 때문이다. ProcArrayLock과 XidGenLock을 모두 공유 잠금으로 잡고 반환한다. 잠금 해제는 호출자 책임이다.
소스 코드 가이드
섹션 제목: “소스 코드 가이드”ProcArrayStruct와 초기화 (procarray.c)
섹션 제목: “ProcArrayStruct와 초기화 (procarray.c)”ProcArrayStruct(struct) — 배열 헤더.numProcs/maxProcs,pgprocnos[],KnownAssignedXids카운터, 복제 슬롯 xmin 필드를 담는다.ProcArrayShmemSize— 핫 스탠바이 모드 여부에 따라TOTAL_MAX_CACHED_SUBXIDS크기의KnownAssignedXids*공간을 추가해 요구 크기를 반환한다.ProcArrayShmemInit—ShmemInitStruct로 공유 메모리를 할당·초기화한다.xactCompletionCount를 1로 시작하는 점이 눈에 띈다.
슬롯 관리 (procarray.c)
섹션 제목: “슬롯 관리 (procarray.c)”ProcArrayAdd—ProcArrayLock+XidGenLock배타 잠금 아래 슬롯을 정렬 삽입. 네 개 밀집 배열을memmove로 시프트하고pgxactoff를 재조정한다.ProcArrayRemove— 역방향으로 슬롯 제거. 2PC 완료 시latestXid를 전달해MaintainLatestCompletedXid직접 호출.ProcArrayEndTransaction— 커밋/롤백 시 XID 소거 진입점. XID가 있으면 잠금을 시도하고, 실패 시ProcArrayGroupClearXid로 위임.ProcArrayEndTransactionInternal— 실제 소거 로직. 밀집 배열과 PGPROC 양쪽을 소거하고 서브XID 캐시를 비우며xactCompletionCount를 증가시킨다.ProcArrayGroupClearXid— CAS 기반 원자 연결 목록으로 그룹 소거 등록. 리더가procArrayGroupFirst목록을 순회하며 대기자 XID를 일괄 소거한다.
진행 중 XID 조회 (procarray.c)
섹션 제목: “진행 중 XID 조회 (procarray.c)”TransactionIdIsInProgress— 단일 XID 확인. 5단계 빠른 탈출 (RecentXmin/ 개인 캐시 / 자신 /latestCompletedXid/ 배열 순회)로 비용을 최소화한다.GetSnapshotData— procarray 인구조사 루틴.mvcc-snapshots.md에서 소비자 관점으로 상세히 다룬다.GetSnapshotDataReuse—snapXactCompletionCount비교 기반 재사용 경로.GetRunningTransactionData— WALRUNNING_XACTS레코드용 진행 중 XID 목록 반환.ProcArrayLock+XidGenLock공유 잠금 유지한 채 반환한다.GetOldestActiveTransactionId— vacuum 등이 쓰는 단순한 oldest active XID 조회.
수평선 계산 (procarray.c)
섹션 제목: “수평선 계산 (procarray.c)”ComputeXidHorizons— 네 가지 수평선(shared_oldest_nonremovable,data_oldest_nonremovable,catalog_oldest_nonremovable,temp_oldest_nonremovable)의 정밀 계산.GlobalVisUpdate가 필요 시 호출한다.GlobalVisState(struct) —definitely_needed/maybe_needed두FullTransactionId경계.GlobalVisUpdate/GlobalVisUpdateApply— 근사 경계 갱신 진입점. 속도 제한 후ComputeXidHorizons호출.GlobalVisTestIsRemovableFullXid/GlobalVisTestIsRemovableXid— 삭제 XID가 제거 가능한지 빠르게 판단. 경계 밖이면 O(1), 경계 안이면 재계산.GlobalVisTestFor— 릴레이션 종류에 따라 네 인스턴스 중 하나(GlobalVisSharedRels/GlobalVisCatalogRels/GlobalVisDataRels/GlobalVisTempRels) 반환.
핫 스탠바이 (procarray.c)
섹션 제목: “핫 스탠바이 (procarray.c)”KnownAssignedXidsAdd/KnownAssignedXidsRemove/KnownAssignedXidsRemoveTree— WAL 적용 시 Startup 프로세스가 호출하는 추가·제거 루틴.KnownAssignedXidsCompress— 유효 항목을 배열 앞으로 압축.KAX_NO_SPACE이거나 휴리스틱 조건 충족 시 수행.KnownAssignedXidsGetAndSetXmin— 스탠바이GetSnapshotData에서 호출.subxip[]를 채우고xmin을 설정한다.KnownAssignedXidsSearch— 이진 탐색으로 XID 존재 여부 확인 혹은 제거.
PGPROC와 PROC_HDR (proc.h)
섹션 제목: “PGPROC와 PROC_HDR (proc.h)”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_WRAPAROUND—statusFlags비트 상수.
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
ProcArrayStruct (struct) | src/backend/storage/ipc/procarray.c | 71 |
GlobalVisState (struct) | src/backend/storage/ipc/procarray.c | 167 |
ProcArrayShmemSize | src/backend/storage/ipc/procarray.c | 376 |
ProcArrayShmemInit | src/backend/storage/ipc/procarray.c | 414 |
ProcArrayAdd | src/backend/storage/ipc/procarray.c | 468 |
ProcArrayRemove | src/backend/storage/ipc/procarray.c | 565 |
ProcArrayEndTransaction | src/backend/storage/ipc/procarray.c | 667 |
ProcArrayEndTransactionInternal | src/backend/storage/ipc/procarray.c | 731 |
ProcArrayGroupClearXid | src/backend/storage/ipc/procarray.c | ~800 |
TransactionIdIsInProgress | src/backend/storage/ipc/procarray.c | 1402 |
ComputeXidHorizons | src/backend/storage/ipc/procarray.c | 1735 |
GetSnapshotDataReuse | src/backend/storage/ipc/procarray.c | 2095 |
GetSnapshotData | src/backend/storage/ipc/procarray.c | 2175 |
GetRunningTransactionData | src/backend/storage/ipc/procarray.c | 2689 |
GlobalVisUpdateApply | src/backend/storage/ipc/procarray.c | 4166 |
GlobalVisUpdate | src/backend/storage/ipc/procarray.c | 4205 |
GlobalVisTestIsRemovableFullXid | src/backend/storage/ipc/procarray.c | 4222 |
GlobalVisTestFor | src/backend/storage/ipc/procarray.c | 4107 |
KnownAssignedXidsCompress | src/backend/storage/ipc/procarray.c | 4665 |
KnownAssignedXidsAdd | src/backend/storage/ipc/procarray.c | 4782 |
KnownAssignedXidsSearch | src/backend/storage/ipc/procarray.c | 4886 |
KnownAssignedXidsGetAndSetXmin | src/backend/storage/ipc/procarray.c | 5127 |
PGPROC (struct) | src/include/storage/proc.h | 148 |
PROC_HDR (struct) | src/include/storage/proc.h | 383 |
PGPROC_MAX_CACHED_SUBXIDS | src/include/storage/proc.h | 39 |
PROC_IN_VACUUM | src/include/storage/proc.h | 58 |
PROC_IN_LOGICAL_DECODING | src/include/storage/proc.h | 64 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”커밋 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로 소거한다. 두 위치를 동기화하는 것이 불변식이다. -
ProcArrayLock과XidGenLock이 모두 필요한 이유는 서로 다른 소비자가 있기 때문이다.GetSnapshotData는ProcArrayLock공유 잠금만 잡고 밀집 배열을 읽는다.GetNewTransactionId는XidGenLock배타 잠금 아래xids[]를 쓴다.ProcArrayAdd/Remove는 두 잠금을 모두 배타적으로 잡아 삽입·삭제 중 어느 소비자와도 충돌이 없음을 보장한다(proc.h주석, 330–345행 확인). -
xactCompletionCount는ProcArrayEndTransactionInternal에서만 증가한다.procarray.c782행 확인.GetSnapshotDataReuse는 이 카운터의 변화 여부만 비교하므로, 커밋·롤백이 없으면 어떤 백엔드가 스냅샷을 아무리 많이 요청해도 재사용이 유효하다. -
ProcArrayGroupClearXid의 목록 헤드는 원자 uint32다.ProcGlobal->procArrayGroupFirst(proc.h~410행)가 원자 타입이다. CAS 루프로 삽입하고, 리더가 목록을 순회해 일괄 소거한다. 잠금이 없으므로 스핀 대기가 발생하지 않는다. -
KnownAssignedXids배열 크기는TOTAL_MAX_CACHED_SUBXIDS로 동일하다.ProcArrayShmemSize주석(~398행)에 “모든 주 구조체가 동일한 크기로 할당돼야 한다”고 명시돼 있다. 스탠바이 스냅샷 구성 시 복사 작업의 편의를 위해서다. -
GlobalVisState의 두 경계는FullTransactionId(64비트)다.procarray.c167–174행 확인. 32비트 XID 순환이 수평선 산술에 영향을 주지 않도록 설계된 것이다. -
ComputeXidHorizons는PROC_IN_VACUUM슬롯도 수평선 계산에 포함한다.GetSnapshotData의 스냅샷 수집 루프와 달리,ComputeXidHorizons는 vacuum 슬롯을oldest_considered_running에는 포함하지만 관계 분류별nonremovable계산에서는 건너뛴다(procarray.c~1840행). 이 미묘한 차이가 의도적임을procarray.c의 주석이 설명한다.
열린 질문
섹션 제목: “열린 질문”-
UINT32_ACCESS_ONCE가 충분한 조건.volatile uint32캐스트로 찢김 읽기를 막는다는 보장이 모든 아키텍처에서 성립하는지는transam/README와 C 표준의 엄밀한 분석이 필요하다. 현재 코드는 이것이 충분하다고 가정하지만, 공식적인 메모리 모델 증명은 없다. 조사 경로:transam/README의 “XID assignment” 절 및GetNewTransactionId의 배리어 주석 추적. -
그룹 소거 시 대기자 깨우기 순서.
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는 가시성 결정을 위한 것이다. 두 쓰임의 차이를 비교하면 “진행 중 집합이 무엇을 위해 존재하는가”를 명확히 볼 수 있다. -
복제 슬롯과 수평선 핀.
ProcArrayStruct의replication_slot_xmin과replication_slot_catalog_xmin이ComputeXidHorizons에 입력된다. 복제 슬롯이 xmin 수평선을 고정하는 메커니즘의 전체 그림은postgres-xlog-wal.md와 walsender 관련 문서에서 다룬다. -
핫 스탠바이의 충돌 처리.
PGPROC.recoveryConflictPending은 스탠바이에서 기본 서버의 트랜잭션과 쿼리 충돌이 발생할 때 신호를 보내는 경로다.KnownAssignedXids와 함께 스탠바이 쿼리 취소의 전체 메커니즘을 이해하려면procsignal.c와hot_standby_feedback설정을 함께 봐야 한다.
사용한 원자료
섹션 제목: “사용한 원자료”- 없음. 이 문서는 REL_18 소스 트리에서 직접 합성했다(
sources: []).
소스 코드 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)
섹션 제목: “소스 코드 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)”src/backend/storage/ipc/procarray.c—ProcArrayStruct,ProcArrayAdd,ProcArrayRemove,ProcArrayEndTransaction,ProcArrayGroupClearXid,TransactionIdIsInProgress,ComputeXidHorizons,GetSnapshotData,GetSnapshotDataReuse,GetRunningTransactionData,GlobalVisState,GlobalVis*함수군,KnownAssignedXids*함수군.src/include/storage/proc.h—PGPROC,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.md—GetSnapshotData의 소비자 관점;SnapshotData구조체,HeapTupleSatisfiesMVCC,XidInMVCCSnapshot, 격리 수준 타이밍.postgres-xact.md— 트랜잭션 수명주기,GetNewTransactionId,xactCompletionCount게시 쪽.postgres-vacuum.md—GlobalVisState수평선의 소비자; vacuum 회수 메커니즘.postgres-xlog-wal.md— WALRUNNING_XACTS레코드, 복제 슬롯 xmin 핀.