콘텐츠로 이동

(KO) PostgreSQL 복구와 Redo — 크래시 복구, PITR, 그리고 Hot Standby

목차

복구(recovery)는 하나의 질문에 답하기 위해 존재한다. 크래시 이후 커밋된 트랜잭션은 모두 보이고 커밋되지 않은 트랜잭션은 모두 보이지 않는 상태로 어떻게 돌아갈 것인가. 이 질문에 대한 정석적 답이 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에 정리되어 있다). PostgreSQL의 복구 구조는 ARIES의 세 원칙을 직접적으로, 충실하게 구현한 사례다.

  1. 미리-쓰기 로깅(write-ahead logging). 데이터 페이지의 모든 변경은 WAL에 먼저 기록된다. LSN L의 WAL 레코드는 PageLSNL인 데이터 페이지보다 먼저 안정 저장소에 도달해야 한다. 이 불변성은 버퍼 매니저의 XLogFlush가 강제하며, steal 정책(더티 페이지를 트랜잭션 커밋 전에 캐시에서 내보낼 수 있음)과 no-force 정책(커밋 시 더티 페이지를 반드시 플러시할 필요 없음)을 모두 안전하게 만든다. 로그만 있으면 두 경우 모두 redo하거나 undo할 수 있기 때문이다.

  2. redo 중 역사 반복. 재시작 시 마지막 체크포인트 이후의 모든 WAL 레코드를 LSN 순서대로 재실행한다. 최종적으로 중단된 트랜잭션의 레코드까지 포함해 크래시 직전의 정확한 페이지 상태를 재현한다. 그런 다음 커밋되지 않은 트랜잭션을 롤백한다. PostgreSQL은 이 원칙을 문자 그대로 구현한다. PerformWalRecovery는 체크포인트의 redo 포인터부터 앞방향으로 재실행하며, 어떤 레코드 타입도 건너뛰지 않는다. 각 rmgr의 rm_redo 콜백이 정상 운영과 정확히 같은 방식으로 레코드를 처리한다.

  3. undo 동작 로깅(compensation log records). 트랜잭션이 롤백될 때 undo 동작 자체도 보상 로그 레코드(compensation log record)로 기록된다. 덕분에 undo 진행 도중 크래시가 발생해도 롤백 진행이 사라지지 않는다. PostgreSQL은 명시적 롤백에 이 방식을 사용하며, 크래시 복구는 abort 레코드를 재실행하고 같은 메커니즘으로 undo한다.

세 원칙을 하나로 묶는 핵심 데이터 구조가 **LSN(Log Sequence Number, 로그 시퀀스 번호)**이다. LSN은 WAL 스트림 안의 64비트 바이트 오프셋이다. 모든 힙 페이지와 인덱스 페이지의 헤더에는 PageLSN 필드가 있다(postgres-xlog-wal.mdpostgres-page-layout.md 참조). 두 가지 비교 연산이 전체 기계를 작동시킨다.

  • WAL 규칙. 데이터 페이지는 flushedLSN >= PageLSN(page) 조건이 충족될 때만 디스크에 기록될 수 있다. FlushBuffer가 이를 강제한다.
  • 멱등(idempotent) redo. LSN L의 WAL 레코드는 PageLSN(page) >= L이면 건너뛴다. 이것이 크래시 후 재시작을 안전하게 반복할 수 있는 이유다.

PostgreSQL은 순수 크래시 복구를 넘어 같은 재실행 루프를 두 가지 추가 모드로 확장한다. **PITR(Point-in-Time Recovery, 시점 복구)**은 WAL 끝 대신 사용자가 지정한 목표 지점(XID, 타임스탬프, 명명된 복원 지점, LSN)에서 재실행을 멈춘다. Hot standby는 재실행을 무기한 계속하면서 스트리밍 복제나 아카이브에서 WAL을 소비하고, 데이터베이스가 일관 상태에 도달하면 읽기 전용 쿼리 연결을 허용한다. 세 모드 모두 같은 PerformWalRecovery 루프를 공유한다. 차이는 WAL의 출처와 루프가 멈추는 조건에 있다.

Database Internals(Petrov, 5장, “Transaction Processing and Recovery”)는 구현 공간을 두 축으로 설명한다.

  1. 로깅 세분성 — 물리적(바이트 범위), 논리적(연산), 생리적(페이지 범위 연산). PostgreSQL은 생리적 방식을 택한다. 각 rmgr은 원시 바이트 차이가 아니라 핸들러가 이해하는 형태로 페이지 편집을 기술한다. 이 덕분에 redo 핸들러를 작성하기 쉽고, FPI(전체 페이지 이미지, full-page image) 중복 기록도 자연스럽다.

  2. redo 전용 vs. undo+redo — WAL만으로 복구가 자기 완결적인지 여부. PostgreSQL은 크래시 복구에서 redo 전용 방식을 쓴다. 별도 undo 로그 없이 abort 레코드를 재실행해 롤백을 처리한다.

이론은 교과서가 설명하고, 이 절은 거의 모든 ARIES 계열 엔진이 수렴하는 공학적 관행을 정리한다. Oracle, InnoDB, SQL Server, DB2, PostgreSQL은 각자 다른 방식으로 구현하지만 아래 패턴들을 공유한다.

