(KO) PostgreSQL 2단계 커밋 — 전역 트랜잭션, 더미 PGPROC, WAL 우선 상태 영속화
목차
- 이론적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 안내
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
이론적 배경
섹션 제목: “이론적 배경”2단계 커밋(two-phase commit, 2PC)은 독립적으로 장애가 발생할 수 있는 여러 리소스 매니저에 걸쳐 원자적 커밋을 달성하기 위한 고전적인 프로토콜이다. Jim Gray의 Notes on Database Operating Systems(1978)와 이후 X/Open XA 명세(1991)가 이 모델을 형식화했다. 코디네이터는 모든 참가자에게 두 라운드의 핸드셰이크를 진행한다. 첫 번째 라운드는 준비(prepare) — “커밋할 수 있는가?” — 이고, 두 번째는 커밋 — “커밋하라” — 이다. 두 라운드 사이에 어떤 참가자가 충돌하더라도 결과는 전체가 커밋되거나 전체가 중단된다. 정확성의 핵심 불변식은 하나다. 참가자가 “준비됨”을 투표한 이후에는 단독으로 중단할 수 없다. 코디네이터의 두 번째 단계 결정을 기다리고 반드시 따라야 한다.
Database System Concepts(Silberschatz, Korth, Sudarshan, 7판, §19.4 “Atomic Commit Protocols”)는 핵심 안전 속성을 이렇게 정리한다. “코디네이터가 commit을 보낸 뒤 어느 사이트가 충돌하더라도, 복구 후 트랜잭션은 커밋되어야 한다.” 이 속성은 내구성의 확정 순간을 참가자가 아닌 코디네이터의 결정 로그 기록 시점으로 끌어올린다. 공학적 귀결은 명확하다. 각 참가자는 준비 시점에 두 번째 단계 — 커밋 또는 중단 — 를 임의의 충돌 이후에도 재실행할 수 있을 만큼 충분한 상태를 영속화해야 한다. 이것이 2PC 상태 레코드이며, 그 내구성 요건은 단일 백엔드 커밋보다 엄격하다. 준비 이후 임의의 시간이 지나도 살아남아야 하기 때문이다.
이 모델에서 세 가지 설계 선택이 뒤따르며, 이것이 모든 2PC 구현을 형성한다.
-
준비 시점에 무엇을 영속화해야 하는가? 최소한 XID, 참가한 리소스 매니저, 그리고 그들이 보유한 리소스(잠금, 보류 중인 파일 삭제, 카탈로그 무효화 메시지)가 필요하다. 기록되지 않은 것은 충돌 후 손실되어 두 번째 단계 완료가 틀리게 된다.
-
준비 상태는 어디에 보관하는가? WAL 우선 방식은 상태 레코드를 WAL에 쓰고 커밋/롤백 시 다시 읽는다. 파일 우선 방식은 즉시 별도 상태 파일을 쓴다. 하이브리드 방식은 파일 쓰기를 체크포인트 시점까지 미루고, 빠른 경로의 기본 저장소로 WAL을 사용한다.
-
준비된 트랜잭션을 시스템의 나머지 부분이 어떻게 보는가? “준비됨” 상태의 XID는 여전히 실행 중으로 취급되어야 한다. 잠금을 보유하고, 쓰기는 아직 다른 이에게 보이지 않으며, XID 랩어라운드 정리를 막아야 한다. 그러나 원래 백엔드는 분리되었다. 엔진은 잠금 충돌, 스냅샷 결정, XID 수평선 추적이 모두 준비된 XID를 특별 취급하지 않고 올바르게 동작하도록 공유 프로세스 배열에 프록시 항목을 유지해야 한다.
PostgreSQL의 twophase.c는 세 질문에 모두 답한다. 준비 시점에 WAL로 직렬화하고, 체크포인트에서 파일로 승격하며, 준비된 XID마다 더미 PGPROC을 유지해 엔진의 나머지 부분이 그것을 일반 실행 중 트랜잭션으로 보이게 한다.
2PC 아래에 있는 복구 기반은 ARIES다(Mohan et al., ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging, ACM TODS 1992; knowledge/research/dbms-papers/aries.md). ARIES의 히스토리 반복 원칙 덕분에 XLOG_XACT_PREPARE WAL 레코드를 재연하면 공유 메모리에 TwoPhaseState 항목이 다시 생성된다. 준비 트랜잭션은 여전히 실행 중인 것으로 간주되고, 충돌 전에 두 번째 단계가 발생했다면 해당 WAL 레코드(커밋 또는 중단)가 redo 중에 완료한다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”Oracle(인-다우트 트랜잭션 테이블), IBM DB2(준비 트랜잭션 코디네이터 로그), MySQL/InnoDB(XA PREPARE), SQL Server(DTC 통합), PostgreSQL에 걸쳐 네 가지 엔지니어링 관례가 반복된다.
준비된 XID마다 더미 프로세스 항목 하나. 잠금 서브시스템, 스냅샷 엔진(procarray), XID 수평선 추적기 모두 공유 프로세스 배열을 기반으로 동작한다. 이 서브시스템 각각에 “준비된 XID” 특수 경로를 뚫는 대신, 모든 주요 엔진은 경량 프록시 항목을 일반 백엔드가 쓰는 것과 같은 배열에 삽입한다. PostgreSQL의 프록시는 pid = 0인 완전한 PGPROC 구조체다. Oracle은 잠금 충돌 탐지에도 참여하는 인-다우트 트랜잭션 테이블 항목을 사용한다. InnoDB는 잠금 매니저에 보이는 준비된 XA 트랜잭션 해시를 유지한다. 공통 통찰은 잠금 및 가시성 기계가 “실행 중인” 트랜잭션에 살아 있는 백엔드가 있는지 알 필요가 없어야 한다는 것이다.
준비 시점에 직렬화한 상태 블롭, 두 번째 단계에서 재독. 어떤 엔진도 커밋 시점에 힙이나 WAL에서 필요한 정리 상태를 재구성하지 않는다. 처리해야 할 정보(삭제할 릴레이션, 무효화할 카탈로그 캐시, 해제할 잠금)는 준비 시점에 한 번만 수집되어 컴팩트한 버전 있는 레코드에 저장된다. 블롭의 스키마는 정리 동작을 등록하는 서브시스템 집합에 의해 결정된다. 이 서브시스템들이 XA 용어의 “2PC 리소스 매니저”가 된다.
확장 가능한 리소스 매니저 콜백 테이블. XA는 이것을 형식화한다. 분산 트랜잭션에 참여하는 리소스 매니저는 prepare, commit, rollback 콜백을 구현한다. PostgreSQL은 이를 작은 TwoPhaseRmgrId(0–4)로 인덱싱된 네 개의 정적 TwoPhaseCallback 함수 포인터 배열로 표현한다. 복구, 포스트-커밋, 포스트-중단, 스탠바이 복구용으로 각각 하나씩이다. 내장 참여자는 잠금 매니저, pgstat, multixact, 프레디케이트 잠금이며, 배열은 twophase_rmgr.c에 컴파일된다.
WAL에서 안정 파일로의 지연 승격. 수 초밖에 살지 않는 준비 트랜잭션은 전용 파일이 필요 없다. 준비 시점에 쓴 WAL 레코드가 아직 WAL 버퍼에 남아 있어 즉시 재독할 수 있기 때문이다. 준비마다 별도 파일을 쓰면 단명 트랜잭션에 소형 파일 I/O가 발생한다. 대신 엔진은 상태를 WAL(또는 동등한 redo 로그)에 쓰고, WAL이 재활용될 수 있는 체크포인트 시점에야 전용 파일로 승격한다. PostgreSQL이 정확히 따르는 패턴이다. GlobalTransactionData의 prepare_start_lsn이 WAL 읽기 포인터이고, ondisk가 WAL과 파일 사이를 전환하는 플래그다.
이론 ↔ PostgreSQL 매핑
| 이론 / 관례 | PostgreSQL 이름 |
|---|---|
| 전역 트랜잭션 ID (GID) | GlobalTransactionData의 gid[GIDSIZE] |
| 실행 중 트랜잭션 집합의 준비된 XID | gxact->pgprocno의 더미 PGPROC, ProcArrayAdd로 삽입 |
| 모든 준비 트랜잭션의 공유 테이블 | TwoPhaseStateData.prepXacts[], TwoPhaseStateLock으로 보호 |
| 준비 상태 블롭 (WAL 주 저장소) | StartPrepare / EndPrepare로 조립한 XLOG_XACT_PREPARE 레코드 |
| 준비 상태 블롭 (파일 보조 저장소) | pg_twophase/<XID> 파일, 체크포인트에서 CheckPointTwoPhase가 승격 |
| 2PC 리소스 매니저 콜백 | twophase_rmgr.c의 TwoPhaseCallback 배열 |
| 코디네이터 결정 (커밋) | FinishPreparedTransaction(gid, true) → RecordTransactionCommitPrepared |
| 코디네이터 결정 (중단) | FinishPreparedTransaction(gid, false) → RecordTransactionAbortPrepared |
| 준비 트랜잭션 복구 | restoreTwoPhaseData + RecoverPreparedTransactions |
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”공유 메모리 레이아웃: TwoPhaseStateData와 GlobalTransactionData
섹션 제목: “공유 메모리 레이아웃: TwoPhaseStateData와 GlobalTransactionData”2PC 런타임 상태 전체는 TwoPhaseShmemInit이 할당하는 고정 크기 공유 메모리 영역 하나에 산다. 루트는 TwoPhaseStateData다.
// TwoPhaseStateData — src/backend/access/transam/twophase.ctypedef struct TwoPhaseStateData{ GlobalTransaction freeGXacts; /* 여유 항목 연결 리스트 */ int numPrepXacts; /* 유효 항목 수 */ GlobalTransaction prepXacts[FLEXIBLE_ARRAY_MEMBER]; /* max_prepared_xacts 크기 */} TwoPhaseStateData;끝에 붙은 가변 배열은 정확히 max_prepared_xacts개의 포인터를 담는다. 각 포인터는 같은 ShmemInitStruct 호출에서 포인터 배열 바로 뒤에 배치된 GlobalTransactionData 구조체 배열을 가리킨다. 시작 시 구조체들은 freeGXacts로 연결되고, 할당은 맨 앞에서 꺼낸다.
각 GlobalTransactionData는 2PC 메타데이터와 함께 InitProcGlobal의 형제 할당인 PreparedXactProcs[] 배열에서 가져온 더미 PGPROC 참조를 가진다.
// GlobalTransactionData — src/backend/access/transam/twophase.ctypedef struct GlobalTransactionData{ GlobalTransaction next; /* 여유 리스트 링크 */ int pgprocno; /* 관련 더미 PGPROC ID */ TimestampTz prepared_at; XLogRecPtr prepare_start_lsn; /* 이 LSN에서 WAL의 상태를 읽음 */ XLogRecPtr prepare_end_lsn; /* 동기 복제는 여기까지 대기 */ TransactionId xid; Oid owner; ProcNumber locking_backend; /* 현재 완료 중인 백엔드 */ bool valid; /* ProcArray에 들어간 뒤 true */ bool ondisk; /* pg_twophase/로 승격 후 true */ bool inredo; /* WAL 재연으로 추가됐으면 true */ char gid[GIDSIZE];} GlobalTransactionData;valid / ondisk / inredo 세 플래그가 이 서브시스템의 거의 모든 분기를 제어한다. valid는 GXACT가 두 번째 단계 명령을 받을 준비가 됐음을 뜻한다. ondisk는 데이터 소스를 XlogReadTwoPhaseData에서 ReadTwoPhaseFile로 전환한다. inredo는 정상 PREPARE TRANSACTION이 아닌 WAL 재연으로 도착한 항목임을 표시한다.
그림 1 — TwoPhaseState 공유 메모리 레이아웃
flowchart LR
subgraph shmem["공유 메모리 (TwoPhaseStateLock)"]
TS["TwoPhaseStateData<br/>freeGXacts → 여유 리스트<br/>numPrepXacts<br/>prepXacts[0..N-1]"]
G0["GlobalTransactionData[0]<br/>gid / xid / valid / ondisk / inredo<br/>prepare_start_lsn<br/>pgprocno"]
G1["GlobalTransactionData[1]<br/>..."]
P0["더미 PGPROC[0]<br/>xid / databaseId / myProcLocks<br/>pid = 0"]
end
TS -- "prepXacts[0]" --> G0
TS -- "prepXacts[1]" --> G1
G0 -- "pgprocno" --> P0
P0 -- "ProcArrayAdd" --> PA["ProcArray<br/>(전역)"]
그림 1 — TwoPhaseState는 GID 키를 가진 GlobalTransactionData 항목을 더미 PGPROC 슬롯에 매핑한다. 더미 프로세스가 ProcArray에 존재하기 때문에 준비된 XID는 스냅샷 획득과 잠금 충돌 탐지에서 정상 XID와 동일하게 취급된다.
1단계: PREPARE TRANSACTION — 준비 파이프라인
섹션 제목: “1단계: PREPARE TRANSACTION — 준비 파이프라인”xact.c의 SQL 레벨 PrepareTransaction()이 1단계를 조율한다. twophase.c를 세 단계로 호출한다.
1단계 — MarkAsPreparing: 슬롯 예약과 GID 중복 확인.
// MarkAsPreparing — src/backend/access/transam/twophase.cGlobalTransactionMarkAsPreparing(TransactionId xid, const char *gid, TimestampTz prepared_at, Oid owner, Oid databaseid){ // ... 길이 검사, max_prepared_xacts 0 검사 ... LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE);
/* GID 중복 확인 */ for (i = 0; i < TwoPhaseState->numPrepXacts; i++) if (strcmp(TwoPhaseState->prepXacts[i]->gid, gid) == 0) ereport(ERROR, ...); /* 중복 GID */
gxact = TwoPhaseState->freeGXacts; /* 여유 리스트에서 꺼냄 */ TwoPhaseState->freeGXacts = gxact->next; MarkAsPreparingGuts(gxact, xid, gid, ...); /* 더미 PGPROC + gxact 초기화 */ gxact->ondisk = false; TwoPhaseState->prepXacts[TwoPhaseState->numPrepXacts++] = gxact; LWLockRelease(TwoPhaseStateLock); return gxact;}MarkAsPreparingGuts는 더미 PGPROC을 초기화한다. 구조체를 0으로 채우고, pid = 0을 설정하며, TwoPhaseGetXidByVirtualXID를 위해 lxid를 복사하고, 빈 myProcLocks[] 파티션을 설정한다. 그리고 MyLockedGxact = gxact를 저장한다. gxact는 prepXacts 배열에 들어갔지만 valid = false이므로 다른 백엔드가 두 번째 단계를 위해 잠글 수 없다.
2단계 — StartPrepare: 상태 블롭 직렬화.
// StartPrepare — src/backend/access/transam/twophase.cvoidStartPrepare(GlobalTransaction gxact){ TwoPhaseFileHeader hdr; // ... children, commitrels, abortrels, stats, invalmsgs 수집 ... hdr.magic = TWOPHASE_MAGIC; hdr.xid = gxact->xid; hdr.nsubxacts = xactGetCommittedChildren(&children); hdr.ncommitrels = smgrGetPendingDeletes(true, &commitrels); hdr.nabortrels = smgrGetPendingDeletes(false, &abortrels); hdr.ninvalmsgs = xactGetCommittedInvalidationMessages(&invalmsgs, ...); // ... 각 섹션을 save_state_data로 저장 ...}save_state_data는 모듈 레벨 연결 리스트인 records(정적 StateFileChunk 블록 체인)에 데이터를 덧붙인다. 그 뒤 2PC 리소스 매니저들이 RegisterTwoPhaseRecord를 호출해 자신의 레코드를 같은 체인에 추가한다. 잠금 매니저, pgstat, multixact, 프레디케이트 잠금 매니저가 각각 트랜잭션별 상태를 직렬화한다.
3단계 — EndPrepare: WAL 기록, 플러시, valid 표시.
// EndPrepare — src/backend/access/transam/twophase.cvoidEndPrepare(GlobalTransaction gxact){ /* 끝 센티넬 레코드 추가 */ RegisterTwoPhaseRecord(TWOPHASE_RM_END_ID, 0, NULL, 0);
/* total_len을 헤더에 채움 */ hdr->total_len = records.total_len + sizeof(pg_crc32c);
START_CRIT_SECTION(); MyProc->delayChkptFlags |= DELAY_CHKPT_START; /* 체크포인트 경쟁 방지 */
XLogBeginInsert(); for (record = records.head; record != NULL; record = record->next) XLogRegisterData(record->data, record->len); XLogSetRecordFlags(XLOG_INCLUDE_ORIGIN); gxact->prepare_end_lsn = XLogInsert(RM_XACT_ID, XLOG_XACT_PREPARE); XLogFlush(gxact->prepare_end_lsn); /* 클라이언트에게 돌아가기 전 내구성 확보 */
gxact->prepare_start_lsn = ProcLastRecPtr;
MarkAsPrepared(gxact, false); /* valid=true, ProcArrayAdd(더미 PGPROC) */
MyProc->delayChkptFlags &= ~DELAY_CHKPT_START; MyLockedGxact = gxact; END_CRIT_SECTION();
SyncRepWaitForLSN(gxact->prepare_end_lsn, false); /* 동기 복제 대기 */}MyProc의 DELAY_CHKPT_START 플래그는 WAL 삽입과 MarkAsPrepared 사이에 체크포인트가 완료되는 것을 막는다. 이 플래그가 없으면 체크포인트가 아직 유효하지 않은 gxact를 fsync하지 않고 지나쳐, 모든 준비 트랜잭션을 체크포인트가 인식한다는 불변식이 깨진다. 플러시는 무조건이다. 2PC에는 synchronous_commit = off 단축 경로가 없다. 준비 레코드는 클라이언트가 PREPARE TRANSACTION 응답을 받기 전에 디스크에 내구성 있게 기록된다.
MarkAsPrepared 이후 더미 PGPROC이 ProcArray에 들어간다. 이 시점에 ProcArray에 같은 XID가 두 번 나타나는 짧은 구간이 있다. 실제 백엔드(MyProc, 아직 자신의 XID를 지우는 중)와 더미 프로세스에 각각 한 번씩이다. 이 이중 존재는 의도적이다. TransactionIdIsInProgress가 XID를 실행 중이 아닌 것으로 보고하는 틈을 없애기 위해서다. 그 틈이 생기면 동시 스냅샷이 잘못된 결론을 낼 수 있다.
그림 2 — PREPARE TRANSACTION 상태 전이
stateDiagram-v2
[*] --> 예약중 : MarkAsPreparing\nvalid=false
예약중 --> 직렬화중 : StartPrepare\n블롭 조립
직렬화중 --> WAL기록됨 : EndPrepare\nXLogInsert+Flush
WAL기록됨 --> 준비됨 : MarkAsPrepared\nvalid=true ProcArrayAdd
준비됨 --> [*] : COMMIT PREPARED\n또는 ROLLBACK PREPARED
그림 2 — GXACT가 PREPARE TRANSACTION 중에 거치는 네 가지 내부 상태. WAL기록됨으로의 전이가 내구성 확정 시점이다. 준비됨은 더미 PGPROC을 다른 백엔드에 보이게 한다.
2단계: COMMIT PREPARED / ROLLBACK PREPARED
섹션 제목: “2단계: COMMIT PREPARED / ROLLBACK PREPARED”준비한 백엔드가 아닌 어느 백엔드든 트랜잭션을 완료할 수 있다. 진입점은 FinishPreparedTransaction(gid, isCommit)이다.
// FinishPreparedTransaction — src/backend/access/transam/twophase.cvoidFinishPreparedTransaction(const char *gid, bool isCommit){ gxact = LockGXact(gid, GetUserId()); /* GID 검증, locking_backend 설정 */ xid = gxact->xid;
/* 상태 읽기: !ondisk면 WAL에서, ondisk면 파일에서 */ if (gxact->ondisk) buf = ReadTwoPhaseFile(xid, false); else XlogReadTwoPhaseData(gxact->prepare_start_lsn, &buf, NULL);
/* 헤더 분해: children[], commitrels[], abortrels[], commitstats[], abortstats[], invalmsgs[] */ hdr = (TwoPhaseFileHeader *) buf; bufptr = buf + MAXALIGN(sizeof(TwoPhaseFileHeader)); bufptr += MAXALIGN(hdr->gidlen); children = (TransactionId *) bufptr; bufptr += ...; commitrels = (RelFileLocator *) bufptr; bufptr += ...; abortrels = (RelFileLocator *) bufptr; bufptr += ...; // ... stats, invalmsgs ...
HOLD_INTERRUPTS();
/* 1. 커밋/중단 WAL 레코드 기록 (항상 플러시) */ if (isCommit) RecordTransactionCommitPrepared(xid, ...); else RecordTransactionAbortPrepared(xid, ...);
/* 2. ProcArray에서 더미 프로세스 제거 */ ProcArrayRemove(proc, latestXid);
gxact->valid = false; /* 실패 시 재진입 방지 */
/* 3. 보류 중인 릴레이션 삭제, 통계 삭제 실행 */ DropRelationFiles(delrels, ndelrels, false); pgstat_execute_transactional_drops(...);
/* 4. 캐시 무효화 메시지 전송 (커밋만) */ if (isCommit) SendSharedInvalidMessages(invalmsgs, hdr->ninvalmsgs);
/* 5. 2PC rmgr 콜백 실행 (잠금 해제, pgstat, multixact, predlock) */ LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE); if (isCommit) ProcessRecords(bufptr, xid, twophase_postcommit_callbacks); else ProcessRecords(bufptr, xid, twophase_postabort_callbacks); PredicateLockTwoPhaseFinish(xid, isCommit); RemoveGXact(gxact); /* 여유 리스트로 반환 */ LWLockRelease(TwoPhaseStateLock);
/* 6. 파일이 디스크로 승격됐다면 pg_twophase 파일 삭제 */ if (ondisk) RemoveTwoPhaseFile(xid, true);
RESUME_INTERRUPTS();}순서는 의도적이다. WAL 레코드 먼저(되돌릴 수 없는 결정), 그 다음 ProcArray 제거(XID가 “실행 중 아님”이 됨), 그 다음 리소스 정리, 마지막으로 콜백 체인을 통한 잠금 해제. 이것은 단일 백엔드 커밋에서 CommitTransaction의 순서를 그대로 반영하며 동일한 안전 속성을 보장한다. 다른 백엔드가 “커밋됐지만 정리 안 됨” 상태를 볼 수 없는 것은 ProcArray 제거가 잠금 해제보다 먼저 일어나기 때문이다.
LockGXact는 같은 GID에 대한 동시 커밋/롤백 시도를 직렬화한다. 호출 백엔드의 ProcNumber를 gxact->locking_backend에 저장하면, 두 번째 시도는 locking_backend != INVALID_PROC_NUMBER를 보고 오류를 발생시킨다. 또한 커밋 백엔드가 준비 트랜잭션과 같은 데이터베이스에 있는지도 강제한다(NOTIFY 및 데이터베이스 로컬 상태로 인한 제약).
RecordTransactionCommitPrepared 경로는 항상 플러시한다(XLogFlush). GID 슬롯을 정당화한 준비 레코드 자체가 플러시됐기 때문에 synchronous_commit 단축이 없다. 중단 경로도 항상 플러시한다. 중단 후 2PC 상태 파일을 제거해야 하고, WAL 레코드가 그 제거보다 앞서야 하기 때문이다.
상태 파일 형식과 WAL ↔ 파일 생명주기
섹션 제목: “상태 파일 형식과 WAL ↔ 파일 생명주기”StartPrepare / RegisterTwoPhaseRecord가 조립한 직렬화 상태 블롭은 고정된 구조를 가진다.
1. TwoPhaseFileHeader (= xl_xact_prepare, magic·xid·GID·카운트 포함)2. TransactionId[] (서브트랜잭션 XID들)3. RelFileLocator[] (커밋 시 삭제할 릴레이션들)4. RelFileLocator[] (중단 시 삭제할 릴레이션들)5. xl_xact_stats_item[] (커밋 시 pgstat 삭제들)6. xl_xact_stats_item[] (중단 시 pgstat 삭제들)7. SharedInvalidationMessage[] (캐시 무효화 메시지들)8. TwoPhaseRecordOnDisk* (rmgr별 레코드: 잠금 상태, multixact, predlock)9. TwoPhaseRecordOnDisk (끝 센티넬, rmid == TWOPHASE_RM_END_ID)10. pg_crc32c (앞선 모든 바이트에 대한 CRC-32C)이 레이아웃은 WAL 레코드 본문과 pg_twophase/ 파일 양쪽에 동일하게 쓰인다(파일은 디스크에 CRC를 추가하고, WAL은 자체 CRC를 사용한다). 커밋 시 FinishPreparedTransaction이 블롭을 재독하고, 고정 섹션을 차례로 지나는 bufptr 워크가 rmgr별 레코드에 도달하면 ProcessRecords로 디스패치된다.
WAL→파일 승격은 CheckPointTwoPhase에서 일어난다.
// CheckPointTwoPhase — src/backend/access/transam/twophase.cvoidCheckPointTwoPhase(XLogRecPtr redo_horizon){ LWLockAcquire(TwoPhaseStateLock, LW_SHARED); for (i = 0; i < TwoPhaseState->numPrepXacts; i++) { GlobalTransaction gxact = TwoPhaseState->prepXacts[i]; if ((gxact->valid || gxact->inredo) && !gxact->ondisk && gxact->prepare_end_lsn <= redo_horizon) { XlogReadTwoPhaseData(gxact->prepare_start_lsn, &buf, &len); RecreateTwoPhaseFile(gxact->xid, buf, len); /* 쓰기 + fsync */ gxact->ondisk = true; gxact->prepare_start_lsn = InvalidXLogRecPtr; /* WAL 포인터 지움 */ } } LWLockRelease(TwoPhaseStateLock); fsync_fname(TWOPHASE_DIR, true); /* 삭제도 포함해 디렉터리 fsync */}redo_horizon 임계값은 체크포인트의 redo LSN이다. prepare_end_lsn이 redo_horizon 이하인 gxact는 다음 복구 스캔 전에 WAL 레코드가 재활용될 수 있다. 파일로 승격해야 한다. 더 최근 LSN을 가진 gxact는 WAL 기반 상태를 유지한다. 가장 최근에 준비된 트랜잭션은 거의 파일시스템을 건드리지 않는다.
그림 3 — 준비에서 커밋까지 상태 데이터 생명주기
flowchart TD
A["PREPARE TRANSACTION\nEndPrepare가 XLOG_XACT_PREPARE 기록\ngxact.ondisk=false\ngxact.prepare_start_lsn=L"]
B{"체크포인트\nredo_horizon ≥ L?"}
C["CheckPointTwoPhase\nRecreateTwoPhaseFile\ngxact.ondisk=true"]
D{"COMMIT / ROLLBACK\nPREPARED"}
E["XlogReadTwoPhaseData\nL에서 WAL 읽기"]
F["ReadTwoPhaseFile\npg_twophase/ 에서 읽기"]
G["FinishPreparedTransaction\n콜백 적용, WAL 레코드, 정리"]
A --> B
B -- "아니오 (빠른 경로)" --> D
B -- "예" --> C
C --> D
D -- "!ondisk" --> E
D -- "ondisk" --> F
E --> G
F --> G
그림 3 — 상태 데이터는 WAL 기반으로 시작한다. 체크포인트의 redo 수평선이 준비 LSN을 지나면 pg_twophase/로 승격된다. 두 번째 단계 경로는 ondisk 플래그에 따라 두 저장소 중 하나에서 읽는다.
복구와 복제
섹션 제목: “복구와 복제”시작 시 StartupXLOG는 restoreTwoPhaseData를 호출해 pg_twophase/를 스캔하고 충돌 전에 이미 디스크에 있던 항목으로 TwoPhaseState를 채운다. WAL 재연은 만나는 XLOG_XACT_PREPARE 레코드마다 PrepareRedoAdd를 호출한다.
// PrepareRedoAdd — src/backend/access/transam/twophase.cvoidPrepareRedoAdd(char *buf, XLogRecPtr start_lsn, XLogRecPtr end_lsn, RepOriginId origin_id){ // ... gxact = MarkAsPreparing(hdr->xid, gid, hdr->prepared_at, hdr->owner, hdr->database); gxact->prepare_start_lsn = start_lsn; gxact->prepare_end_lsn = end_lsn; gxact->ondisk = (start_lsn == InvalidXLogRecPtr); /* 디스크에서 = LSN 없음 */ gxact->inredo = true; MarkAsPrepared(gxact, true);}PrepareRedoAdd로 추가된 항목은 inredo = true다. WAL 스트림에서 XLOG_XACT_COMMIT_PREPARED 또는 XLOG_XACT_ABORT_PREPARED 레코드가 뒤따르면 PrepareRedoRemove가 gxact를 제거한다. 복구 종료 시점에 남아 있는 항목은 RecoverPreparedTransactions가 복원한다. MarkAsPreparingGuts를 다시 실행하고, 서브트랜잭션 데이터를 로드하고, MarkAsPrepared로 더미 PGPROC을 ProcArray에 되돌리고, twophase_recover_callbacks를 디스패치해 잠금과 multixact 상태를 재획득한다. 이 과정이 끝나면 준비 트랜잭션은 현재 실행 중인 프라이머리에서 준비된 것과 구분할 수 없다.
핫 스탠바이는 더 가벼운 형태가 필요하다. StandbyRecoverPreparedTransactions는 setParent = true로 ProcessTwoPhaseBuffer를 실행해 pg_subtrans를 채우고(스냅샷 오버플로 시 올바른 동작을 위해) 잠금은 재획득하지 않는다. 스탠바이 쿼리와 준비 트랜잭션의 충돌은 StandbyReleaseLockTree 경로가 처리한다.
twophase_rmgr.c의 2PC 리소스 매니저 콜백 테이블은 네 개의 배열을 노출한다.
// twophase_rmgr.c — src/backend/access/transam/twophase_rmgr.cconst TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = { NULL, /* END ID */ lock_twophase_recover, /* TWOPHASE_RM_LOCK_ID */ NULL, /* TWOPHASE_RM_PGSTAT_ID */ multixact_twophase_recover, /* TWOPHASE_RM_MULTIXACT_ID */ predicatelock_twophase_recover /* TWOPHASE_RM_PREDICATELOCK_ID */};const TwoPhaseCallback twophase_postcommit_callbacks[...] = { ..., lock_twophase_postcommit, pgstat_twophase_postcommit, multixact_twophase_postcommit, NULL };const TwoPhaseCallback twophase_postabort_callbacks[...] = { ..., lock_twophase_postabort, pgstat_twophase_postabort, multixact_twophase_postabort, NULL };const TwoPhaseCallback twophase_standby_recover_callbacks[...] = { ..., lock_twophase_standby_recover, NULL, NULL, NULL };predicatelock_twophase_recover는 준비 트랜잭션이 관련된 충돌 후에도 SSI 프레디케이트 잠금이 올바르게 복원되게 한다. 포스트-커밋/중단 콜백은 잠금을 해제하고(잠금 매니저), 통계를 갱신하며(pgstat), multixact 정리를 처리한다.
소스 코드 안내
섹션 제목: “소스 코드 안내”공유 메모리와 생명주기
섹션 제목: “공유 메모리와 생명주기”TwoPhaseShmemSize/TwoPhaseShmemInit— 크기 계산과 초기화.PreparedXactProcs[]더미 프로세스를 각GlobalTransactionData.pgprocno에 연결한다.MarkAsPreparing— 여유 gxact를 꺼내고, GID 유일성을 확인하고,MarkAsPreparingGuts로 더미 PGPROC을 초기화하고,prepXacts[]에 삽입한다.MarkAsPreparingGuts— 더미 PGPROC 구조체를 0으로 채우고 초기화한다(pid=0, myProcLocks[], vxid 복제).MyLockedGxact를 설정한다.MarkAsPrepared—valid = true로 설정하고,ProcArrayAdd를 호출한다.LockGXact— GID를 위해prepXacts[]를 선형 탐색하고,locking_backend를 설정해 동시 두 번째 단계를 방지한다.RemoveGXact—prepXacts[]배열에서 제거하고, 여유 리스트로 돌려 보낸다.AtAbort_Twophase/PostPrepare_Twophase— 오류 시 준비 중이던 백엔드용 정리 훅.
준비 파이프라인
섹션 제목: “준비 파이프라인”StartPrepare—records체인을 할당하고,TwoPhaseFileHeader를 쓰고,GXactLoadSubxactData를 호출한다.save_state_data— MAXALIGN 패딩된 바이트를records체인에 덧붙인다.RegisterTwoPhaseRecord— 2PC 리소스 매니저의 공개 API.TwoPhaseRecordOnDisk헤더와 데이터를 덧붙인다.EndPrepare— 끝 센티넬을 추가하고,total_len을 채우고,XLogInsert+XLogFlush로XLOG_XACT_PREPARE를 기록하고,MarkAsPrepared를 호출하고, 동기 복제를 기다린다.
두 번째 단계 완료
섹션 제목: “두 번째 단계 완료”FinishPreparedTransaction— 메인 디스패치.LockGXact→ 상태 읽기 → WAL 커밋/중단 레코드 →ProcArrayRemove→ 파일 삭제 → 무효화 전송 →ProcessRecords(콜백) →RemoveGXact→ 선택적 파일 삭제.RecordTransactionCommitPrepared/RecordTransactionAbortPrepared—xact.c의RecordTransactionCommit/RecordTransactionAbort를 반영하지만 항상 플러시한다.synchronous_commit = off를 사용할 수 없다.ProcessRecords—TwoPhaseRecordOnDisk체인을 순회하며 각 레코드의 rmid를 해당 콜백 배열로 디스패치한다.
상태 파일 I/O
섹션 제목: “상태 파일 I/O”ReadTwoPhaseFile—pg_twophase/<XID>를 열고, magic + CRC-32C를 검증하고, palloc된 버퍼를 반환한다.XlogReadTwoPhaseData—XLogReaderState를 할당하고,prepare_start_lsn에서XLOG_XACT_PREPARE레코드를 읽고, 데이터 부분을 반환한다.RecreateTwoPhaseFile—content+ CRC-32C를pg_twophase/<XID>에 쓰고 fsync한다(복구 종료 체크포인트는 재fsync하지 않는다).RemoveTwoPhaseFile—unlink(pg_twophase/<XID>).CheckPointTwoPhase—prepXacts[]를 순회하며prepare_end_lsn ≤ redo_horizon인 gxact를 WAL에서 파일로 승격한다. 디렉터리를 fsync한다.TwoPhaseFilePath—pg_twophase/<16자리 16진수 FullXID>경로를 포맷한다.
restoreTwoPhaseData— 복구 시작 시 호출.pg_twophase/를 스캔하고, 유효 파일마다ProcessTwoPhaseBuffer+PrepareRedoAdd를 호출한다.PrepareRedoAdd—XLOG_XACT_PREPAREWAL 재연 중 호출.inredo = true인 gxact를 생성하고, LSN 포인터를 기록하고,MarkAsPrepared를 호출한다.PrepareRedoRemove— 커밋/중단 준비 WAL 재연 중 호출.TwoPhaseState에서 gxact를 제거하고, 선택적으로 파일을 삭제한다.PrescanPreparedTransactions— WAL 재연 후 스캔. 가장 오래된 준비 XID를 계산하고(pg_subtrans시작을 위해),TransamVariables->nextXid전진을 위해 XID를 수집한다.StandbyRecoverPreparedTransactions— 핫 스탠바이 설정. 잠금을 재획득하지 않고SubTransSetParent로pg_subtrans를 채운다.RecoverPreparedTransactions— 복구 종료 시점.twophase_recover_callbacks로 잠금과 multixact 상태를 재획득하고,PostPrepare_Twophase로 gxact를 잠금 해제한다.ProcessTwoPhaseBuffer— 공통 헬퍼. 디스크 또는 WAL에서 읽고, 헤더를 검증하고, 선택적으로nextXid를 전진시키고, 선택적으로 서브트랜잭션 부모를 설정한다.
위치 힌트 (2026-06-05 기준, 커밋 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, 커밋 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
TwoPhaseStateData | src/backend/access/transam/twophase.c | 176 |
GlobalTransactionData | src/backend/access/transam/twophase.c | 147 |
TwoPhaseShmemSize | src/backend/access/transam/twophase.c | 237 |
TwoPhaseShmemInit | src/backend/access/transam/twophase.c | 252 |
MarkAsPreparing | src/backend/access/transam/twophase.c | 358 |
MarkAsPreparingGuts | src/backend/access/transam/twophase.c | 432 |
MarkAsPrepared | src/backend/access/transam/twophase.c | 529 |
LockGXact | src/backend/access/transam/twophase.c | 551 |
RemoveGXact | src/backend/access/transam/twophase.c | 627 |
StartPrepare | src/backend/access/transam/twophase.c | 1050 |
EndPrepare | src/backend/access/transam/twophase.c | 1143 |
RegisterTwoPhaseRecord | src/backend/access/transam/twophase.c | 1264 |
ReadTwoPhaseFile | src/backend/access/transam/twophase.c | 1287 |
XlogReadTwoPhaseData | src/backend/access/transam/twophase.c | 1404 |
FinishPreparedTransaction | src/backend/access/transam/twophase.c | 1487 |
ProcessRecords | src/backend/access/transam/twophase.c | 1681 |
RecreateTwoPhaseFile | src/backend/access/transam/twophase.c | 1727 |
CheckPointTwoPhase | src/backend/access/transam/twophase.c | 1807 |
restoreTwoPhaseData | src/backend/access/transam/twophase.c | 1888 |
PrepareRedoAdd | src/backend/access/transam/twophase.c | 2469 |
PrepareRedoRemove | src/backend/access/transam/twophase.c | 2572 |
PrescanPreparedTransactions | src/backend/access/transam/twophase.c | 1952 |
StandbyRecoverPreparedTransactions | src/backend/access/transam/twophase.c | 2033 |
RecoverPreparedTransactions | src/backend/access/transam/twophase.c | 2073 |
ProcessTwoPhaseBuffer | src/backend/access/transam/twophase.c | 2176 |
twophase_recover_callbacks | src/backend/access/transam/twophase_rmgr.c | 24 |
twophase_postcommit_callbacks | src/backend/access/transam/twophase_rmgr.c | 33 |
twophase_postabort_callbacks | src/backend/access/transam/twophase_rmgr.c | 42 |
twophase_standby_recover_callbacks | src/backend/access/transam/twophase_rmgr.c | 51 |
TWOPHASE_RM_LOCK_ID | src/include/access/twophase_rmgr.h | 23 |
GIDSIZE | src/include/access/xact.h | — |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
max_prepared_xacts = 0은 2PC를 완전히 비활성화한다.MarkAsPreparing은 모든 작업 전에 이 값을 확인하고ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE를 발생시킨다. 기본값은 0이다. 사용자는max_prepared_transactions를 0보다 크게 명시적으로 설정해야 한다.MarkAsPreparing(twophase.c ~373)에서 검증. -
상태 데이터는 먼저 WAL에, 파일은 체크포인트에서만 쓴다.
twophase.c상단 주석(38–68행)과EndPrepare/CheckPointTwoPhase구현이 확인한다.PREPARE TRANSACTION시점에는 WAL 레코드만 기록된다.pg_twophase/파일은 체크포인트 시점에prepare_end_lsn ≤ redo_horizon일 때만 나타난다. 다음 체크포인트 전에 커밋되는 빠른 경로의 준비 트랜잭션은 파일시스템을 전혀 건드리지 않는다. 두 함수를 읽어 검증. -
더미 PGPROC은 준비 백엔드가 종료되기 전에 ProcArray에 나타난다.
MarkAsPrepared는EndPrepare가 반환되기 전에ProcArrayAdd를 호출하므로, ProcArray에 같은 XID가 잠시 이중으로 존재한다. 1222–1230행의 주석이 이것이 의도적임을 설명한다.EndPrepare와MarkAsPrepared에서 검증. -
COMMIT PREPARED는synchronous_commit설정과 무관하게 항상 WAL을 플러시한다.RecordTransactionCommitPrepared는 무조건XLogFlush(recptr)를 호출한다(twophase.c ~2367). 주석은 “준비된 xact의 비동기 커밋은 지원하지 않는다(그 개념 자체가 아마도 모순)“고 설명한다. 검증됨. -
두 번째 단계는 같은 데이터베이스로 제한된다.
LockGXact는MyDatabaseId != proc->databaseId를 확인하고 오류를 발생시킨다(twophase.c ~595–599). NOTIFY 및 기타 데이터베이스 로컬 부작용이 명시된 이유다. 검증됨. -
CRC-32C가 상태 파일을 보호한다.
ReadTwoPhaseFile은 magic(TWOPHASE_MAGIC = 0x57F94534)과 앞선 모든 바이트에 대한 CRC-32C를 검증한다.RecreateTwoPhaseFile은 쓸 때 CRC를 재계산하여 추가한다. 두 함수에서 검증. -
twophase_recover_callbacks에 pgstat가 없다.twophase_rmgr.c(28행)에서TWOPHASE_RM_PGSTAT_ID의 복구 콜백은NULL이다. pgstat는 포스트-커밋 및 포스트-중단 경로에 참여하지만 복구 시 pgstat 상태 복원은 시도하지 않는다.twophase_rmgr.c를 읽어 검증. -
predicatelock_twophase_recover는 충돌 후 SSI 잠금을 복원한다.twophase_recover_callbacks[TWOPHASE_RM_PREDICATELOCK_ID]→predicatelock_twophase_recover(storage/lmgr/predicate.c)를 추적하여 검증. 이 콜백 덕분에 준비 트랜잭션이 복구 후 SSI 추적에서 벗어나지 않는다.
열린 질문
섹션 제목: “열린 질문”-
GIDSIZE값.GlobalTransactionData.gid[GIDSIZE]에 쓰이지만twophase.h에는 보이지 않는다.src/include/access/xact.h에 정의되어 있으며 값은 200(null 종료자 포함 바이트)이다. 위치 힌트 테이블을 갱신할 때xact.h에서 확인하라. -
상태 블롭의 최대 크기.
EndPrepare는hdr->total_len > MaxAllocSize를 확인하고 오류를 발생시키지만, 서브트랜잭션이 많고 릴레이션 삭제가 많고 잠금 상태가 큰 트랜잭션의 실제 최대 블롭 크기는 문서화되어 있지 않다. 극단적인 워크로드(수천 개의 서브트랜잭션 + 수천 개의 잠금)는 한계에 근접할 수 있다. 정확한 임계값은 측정이 필요하다. -
DELAY_CHKPT_START와DELAY_CHKPT_COMPLETE의 상호작용.EndPrepare의 주석은 방지하려는 경쟁을 설명하지만, 동시 체크포인트 부하 아래CheckPointTwoPhase의 자체 잠금 프로토콜과의 전체 상호작용은 이 문서에서 추적되지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”-
X/Open XA 표준 —
PREPARE TRANSACTION/COMMIT PREPARED/ROLLBACK PREPAREDSQL 문법과 GID 문자열은 XA 인터페이스(X/Open CAE Specification, Distributed Transaction Processing: The XA Specification, 1991)의 직접 구현이다. PostgreSQL의twophase_rmgr.c콜백 배열이 XA 리소스 매니저 역할이다. PostgreSQL의 충돌 복구 동작을 XA의 “휴리스틱 완료” 개념(TM 부재 시 리소스 매니저가 타임아웃 후 단독 결정을 내리는 것)과 비교하면 TM 없이 PostgreSQL이 무엇을 보장하고 무엇을 보장하지 않는지 명확해진다. -
분산 트랜잭션 코디네이터 (TM 없는 2PC). PostgreSQL은 2PC 기본 요소를 노출하지만 트랜잭션 매니저를 탑재하지 않는다. 애플리케이션이나 미들웨어(Pgpool-II,
postgres_fdw+pg_prepared_statements, Patroni, Citus)가 코디네이터 역할을 한다. Spanner의 “Paxos 기반 2PC”(Corbett et al., 2012)나 CockroachDB의 병렬 커밋과 비교하면 블로킹하는 두 번째 단계 또는 코디네이터 단일 장애점을 제거할 때 지연 시간/가용성 트레이드오프가 어떻게 달라지는지 보인다. -
2PC 대안으로서의 Saga 패턴. 1단계 중 블로킹을 감당할 수 없는 장기 실행 분산 트랜잭션은 종종 사가(Garcia-Molina & Salem, Sagas, SIGMOD 1987)를 사용한다. 보상 동작을 가진 로컬 트랜잭션 시퀀스다. PostgreSQL의 2PC는 고전적인 대안이다. 각각이 언제 적합한지 비교하면
postgres-xact.md의 서브트랜잭션 vs. 세이브포인트 논의를 보완한다. -
MySQL/InnoDB XA 경로. InnoDB는
XA PREPARE/XA COMMIT/XA ROLLBACK을 잠금 매니저 레이어의 준비 XA 트랜잭션 해시 테이블로 노출한다. PostgreSQL의 더미 PGPROC 방식과 달리, InnoDB의 준비 XA 트랜잭션은 잠금 충돌 탐지를 위해 모든 백엔드에 보이는 공유 프로세스 배열 항목을 유지하지 않는다. 충돌은 InnoDB 트랜잭션 레이어에서 해결한다. 두 설계는 통합 깊이와 코드 복잡성 사이의 서로 다른 트레이드오프 지점을 나타낸다. -
증분 체크포인트와 2PC 파일 공간. PostgreSQL의 증분 백업 기능(PG17+,
pg_combinebackup)은pg_twophase/파일을 올바르게 처리해야 한다. WAL 세그먼트가 재활용된 경우 WAL에서 직접 복구할 수 없는 내구성 있는 상태이기 때문이다.CheckPointTwoPhase의 승격 로직과 증분 백업의 변경 블록 추적 간의 상호작용은 정확성 검증을 위한 잠재적 연구 영역이다.
소스 파일
섹션 제목: “소스 파일”src/backend/access/transam/twophase.c— 메인 구현 (2753행, 커밋 273fe94)src/backend/access/transam/twophase_rmgr.c— 2PC 리소스 매니저 콜백 테이블 (58행)src/include/access/twophase.h— 공개 API 선언src/include/access/twophase_rmgr.h—TwoPhaseCallback,TwoPhaseRmgrId,TWOPHASE_RM_*상수
이 지식 베이스 내 교차 참조
섹션 제목: “이 지식 베이스 내 교차 참조”knowledge/code-analysis/postgres/postgres-xact.md— 단일 백엔드 트랜잭션 생명주기.PrepareTransaction()이twophase.c를 호출한다.knowledge/code-analysis/postgres/postgres-xlog-wal.md— WAL 삽입 기계 (XLogBeginInsert/XLogInsert/XLogFlush)knowledge/code-analysis/postgres/postgres-recovery-redo.md— WAL 재연, redo 경로의PrepareRedoAdd/PrepareRedoRemoveknowledge/code-analysis/postgres/postgres-lock-manager.md— 잠금 매니저 2PC 콜백 (lock_twophase_recover,lock_twophase_postcommit등)knowledge/code-analysis/postgres/postgres-mvcc-snapshots.md— procarray 스냅샷 획득.GetSnapshotData에서 더미 PGPROC 가시성knowledge/research/dbms-papers/aries.md— ARIES 복구 이론