(KO) PostgreSQL CLOG & 커밋 타임스탬프 — 트랜잭션 상태 비트맵, 서브트랜잭션 부모 추적, commit_ts SLRU
목차
- 이론적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 안내
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
이론적 배경
섹션 제목: “이론적 배경”트랜잭션이 커밋되면 두 가지가 영구적으로 참이 되어야 한다. 첫째, 커밋 결정을 담은 WAL 레코드가 안정 저장소에 플러시되어야 한다. 둘째, 동시에 실행 중이거나 이후에 실행될 모든 읽기 요청에게 “이 트랜잭션은 커밋되었다”는 사실을 알려 줄 어떤 영구적인 지표가 있어야 한다.
PostgreSQL처럼 MVCC(다중 버전 동시성 제어, Multi-Version Concurrency Control) 방식을 쓰는 엔진에서 이 지표는 특히 중요하다. 힙 튜플은 XID 스탬프를 담고 있지만, 가시성이 미리 결정된 비트를 담고 있지 않기 때문이다. 모든 가시성 판단은 결국 “XID X는 커밋됐는가, 중단됐는가, 아직 진행 중인가?”라는 조회로 귀결된다. 이 조회는 빠르고 저렴하며 정확해야 한다.
Database System Concepts (Silberschatz 외, 7판, §17.6 “Implementation of Atomicity and Durability”)는 커밋 로그의 커밋 레코드를 원자적 커밋 지점으로 정의한다. 커밋 레코드가 안정 저장소에 도달한 순간, 이후 데이터 페이지에 무슨 일이 생기든 그 트랜잭션은 커밋된 것이다. PostgreSQL은 이를 정확히 따른다. WAL 커밋 레코드가 결정이라면, pg_xact의 2비트 상태 항목은 그 결정을 시스템 나머지 부분에 공표하는 행위다.
영구적인 트랜잭션 상태 저장소를 설계할 때 두 가지 선택이 구조를 결정한다.
-
무엇을 저장하는가. 최소한은 최상위 XID마다 커밋/중단 1비트다. 더 풍부한 설계는 서브트랜잭션별 상태, 커밋 타임스탬프, 복제 출처 태그를 추가로 저장한다. 필드마다 저장 비용이 발생하고, 필드마다 관측 가능성이 높아진다.
-
내구성 요건은 무엇인가. 커밋 상태 비트맵은 크래시에서 살아남아야 한다. 복구 과정이 WAL로 재현한다. 반면 서브트랜잭션의 부모 연결 인덱스는 그 서브트랜잭션이 열려 있는 동안만 필요하다. 최상위 트랜잭션이 커밋되거나 중단되면 모든 서브트랜잭션 XID는 그 결과를 상속한다. 부모 연결 데이터는 그래서 휘발성으로 설계할 수 있다. 시작 시 0으로 초기화해도 충분하다.
PostgreSQL은 이 두 선택을 세 개의 독립 SLRU 저장소로 해소한다. pg_xact(내구성, XID당 2비트), pg_subtrans(휘발성, XID당 4바이트), pg_commit_ts(내구성이지만 선택적, XID당 10바이트). 세 모듈이 공유하는 SLRU 기반은 postgres-slru.md에서 다룬다. 이 문서는 각 모듈이 무엇을 저장하고, 어떻게 읽고 쓰며, 어디서 생애 주기 훅을 호출하는지에 집중한다.
내구성을 보장하는 이론적 근거는 ARIES 복구 프레임워크다(ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging, Mohan 외, ACM TODS 1992; knowledge/research/dbms-papers/aries.md). ARIES가 확립한 “데이터 페이지보다 먼저 로그를 써라(WAL 규칙)“가 pg_xact를 안전한 공표 매체로 만든다. 동기 커밋에서는 커밋 WAL 레코드가 플러시된 후에 pg_xact가 기록되므로, pg_xact 비트맵은 항상 WAL과 일치한다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”거의 모든 MVCC 엔진은 어떤 형태로든 트랜잭션 상태 테이블(TST, Transaction Status Table)을 유지한다. 트랜잭션 식별자를 최종 처분(커밋 또는 중단)으로 매핑하는 영구적이고 인덱스된 구조다. TST는 독자가 두 번째로 참조하는 곳이다. 첫 번째는 힙 튜플의 XID 스탬프, 두 번째는 TST에서 그 스탬프가 커밋된 것인지 확인하는 단계다. 모든 TST 구현을 형성하는 보편적인 엔지니어링 관례를 정리한다.
고정 폭의 촘촘한 항목
섹션 제목: “고정 폭의 촘촘한 항목”트랜잭션 ID는 순차 정수다. TST의 자연스러운 인덱스는 밀집 배열이다. 항목 i에 트랜잭션 i의 상태를 저장한다. 상태가 논리적으로 두세 가지 값(진행 중, 커밋, 중단, 서브커밋 중간 상태)으로 충분하므로 2비트면 된다. 바이트당 네 트랜잭션, 페이지당 수천 트랜잭션이 들어간다. 촘촘한 표현은 I/O 횟수를 줄이고 공유 메모리 버퍼 풀을 작게 유지한다.
단일 락 아래 페이지 단위 일괄 기록
섹션 제목: “단일 락 아래 페이지 단위 일괄 기록”같은 TST 페이지에 걸리는 트랜잭션 묶음은 락을 한 번만 잡고 일괄로 기록할 수 있다. 프로덕션 엔진들은 같은 페이지에 닿는 업데이트를 묶어 페이지 락을 배치 전체에 유지한다. 동시에 많은 트랜잭션이 커밋하는 상황에서 특히 효과적이다.
서브트랜잭션용 별도 휘발성 부모 인덱스
섹션 제목: “서브트랜잭션용 별도 휘발성 부모 인덱스”SQL 세이브포인트(savepoint)와 PL 예외 블록은 서브트랜잭션을 생성한다. 힙 튜플에서 서브트랜잭션 XID를 만난 독자는 그 서브트랜잭션 자체의 상태가 아니라 최상위 부모의 상태를 알아야 한다. 서브트랜잭션은 모든 조상이 커밋해야만 커밋된 것으로 간주되기 때문이다.
부모 연결 데이터(자식 → 부모 링크)는 서브트랜잭션 트리가 열려 있는 동안만 필요하다. 최상위 트랜잭션이 종료되면 모든 서브트랜잭션 XID는 커밋 상태를 상속하거나 중단으로 표시된다. 부모 연결 인덱스는 휘발성으로 설계할 수 있다. 가장 오래된 활성 XID보다 오래된 XID에 대해서는 독자가 그 정보를 필요로 하지 않기 때문이다.
복제와 감사를 위한 선택적 XID별 타임스탬프
섹션 제목: “복제와 감사를 위한 선택적 XID별 타임스탬프”일부 엔진은 커밋 상태 옆에 커밋 타임스탬프를 기록한다. 가시성 판단에 필요하지 않다. 스냅샷 격리는 벽시계 시간이 아닌 순서 관계만 필요로 한다. 그러나 논리적 복제 충돌 해소, 커밋 순서가 필요한 CDC 소비자, 감사 쿼리(pg_xact_commit_timestamp)에는 매우 유용하다. 선택적이기 때문에 구현은 설정 플래그로 제어하고 별도 파일에 저장한다.
이론 ↔ PostgreSQL 대응표
섹션 제목: “이론 ↔ PostgreSQL 대응표”| 개념 | PostgreSQL 이름 |
|---|---|
| 트랜잭션 상태 테이블 | pg_xact/ (CLOG), clog.c 관리 |
| 2비트 상태 항목 | XidStatus (0=진행 중, 1=커밋, 2=중단, 3=서브커밋) |
| 서브커밋 중간 상태 | TRANSACTION_STATUS_SUB_COMMITTED (0x03) |
| 서브트랜잭션 부모 인덱스 | pg_subtrans/, subtrans.c 관리 |
| 선택적 커밋 타임스탬프 저장소 | pg_commit_ts/, commit_ts.c 관리 |
| SLRU 페이지 버퍼 풀 | SlruCtlData / SimpleLru* API (slru.c) |
| 페이지 단위 락 | SLRU 뱅크 락 (SimpleLruGetBankLock) |
| 그룹 커밋 배치 | TransactionGroupUpdateXidStatus, clogGroupFirst 연결 리스트 경유 |
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”세 SLRU의 전체 지형
섹션 제목: “세 SLRU의 전체 지형”PostgreSQL의 트랜잭션 메타데이터는 서로 협력하는 세 SLRU 볼륨에 나뉘어 저장된다. 각각 $PGDATA 아래 별도 디렉터리를 갖는다.
pg_xact/ — XID당 2비트 — 내구성, WAL 리두로 복구 가능pg_subtrans/ — XID당 4바이트 — 휘발성, 시작 시 0 초기화pg_commit_ts/ — XID당 10바이트 — 내구성, 선택적(track_commit_timestamp)세 모듈 모두 동일한 slru.c 기반 위에 올라간다. 각각 SlruCtlData 인스턴스(XactCtlData, SubTransCtlData, CommitTsCtlData)를 등록하고 포스트마스터 시작 시 SimpleLruInit을 호출한다. 차이는 트랜잭션마다 무엇을 저장하는가, 그리고 내구성 계약이 어떠한가다.
그림 1 — 세 SLRU 클라이언트와 공유 기반
flowchart TD
A[xact.c<br/>CommitTransaction] -->|TransactionIdCommitTree| B[transam.c<br/>TransactionIdSetTreeStatus]
A -->|TransactionTreeSetCommitTsData| E[commit_ts.c<br/>CommitTsCtlData]
B --> C[clog.c<br/>XactCtlData<br/>pg_xact/]
A -->|AssignTransactionId 시 SubTransSetParent| D[subtrans.c<br/>SubTransCtlData<br/>pg_subtrans/]
C --> F[slru.c SimpleLru API]
D --> F
E --> F
F --> G[공유 메모리 페이지 버퍼]
G --> H[디스크 세그먼트]
그림 1 — xact.c가 커밋 시점에 세 SLRU 클라이언트를 모두 구동한다. pg_subtrans는 커밋이 아니라 서브 XID 할당 시점에 채워진다.
pg_xact (CLOG): 2비트 상태 비트맵
섹션 제목: “pg_xact (CLOG): 2비트 상태 비트맵”저장 구조
섹션 제목: “저장 구조”각 XID는 정확히 2비트를 차지한다. 인코딩은 clog.h에 정의되어 있다.
// XidStatus — src/include/access/clog.htypedef int XidStatus;
#define TRANSACTION_STATUS_IN_PROGRESS 0x00#define TRANSACTION_STATUS_COMMITTED 0x01#define TRANSACTION_STATUS_ABORTED 0x02#define TRANSACTION_STATUS_SUB_COMMITTED 0x03한 바이트에 네 트랜잭션, 한 페이지에 CLOG_XACTS_PER_PAGE = BLCKSZ * 4 개의 트랜잭션이 들어간다. 특정 XID가 페이지의 어느 바이트, 어느 비트에 해당하는지는 순수한 산술 연산으로 계산된다.
// TransactionIdToByte / TransactionIdToBIndex — clog.c#define CLOG_BITS_PER_XACT 2#define CLOG_XACTS_PER_BYTE 4#define CLOG_XACT_BITMASK ((1 << CLOG_BITS_PER_XACT) - 1)
#define TransactionIdToPgIndex(xid) ((xid) % (TransactionId) CLOG_XACTS_PER_PAGE)#define TransactionIdToByte(xid) (TransactionIdToPgIndex(xid) / CLOG_XACTS_PER_BYTE)#define TransactionIdToBIndex(xid) ((xid) % (TransactionId) CLOG_XACTS_PER_BYTE)상태 읽기: TransactionIdGetStatus
섹션 제목: “상태 읽기: TransactionIdGetStatus”읽기 경로는 의도적으로 단순하다. TransactionIdGetStatus는 페이지와 바이트 오프셋을 계산하고, SimpleLruReadPage_ReadOnly(SLRU 뱅크 락을 공유 모드로 잡고 버퍼 슬롯을 반환)를 호출하여 2비트 필드를 추출한다. 비동기 커밋 호출자가 WAL을 얼마나 플러시해야 하는지 알 수 있도록 해당 페이지의 그룹 LSN도 반환한다.
// TransactionIdGetStatus — src/backend/access/transam/clog.cXidStatusTransactionIdGetStatus(TransactionId xid, XLogRecPtr *lsn){ int64 pageno = TransactionIdToPage(xid); int byteno = TransactionIdToByte(xid); int bshift = TransactionIdToBIndex(xid) * CLOG_BITS_PER_XACT; int slotno; char *byteptr; XidStatus status;
slotno = SimpleLruReadPage_ReadOnly(XactCtl, pageno, xid); byteptr = XactCtl->shared->page_buffer[slotno] + byteno; status = (*byteptr >> bshift) & CLOG_XACT_BITMASK; *lsn = XactCtl->shared->group_lsn[GetLSNIndex(slotno, xid)]; LWLockRelease(SimpleLruGetBankLock(XactCtl, pageno)); return status;}이 함수는 저수준 기본 연산이다. 대부분의 호출자가 사용하는 진입점은 transam.c의 TransactionLogFetch다. 이 함수는 마지막으로 조회한 XID를 기억하는 단일 슬롯 캐시를 추가하고, TRANSACTION_STATUS_SUB_COMMITTED 중간 상태를 만나면 pg_subtrans를 따라 최상위 부모를 찾아 재확인한다.
상태 쓰기: 다중 페이지 원자성 프로토콜
섹션 제목: “상태 쓰기: 다중 페이지 원자성 프로토콜”상태 쓰기는 더 복잡하다. 서브트랜잭션 트리가 여러 CLOG 페이지에 걸쳐 있을 수 있고, 동시 독자에게 커밋이 원자적으로 보여야 하기 때문이다. transam.c의 진입점은 TransactionIdCommitTree → TransactionIdSetTreeStatus다. 다중 페이지 커밋 프로토콜은 세 단계로 이루어진다.
- 다른 페이지에 있는 모든 서브 XID를
SUB_COMMITTED로 표시한다. - 최상위 XID(와 같은 페이지의 서브 XID)를
COMMITTED로 원자적으로 표시한다. - 나머지 서브 XID를
COMMITTED로 표시한다.
// TransactionIdSetTreeStatus — src/backend/access/transam/clog.cvoidTransactionIdSetTreeStatus(TransactionId xid, int nsubxids, TransactionId *subxids, XidStatus status, XLogRecPtr lsn){ int64 pageno = TransactionIdToPage(xid); int i;
/* xid와 같은 페이지에 있는 서브 XID 개수 세기 */ for (i = 0; i < nsubxids; i++) if (TransactionIdToPage(subxids[i]) != pageno) break;
if (i == nsubxids) { /* 단일 페이지 — 락 한 번 */ TransactionIdSetPageStatus(xid, nsubxids, subxids, status, lsn, pageno, true); } else { /* 다중 페이지: 다른 페이지 서브 XID를 먼저 서브커밋, 그 다음 최상위 커밋, 마지막으로 확정 */ if (status == TRANSACTION_STATUS_COMMITTED) set_status_by_pages(nsubxids - i, subxids + i, TRANSACTION_STATUS_SUB_COMMITTED, lsn); TransactionIdSetPageStatus(xid, i, subxids, status, lsn, pageno, false); set_status_by_pages(nsubxids - i, subxids + i, status, lsn); }}독자가 SUB_COMMITTED를 보면 2단계가 이미 완료됐다는 의미다. 최상위 XID가 이미 커밋됐으므로, 3단계가 아직 끝나지 않아도 가시성은 일관적이다.
그림 2 — 다중 페이지 커밋 원자성 프로토콜
flowchart TD
S[TransactionIdSetTreeStatus<br/>최상위 XID는 페이지 p1] --> A{모든 서브 XID가<br/>p1에 있는가?}
A -- 예 --> B[단일 TransactionIdSetPageStatus<br/>p1 락 한 번]
A -- 아니오 --> C[set_status_by_pages p2..pN<br/>SUB_COMMITTED]
C --> D[TransactionIdSetPageStatus p1<br/>최상위 XID + 동일 페이지 서브 XID COMMITTED]
D --> E[set_status_by_pages p2..pN<br/>COMMITTED]
그림 2 — 트랜잭션 트리가 여러 CLOG 페이지에 걸쳐 있을 때, 원격 페이지의 서브 XID는 최상위 커밋이 보이기 전에 SUB_COMMITTED를 거쳐 간다. 이 방식으로 외관상 원자성이 유지된다.
그룹 커밋 최적화
섹션 제목: “그룹 커밋 최적화”동시 접속이 많을 때 여러 백엔드가 동시에 커밋하면서 현재 CLOG 페이지의 같은 SLRU 뱅크 락을 두고 경합한다. PostgreSQL은 연결 리스트 기반 그룹 업데이트 메커니즘으로 이 쓰기를 일괄 처리한다. 첫 번째로 경합한 백엔드가 리더가 되어, ProcGlobal->clogGroupFirst를 통한 CAS 연결 리스트(pg_atomic_compare_exchange_u32)로 대기 중인 모든 백엔드를 수집하고, 뱅크 락을 한 번만 잡아 그룹 전체의 상태를 기록한 뒤, PGSemaphoreUnlock으로 팔로워들을 깨운다.
// TransactionGroupUpdateXidStatus (리더 경로) — clog.cnextidx = pg_atomic_exchange_u32(&procglobal->clogGroupFirst, INVALID_PROC_NUMBER);while (nextidx != INVALID_PROC_NUMBER){ PGPROC *nextproc = &ProcGlobal->allProcs[nextidx]; TransactionIdSetPageStatusInternal(nextproc->clogGroupMemberXid, nextproc->subxidStatus.count, nextproc->subxids.xids, nextproc->clogGroupMemberXidStatus, nextproc->clogGroupMemberLsn, nextproc->clogGroupMemberPage); nextidx = pg_atomic_read_u32(&nextproc->clogGroupNext);}/* ... 팔로워 깨우기 ... */그룹 최적화가 적용되는 조건은 세 가지다. (a) 그룹 구성원 모두가 같은 CLOG 페이지를 업데이트할 때, (b) MyProc->xid가 쓰려는 XID와 일치할 때(즉 자신의 커밋이지 복구 리플레이가 아닐 때), (c) 서브트랜잭션 수가 THRESHOLD_SUBTRANS_CLOG_OPT(5)를 초과하지 않을 때다. 그룹이 두 뱅크 락 파티션에 걸쳐지는 경쟁 상태가 발생하면, 리더가 중간에 뱅크 락을 교체한다.
비동기 커밋 LSN 추적
섹션 제목: “비동기 커밋 LSN 추적”비동기 커밋(synchronous_commit = off)에서는 pg_xact가 업데이트되기 전에 WAL 커밋 레코드가 플러시되지 않을 수 있다. 체크포인트 시점에 WAL 규칙을 지키기 위해(더티 CLOG 페이지는 자신을 덮는 WAL이 플러시되기 전에 디스크에 쓰여서는 안 된다), clog.c는 SLRU 공유 세그먼트 안에 group_lsn 배열을 유지한다. CLOG_XACTS_PER_LSN_GROUP(32) 개의 트랜잭션마다 XLogRecPtr 하나를 가진다. TransactionIdSetStatusBit은 lsn이 유효할 때 그룹 LSN을 업데이트한다.
pg_subtrans: 휘발성 부모 XID 링크
섹션 제목: “pg_subtrans: 휘발성 부모 XID 링크”pg_subtrans는 XID마다 하나의 TransactionId(4바이트)를 저장한다. 즉시 부모가 그 값이다. 최상위 트랜잭션은 InvalidTransactionId를 저장하고, 세이브포인트 자식은 SAVEPOINT 구문을 발행한 트랜잭션의 XID를 저장한다.
결정적인 설계 특성은 휘발성이다. 서브트랜잭션은 정의상 부모 연결이 필요한 동안은 아직 열려 있다. 복구 과정은 WAL에서 부모 연결을 재구성할 수 있다. 따라서 pg_subtrans는 WAL 로깅이 전혀 없다. 시작 시 StartupSUBTRANS가 oldestActiveXID부터 nextXid 사이의 모든 XID를 포함하는 페이지들을 0으로 초기화한다.
// StartupSUBTRANS — src/backend/access/transam/subtrans.cvoidStartupSUBTRANS(TransactionId oldestActiveXID){ FullTransactionId nextXid; int64 startPage, endPage; // ... 페이지별로 뱅크 락 획득 ... for (;;) { (void) ZeroSUBTRANSPage(startPage); if (startPage == endPage) break; startPage++; if (startPage > TransactionIdToPage(MaxTransactionId)) startPage = 0; } // ...}부모 링크를 쓰는 것은 SubTransSetParent다. xact.c의 AssignTransactionId가 서브 XID를 할당할 때 호출한다. 읽는 것은 SubTransGetParent이며, SubTransGetTopmostTransaction이 이를 사용해 자식 → 부모 → 조부모 체인을 따라간다. InvalidTransactionId에 닿거나 TransactionXmin보다 오래된 XID에 닿으면 멈춘다.
// SubTransGetTopmostTransaction — subtrans.cTransactionIdSubTransGetTopmostTransaction(TransactionId xid){ TransactionId parentXid = xid, previousXid = xid; while (TransactionIdIsValid(parentXid)) { previousXid = parentXid; if (TransactionIdPrecedes(parentXid, TransactionXmin)) break; parentXid = SubTransGetParent(parentXid); if (!TransactionIdPrecedes(parentXid, previousXid)) elog(ERROR, "pg_subtrans contains invalid entry: xid %u -> %u", previousXid, parentXid); } return previousXid;}단조롭지 않은 부모 체인에 대한 루프 가드는 손상된 데이터로 인한 무한 루프를 방지한다. TransactionXmin 컷오프는 이미 잘린 pg_subtrans 페이지에 대한 페이지 폴트를 막는다.
pg_commit_ts: 선택적 커밋 타임스탬프와 복제 출처
섹션 제목: “pg_commit_ts: 선택적 커밋 타임스탬프와 복제 출처”pg_commit_ts는 XID마다 10바이트의 CommitTimestampEntry를 저장한다.
// CommitTimestampEntry — src/backend/access/transam/commit_ts.ctypedef struct CommitTimestampEntry{ TimestampTz time; /* 8바이트 */ RepOriginId nodeid; /* 2바이트 */} CommitTimestampEntry;RepOriginId는 복제 출처를 식별한다. 이 트랜잭션이 어느 프라이머리 또는 중간 노드에서 유래했는지를 나타내며, 논리적 복제 소비자가 출처별로 커밋을 필터링하거나 순서를 정할 수 있게 한다.
모듈 전체가 track_commit_timestamp GUC로 제어된다. 비활성화 상태에서는 모든 쓰기 호출이 즉시 반환되는 no-op이다(commitTsShared->commitTsActive == false). 활성화 상태에서는 xact.c의 RecordTransactionCommit이 WAL 커밋 레코드를 쓰면서 동시에 TransactionTreeSetCommitTsData를 호출해 최상위 XID와 모든 서브 XID의 타임스탬프와 출처를 기록한다.
// TransactionTreeSetCommitTsData (쓰기 경로) — commit_ts.cvoidTransactionTreeSetCommitTsData(TransactionId xid, int nsubxids, TransactionId *subxids, TimestampTz timestamp, RepOriginId nodeid){ if (!commitTsShared->commitTsActive) return;
headxid = xid; i = 0; for (;;) { int64 pageno = TransactionIdToCTsPage(headxid); /* 같은 페이지의 서브 XID 찾기 ... */ SetXidCommitTsInPage(headxid, j - i, subxids + i, timestamp, nodeid, pageno); if (j >= nsubxids) break; headxid = subxids[j]; i = j + 1; }
/* 메모리 내 가장 최근 커밋 캐시 업데이트 */ LWLockAcquire(CommitTsLock, LW_EXCLUSIVE); commitTsShared->xidLastCommit = xid; commitTsShared->dataLastCommit.time = timestamp; commitTsShared->dataLastCommit.nodeid = nodeid; // ... newestCommitTsXid 갱신 ... LWLockRelease(CommitTsLock);}CLOG와 달리 pg_commit_ts에는 그룹 커밋 메커니즘이 없다. 항목이 10바이트로 커서 페이지당 트랜잭션 수가 적고, 커밋 타임스탬프는 한 번 쓰고 크리티컬 패스에서 자주 재읽히지 않기 때문에 경합이 병목이 되지 않는다.
읽기 경로인 TransactionIdGetCommitTsData는 먼저 인메모리 CommitTimestampShared 캐시(xidLastCommit)를 확인하고, 요청된 XID가 가장 최근이 아니면 SLRU 읽기로 내려간다. 페이지 I/O를 하기 전에 요청된 XID가 [oldestCommitTsXid, newestCommitTsXid] 범위 안에 있는지 검증한다.
활성화/비활성화 생애 주기
섹션 제목: “활성화/비활성화 생애 주기”pg_commit_ts에는 다른 두 SLRU에는 없는 활성화 레이어가 있다. GUC가 재시작 사이에 바뀔 수 있고, 스탠바이가 프라이머리의 설정을 미러링해야 하기 때문이다.
StartupCommitTs() — ActivateCommitTs() 호출CompleteCommitTsInitialization() — GUC에 따라 활성화 또는 비활성화CommitTsParameterChange() — XLOG_PARAMETER_CHANGE WAL 리플레이 시 호출ActivateCommitTs는 없으면 초기 세그먼트를 생성하고 commitTsActive = true로 설정한다. DeactivateCommitTs는 데이터 디렉터리를 비우고 commitTsActive = false로 설정한다.
생애 주기 훅 — 세 SLRU 공통
섹션 제목: “생애 주기 훅 — 세 SLRU 공통”세 SLRU는 모두 같은 생애 주기 훅 패턴을 따른다. 포스트마스터 시작, 복구, 체크포인트 코드의 고정된 호출 지점에서 순서대로 실행된다.
| 단계 | CLOG | SUBTRANS | COMMIT_TS |
|---|---|---|---|
| 포스트마스터 할당 | CLOGShmemSize / CLOGShmemInit | SUBTRANSShmemSize / SUBTRANSShmemInit | CommitTsShmemSize / CommitTsShmemInit |
| initdb | BootStrapCLOG | BootStrapSUBTRANS | BootStrapCommitTs (no-op) |
| 시작 | StartupCLOG + TrimCLOG | StartupSUBTRANS | StartupCommitTs |
| 체크포인트 | CheckPointCLOG | CheckPointSUBTRANS | CheckPointCommitTs |
| XID 확장 | ExtendCLOG | ExtendSUBTRANS | AdvanceOldestCommitTsXid |
| 잘라내기 | TruncateCLOG | TruncateSUBTRANS | TruncateCommitTs |
TrimCLOG은 시작/복구가 끝난 직후 한 번 호출되어 현재 CLOG 페이지의 미사용 뒷부분을 0으로 초기화한다. 이전 데이터베이스 생애 주기에서 남겨진 비트가 유효한 상태로 오인되는 것을 방지하기 위해서다.
소스 코드 안내
섹션 제목: “소스 코드 안내”clog.c — pg_xact 관리
섹션 제목: “clog.c — pg_xact 관리”CLOGShmemInit—XactCtlData를SimpleLruInit에 등록. 디렉터리pg_xact, LWTrancheLWTRANCHE_XACT_BUFFER/LWTRANCHE_XACT_SLRU.TransactionIdSetTreeStatus— 상태 쓰기의 최상위 진입점. 단일 페이지 트리와 다중 페이지 트리를 처리.TransactionIdSetPageStatus를 호출.TransactionIdSetPageStatus— 페이지 단위 디스패치. 그룹 업데이트 최적화(TransactionGroupUpdateXidStatus)를 먼저 시도. 실패하면 직접LWLockAcquire+TransactionIdSetPageStatusInternal.TransactionGroupUpdateXidStatus— 그룹 커밋 리더/팔로워 로직.ProcGlobal->clogGroupFirstCAS 리스트로 구현. 리더가 뱅크 락을 잡고 구성원 전체를 기록한 뒤 팔로워를PGSemaphoreUnlock으로 깨운다.TransactionIdSetPageStatusInternal— 실제 2비트 쓰기. XID마다TransactionIdSetStatusBit호출. SLRU 페이지를 더티로 표시.TransactionIdSetStatusBit— 비트 단위 쓰기. 바이트와 시프트를 계산하고 마스크를 적용. 비동기 커밋 추적을 위해group_lsn을 업데이트.TransactionIdGetStatus— 읽기 경로.SimpleLruReadPage_ReadOnly사용. 상태 + 그룹 LSN 반환.StartupCLOG—latest_page_number를 공유 메모리에 기록.TrimCLOG— 복구 후 현재 페이지의 미사용 비트를 0으로 초기화.CheckPointCLOG—SimpleLruWriteAll을 호출해 더티 페이지를 플러시.ExtendCLOG—nextXid가 페이지 경계를 넘을 때 새 페이지를 0으로 초기화(XidGenLock보유 중 호출).TruncateCLOG—SimpleLruTruncate호출. 프리즈 경계 추적을 위한pg_database관련 정리도 수행.
transam.c — 상위 수준 호출자
섹션 제목: “transam.c — 상위 수준 호출자”TransactionLogFetch—TransactionIdGetStatus를 감싸는 단일 슬롯 캐시.SUB_COMMITTED를 만나면SubTransGetTopmostTransaction으로 부모를 추적한 뒤 재확인.TransactionIdCommitTree/TransactionIdAsyncCommitTree— 동기/비동기 커밋 래퍼.InvalidXLogRecPtr또는 유효한 LSN을TransactionIdSetTreeStatus에 전달.TransactionIdAbortTree— 중단 래퍼. 항상InvalidXLogRecPtr전달. 중단 레코드는 상태 기록 전에 플러시할 필요가 없다.
subtrans.c — pg_subtrans 관리
섹션 제목: “subtrans.c — pg_subtrans 관리”SUBTRANSShmemInit—SubTransCtlData를SimpleLruInit에 등록. 디렉터리pg_subtrans, LSN 배열 없음(휘발성, WAL 없음),SYNC_HANDLER_NONE.SubTransSetParent—TransactionId슬롯 하나를 기록.xact.c의AssignTransactionId가 서브 XID를 할당할 때 호출.SubTransGetParent— 읽기 전용 페이지 조회.SubTransGetTopmostTransaction—TransactionXmin에서 멈추는 자식→부모 체인 탐색. 서브 XID 튜플의 MVCC 가시성 검사에서 사용.StartupSUBTRANS— 활성 범위 페이지를 0으로 초기화. WAL 불필요.ExtendSUBTRANS— XID 페이지 경계에서 새 페이지를 0으로 초기화.XidGenLock보유 중 호출.TruncateSUBTRANS—TransactionXmin아래 페이지를 해제.
commit_ts.c — pg_commit_ts 관리
섹션 제목: “commit_ts.c — pg_commit_ts 관리”CommitTsShmemInit—CommitTsCtlData를 등록하고 인메모리 캐시용CommitTimestampShared공유 메모리 구조체를 할당.TransactionTreeSetCommitTsData— 주 쓰기 진입점. 페이지별로 반복하며 페이지당SetXidCommitTsInPage호출.commitTsShared캐시 갱신.SetXidCommitTsInPage— 뱅크 락 획득, 페이지 읽기, XID마다TransactionIdSetCommitTs호출.TransactionIdGetCommitTsData— 읽기 진입점. 캐시 확인, XID 범위 검증, SLRU 읽기 순서로 진행.GetLatestCommitTsData—CommitTsLock아래xidLastCommit+ 캐시된 항목 반환.ActivateCommitTs/DeactivateCommitTs—commitTsActive플래그 토글. 시작 시와XLOG_PARAMETER_CHANGEWAL 리플레이 시 호출.StartupCommitTs/CompleteCommitTsInitialization— 시작 생애 주기 훅.TruncateCommitTs—vacuumlazy.c의vac_truncate_clog에서 호출.
위치 힌트 표 (커밋 273fe94, 2026-06-05 기준)
섹션 제목: “위치 힌트 표 (커밋 273fe94, 2026-06-05 기준)”| 심볼 | 파일 | 줄 |
|---|---|---|
TRANSACTION_STATUS_* | src/include/access/clog.h | 27–30 |
TransactionIdSetTreeStatus | src/backend/access/transam/clog.c | 183 |
TransactionIdSetPageStatus | src/backend/access/transam/clog.c | 293 |
TransactionIdSetPageStatusInternal | src/backend/access/transam/clog.c | 364 |
TransactionGroupUpdateXidStatus | src/backend/access/transam/clog.c | 441 |
TransactionIdSetStatusBit | src/backend/access/transam/clog.c | 661 |
TransactionIdGetStatus | src/backend/access/transam/clog.c | 735 |
CLOGShmemInit | src/backend/access/transam/clog.c | 787 |
StartupCLOG | src/backend/access/transam/clog.c | 877 |
TrimCLOG | src/backend/access/transam/clog.c | 892 |
CheckPointCLOG | src/backend/access/transam/clog.c | 937 |
ExtendCLOG | src/backend/access/transam/clog.c | 959 |
TruncateCLOG | src/backend/access/transam/clog.c | 1000 |
TransactionLogFetch | src/backend/access/transam/transam.c | 52 |
TransactionIdCommitTree | src/backend/access/transam/transam.c | 240 |
TransactionIdAsyncCommitTree | src/backend/access/transam/transam.c | 252 |
TransactionIdAbortTree | src/backend/access/transam/transam.c | 270 |
SubTransSetParent | src/backend/access/transam/subtrans.c | 85 |
SubTransGetParent | src/backend/access/transam/subtrans.c | 122 |
SubTransGetTopmostTransaction | src/backend/access/transam/subtrans.c | 163 |
SUBTRANSShmemInit | src/backend/access/transam/subtrans.c | 220 |
StartupSUBTRANS | src/backend/access/transam/subtrans.c | 309 |
CommitTimestampEntry | src/backend/access/transam/commit_ts.c | 55 |
CommitTimestampShared | src/backend/access/transam/commit_ts.c | 98 |
TransactionTreeSetCommitTsData | src/backend/access/transam/commit_ts.c | 141 |
TransactionIdGetCommitTsData | src/backend/access/transam/commit_ts.c | 274 |
CommitTsShmemInit | src/backend/access/transam/commit_ts.c | 530 |
ActivateCommitTs | src/backend/access/transam/commit_ts.c | 705 |
DeactivateCommitTs | src/backend/access/transam/commit_ts.c | 785 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
pg_xact는 XID당 정확히 2비트를 사용하며, 상태 값은 네 가지다.clog.h상수와clog.c의TransactionIdSetStatusBit으로 확인. 네 번째 값SUB_COMMITTED(0x03)는 다중 페이지 트리 커밋 중에만 사용되는 중간 상태이며, 외부에서 최종 상태로 보이지 않는다. -
그룹 업데이트 최적화는 THRESHOLD_SUBTRANS_CLOG_OPT = 5를 임계값으로 사용한다.
clog.c:301–326에서 확인.nsubxids와MyProc->subxidStatus.count를 비교하는 안전 조건으로 동작한다. 서브 XID가 5개를 초과하는 트랜잭션은 그룹 경로를 우회하고 직접 뱅크 락을 잡는다. -
pg_subtrans는 매번 시작 시 완전히 0으로 초기화되며 WAL에서 리플레이되지 않는다.subtrans.c:309–349에서 확인. 모듈 헤더 주석과StartupSUBTRANS모두 “현재 열린 트랜잭션에 대해서만 pg_subtrans 정보를 기억하면 된다”고 명시한다.SimpleLruInit의SYNC_HANDLER_NONE이 동기 콜백이 등록되지 않음을 확인해 준다. -
track_commit_timestamp = off이면 모든pg_commit_ts쓰기가 no-op이 된다.TransactionTreeSetCommitTsData(commit_ts.c:157)에서 확인.!commitTsShared->commitTsActive일 때 즉시 반환된다. 스탠바이에서는 이 플래그를 변경하는 것이 복구 프로세스뿐이고, 이 함수를 호출하는 것도 복구 프로세스뿐이므로 락 없는 읽기가 안전하다. -
TrimCLOG은 현재 CLOG 페이지의 미사용 뒷부분을 0으로 초기화한다.clog.c:892–931에서 확인. 주석은 이유를 설명한다. WAL 리플레이가 이전 데이터베이스 생애 주기가 실제로 사용하고 표시한 마지막 XID보다 작은nextXID로 정착할 수 있으며, 그러면 그 너머의 비트가 0이 아닌 채 남을 수 있다. -
CLOG 공유 메모리의 그룹 LSN 배열은 32개 XID당 항목 하나를 갖는다.
CLOG_XACTS_PER_LSN_GROUP = 32(clog.c:92),CLOG_LSNS_PER_PAGE = CLOG_XACTS_PER_PAGE / 32(clog.c:93)에서 확인. 비동기 커밋 LSN 추적이 동작하는 단위다. -
CommitTimestampShared는 가장 최근에 커밋된 XID 하나만 캐시한다. commit_ts.c:98–103에서 확인. 캐시는 단일 슬롯 구조(xidLastCommit+dataLastCommit)이며, 링이나 다중 슬롯 구조가 아니다. 캐시 미스 시 SLRU 페이지로 내려간다.
미해결 질문
섹션 제목: “미해결 질문”-
TruncateCLOG과 pg_database 정리.TruncateCLOG는AdvanceOldestClogXid를 호출하고 프리즈 경계 관리를 위한pg_database.datfrozenxid정리를 수행한다.vacuumlazy.c의vac_truncate_clog및 오토베큠의 프리즈 로직과의 정확한 상호작용은 여기서 분석하지 않는다. 전체 체인은 오토베큠 프리즈 →vac_truncate_clog→TruncateCLOG이며,postgres-vacuum.md와postgres-xid-wraparound-freeze.md에서 다룰 내용이다. -
그룹 업데이트 페이지 불일치 처리. 그룹 업데이트 리더가 팔로워의 페이지 번호가 처음 페이지와 다르다는 것을 발견하면(clog.c:490–513에 설명된 경쟁 상태), 중간에 뱅크 락을 교체한다. 이 다중 뱅크 락 그룹 탐색 코드 경로는 존재하지만, 대규모 서브트랜잭션 트리와 높은 경합 하에서의 성능 특성은 실험적으로 검증되지 않았다.
-
pg_commit_ts와oldestCommitTsXid유지 관리.TransamVariables->oldestCommitTsXid는TransactionIdGetCommitTsData가 유효한 조회 범위를 판단하는 데 사용된다. 잘라내기 시 이 하한을 올리는 코드와 정확한 잘라내기 트리거(체크포인트? 오토베큠? 둘 다?)는TruncateCommitTs(commit_ts.c:890)에 있지만, 전체 오케스트레이션 경로는 검증되지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”-
Oracle의 ITL(Interested Transaction List)과 언두 기반 가시성. Oracle은 힙 블록 헤더(ITL)에 트랜잭션 슬롯을 직접 저장한다. 중앙 상태 비트맵이 없다. 가시성은 ITL 슬롯을 확인하거나, 오래된 트랜잭션이면 언두 체인을 따라 확인한다. CLOG에 해당하는 핫스팟이 없는 대신, 블록별 트랜잭션 메타데이터 비용이 발생한다. 나란히 비교하면 PostgreSQL의 그룹 업데이트 최적화가 완화하려는 CLOG 뱅크 락 경합을 정량화할 수 있을 것이다.
-
MySQL/InnoDB의 trx_sys와 퍼지 시스템. InnoDB의 트랜잭션 디스크립터는
trx_sys(특수 세그먼트)에 있고, 가시성은 활성 트랜잭션 목록의 스냅샷인 “리드 뷰”를 사용한다. 커밋된 트랜잭션 레코드는 퍼지 스레드에 의해 지연 삭제된다. PostgreSQL 베큠의 InnoDB 유사체다. CLOG에 해당하는 것은 언두 로그에 내재되어 있다. 언두 공간이 회수된 트랜잭션은 퍼지 시스템 관점에서 커밋된 것이다. -
중앙 TST 없는 MVCC: HANA의 인메모리 MVCC. SAP HANA 등 인메모리 엔진은 데이터베이스 전체가 DRAM에 들어가므로 트랜잭션 상태를 메모리에만 유지하고, 재시작 시 커밋된 트랜잭션 로그에서 재구성한다. 재시작 시간이 제한적이고 상태 테이블이 DRAM에 들어갈 때만 가능하다. PostgreSQL의
pg_xact는 두 조건이 성립하지 않을 때를 위한 내구성 있는 대안이다. -
pg_commit_ts와 논리적 복제 충돌 해소.pg_commit_ts의 커밋 타임스탬프 +RepOriginId조합은 멀티 마스터 충돌 해소를 가능하게 하는 핵심 데이터 구조다. 논리적 복제 적용 워커는 출처 간 타임스탬프를 비교해 마지막 쓰기 우선(last-write-wins) 정책을 구현할 수 있다. Serializable Snapshot Isolation in PostgreSQL (Ports & Grittner, VLDB 2012;knowledge/research/dbms-papers/)은 커밋 타임스탬프를 직접 다루지 않지만, 아키텍처적으로 맞닿아 있다. SSI가 커밋 시점에 직렬화 충돌을 감지하고, commit_ts가 순서 증거를 제공한다. -
SUBTRANS 휘발성 vs. 완전 영구성.
pg_subtrans를 WAL 로깅하지 않는 설계는 I/O 비용을 절약하는 대신 제약을 수반한다.TransactionXmin보다 오래된 트랜잭션에 대한 서브트랜잭션 체인을 따라갈 수 없다는 점이다.SubTransGetTopmostTransaction이 오래된 서브 XID를 조회할 때 최상위 부모 대신 중간 XID를 반환할 수 있다는 점이 subtrans.c:151–159에 문서화되어 있고 의도된 동작이다. 전역 서브 XID 해소가 필요한 일부 분산 MVCC 시스템처럼 완전히 영구적인 설계를 선택하면 WAL 로깅과 잘라내기 안전 읽기 경로가 필요할 것이다.
소스 코드 (REL_18_STABLE, 커밋 273fe94)
src/backend/access/transam/clog.c— 1152줄, CLOG SLRU 클라이언트src/backend/access/transam/subtrans.c— 447줄, SUBTRANS SLRU 클라이언트src/backend/access/transam/commit_ts.c— 1073줄, CommitTs SLRU 클라이언트src/backend/access/transam/transam.c— 상위 수준 호출자 (TransactionIdCommitTree,TransactionLogFetch)src/include/access/clog.h—XidStatustypedef 및 상태 상수src/include/access/slru.h—SlruCtlData,SimpleLru*API 선언
교재
- Database System Concepts, Silberschatz 외 (7판) — §17.6 “Implementation of Atomicity and Durability” (WAL, 커밋 레코드가 커밋 지점임)
- Database Internals, Petrov (2019) — 5장 MVCC, 7장 Log-Structured Storage (TST 설계 패턴 배경)
논문
- ARIES (Mohan 외, ACM TODS 1992) —
pg_xact내구성 보장의 근거가 되는 WAL 정확성 및 복구 프로토콜;knowledge/research/dbms-papers/aries.md
이 KB의 인접 문서
postgres-slru.md— SLRU 기반(slru.c): 버퍼 풀, 뱅크 락, 페이지 생애 주기,SimpleLru*API (전방 참조 — 2026-06-05 현재 미작성)postgres-xact.md— 트랜잭션 상태 머신과 커밋 파이프라인. CLOG 및 commit_ts 쓰기의 생산자postgres-mvcc-snapshots.md— 스냅샷 취득과 가시성 검사.TransactionLogFetch의 주요 소비자postgres-recovery-redo.md— 재시작 시 CLOG WAL 레코드(XLOG_CLOG_ZEROPAGE,XLOG_CLOG_TRUNCATE) 리플레이postgres-vacuum.md—vac_truncate_clog가TruncateCLOG와TruncateSUBTRANS를 구동