ARIES는 재시작 시 로그를 세 번 훑는 방식을 규정한다.

  1. 분석 단계 — 마지막 체크포인트부터 앞방향으로 스캔해 더티 페이지 테이블(어떤 페이지가 수정됐지만 아직 플러시되지 않았는가)과 활성 트랜잭션 테이블(크래시 시점에 진행 중인 트랜잭션은 무엇인가)을 재구성한다. 이 단계에서 redo 시작점과 undo 대상 집합을 결정한다.
  2. redo 단계 — redo 시작점부터 모든 로그 레코드를 재실행한다. PageLSN이 레코드 LSN보다 낮은 페이지만 실제로 변경을 적용한다. 이 단계가 끝나면 버퍼 풀은 크래시 직전의 상태를 정확히 반영한다.
  3. undo 단계 — 크래시 당시 활성 상태였던 모든 트랜잭션을 롤백하고 각 undo 동작을 보상 레코드로 기록한다.

PostgreSQL은 분석 단계를 단순화한다. WAL에 명시적 체크포인트 레코드가 있고, 그 레코드 안에 redo 포인터와 체크포인트 시점의 활성 XID 목록이 담겨 있기 때문이다. InitWalRecovery는 체크포인트 레코드를 읽어 redo 시작점과 활성 트랜잭션 집합을 직접 얻는다. 로그를 역방향으로 스캔할 필요가 없다. undo 단계도 암묵적이다. redo 단계에서 ABORT 레코드가 재실행되면서 진행 중이던 트랜잭션이 처리되고, 완전 재실행 후에도 활성 상태인 트랜잭션은 복구 후 정리 단계에서 롤백된다.

프로덕션 엔진은 크래시 복구(자동, 설정 불필요), 아카이브 복구/PITR(운영자가 시작, restore_command 필요), 스탠바이 모드(지속적으로 WAL 소비)를 구분한다. 표준 메커니즘은 시그널 파일이다. $PGDATArecovery.signal이나 standby.signal이 있으면 해당 모드를 선택하고, 둘 다 없으면 크래시 복구 모드다. GUC 파라미터(recovery_target, primary_conninfo, restore_command)는 외부 조작 손잡이이고, 시그널 파일은 모드 선택기다.

스탠바이가 승격되거나 PITR 목표에 도달해 새 WAL 쓰기가 시작되면, 새 WAL 스트림은 같은 LSN 위치의 이전 스트림과 구별되어야 한다. 다른 레플리카가 이미 이전 스트림을 승격 지점까지 소비했을 수 있고, 두 스트림은 그 지점 이후 갈라지기 때문이다. 이 문제의 보편적 해법이 **타임라인 ID(timeline ID)**다. 단조 증가하는 정수로, WAL 세그먼트 파일명 앞에 붙는다. 승격 때마다 타임라인 카운터가 올라가고, .history 파일에 이 타임라인이 부모 타임라인에서 분기한 LSN이 기록된다. 복구는 이 히스토리 체인을 참고해 특정 LSN 범위에 어느 타임라인의 WAL을 소비해야 할지 결정한다.

스탠바이는 보이는 모든 튜플의 삽입 트랜잭션이 커밋됐음을 알 수 있고, pg_control에 기록된 최소 복구 지점(minRecoveryPoint)을 통과했을 때에야 쿼리를 처리할 수 있다. 복구 중 읽기 쿼리를 지원하는 엔진은 이 “일관성 지점”을 추적하고 연결 수락 컴포넌트에 신호를 보낸다.

재실행 중 선행 읽기와 프리페치

섹션 제목: “재실행 중 선행 읽기와 프리페치”

작업 집합이 버퍼 풀을 초과하면 재실행은 I/O에 의해 병목된다. rm_redo 호출이 풀에 없는 페이지를 필요로 할 때마다 동기 읽기를 기다려야 하기 때문이다. 표준 완화책이 WAL 프리페치다. 별도 스레드나 코루틴이 디코딩된 WAL 스트림을 앞서 훑어 곧 필요한 페이지를 파악하고, redo 루프가 해당 레코드에 도달하기 전에 커널 페이지 캐시를 준비시키기 위해 readahead(2) 또는 posix_fadvise(POSIX_FADV_WILLNEED) 힌트를 비차단 방식으로 발행한다.

이론 개념PostgreSQL 이름
체크포인트 redo 포인터CheckPoint.redo (CheckPoint 구조체 내)
더티 페이지 테이블 / redo 시작점RedoStartLSN (체크포인트 레코드에서)
크래시 당시 활성 트랜잭션 집합CheckPoint 구조체 내 활성 XID 목록
분석 단계암묵적 — InitWalRecovery에서 체크포인트 레코드를 읽어 처리
redo 단계PerformWalRecovery / ApplyWalRecord 루프
undo 단계ABORT 레코드 재실행 + 복구 후 RecoverPreparedTransactions
보상 로그 레코드XLOG_XACT_ABORTxact_redo의 undo 로깅
LSN (바이트 오프셋)XLogRecPtr (64비트; SQL 타입은 pg_lsn)
PageLSNPageHeaderData.pd_lsn (모든 페이지의 첫 8바이트)
타임라인 IDTimeLineID (uint32; 승격 때마다 증가)
타임라인 히스토리 파일pg_wal/ 안의 <tli>.history
일관성 지점pg_controlminRecoveryPoint; reachedConsistency 플래그
Hot standby 활성화CheckRecoveryConsistency에서 PMSIGNAL_BEGIN_HOT_STANDBY 발송
WAL 프리페치 힌트XLogPrefetcherNextBlockPrefetchSharedBuffer

PostgreSQL의 복구 구조는 단일 소스 파일 xlogrecovery.c에 집중되어 있으며, 모든 작업이 스타트업 프로세스 안에서 실행된다. 세 함수가 작업을 깔끔하게 분담한다.

// InitWalRecovery — src/backend/access/transam/xlogrecovery.c
InitWalRecovery(ControlFileData *ControlFile,
bool *wasShutdown_ptr,
bool *haveBackupLabel_ptr,
bool *haveTblspcMap_ptr)

InitWalRecoverypg_control을 읽고, readRecoverySignalFile()recovery.signal이나 standby.signal을 감지하며, validateRecoveryParameters()로 GUC 복구 목표 설정이 선택된 모드와 일관성이 있는지 확인한다. XLogReaderState를 할당하고 XLogPrefetcher로 감싼다. 체크포인트 레코드를 pg_control에서 읽고(베이스 백업 복원의 경우 backup_label에서), 체크포인트의 redo 필드에서 RedoStartLSN을 결정한다. 이 함수가 반환하면 시스템은 재실행할 첫 WAL 레코드 위치에 자리잡은 상태다.

// PerformWalRecovery — src/backend/access/transam/xlogrecovery.c
void PerformWalRecovery(void)

PerformWalRecovery는 주 루프다. 공유 XLogRecoveryCtlData 수위 마커를 초기화하고, RmgrStartup()으로 모든 리소스 매니저를 등록한 뒤, do { ... } while (record != NULL) 재실행 루프에 진입한다. 각 반복에서 ReadRecord(프리페처 경유)로 다음 디코딩된 레코드를 가져오고, PITR 목표 도달 여부를 recoveryStopsBefore / recoveryStopsAfter로 확인하며, ApplyWalRecord를 호출한다. WAL이 더 이상 없거나 복구 목표에 도달하면 루프가 종료된다.

// FinishWalRecovery — src/backend/access/transam/xlogrecovery.c
EndOfWalRecoveryInfo *FinishWalRecovery(void)

FinishWalRecovery는 WAL 수신기를 종료하고, 마지막 유효 레코드의 끝(endOfLog)을 결정하며, 호출자(xlog.cStartupXLOG)가 WAL 쓰기 시작에 사용하는 EndOfWalRecoveryInfo 구조체를 반환한다. 이 호출이 끝나면 엔진은 연결 수락과 새 쓰기를 받을 준비가 된 것이다.

세 함수는 XLogRecoveryShmemInit에서 할당된 고정 크기 공유 메모리 구조체로 통신한다.

// XLogRecoveryCtlData — src/backend/access/transam/xlogrecovery.c
typedef struct XLogRecoveryCtlData
{
bool SharedHotStandbyActive;
bool SharedPromoteIsTriggered;
Latch recoveryWakeupLatch;
XLogRecPtr lastReplayedReadRecPtr; /* 마지막으로 재실행한 레코드의 시작 위치 */
XLogRecPtr lastReplayedEndRecPtr; /* 마지막으로 재실행한 레코드의 끝+1 위치 */
TimeLineID lastReplayedTLI;
/* rm_redo 호출 중: 현재 재실행 중인 레코드의 끝+1 위치 */
XLogRecPtr replayEndRecPtr;
TimeLineID replayEndTLI;
TimestampTz recoveryLastXTime;
TimestampTz currentChunkStartTime;
RecoveryPauseState recoveryPauseState;
ConditionVariable recoveryNotPausedCV;
slock_t info_lck;
} XLogRecoveryCtlData;

두 수위 마커 lastReplayedEndRecPtr(레코드가 성공적으로 재실행된 갱신)과 replayEndRecPtr(rm_redo 호출 에 갱신)은 소비자가 다르다. replayEndRecPtrXLogFlush가 읽어 레코드 재실행 도중에도 minRecoveryPoint를 올바르게 갱신하는 데 사용된다. lastReplayedEndRecPtrGetXLogReplayRecPtr이 보고하는 값이며, CheckRecoveryConsistency가 hot standby 연결을 열 시점을 결정할 때 사용한다.

recoveryPauseState / recoveryNotPausedCV 쌍은 pg_wal_replay_pause() / pg_wal_replay_resume() SQL 함수를 구현한다. 루프 중간에 래치 대기로 PerformWalRecovery를 일시 중단시키는 방식이다.

// ApplyWalRecord — src/backend/access/transam/xlogrecovery.c
static void
ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record,
TimeLineID *replayTLI)
{
// ... condensed ...
AdvanceNextFullTransactionIdPastXid(record->xl_xid);
/* 타임라인 전환 감지 */
if (record->xl_rmid == RM_XLOG_ID)
{
// XLOG_CHECKPOINT_SHUTDOWN / XLOG_END_OF_RECOVERY 감지
// newReplayTLI != *replayTLI이면 *replayTLI 갱신
}
/* rm_redo 호출 전에 replayEndRecPtr 갱신 */
XLogRecoveryCtl->replayEndRecPtr = xlogreader->EndRecPtr;
/* hot standby 충돌 해소를 위한 진행 중 XID 추적 */
if (standbyState >= STANDBY_INITIALIZED && TransactionIdIsValid(record->xl_xid))
RecordKnownAssignedTransactionIds(record->xl_xid);
/* 디스패치 */
if (record->xl_rmid == RM_XLOG_ID)
xlogrecovery_redo(xlogreader, *replayTLI);
GetRmgr(record->xl_rmid).rm_redo(xlogreader);
/* FPI 레코드에 대한 일관성 검사 */
if ((record->xl_info & XLR_CHECK_CONSISTENCY) != 0)
verifyBackupPageConsistency(xlogreader);
/* rm_redo 반환 후 lastReplayedEndRecPtr 갱신 */
XLogRecoveryCtl->lastReplayedEndRecPtr = xlogreader->EndRecPtr;
// ... condensed ...
}

멱등성 불변성은 ApplyWalRecord 자체가 아니라 각 rmgr의 rm_redo 콜백 안에서 강제된다. 힙과 인덱스 rmgr 모두 사용하는 표준 패턴은 XLogInitBufferForRedo 또는 XLogReadBufferForRedo를 호출하는 것이다. 두 함수 모두 페이지의 PageLSN을 레코드 LSN과 비교해 페이지에 갱신이 필요할 때만 BLK_NEEDS_REDO를 반환한다. 이 덕분에 어떤 체크포인트에서 재시작해도 안전하다. 효과가 이미 페이지에 반영된 레코드는 자동으로 건너뛰어지기 때문이다.

AdvanceNextFullTransactionIdPastXidTransamVariables->nextXid가 항상 재실행된 레코드의 XID보다 크도록 보장한다. 복구 후 할당되는 새 트랜잭션이 이미 재실행된 것과 충돌하지 않기 위해서다.

// readTimeLineHistory — src/backend/access/transam/timeline.c
List *readTimeLineHistory(TimeLineID targetTLI)

pg_wal/ 안의 타임라인 히스토리 파일 <tli>.history에는 조상 타임라인마다 <tli>\t<switchpoint> 형식의 한 줄이 있다. 타임라인 1은 루트이므로 히스토리 파일이 없다. readTimeLineHistory는 이 파일을 TimeLineHistoryEntry 아이템의 List *로 파싱한다. 호출자(주로 InitWalRecovery)는 tliSwitchPoint로 목표 타임라인이 특정 조상 타임라인에서 분기한 LSN을 찾는다.

// writeTimeLineHistory — src/backend/access/transam/timeline.c
void writeTimeLineHistory(TimeLineID newTLI, TimeLineID parentTLI,
XLogRecPtr switchpoint, char *reason)

writeTimeLineHistory는 승격 시 호출된다. 새 .history 파일에 (parentTLI, switchpoint) 한 줄을 추가한다. 스탠바이와 PITR 노드는 이 히스토리로 세그먼트 파일의 소속 타임라인을 파악한다. 타임라인 N의 세그먼트 파일은 타임라인 NN+1로 전환한 LSN까지만 유효하다는 점을 이해할 수 있다.

그림 1 — 타임라인 분기와 히스토리 체인:

flowchart LR
    TL1["TLI 1<br/>WAL 0/1 → 0/A0"]
    TL2["TLI 2<br/>WAL 0/A0 → 0/C0"]
    TL3["TLI 3<br/>WAL 0/B0 → ..."]
    HF2["2.history\n1\t0/A0"]
    HF3["3.history\n1\t0/A0\n2\t0/B0"]
    TL1 -->|"0/A0에서 승격"| TL2
    TL2 -->|"0/B0에서 승격"| TL3
    HF2 -. "레플리카가 참조".- TL2
    HF3 -. "레플리카가 참조".- TL3

그림 1 — 승격할 때마다 TLI 카운터가 올라가고 분기 LSN을 기록한 .history 파일이 생성된다. TLI-3 WAL을 읽는 레플리카는 두 히스토리 파일을 체인으로 연결해, TLI-1 WAL을 0/A0까지, TLI-2 WAL을 0/A0부터 0/B0까지, TLI-3 WAL을 0/B0부터 읽어야 한다는 것을 파악한다.

PerformWalRecovery의 주 루프는 두 가지 경계 검사를 사용한다.

// PerformWalRecovery (루프 본문) — src/backend/access/transam/xlogrecovery.c
do {
/* 이 레코드를 적용하기 전에 멈춰야 하는가? */
if (recoveryStopsBefore(xlogreader)) { reachedRecoveryTarget = true; break; }
/* recovery_min_apply_delay를 위한 선택적 지연 */
if (recoveryApplyDelay(xlogreader)) { /* 래치 대기 */ }
ApplyWalRecord(xlogreader, record, &replayTLI);
/* 이 레코드를 적용한 후에 멈춰야 하는가? */
if (recoveryStopsAfter(xlogreader)) { reachedRecoveryTarget = true; break; }
record = ReadRecord(xlogprefetcher, LOG, false, replayTLI);
} while (record != NULL);

recoveryStopsBefore는 배타적 목표(recovery_target_inclusive = false)를 처리한다. recoveryStopsAfter는 포함적 목표를 처리한다. 두 함수 모두 레코드를 다섯 가지 RecoveryTargetType 변형(XID, TIME, NAME, LSN, IMMEDIATE)과 비교한다.

reachedRecoveryTarget이 참이 되면 엔진은 recoveryTargetAction을 확인한다. pauseSetRecoveryPause(true)를 호출해 pg_wal_replay_resume() 호출이 올 때까지 블록한다. promote는 루프를 빠져나가 FinishWalRecovery로 이어진다. shutdownproc_exit(3)을 호출해 포스트마스터에게 종료를 요청한다.

// CheckRecoveryConsistency — src/backend/access/transam/xlogrecovery.c
void CheckRecoveryConsistency(void)
{
// ... condensed ...
if (!reachedConsistency && !backupEndRequired &&
minRecoveryPoint <= lastReplayedEndRecPtr)
{
XLogCheckInvalidPages();
CheckTablespaceDirectory();
reachedConsistency = true;
SendPostmasterSignal(PMSIGNAL_RECOVERY_CONSISTENT);
}
if (standbyState == STANDBY_SNAPSHOT_READY &&
!LocalHotStandbyActive &&
reachedConsistency && IsUnderPostmaster)
{
XLogRecoveryCtl->SharedHotStandbyActive = true;
LocalHotStandbyActive = true;
SendPostmasterSignal(PMSIGNAL_BEGIN_HOT_STANDBY);
}
}

포스트마스터로 두 신호가 전송된다는 점이 중요하다. PMSIGNAL_RECOVERY_CONSISTENT는 데이터베이스가 구조적으로 건전하다는 뜻이다(잘못된 페이지 참조 없음, 매달린 테이블스페이스 디렉터리 없음). PMSIGNAL_BEGIN_HOT_STANDBY는 읽기 전용 연결을 허용한다는 뜻이다. 포스트마스터는 두 번째 신호를 받은 후에야 클라이언트 연결 수락을 시작한다.

standbyState >= STANDBY_SNAPSHOT_READY 조건은 두 번째 요구사항을 반영한다. Hot standby에는 가시성 쿼리에 답할 수 있을 만큼 KnownAssignedXids(프라이머리 활성 트랜잭션 집합의 인메모리 복제)가 충분히 채워져 있어야 한다는 것이다. standbyState는 XID가 쌓이면서 STANDBY_DISABLEDSTANDBY_INITIALIZEDSTANDBY_SNAPSHOT_PENDINGSTANDBY_SNAPSHOT_READY 순으로 진행된다.

그림 2 — 복구 상태 기계:

stateDiagram-v2
    [*] --> InitWalRecovery
    InitWalRecovery --> CrashRecovery : 시그널 파일 없음
    InitWalRecovery --> ArchiveRecovery : recovery.signal
    InitWalRecovery --> StandbyMode : standby.signal
    CrashRecovery --> PerformWalRecovery
    ArchiveRecovery --> PerformWalRecovery
    StandbyMode --> PerformWalRecovery
    PerformWalRecovery --> ConsistencyReached : minRecoveryPoint 통과
    ConsistencyReached --> HotStandbyActive : standbyState SNAPSHOT_READY
    ConsistencyReached --> PITRTarget : 복구 목표 도달
    PerformWalRecovery --> EndOfWAL : 레코드 없음
    PITRTarget --> FinishWalRecovery
    EndOfWAL --> FinishWalRecovery
    HotStandbyActive --> PromotionTriggered : 승격 신호
    PromotionTriggered --> FinishWalRecovery
    FinishWalRecovery --> [*]

그림 2 — 세 가지 진입 경로(크래시, 아카이브, 스탠바이)가 모두 단일 PerformWalRecovery 루프로 모인다. Hot standby는 루프 안에 머문다. PITR과 WAL 끝은 FinishWalRecovery로 빠져나간다. 스탠바이 승격도 FinishWalRecovery를 거쳐 다음 타임라인에서 새 WAL 쓰기를 시작한다.

작업 집합이 버퍼 풀을 초과하면 재실행 처리량이 I/O에 의해 제한된다. PostgreSQL 15는 XLogPrefetcher를 도입했다. XLogReaderState를 얇게 감싸는 이 구조체는 재실행 위치 앞에서 WAL 레코드를 미리 디코딩하고, 그 레코드들이 참조할 페이지에 PrefetchSharedBuffer 호출(결국 posix_fadvise(POSIX_FADV_WILLNEED) 또는 readahead)을 발행한다.

// XLogPrefetcherAllocate — src/backend/access/transam/xlogprefetcher.c
XLogPrefetcher *
XLogPrefetcherAllocate(XLogReaderState *reader)
{
XLogPrefetcher *prefetcher = palloc0(sizeof(XLogPrefetcher));
prefetcher->reader = reader;
// filter_table: 이미 버퍼 풀에 있거나 아직 생성되지 않은 페이지 건너뜀
prefetcher->filter_table = hash_create("XLogPrefetcherFilterTable", 1024, ...);
dlist_init(&prefetcher->filter_queue);
prefetcher->reconfigure_count = XLogPrefetchReconfigureCount - 1;
return prefetcher;
}

프리페처는 LSN으로 인덱싱된 보류 I/O 슬롯 링인 LsnReadQueue를 유지한다. XLogPrefetcherNextBlock은 디코딩된 레코드 큐를 앞서 훑어 이미 필터링되지 않은(예: 버퍼 풀에 없고, CREATE DATABASE FILE COPY로 생성 중인 데이터베이스에 속하지 않은) 다음 블록 참조를 찾아 PrefetchSharedBuffer를 호출한다.

// XLogPrefetcherNextBlock (요약) — src/backend/access/transam/xlogprefetcher.c
static LsnReadQueueNextStatus
XLogPrefetcherNextBlock(uintptr_t pgsr_private, XLogRecPtr *lsn)
{
// ... condensed ...
record = XLogReadAhead(prefetcher->reader, nonblocking);
// filter_table 확인: 이미 버퍼링된 경우 건너뜀
// 메인 포크 블록에 PrefetchSharedBuffer 호출
// TLI 전환 체크포인트 이전까지 선행 읽기 억제:
// prefetcher->no_readahead_until = record->lsn;
// LRQ_NEXT_IO / LRQ_NEXT_NO_IO / LRQ_NEXT_AGAIN 반환
}

wal_decode_buffer_size GUC는 디코더가 얼마나 앞서 실행할 수 있는지를 제어한다. recovery_prefetch GUC(기본값 try)는 프리페치를 활성화한다. off로 설정하면 선행 읽기는 비활성화되지만 디코드 큐 메커니즘은 유지된다. 통계는 pg_stat_recovery_prefetch 뷰에서 확인할 수 있다(XLogPrefetcherComputeStats가 채운다).

두 가지 중요한 억제 규칙이 잘못된 프리페치를 방지한다.

  1. TLI 전환 억제. 디코더가 타임라인 전환을 수반할 수 있는 XLOG_CHECKPOINT_SHUTDOWN 또는 XLOG_END_OF_RECOVERY 레코드를 만나면, no_readahead_until이 해당 레코드의 LSN으로 설정된다. TLI 전환 이후까지 프리페치하면 잘못된 세그먼트 파일에서 읽을 수 있기 때문이다.
  2. 릴레이션 생성 필터. XLOG_DBASE_CREATE_FILE_COPY 레코드가 발견되면, 그 레코드가 재실행될 때까지 해당 데이터베이스의 모든 블록이 filter_table에 추가된다. 아직 디스크에 존재하지 않는 데이터베이스로 프리페치해 ENOENT 오류가 발생하는 것을 방지하기 위해서다.
  • XLogRecoveryShmemInit — 공유 메모리에 XLogRecoveryCtlData를 할당한다. CreateSharedMemoryAndSemaphores에서 호출된다.
  • InitWalRecoveryStartupXLOG의 최상위 진입점. pg_control을 읽고, 시그널 파일을 감지하며, RedoStartLSNCheckPointLoc를 결정하고, xlogreaderxlogprefetcher를 할당한다.
  • readRecoverySignalFile$PGDATA에서 standby.signalrecovery.signal을 확인한다. StandbyModeRequested, ArchiveRecoveryRequested를 설정한다.
  • validateRecoveryParametersrecovery_target* GUC의 일관성을 확인한다. 제거된 recovery.conf 사용을 감지한다.
  • read_backup_labelbackup_label이 있으면 읽어 베이스 백업 복원에 사용할 CheckPointLocRedoStartLSN을 제공한다.
  • EnableStandbyModeStandbyMode = true로 설정하고 hot standby 구조를 초기화한다.
  • PerformWalRecovery — 주 redo 루프. XLogRecoveryCtlData 수위 마커를 초기화하고, RmgrStartup을 호출하며, ReadRecord / ApplyWalRecord 루프를 실행한다.
  • ApplyWalRecord — 레코드당 디스패치. nextXid를 전진시키고, TLI 전환을 감지하며, replayEndRecPtr을 갱신하고, hot standby용 RecordKnownAssignedTransactionIds를 호출하며, rm_redo로 디스패치하고, XLR_CHECK_CONSISTENCY가 설정된 경우 FPI 일관성을 검증하며, lastReplayedEndRecPtr을 갱신한다.
  • ReadRecordXLogPrefetcherReadRecord의 얇은 래퍼. 결국 XLogReaderValidatePageHeaderDecodeXLogRecord를 호출한다.
  • recoveryStopsBefore / recoveryStopsAfter — 현재 레코드를 다섯 가지 RecoveryTargetType 변형과 비교해 평가한다.
  • recoveryApplyDelayrecovery_min_apply_delay를 구현한다. recoveryWakeupLatch에서 대기한다.
  • xlogrecovery_redo — 복구 구조가 직접 해석하는 RM_XLOG_ID 레코드(XLOG_BACKUP_END 등)를 처리한다.
  • CheckRecoveryConsistency — 모든 레코드 후 확인된다. PMSIGNAL_RECOVERY_CONSISTENTPMSIGNAL_BEGIN_HOT_STANDBY를 보낸다.
  • RmgrStartup / RmgrCleanup — redo 시작/종료 시 각 rmgr의 rm_startup / rm_cleanup을 호출한다.
  • FinishWalRecovery — WAL 수신기를 종료하고, 마지막 유효 레코드의 끝을 찾으며, StartupXLOGEndOfWalRecoveryInfo를 반환한다.
  • ShutdownWalRecoveryxlogprefetcherxlogreader를 해제한다.
  • readTimeLineHistory<tli>.historyList *<TimeLineHistoryEntry>로 파싱한다.
  • writeTimeLineHistory — 승격 시 새 .history 항목을 추가한다. archive_mode가 설정된 경우 아카이빙한다.
  • tliSwitchPoint — 히스토리 목록에서 특정 TLI가 다른 TLI로부터 분기한 LSN을 반환한다.
  • tliOfPointInHistoryList *와 LSN이 주어지면 해당 LSN에서 활성이었던 TLI를 반환한다.
  • existsTimeLineHistory.history 파일이 존재하는지 확인한다(아카이브나 pg_wal/ 조회).
  • checkTimeLineSwitch — redo 중 발견된 TLI 전환이 예상되는 히스토리 체인과 일관성이 있는지 검증한다.
  • XLogPrefetcherAllocate / XLogPrefetcherFree — 생명주기.
  • XLogPrefetcherNextBlock — 디코딩된 블록 참조마다 LsnReadQueue가 호출하는 콜백. PrefetchSharedBuffer를 발행한다.
  • XLogPrefetcherComputeStatspg_stat_recovery_prefetch가 읽는 SharedStats 필드를 갱신한다.
  • XLogPrefetcherAddFilter / XLogPrefetcherIsFiltered / XLogPrefetcherCompleteFilters — 필터 테이블 관리.
  • lrq_alloc / lrq_prefetch / lrq_complete_lsnLsnReadQueue 링 버퍼 연산.

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

섹션 제목: “위치 힌트 (커밋 273fe94, 2026-06-05 기준)”
심볼파일
XLogRecoveryCtlDataxlogrecovery.c311
XLogRecoveryShmemInitxlogrecovery.c465
InitWalRecoveryxlogrecovery.c519
readRecoverySignalFilexlogrecovery.c1046
PerformWalRecoveryxlogrecovery.c1671
ApplyWalRecordxlogrecovery.c1928
FinishWalRecoveryxlogrecovery.c1477
CheckRecoveryConsistencyxlogrecovery.c2196
xlogrecovery_redoxlogrecovery.c2092
rm_redo_error_callbackxlogrecovery.c2297
XLogPrefetcherAllocatexlogprefetcher.c362
XLogPrefetcherNextBlockxlogprefetcher.c459
XLogPrefetcherComputeStatsxlogprefetcher.c410
readTimeLineHistorytimeline.c76
writeTimeLineHistorytimeline.c304
tliSwitchPointtimeline.c572
checkTimeLineSwitchxlogrecovery.c2399
RecoveryTargetTypexlogrecovery.h23
EndOfWalRecoveryInfoxlogrecovery.h91
RecoveryPauseStatexlogrecovery.h44
  • InitWalRecovery, PerformWalRecovery, FinishWalRecoveryxlogrecovery.c가 내보내는 세 가지 함수 API다. 각각 519, 1671, 1477번 줄의 함수 시그너처와 xlogrecovery.h의 일치하는 선언으로 검증했다.

  • XLogRecoveryCtlData는 스택이 아닌 공유 메모리에 할당된다. XLogRecoveryShmemInit이 469번 줄에서 ShmemInitStruct를 호출하는 것으로 검증했다. 두 수위 마커 lastReplayedEndRecPtrreplayEndRecPtr은 별개다. 전자는 rm_redo 반환 후, 후자는 진입 전에 갱신된다(각각 2030번 줄과 1992번 줄).

  • ApplyWalRecordrm_redo를 호출하기 전에 TransamVariables->nextXid를 레코드의 XID보다 크게 전진시킨다. 1942번 줄에서 AdvanceNextFullTransactionIdPastXid(record->xl_xid)가 디스패치 블록보다 먼저 무조건 실행되는 것으로 검증했다.

  • 멱등성 검사는 ApplyWalRecord 안이 아니라 각 rmgr의 rm_redo 콜백 안에 있다. ApplyWalRecord를 전체 읽어 xlogrecovery.cPageLSN >= 레코드 LSN 비교가 없음을 확인했다. 이 패턴은 모든 힙/인덱스 rmgr redo 핸들러가 호출하는 xlogutils.cXLogReadBufferForRedo 안에 있다.

  • CheckRecoveryConsistency는 두 개의 서로 다른 신호를 보낸다. 2267번 줄(PMSIGNAL_RECOVERY_CONSISTENT)과 2289번 줄(PMSIGNAL_BEGIN_HOT_STANDBY)로 검증했다. Hot standby 활성화에는 reachedConsistencystandbyState == STANDBY_SNAPSHOT_READY 모두 필요하다.

  • 타임라인 1은 .history 파일이 없다. readTimeLineHistory 88번 줄에서 검증했다. targetTLI == 1인 경우 파일 읽기 없이 단일 항목 리스트를 합성해 반환한다.

  • WAL 프리페처는 TLI 전환 체크포인트를 넘어서는 선행 읽기를 억제한다. XLogPrefetcherNextBlock 537–553번 줄에서 검증했다. XLOG_CHECKPOINT_SHUTDOWNXLOG_END_OF_RECOVERY 모두 prefetcher->no_readahead_until = record->lsn으로 설정한다.

  • recovery.conf는 명시적으로 거부된다. readRecoverySignalFile 1046번 줄에서 검증했다. $PGDATArecovery.conf가 있으면 스타트업 프로세스가 recovery.signal로 이동하라고 안내하는 FATAL 오류를 남긴다.

  1. 병렬 redo. 현재 redo 루프는 스타트업 프로세스 안에서 단일 스레드로 실행된다. PG18에는 크래시 복구용 병렬 redo 경로가 없다(논리 복제의 병렬 apply와 다르다). 조사 경로: xlogrecovery.c에서 ParallelApply 패턴을 검색하고 병렬 redo 제안에 관한 메일링 리스트 아카이브를 확인한다.

  2. XLR_CHECK_CONSISTENCY 발동 조건. ApplyWalRecordxl_infoXLR_CHECK_CONSISTENCY가 설정된 경우 verifyBackupPageConsistency를 호출한다. 정상 운영 중(대 wal_consistency_checking GUC가 활성화된 경우만) WAL 레코드가 이 플래그를 받는 정확한 조건은 완전히 추적하지 못했다. 조사 경로: xloginsert.c와 rmgr 소스 파일 전체에서 XLR_CHECK_CONSISTENCY를 grep한다.

  3. LsnReadQueue 크기와 메모리 압력. LsnReadQueue 링(lrq_alloc)의 크기는 wal_decode_buffer_size에 의해 결정된다. 복구 중 디코드 버퍼 크기, 프리페치 거리, 공유 버퍼 히트율 간의 상호작용은 소스 주석에서 프로파일링되지 않았다. 조사 경로: XLogPrefetchReconfigurepg_stat_recovery_prefetchblocks_prefetched, blocks_skipped_on_relationship, blocks_skipped_init 컬럼을 읽는다.

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

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”
  • ARIES undo 로그 vs. PostgreSQL의 no-undo 방식. ARIES는 redo 후 전체 undo 패스를 규정해 보상 레코드를 기록하며 모든 패배 트랜잭션을 롤백한다. PostgreSQL은 MVCC에 기댐으로써 별도 undo 로그를 피한다. 힙 페이지에 이전 행 버전이 남아 있고, VACUUM이 비동기적으로 회수한다. 크래시 복구는 abort 레코드를 재실행하기만 한다. 행 단위 undo 탐색이 없다. 트레이드오프는 PostgreSQL이 VACUUM을 실행해야 공간을 회수하는 반면, undo 기반 엔진(Oracle, InnoDB)은 롤백 즉시 공간을 회수한다는 점이다. 비교 연구: knowledge/research/dbms-papers/aries.md.

  • 병렬 redo. MySQL InnoDB와 MariaDB는 크래시 복구 중 멀티코어 하드웨어를 활용하기 위해 병렬 redo(멀티스레드 apply)를 도입했다. PostgreSQL의 redo는 현재 스타트업 프로세스에서 단일 스레드로 실행된다. XLogPrefetcher가 I/O 지연을 숨김으로써 부분적으로 보완하지만, CPU 바운드 redo(예: 인덱스 재구성)는 이점이 없다. 병렬 redo는 PG 개발의 활발한 연구 분야다.

  • 논리 복제의 병렬 apply. PG16은 논리 복제용 병렬 apply(max_parallel_apply_workers_per_subscription)를 도입했다. 구조적으로 논리 수준의 병렬 redo와 유사하다. XID 의존성을 추적하는 리더가 apply 워커들을 조율하는 설계는 미래의 물리 redo 병렬화에 참고가 될 수 있다.

  • WAL 요약화와 증분 백업. WAL 요약화 기능(PG17)은 블록 단위 WAL 요약을 생성해 증분 베이스 백업(pg_basebackup --incremental)을 지원한다. 요약 생성기(walsummarizer.c)는 복구 중과 정상 운영 중 WAL을 읽는다. 복구 재실행 위치와의 상호작용은 postgres-archiving-walsummary.md의 관련 맥락이다.

  • 즉시 복구(redo 전용 설계). 재실행에만 의존하는 엔진(undo 로그가 전혀 없고 MVCC에만 의존)은 PostgreSQL의 하이브리드 방식과 유용한 대조를 이룬다. PostgreSQL에 더 가까운 논의: Hellerstein et al. 2007(Architecture of a DB System, dbms-papers/fntdb07-architecture.md) §“Storage and Buffer Management”는 steal/no-force 설계 공간과 각 선택의 복구 비용을 설명한다.

  • src/backend/access/transam/xlogrecovery.c — 복구 상태 기계, InitWalRecovery, PerformWalRecovery, FinishWalRecovery, ApplyWalRecord, CheckRecoveryConsistency
  • src/backend/access/transam/xlogprefetcher.cXLogPrefetcher, LsnReadQueue, XLogPrefetcherNextBlock
  • src/backend/access/transam/timeline.c — 타임라인 히스토리 읽기/쓰기/쿼리
  • src/include/access/xlogrecovery.hRecoveryTargetType, EndOfWalRecoveryInfo, RecoveryPauseState
  • src/include/access/timeline.hTimeLineHistoryEntry
  • src/backend/access/transam/README — transam 서브시스템의 인트리 설계 노트
  • Mohan et al. 1992, ARIES: A Transaction Recovery Methodknowledge/research/dbms-papers/aries.md에 정리
  • Petrov 2019, Database Internals, 5장 — WAL, 복구, LSN 모델
  • Silberschatz et al. 2020, Database System Concepts, 7판, 19장 — 복구 이론
  • Hellerstein et al. 2007, Architecture of a Database Systemknowledge/research/dbms-papers/fntdb07-architecture.md에 정리