콘텐츠로 이동

(KO) PostgreSQL WAL 레코드와 리소스 매니저 — rmgr 테이블과 레코드 해부

목차

write-ahead log (미리-쓰기 로그)는 모든 디스크 기반 관계형 엔진의 충돌 복구 척추다. 그 정확성은 ARIES 논문(Mohan et al. 1992, “ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging”; knowledge/research/dbms-papers/aries.md 참조)이 명문화한 단 하나의 규칙 위에 선다. 페이지 변경을 기술하는 로그 레코드는 변경된 데이터 페이지보다 먼저 안정 저장소에 도달해야 한다는 “미리-쓰기” 불변성이다. 이 규칙이 지켜지면, 커밋되지 않은 더티 버퍼가 남은 채 크래시가 발생해도 마지막 체크포인트부터 로그를 앞방향으로 재생(redo 패스)하고, 끝내 커밋되지 않은 트랜잭션의 효과를 되돌려(undo 패스) 트랜잭션 일관 상태로 돌아올 수 있다.

이 문서를 읽는 내내 세 가지 ARIES 개념을 머릿속에 두는 것이 좋다. 첫째, 모든 로그 레코드는 단조 증가하는 **LSN(Log Sequence Number, 로그 시퀀스 번호)**을 갖는다. 둘째, 모든 데이터 페이지는 그 페이지를 마지막으로 수정한 레코드의 LSN(pageLSN)을 저장하므로, 재생 시 레코드의 효과가 이미 반영되어 있는지를 한 번의 비교로 알 수 있다. 셋째, 복구는 **반복 가능(repeatable)**하다. 같은 레코드를 두 번 재생해도 pageLSN 비교 덕분에 같은 페이지가 나온다.

ARIES는 로그를 어떻게 물리적으로 포맷할지는 명시하지 않는다. 바로 이 공백을 이 문서가 채운다. 실제 엔진은 힙, 인덱스 접근 방식 전체, 프리 스페이스 맵, 커밋 로그, 멀티트랜잭션, 시퀀스, 테이블스페이스, 복제 부기 등 수십 개의 독립적 서브시스템에서 레코드를 내보낸다. 여기서 두 가지 구조적 질문이 생긴다.

첫 번째: 하나의 물리 로그를 여러 서브시스템이 어떻게 공유하는가? 힙 INSERT와 B-트리 페이지 분할은 전혀 다른 연산이다. 각 레코드에 “누가 썼는지”를 태그하고 올바른 재생 코드로 라우팅하는 무언가가 필요하다.

두 번째: 레코드의 온-디스크 형태가 어떠해야, 인메모리 상태가 전혀 없는 새 프로세스가 파싱하고 변경된 페이지를 찾아 정확히 redo를 적용할 수 있는가?

첫 번째 질문의 고전적 답이 리소스 매니저(resource manager) 추상화다. ARIES 자체가 이를 명명했다. 복구 드라이버는 LSN, 로그 스캔, 더티 페이지·트랜잭션 테이블만 알면 되는 범용 모듈이고, 각 레코드의 의미는 자신의 레코드 유형을 redo·undo하는 방법을 아는 서브시스템별 모듈, 즉 “리소스 매니저”에 위임한다. 복구 관리자는 결국 리소스 매니저 테이블 위의 얇은 디스패처이며, 모든 레코드에 저장된 작은 정수로 인덱싱된다. PostgreSQL은 이 설계를 거의 그대로 채택하되, 아래에서 설명하는 단 하나의 큰 단순화를 더했다. per-record undo 패스가 없다는 것이다.

두 번째 질문의 답은 **자기 기술적 레코드 포맷(self-describing record format)**이다. 모든 레코드 유형에 공통인 고정 헤더 뒤에, 길이-접두사와 태그를 가진 청크들이 이어진다. 블록 참조와 불투명한 주 데이터 블롭으로 구성된 이 청크들은, 레코드의 의미를 모르는 범용 파서도 순회할 수 있으면서 동시에 소유 리소스 매니저는 바이트를 해석할 수 있게 설계되었다.

WAL 포맷이 인코딩해야 하는 또 하나의 개념이 **전체 페이지 이미지(full-page image, FPI)**다. 증분 redo, 예를 들어 “블록 17의 오프셋 120에 4바이트를 기록하라”는 지시는 크래시로 페이지가 torn(절반만 기록된) 상태가 아닐 때만 올바르다. 그런데 범용 저장 장치는 섹터보다 큰 원자성을 보장하지 않는다. ARIES 계열 시스템은 체크포인트 이후 페이지가 처음 변경될 때 완전한 페이지 사본을 로그에 남겨 torn page를 방어한다. 재생 시 증분 논리 대신 그 사본을 통째로 복원한다. 레코드 포맷은 따라서 수정된 블록마다 선택적 전체 이미지를 담을 수 있어야 하고, 페이지 중간의 큰 제로 영역(“hole”)을 제거해 저장하면 좋다. 레코드 태깅, 자기 기술적 블록 참조, 선택적 FPI라는 세 가지 아이디어가 PostgreSQL의 XLogRecord와 블록 참조 서브헤더에 그대로 드러난다.

ARIES 계열 엔진에는 WAL 레코드 포맷과 디스패치에 관해 공통적으로 정착된 관행들이 있다. PostgreSQL은 그 관행 안에 확실히 자리 잡으면서 몇 가지 특징적 선택을 한다.

소유 서브시스템을 작은 정수로 명명한다. Oracle은 로그 레코드를 change vector로 구성하며 각각에 계층.하위코드 형태의 opcode를 붙인다. SQL Server는 각 로그 레코드에 operationcontext를 태그한다. InnoDB는 각 미니 트랜잭션 로그 레코드에 mlog 타입 바이트를 붙인다. 어떤 경우에나 하나의 작은 필드가 레코드를 이해하는 코드로 라우팅한다. PostgreSQL의 버전은 8비트 xl_rmidxl_info에서 4비트를 떼어 만든 사설 opcode다.

공통 헤더 뒤에 불투명 페이로드가 온다. 어디서나 레코드는 고정 레이아웃 헤더(전체 길이, 트랜잭션 id, 이전 레코드 역방향 포인터, 체크섬, 리소스 매니저·연산 태그)로 시작하며, 그 뒤는 소유 모듈의 영역이다. 헤더는 범용 로그 스캐너가 필요한 것이고, 페이로드는 리소스 매니저가 필요한 것이다.

페이지 단위 로깅과 생리적(physiological) redo. ARIES가 도입한 physiological 로깅은 페이지에 대해서는 물리적이고 페이지 내에서는 논리적이다. 레코드는 특정 페이지(물리적)를 명시하지만, 변경 내용은 원시 바이트 위치보다는 슬롯·오프셋으로 기술한다(페이지 내에서 논리적). PostgreSQL의 블록 참조는 릴레이션·포크·블록이라는 물리적 페이지 신원을 담고, rmgr의 주 데이터 블롭은 변경을 논리적으로 기술한다(예: “이 오프셋에 이 튜플을 삽입”).

torn page 방어를 위한 FPI. 체크포인트 이후 첫 번째 터치에 전체 페이지 이미지를 로그에 남기는 방식은 Oracle, SQL Server, MySQL/InnoDB(InnoDB의 doublewrite는 보완적 메커니즘)와 PostgreSQL 모두가 공유한다. 공학적 세부 구현이 다를 뿐이다. PostgreSQL은 pd_lower/pd_upper 사이의 hole을 제거하고 선택적으로 이미지를 압축(pglz/lz4/zstd)한다.

디스크립터·덤프 도구. 프로덕션 엔진은 로그를 사람이 읽을 수 있게 렌더링하는 도구를 함께 배포한다(Oracle LogMiner, SQL Server fn_dblog, InnoDB innodb_log 파서). 레코드 유형별 포맷 로직은 자연스럽게 redo 로직 옆에 위치한다. PostgreSQL은 이를 descidentify 콜백으로 분리해 pg_waldump가 호출한다.

PostgreSQL이 가장 크게 벗어나는 지점은 로그에 논리적 undo가 없다는 것이다. 고전 ARIES는 redo와 undo 정보를 모두 로그에 남기고 롤백 시 *보상 로그 레코드(CLR)*를 기록한다. PostgreSQL의 MVCC 스토리지는 중단된 트랜잭션의 튜플을 물리적으로 그대로 두고 pg_xact에 해당 xid를 중단 표시만 한다. 그래서 행 변경을 물리적으로 되돌릴 필요가 없다. WAL은 redo 전용이다. RmgrData에는 rm_redo 콜백이 있지만 rm_undo는 없고, 복구는 ARIES 의미의 분석·undo 단계 없이 단일 앞방향 redo 패스로 끝난다. 이것이 PostgreSQL 복구 코드가 교과서 ARIES 구현보다 극적으로 단순한 이유다.

아래 다이어그램은 모든 ARIES 계열 엔진이 공유하는 개념적 계층화를 PostgreSQL의 구체적인 이름으로 보여 준다.

flowchart TD
    subgraph emit["emit 측 (정상 실행 백엔드)"]
        A["서브시스템 코드<br/>(heapam, nbtinsert, ...)"] --> B["XLogBeginInsert /<br/>XLogRegisterBuffer /<br/>XLogRegisterData"]
        B --> C["XLogInsert(rmid, info)<br/>XLogRecord 조립"]
        C --> D["WAL 바이트 스트림<br/>(단일 공유 로그)"]
    end
    subgraph replay["재생 측 (startup 프로세스)"]
        D --> E["XLogReader<br/>헤더 + 청크 파싱"]
        E --> F["RmgrTable[xl_rmid].rm_redo(record)"]
        F --> G["XLogReadBufferForRedo<br/>각 블록 참조 재핀"]
        G --> H["pageLSN &lt; record LSN이면<br/>변경 적용"]
    end
    subgraph tools["도구"]
        E --> I["rm_identify(info) -> 이름"]
        E --> J["rm_desc(buf, record) -> 상세"]
        I --> K["pg_waldump 출력"]
        J --> K
    end

PostgreSQL은 리소스 매니저 디스패치 테이블을 X-매크로 하나와 단일 목록으로 컴파일 타임에 구성한다. 목록은 rmgrlist.h에 있다. 인클루드 가드가 없어 서로 다른 PG_RMGR(...) 매크로 정의 아래 여러 번 #include할 수 있도록 의도적으로 설계된 헤더다. 목록 자체는 PG_RMGR 호출의 연속이고, 그 순서가 곧 와이어 포맷이다. 각 rmgr의 숫자 id가 목록에서의 위치이기 때문이다.

// rmgrlist.h — src/include/access/rmgrlist.h
/* symbol name, textual name, redo, desc, identify, startup, cleanup, mask, decode */
PG_RMGR(RM_XLOG_ID, "XLOG", xlog_redo, xlog_desc, xlog_identify, NULL, NULL, NULL, xlog_decode)
PG_RMGR(RM_XACT_ID, "Transaction", xact_redo, xact_desc, xact_identify, NULL, NULL, NULL, xact_decode)
PG_RMGR(RM_SMGR_ID, "Storage", smgr_redo, smgr_desc, smgr_identify, NULL, NULL, NULL, NULL)
/* ... 18개 더 ... */
PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL)
PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode)

헤더의 주석은 계약을 명확히 한다. “항목의 순서가 각 rmgr id의 숫자 값을 정의하며, 이 값은 WAL 레코드에 저장된다. 새 항목은 끝에 추가해야 한다.” 목록 순서를 바꾸면 기존 WAL 스트림의 의미가 조용히 달라지므로, 추가는 끝에만 하고 어떤 변경이든 XLOG_PAGE_MAGIC 증가가 필요할 수 있다. REL_18 기준 내장 집합은 id 0–21(RM_XLOG_ID부터 RM_LOGICALMSG_ID까지) 22개 항목이다. XLOG2 rmgr는 존재하지 않는다. 나중 버전에서 추가된 것으로 이 리비전에는 없다.

같은 헤더가 세 가지 방식으로 사용된다. rmgr.h에서는 매크로가 열거자로 확장되어 RmgrIds enum을 만들고, 그 값들이 정확히 목록에서 참조하는 id가 된다.

// rmgr.h — src/include/access/rmgr.h
#define PG_RMGR(symname,name,redo,desc,identify,startup,cleanup,mask,decode) \
symname,
typedef enum RmgrIds
{
#include "access/rmgrlist.h"
RM_NEXT_ID
} RmgrIds;

rmgr.c에서는 매크로가 구조체 이니셜라이저로 확장되어 실제 디스패치 배열 RmgrTable[]을 구체화한다. 이 문서에서 가장 중요한 자료 구조다.

// RmgrTable — src/backend/access/transam/rmgr.c
#define PG_RMGR(symname,name,redo,desc,identify,startup,cleanup,mask,decode) \
{ name, redo, desc, identify, startup, cleanup, mask, decode },
RmgrData RmgrTable[RM_MAX_ID + 1] = {
#include "access/rmgrlist.h"
};

RM_MAX_IDUINT8_MAX(255)이므로 테이블에 256개 슬롯이 있지만 22개만 내장되어 있다. 22–127 범위는 예약되었으나 미사용이고, 128–255는 커스텀(익스텐션) 리소스 매니저 용도다. rm_nameNULL인 항목이 “미등록” 슬롯이며, RmgrIdExists 조건자가 이로 빈 슬롯을 구별한다.

각 행은 이름 하나와 함수 포인터 일곱 개로 구성된 RmgrData 구조체로, xlog_internal.h에 정의되어 있다.

// RmgrData — src/include/access/xlog_internal.h
typedef struct RmgrData
{
const char *rm_name;
void (*rm_redo) (XLogReaderState *record);
void (*rm_desc) (StringInfo buf, XLogReaderState *record);
const char *(*rm_identify) (uint8 info);
void (*rm_startup) (void);
void (*rm_cleanup) (void);
void (*rm_mask) (char *pagedata, BlockNumber blkno);
void (*rm_decode) (struct LogicalDecodingContext *ctx,
struct XLogRecordBuffer *buf);
} RmgrData;

콜백은 용도에 따라 깔끔하게 나뉜다. **rm_redo**는 복구의 핵심으로, 파싱된 레코드를 받아 변경을 재적용한다. **rm_desc**와 **rm_identify**는 pg_waldump가 쓰는 디스크립터 쌍으로, rm_identify는 opcode 비트를 "INSERT" 같은 짧은 이름으로 변환하고 rm_desc는 레코드 특유의 세부 정보를 문자열 버퍼에 추가한다. **rm_startup**과 **rm_cleanup**은 rmgr가 복구 시간 임시 상태를 할당·해제하도록 한다(btree, gin, gist, spgist만 사용하며, “불완전 액션” 추적 용도다). **rm_mask**는 wal_consistency_checking을 지원한다. 복구가 redo된 페이지와 레코드의 FPI를 비교하기 전에 비결정적 페이지 비트(힌트 비트, 빈 공간)를 지운다. **rm_decode**는 *논리 디코딩(logical decoding)*의 훅으로, 논리적으로 의미 있는 변경을 하는 rmgr(XLOG, Transaction, Standby, Heap, Heap2, LogicalMessage)만 제공한다. 어떤 슬롯에든 NULL은 단순히 “이 rmgr은 여기서 할 일이 없다”는 뜻이다.

rmgrlist.h 열들을 훑어보면 패턴이 보인다. 거의 모든 rmgr가 redo/desc/identify를 제공하고, 다중 레코드 분할 프로토콜을 가진 네 인덱스 AM만 startup/cleanup을 제공한다. 스토리지 AM(Heap, Heap2, 인덱스들, Sequence, Generic)만 mask를 제공하고, 논리 디코딩 관련 여섯 rmgr만 decode를 제공한다. 아래 다이어그램은 원시 레코드에서 콜백까지의 역다중화 흐름을 보여 준다.

flowchart TD
    R["디스크 위의 XLogRecord<br/>xl_rmid = N, xl_info = 0xI0 | flags"] --> P["XLogReader: 헤더,<br/>블록 참조, 주 데이터 파싱"]
    P --> IDX["rmid = XLogRecGetRmid(record)<br/>info = XLogRecGetInfo &amp; ~XLR_INFO_MASK"]
    IDX --> T{"RmgrTable[rmid]"}
    T --> RD["rm_redo(record)"]
    RD --> SW["switch (info &amp; OPMASK)"]
    SW --> OP1["heap_xlog_insert"]
    SW --> OP2["heap_xlog_update"]
    SW --> OP3["heap_xlog_delete"]
    OP1 --> BUF["XLogReadBufferForRedo(record, block_id)"]
    OP2 --> BUF
    OP3 --> BUF
    BUF --> ACT{"action"}
    ACT --> NR["BLK_NEEDS_REDO:<br/>적용 후 PageSetLSN"]
    ACT --> RST["BLK_RESTORED:<br/>FPI 이미 복원됨"]
    ACT --> DN["BLK_DONE:<br/>pageLSN >= 레코드, 건너뜀"]
    ACT --> NF["BLK_NOTFOUND:<br/>페이지 없음, 건너뜀"]

redo 루틴이 제일 먼저 하는 일은 xl_info에서 사설 opcode를 추출하는 것이다. xl_info의 하위 4비트(XLR_INFO_MASK = 0x0F)는 WAL 기계 자신의 플래그 용도로 예약되어 있고, 상위 4비트(XLR_RMGR_INFO_MASK = 0xF0)는 rmgr 소유다. heap_redo에서 볼 수 있는 관용 패턴은 예약 비트를 마스크로 지우고 나머지로 분기한다.

// heap_redo — src/backend/access/heap/heapam_xlog.c
void
heap_redo(XLogReaderState *record)
{
uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
switch (info & XLOG_HEAP_OPMASK)
{
case XLOG_HEAP_INSERT:
heap_xlog_insert(record);
break;
case XLOG_HEAP_DELETE:
heap_xlog_delete(record);
break;
case XLOG_HEAP_UPDATE:
heap_xlog_update(record, false);
break;
/* ... HOT_UPDATE, CONFIRM, LOCK, INPLACE, TRUNCATE ... */
default:
elog(PANIC, "heap_redo: unknown op code %u", info);
}
}

이 두 단계 디스패치(rmid가 rmgr를 선택하고, opcode가 연산을 선택)가 단 하나의 바이트(xl_rmid)와 4비트(xl_info 상위 니블)로 24개 rmgr 시스템의 모든 레코드를 정확히 하나의 redo 함수로 라우팅하는 방식이다. default: PANIC은 의도적이다. 인식되지 않는 opcode는 호환되지 않는 빌드에서 온 WAL이거나 손상된 것을 의미하므로, 계속 진행하면 조용한 데이터 손실 위험이 있다.

이 절은 문서 범위의 네 코드 영역을 순서대로 살펴본다. XLogRecord 온-디스크 구조, rmgr.c의 rmgr 테이블 기계, xlogutils.c의 redo 시간 버퍼 접근, 그리고 generic_xlog.c 델타 엔진이다. WAL 삽입(XLogInsert, 삽입 락 기계, 세그먼트 관리)은 postgres-xlog-wal.md의 주제다. 복구 드라이버 루프(PerformWalRecovery, redo 메인 루프, 체크포인트·재시작점 시퀀싱)는 postgres-recovery-redo.md의 주제다. 여기서는 레코드 포맷과 그 사이에 위치한 디스패치 계층에 집중한다.

모든 WAL 레코드는 정확히 SizeOfXLogRecord 바이트(64비트 빌드에서 24)의 XLogRecord 헤더로 시작한다. xlogrecord.h에 정의되어 있다.

// XLogRecord — src/include/access/xlogrecord.h
typedef struct XLogRecord
{
uint32 xl_tot_len; /* total len of entire record */
TransactionId xl_xid; /* xact id */
XLogRecPtr xl_prev; /* ptr to previous record in log */
uint8 xl_info; /* flag bits, see below */
RmgrId xl_rmid; /* resource manager for this record */
/* 2 bytes of padding here, initialize to zero */
pg_crc32c xl_crc; /* CRC for this record */
/* XLogRecordBlockHeaders and XLogRecordDataHeader follow, no padding */
} XLogRecord;
#define SizeOfXLogRecord (offsetof(XLogRecord, xl_crc) + sizeof(pg_crc32c))

xl_tot_len은 파싱 범위를 한정한다. xl_xid는 레코드를 트랜잭션에 연결한다(복구의 known-assigned-xids 추적과 논리 디코딩에 사용). xl_prev이전 레코드의 LSN으로 역방향 체인을 형성해, 리더가 실제 레코드 경계를 읽고 있는지 가비지가 아닌지 검증하게 한다. xl_info는 rmgr opcode(상위 니블)와 WAL 플래그(하위 니블)를 담는다. xl_rmid가 디스패치 키이고, xl_crc는 레코드 전체(CRC 필드 제로화 포함)에 대한 CRC-32C로 torn 또는 손상된 레코드 재생에 맞선 1차 방어선이다. XLogInsert 호출자가 설정할 수 있는 xl_info 플래그는 두 가지다. XLR_SPECIAL_REL_UPDATE(0x01, 외부 블록 추적 도구에 대한 힌트)와 XLR_CHECK_CONSISTENCY(0x02, 사후 일관성 검사를 위해 FPI 강제)다.

레코드 구조: 블록 참조와 전체 페이지 이미지

섹션 제목: “레코드 구조: 블록 참조와 전체 페이지 이미지”

헤더 다음에 자기 기술적 청크들이 이어진다. 블록 참조는 XLogRecordBlockHeader로 시작하며, 주석에 따르면 정렬되지 않아 사용 전에 정렬된 저장소에 복사해야 한다.

// XLogRecordBlockHeader — src/include/access/xlogrecord.h
typedef struct XLogRecordBlockHeader
{
uint8 id; /* block reference ID */
uint8 fork_flags; /* fork within the relation, and flags */
uint16 data_length; /* number of payload bytes (not including
* page image) */
/* If BKPBLOCK_HAS_IMAGE, an XLogRecordBlockImageHeader struct follows */
/* If BKPBLOCK_SAME_REL is not set, a RelFileLocator follows */
/* BlockNumber follows */
} XLogRecordBlockHeader;

id는 emit 코드가 XLogRegisterBuffer에서 선택한 작은 정수(기본 0..4)로, redo 루틴이 “블록 0”, “블록 1” 등으로 참조하는 값과 동일하다. fork_flagsForkNumber(하위 4비트)와 플래그 비트(상위 4비트)를 묶는다. BKPBLOCK_HAS_IMAGE(FPI가 따라옴), BKPBLOCK_HAS_DATA(버퍼별 데이터가 따라옴), BKPBLOCK_WILL_INIT(redo가 페이지를 처음부터 재초기화하므로 FPI 불필요, 이전 내용 읽기는 버그), BKPBLOCK_SAME_REL(RelFileLocator 생략, 이전 블록 참조와 같은 릴레이션이므로 WAL 볼륨 최적화)이 있다. 페이지의 물리 신원, 즉 릴레이션·포크·블록은 레코드만으로 완전히 복원 가능하다. 릴캐시가 없는 startup 프로세스가 페이지를 다시 찾을 수 있는 이유가 여기에 있다.

전체 페이지 이미지가 있을 때는 XLogRecordBlockImageHeader가 이어지며, “hole” 최적화가 여기에 있다.

// XLogRecordBlockImageHeader — src/include/access/xlogrecord.h
typedef struct XLogRecordBlockImageHeader
{
uint16 length; /* number of page image bytes */
uint16 hole_offset; /* number of bytes before "hole" */
uint8 bimg_info; /* flag bits, see below */
/* If BKPIMAGE_HAS_HOLE and BKPIMAGE_COMPRESSED(), an
* XLogRecordBlockCompressHeader struct follows. */
} XLogRecordBlockImageHeader;
/* Information stored in bimg_info */
#define BKPIMAGE_HAS_HOLE 0x01 /* page image has "hole" */
#define BKPIMAGE_APPLY 0x02 /* page image should be restored
* during replay */
#define BKPIMAGE_COMPRESS_PGLZ 0x04
#define BKPIMAGE_COMPRESS_LZ4 0x08
#define BKPIMAGE_COMPRESS_ZSTD 0x10

표준 PostgreSQL 페이지는 pd_lower(라인 포인터 배열 끝)와 pd_upper(끝에서 자라는 튜플 데이터 시작) 사이에 제로 바이트 연속 구간이 있다. 그 바이트들은 알려진 제로이므로 FPI에서 생략한다. hole_offset은 hole이 시작되는 위치를 기록하고, 압축 헤더(있을 경우)는 그 길이를 기록해 재생 시 갭을 제로로 채워 완전한 BLCKSZ 페이지를 복원할 수 있다. BKPIMAGE_APPLY는 재생 중 복원해야 하는 이미지(정상적인 체크포인트 이후 첫 번째 터치 케이스)와 wal_consistency_checking 비교용으로만 로그된 이미지를 구별한다. 세 COMPRESS_* 비트는 wal_compression이 켜져 있을 때 알고리즘을 선택한다.

rmgr.c는 작고 거의 완전히 테이블 구동 방식이다. RmgrTable[] 정의 외에, 복구 드라이버가 호출하는 라이프사이클 훅을 제공한다. RmgrStartupRmgrCleanup은 테이블을 한 번씩 순회하며 선택적 rm_startup/rm_cleanup 콜백을 호출해 인덱스 AM들이 불완전-액션 부기를 설정하고 해제하도록 한다.

// RmgrStartup — src/backend/access/transam/rmgr.c
void
RmgrStartup(void)
{
for (int rmid = 0; rmid <= RM_MAX_ID; rmid++)
{
if (!RmgrIdExists(rmid))
continue;
if (RmgrTable[rmid].rm_startup != NULL)
RmgrTable[rmid].rm_startup();
}
}

RmgrNotFound는 오류 경로다. 등록된 항목이 없는 rmid를 가진 레코드가 들어오면, 일반적으로 커스텀 rmgr 익스텐션이 shared_preload_libraries에 로드되지 않은 경우인데, 복구가 모듈을 로드하라는 힌트와 함께 ereport(ERROR)를 발생시킨다. GetRmgr 인라인 접근자(xlog_internal.h)는 모든 조회를 RmgrNotFound로 라우팅해 누락된 rmgr가 조용히 건너뛰어지지 않도록 한다.

커스텀 리소스 매니저는 id 128–255를 차지하며 RegisterCustomRmgr로 프로세스 시작 시 설치된다. 함수는 몇 가지 불변성을 강제한다. id가 커스텀 범위(RmgrIdIsCustom)여야 하고, shared_preload_libraries 초기화 중(process_shared_preload_libraries_in_progress)에 등록해야 하며, id가 이미 사용 중이면 안 되고, 이름이 비어 있거나 기존 rmgr 이름과 충돌하면 안 된다.

// RegisterCustomRmgr — src/backend/access/transam/rmgr.c
void
RegisterCustomRmgr(RmgrId rmid, const RmgrData *rmgr)
{
if (rmgr->rm_name == NULL || strlen(rmgr->rm_name) == 0)
ereport(ERROR, (errmsg("custom resource manager name is invalid"), ...));
if (!RmgrIdIsCustom(rmid))
ereport(ERROR, (errmsg("custom resource manager ID %d is out of range", rmid), ...));
if (!process_shared_preload_libraries_in_progress)
ereport(ERROR, ( ... "must be registered while initializing modules in "
"\"shared_preload_libraries\"." ));
if (RmgrTable[rmid].rm_name != NULL)
ereport(ERROR, ( ... "already registered with the same ID." ));
/* ... name-collision scan ... */
RmgrTable[rmid] = *rmgr; /* register it */
ereport(LOG, (errmsg("registered custom resource manager \"%s\" with ID %d",
rmgr->rm_name, rmid)));
}

RM_EXPERIMENTAL_ID(128)는 PostgreSQL 위키에 고유 번호를 예약하기 전 개발 중에 쓰는 관례적 id다. pg_get_wal_resource_managerspg_get_wal_resource_managers() 뷰를 지원하는 SQL 집합 반환 함수로, 테이블을 순회하며 (rmid, name, builtin) 행을 내보낸다. RmgrIdIsBuiltin으로 내장인지 커스텀 등록인지를 표시한다.

redo 루틴은 정상 릴캐시·버퍼 경로로 페이지를 읽지 않는다. 복구 중에는 릴캐시가 없고 카탈로그도 일관된 상태가 아닐 수 있다. 대신 xlogutils.c를 통하며, 그 핵심 진입점이 XLogReadBufferForRedo다. 레코드의 블록 참조를 실제 공유 버퍼에 연결하고 변경이 아직 적용되어야 하는지 결정하는 단일 함수다.

// XLogReadBufferForRedoExtended — src/backend/access/transam/xlogutils.c
XLogRedoAction
XLogReadBufferForRedoExtended(XLogReaderState *record, uint8 block_id,
ReadBufferMode mode, bool get_cleanup_lock,
Buffer *buf)
{
XLogRecPtr lsn = record->EndRecPtr;
RelFileLocator rlocator;
ForkNumber forknum;
BlockNumber blkno;
/* ... */
if (!XLogRecGetBlockTagExtended(record, block_id, &rlocator, &forknum,
&blkno, &prefetch_buffer))
elog(PANIC, "failed to locate backup block with ID %d in WAL record", block_id);
/* If it has a full-page image and it should be restored, do it. */
if (XLogRecBlockImageApply(record, block_id))
{
*buf = XLogReadBufferExtended(rlocator, forknum, blkno, ...);
page = BufferGetPage(*buf);
if (!RestoreBlockImage(record, block_id, page))
ereport(ERROR, ...);
if (!PageIsNew(page))
PageSetLSN(page, lsn);
MarkBufferDirty(*buf);
return BLK_RESTORED;
}
else
{
*buf = XLogReadBufferExtended(rlocator, forknum, blkno, mode, prefetch_buffer);
if (BufferIsValid(*buf))
{
/* ... acquire lock ... */
if (lsn <= PageGetLSN(BufferGetPage(*buf)))
return BLK_DONE;
else
return BLK_NEEDS_REDO;
}
else
return BLK_NOTFOUND;
}
}

네 가지 반환 값이 ARIES 멱등 redo 논리를 구체적으로 실현한다. BLK_RESTORED — 레코드에 적용할 FPI가 있어 페이지 전체를 덮어쓰고 rmgr의 증분 논리는 건너뛴다. BLK_NEEDS_REDO — 페이지가 존재하고 pageLSN이 이 레코드 LSN보다 오래됐으므로 증분 변경이 아직 적용되지 않았다. rmgr가 변경을 적용하고 새 LSN을 기록한다. BLK_DONE — 페이지의 LSN이 이미 레코드 LSN >=이므로 변경이 이미 반영되어 있어 redo를 건너뛴다. BLK_NOTFOUND — 페이지가 더 이상 존재하지 않아(나중에 릴레이션이 드롭되거나 잘림) 변경을 무해하게 무시한다. lsn <= PageGetLSN(...) 비교가 재생을 두 번 실행해도 안전하게 만드는 정확한 메커니즘이다.

XLogReadBufferExtended는 하위 레벨 페이지 가져오기다. 복구 중에는 스토리지 매니저(smgr) 수준에서 릴레이션을 열고, 릴캐시를 완전히 우회하며, 파일이 없으면 smgrcreate로 생성한다. 요청된 블록이 파일 끝을 넘어서면 RBM_ZERO_* 모드에서는 제로 페이지로 확장하고, RBM_NORMAL에서는 invalid page 참조를 기록하고 InvalidBuffer를 반환한다.

// XLogReadBufferExtended — src/backend/access/transam/xlogutils.c (condensed)
smgr = smgropen(rlocator, INVALID_PROC_NUMBER);
smgrcreate(smgr, forknum, true);
lastblock = smgrnblocks(smgr, forknum);
if (blkno < lastblock)
buffer = ReadBufferWithoutRelcache(rlocator, forknum, blkno, mode, NULL, true);
else
{
if (mode == RBM_NORMAL) /* hm, page doesn't exist in file */
{
log_invalid_page(rlocator, forknum, blkno, false);
return InvalidBuffer;
}
/* ... RBM_ZERO_* modes extend the file with zero pages ... */
}

invalid-page 메커니즘은 미묘한 정확성 장치다. full_page_writes=off에서는 나중에 드롭·잘림된 릴레이션의 증분 레코드가 스트림 앞부분에 있을 수 있다. log_invalid_page(릴레이션, 포크, 블록)을 해시 테이블에 기록하고, 나중에 드롭·잘림 레코드가 해당 릴레이션을 처리하면 항목이 지워진다. 복구 끝에 XLogCheckInvalidPages가 테이블을 스캔해 항목이 남아 있으면 WAL이 처리되지 않은 페이지를 참조했다는 의미이므로 PANIC 수준 손상 신호를 보낸다(ignore_invalid_pages GUC로 WARNING으로 낮출 수 있다).

// XLogCheckInvalidPages — src/backend/access/transam/xlogutils.c (condensed)
while ((hentry = (xl_invalid_page *) hash_seq_search(&status)) != NULL)
{
report_invalid_page(WARNING, hentry->key.locator, hentry->key.forkno,
hentry->key.blkno, hentry->present);
foundone = true;
}
if (foundone)
elog(ignore_invalid_pages ? WARNING : PANIC,
"WAL contains references to invalid pages");

desc/identify 콜백은 WAL을 사람이 읽을 수 있게 렌더링하기 위해 존재하며, 백엔드는 정상 운영 중에 이를 호출하지 않는다. rmgr별 디스크립터 파일은 src/backend/access/rmgrdesc/에 있다. identify는 opcode 비트(redo 루틴이 switch하는 동일한 xl_info & ~XLR_INFO_MASK)를 짧은 문자열로 매핑한다. XLOG_HEAP_INIT_PAGE가 설정되면 +INIT 접미사가 붙는다.

// heap_identify — src/backend/access/rmgrdesc/heapdesc.c (condensed)
const char *
heap_identify(uint8 info)
{
const char *id = NULL;
switch (info & ~XLR_INFO_MASK)
{
case XLOG_HEAP_INSERT: id = "INSERT"; break;
case XLOG_HEAP_INSERT | XLOG_HEAP_INIT_PAGE: id = "INSERT+INIT"; break;
case XLOG_HEAP_DELETE: id = "DELETE"; break;
case XLOG_HEAP_UPDATE: id = "UPDATE"; break;
/* ... HOT_UPDATE, TRUNCATE, CONFIRM, LOCK, INPLACE ... */
}
return id;
}

desc는 레코드의 주 데이터 블롭을 해당 opcode의 구조체로 캐스팅해 필드별 상세 정보를 디코딩한다.

// heap_desc — src/backend/access/rmgrdesc/heapdesc.c (condensed)
void
heap_desc(StringInfo buf, XLogReaderState *record)
{
char *rec = XLogRecGetData(record);
uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
info &= XLOG_HEAP_OPMASK;
if (info == XLOG_HEAP_INSERT)
{
xl_heap_insert *xlrec = (xl_heap_insert *) rec;
appendStringInfo(buf, "off: %u, flags: 0x%02X", xlrec->offnum, xlrec->flags);
}
else if (info == XLOG_HEAP_DELETE) { /* ... */ }
/* ... */
}

pg_waldump프론트엔드 프로그램으로 백엔드의 redo 코드를 링크할 수 없어, 동일한 rmgrlist.h에서 이번에는 name/desc/identify 열만 유지하도록 PG_RMGR을 확장해 자체 디스크립터 전용 테이블을 만든다.

// RmgrDescTable — src/bin/pg_waldump/rmgrdesc.c
#define PG_RMGR(symname,name,redo,desc,identify,startup,cleanup,mask,decode) \
{ name, desc, identify},
static const RmgrDescData RmgrDescTable[RM_N_BUILTIN_IDS] = {
#include "access/rmgrlist.h"
};

각 레코드마다 pg_waldumpGetRmgrDesc(XLogRecGetRmid(record))를 가져와 연산 이름에 rm_identify(info)를, 상세 줄에 rm_desc(&s, record)를 호출한다. 단일 헤더(rmgrlist.h)가 백엔드의 redo 디스패치와 독립형 덤프 도구를 완벽하게 동기화 상태로 유지하는 이유가 여기 있다. 둘 다 하나의 목록에서 생성된다.

표준 PostgreSQL 페이지에 데이터를 저장하지만 완전한 커스텀 리소스 매니저를 작성하고 싶지 않은 익스텐션은 generic_xlog.c, 즉 Generic rmgr(RM_GENERIC_ID)를 사용할 수 있다. 익스텐션이 레코드 유형이나 redo 루틴을 정의하지 않아도 페이지의 바이트 수준 델타를 WAL에 기록한다. 공유 generic_redo가 그런 레코드를 재생한다. 구성 API는 세 호출로 이루어진 라이프사이클이다. GenericXLogStart는 최대 MAX_GENERIC_XLOG_PAGES개 페이지 슬롯을 가진 I/O 정렬 상태를 할당한다.

// GenericXLogStart — src/backend/access/transam/generic_xlog.c (condensed)
GenericXLogState *
GenericXLogStart(Relation relation)
{
GenericXLogState *state;
state = (GenericXLogState *) palloc_aligned(sizeof(GenericXLogState),
PG_IO_ALIGN_SIZE, 0);
state->isLogged = RelationNeedsWAL(relation);
for (i = 0; i < MAX_GENERIC_XLOG_PAGES; i++)
{
state->pages[i].image = state->images[i].data;
state->pages[i].buffer = InvalidBuffer;
}
return state;
}

GenericXLogRegisterBuffer는 페이지의 사본을 상태의 image 버퍼에 복사하고, 그 사본을 호출자에게 반환해 제자리에서 수정하게 한다. 호출자는 라이브 버퍼가 아닌 이미지를 변형한다.

// GenericXLogRegisterBuffer — src/backend/access/transam/generic_xlog.c (condensed)
Page
GenericXLogRegisterBuffer(GenericXLogState *state, Buffer buffer, int flags)
{
for (block_id = 0; block_id < MAX_GENERIC_XLOG_PAGES; block_id++)
{
GenericXLogPageData *page = &state->pages[block_id];
if (BufferIsInvalid(page->buffer))
{
page->buffer = buffer;
page->flags = flags;
memcpy(page->image, BufferGetPage(buffer), BLCKSZ);
return (Page) page->image;
}
else if (page->buffer == buffer)
return (Page) page->image; /* already registered */
}
elog(ERROR, "maximum number %d of generic xlog buffers is exceeded",
MAX_GENERIC_XLOG_PAGES);
}

GenericXLogFinish에서 핵심 작업이 일어난다. 원본 페이지와 수정된 이미지를 diff해 컴팩트한 델타를 만들고, 이미지를 실제 버퍼에 복사하면서 hole을 제로로 채워 재생이 만들 결과와 일치시키고, 버퍼를 더티로 표시하고, 델타를 Generic 레코드의 버퍼별 데이터로 등록한다.

// GenericXLogFinish — src/backend/access/transam/generic_xlog.c (condensed)
if (!(pageData->flags & GENERIC_XLOG_FULL_IMAGE))
computeDelta(pageData, page, (Page) pageData->image);
/* apply the image, zeroing the hole between pd_lower and pd_upper */
memcpy(page, pageData->image, pageHeader->pd_lower);
memset(page + pageHeader->pd_lower, 0, pageHeader->pd_upper - pageHeader->pd_lower);
memcpy(page + pageHeader->pd_upper, pageData->image + pageHeader->pd_upper,
BLCKSZ - pageHeader->pd_upper);
MarkBufferDirty(pageData->buffer);
if (pageData->flags & GENERIC_XLOG_FULL_IMAGE)
XLogRegisterBuffer(i, pageData->buffer, REGBUF_FORCE_IMAGE | REGBUF_STANDARD);
else
{
XLogRegisterBuffer(i, pageData->buffer, REGBUF_STANDARD);
XLogRegisterBufData(i, pageData->delta, pageData->deltaLen);
}
/* ... */
lsn = XLogInsert(RM_GENERIC_ID, 0);

델타 자체는 변경된 영역만 커버하는 (offset, length, bytes) 트리플의 목록인 프래그먼트들이다. computeDelta는 페이지의 하단(0..pd_lower)과 상단(pd_upper..BLCKSZ) 영역을 따로 diff하며 hole은 완전히 건너뛴다. computeRegionDelta는 빡빡한 바이트 매칭 루프를 돌면서 MATCH_THRESHOLD보다 적은 미변경 바이트로 분리된 프래그먼트를 병합한다. 프래그먼트 헤더 하나에 2*sizeof(OffsetNumber) 비용이 있어 작은 갭을 분할하는 것은 득이 없기 때문이다. 최악의 경우 델타 크기는 MAX_DELTA_SIZE = BLCKSZ + 2*FRAGMENT_HEADER_SIZE로 한정된다. 재생 시 generic_redoXLogReadBufferForRedo로 각 블록을 재핀하고 applyPageRedo로 프래그먼트를 적용한다.

// applyPageRedo — src/backend/access/transam/generic_xlog.c
static void
applyPageRedo(Page page, const char *delta, Size deltaSize)
{
const char *ptr = delta;
const char *end = delta + deltaSize;
while (ptr < end)
{
OffsetNumber offset, length;
memcpy(&offset, ptr, sizeof(offset)); ptr += sizeof(offset);
memcpy(&length, ptr, sizeof(length)); ptr += sizeof(length);
memcpy(page + offset, ptr, length);
ptr += length;
}
}

적용 후 generic_redo는 재생된 페이지의 hole을 제로로 채우고(델타에는 hole 바이트가 없다) LSN을 기록한다. 프라이머리에서 GenericXLogFinish가 한 것과 정확히 일치하므로 일관성 검사가 통과된다. generic_mask(rmgr의 rm_mask)는 검사 전에 페이지 LSN, 체크섬, 미사용 공간을 지운다. generic_xlog의 핵심 가치는 익스텐션이 커스텀 redo 코드 없이 크래시 안전하고 복제 안전한 페이지 편집을 얻는다는 데 있다. 단, diff가 의미 단위가 아닌 바이트 단위라는 비용이 따른다.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
PG_RMGR 목록 (rmgr 정의)src/include/access/rmgrlist.h28–49
RmgrIds enum (PG_RMGR → 열거자)src/include/access/rmgr.h22–30
RM_MAX_ID / RM_MIN_CUSTOM_ID / RM_EXPERIMENTAL_IDsrc/include/access/rmgr.h33–60
RmgrIdIsBuiltin / RmgrIdIsCustomsrc/include/access/rmgr.h41–53
RmgrData 구조체src/include/access/xlog_internal.h349–360
GetRmgr / RmgrIdExistssrc/include/access/xlog_internal.h369–382
RmgrTable[] 정의src/backend/access/transam/rmgr.c47–52
RmgrStartupsrc/backend/access/transam/rmgr.c58
RmgrCleanupsrc/backend/access/transam/rmgr.c74
RmgrNotFoundsrc/backend/access/transam/rmgr.c91
RegisterCustomRmgrsrc/backend/access/transam/rmgr.c107
pg_get_wal_resource_managerssrc/backend/access/transam/rmgr.c150
XLogRecord 구조체src/include/access/xlogrecord.h41–53
XLR_INFO_MASK / XLR_RMGR_INFO_MASKsrc/include/access/xlogrecord.h62–63
XLR_SPECIAL_REL_UPDATE / XLR_CHECK_CONSISTENCYsrc/include/access/xlogrecord.h82–91
XLogRecordBlockHeadersrc/include/access/xlogrecord.h103–113
XLogRecordBlockImageHeader + BKPIMAGE_*src/include/access/xlogrecord.h141–167
BKPBLOCK_* 플래그src/include/access/xlogrecord.h196–202
XLogReadBufferForRedosrc/backend/access/transam/xlogutils.c303
XLogInitBufferForRedosrc/backend/access/transam/xlogutils.c315
XLogReadBufferForRedoExtendedsrc/backend/access/transam/xlogutils.c340
XLogReadBufferExtendedsrc/backend/access/transam/xlogutils.c460
log_invalid_pagesrc/backend/access/transam/xlogutils.c101
XLogCheckInvalidPagessrc/backend/access/transam/xlogutils.c234
GenericXLogState / GenericXLogPageDatasrc/backend/access/transam/generic_xlog.c49–71
GenericXLogStartsrc/backend/access/transam/generic_xlog.c269
GenericXLogRegisterBuffersrc/backend/access/transam/generic_xlog.c299
GenericXLogFinishsrc/backend/access/transam/generic_xlog.c337
computeDelta / computeRegionDelta / writeFragmentsrc/backend/access/transam/generic_xlog.c90 / 121 / 228
generic_redo / applyPageRedo / generic_masksrc/backend/access/transam/generic_xlog.c478 / 453 / 539
MAX_GENERIC_XLOG_PAGES / GENERIC_XLOG_FULL_IMAGEsrc/include/access/generic_xlog.h23 / 26
heap_redo (opcode 디스패치)src/backend/access/heap/heapam_xlog.c1181
heap_desc / heap_identifysrc/backend/access/rmgrdesc/heapdesc.c184 / 389
RmgrDescTable (프론트엔드)src/bin/pg_waldump/rmgrdesc.c35–40

/data/hgryoo/references/postgres의 REL_18_STABLE 체크아웃, 커밋 273fe94 기준으로 검증했다.

  • rmgr 집합과 id. rmgrlist.h는 정확히 22개 PG_RMGR 항목(id 0–21)을 정의한다. XLOG, Transaction, Storage, CLOG, Database, Tablespace, MultiXact, RelMap, Standby, Heap2, Heap, Btree, Hash, Gin, Gist, Sequence, SPGist, BRIN, CommitTs, ReplicationOrigin, Generic, LogicalMessage. XLOG2 rmgr는 이 리비전에 없다. 나중 버전 추가 사항으로 목록은 RM_LOGICALMSG_ID에서 끝난다. RM_MAX_BUILTIN_ID는 따라서 RM_NEXT_ID - 1 = 21이다.
  • X-매크로 세 가지 사용. rmgrlist.h에 인클루드 가드가 없고 세 가지 다른 PG_RMGR 정의 아래 #include된다는 것을 확인했다. 열거자(rmgr.h), 전체 구조체 이니셜라이저(rmgr.c), {name,desc,identify} 트리플(pg_waldump/rmgrdesc.c). rmgr.c 매크로 주석 “must be kept in sync with RmgrData definition in xlog_internal.h”가 8-필드 RmgrData 구조체 순서와 그대로 일치한다.
  • RmgrData 콜백 순서. xlog_internal.h의 구조체가 rm_name, rm_redo, rm_desc, rm_identify, rm_startup, rm_cleanup, rm_mask, rm_decode 순서임을 확인했다. PG_RMGR 인수 순서와 동일하다.
  • XLogRecord 헤더. xlogrecord.h의 여섯 필드와 2바이트 패딩 주석을 확인했다. SizeOfXLogRecord = offsetof(XLogRecord, xl_crc) + sizeof(pg_crc32c). xl_rmidRmgrId = uint8이다.
  • opcode 마스킹. XLR_INFO_MASK = 0x0F, XLR_RMGR_INFO_MASK = 0xF0을 확인했다. heap_redo& ~XLR_INFO_MASK& XLOG_HEAP_OPMASK로 마스킹한다는 것도 확인했다.
  • 블록 플래그 / FPI. BKPBLOCK_HAS_IMAGE/HAS_DATA/WILL_INIT/SAME_REL(0x10/0x20/0x40/0x80)과 BKPIMAGE_HAS_HOLE/APPLY/COMPRESS_* 비트 값을 xlogrecord.h에서 확인했다.
  • redo 버퍼 반환 값. XLogReadBufferForRedoExtendedBLK_RESTORED(FPI 적용), BLK_NEEDS_REDO(lsn > PageGetLSN), BLK_DONE(lsn <= PageGetLSN), BLK_NOTFOUND(버퍼 없음)를 반환한다는 것을 함수 본문에서 직접 읽었다.
  • generic_xlog 한도. MAX_GENERIC_XLOG_PAGESXLR_NORMAL_MAX_BLOCK_ID이고, GENERIC_XLOG_FULL_IMAGE = 0x0001임을 generic_xlog.h에서 확인했다. MAX_DELTA_SIZE = BLCKSZ + 2 * FRAGMENT_HEADER_SIZE, FRAGMENT_HEADER_SIZE = 2 * sizeof(OffsetNumber)임을 generic_xlog.c에서 확인했다.
  • 커스텀 rmgr 범위. RM_MIN_CUSTOM_ID = 128, RM_MAX_CUSTOM_ID = UINT8_MAX, RM_EXPERIMENTAL_ID = 128. RegisterCustomRmgr가 인용한 네 가지 불변성(범위, preload-진행-중, id 중복 없음, 이름 중복 없음)을 모두 강제함을 직접 읽었다.
  • 줄 번호. 위치 힌트 표의 줄 번호는 이 체크아웃의 2026-06-05 캡처다. 코드 재포맷 시 달라진다. 심볼 이름이 영구적 기준이다.

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

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

PostgreSQL의 rmgr 테이블은 모든 ARIES 계열 엔진에서 반복되는 패턴의 특히 깔끔한 사례지만, 그것이 하는 선택들(redo 전용 로깅, 컴파일 타임 X-매크로 디스패치 테이블, 선택적 FPI를 가진 생리적 페이지 단위 레코드)은 더 큰 설계 공간의 한 구석에 자리한다. 동료들과 연구 문헌에 놓고 보면 무엇이 본질이고 무엇이 우연인지가 선명해진다.

ARIES와 리소스 매니저 추상화. ARIES 논문(Mohan et al. 1992, knowledge/research/dbms-papers/aries.md)은 이미 복구를 범용 드라이버와 리소스 매니저별 redo·undo 로직으로 분리했다. IBM DB2와 System R 계보(knowledge/research/dbms-papers/systemr.md)가 이를 직접 구현한다. 가장 깊은 분기점은 고전 ARIES가 redo-undo 로그라는 것이다. 모든 업데이트가 재적용 방법과 롤백 방법을 모두 로그에 남기고, 롤백 중에는 UndoNxtLSN이 있는 **보상 로그 레코드(CLR)**를 기록해 이미 undo된 작업을 복구가 건너뛰고 롤백 도중 크래시가 발생해도 종료를 보장한다. PostgreSQL은 undo 절반 전체를 버린다. MVCC가 중단된 트랜잭션의 튜플을 물리적으로 그대로 두고 pg_xact에 xid만 중단 표시하므로 행 변경을 물리적으로 undo할 필요가 없다. RmgrData에는 rm_redo만 있고 rm_undo는 없으며, 복구는 ARIES 의미의 분석/undo 단계 없이 단일 앞방향 패스로 끝난다. 그 비용은 다른 곳, 즉 죽은 튜플로 인한 bloat를 VACUUM(postgres-vacuum.md)에 미루는 형태로 치른다. 그러나 복구 코드는 극적으로 단순해진다. PostgreSQL WAL 서브시스템과 교과서 ARIES 구현 사이의 가장 중요한 구조적 차이가 이것이다.

undo 기반 엔진과 가지 않은 길. Oracle과 MySQL/InnoDB는 반대 입장을 취한다. 명시적 undo/롤백 세그먼트를 유지해 undo로부터 구버전 행을 온-디맨드로 재구성하고, 메인 힙에는 최신 버전만 보관한다. 그들의 redo 로그(Oracle의 layer.opcode 태그 change vector, InnoDB의 mlog 타입 미니 트랜잭션 레코드)도 xl_rmid와 마찬가지로 작은 타입 태그로 여러 서브시스템을 다중화하지만, 복구는 redo 패스와 rollback 세그먼트를 적용하는 undo 패스를 모두 실행해야 한다. PostgreSQL 커뮤니티는 MVCC bloat를 피하기 위해 undo 기반 스토리지 엔진(zheap 프로젝트)을 반복적으로 제안했다. 주목할 점은 zheap이 undo 로그와 그에 따른 CLR 기계를 재도입했을 것이라는 사실이다. PostgreSQL의 redo 전용 단순성이 힙의 추가-전용 버전 스토리지의 직접적 결과이지 공짜가 아님을 상기시켜 준다.

생리적 로깅과 FPI의 공통 최적점. 페이지 단위 물리적, 페이지 내 논리적 레코드에 첫 번째 터치 전체 페이지 이미지를 더하는 결정은 거의 보편적이다. Oracle, SQL Server, InnoDB 모두 어떤 형태로든 한다(InnoDB의 doublewrite 버퍼는 대체가 아닌 보완적 torn-page 방어다). PostgreSQL의 개선, 즉 pd_lower/pd_upper hole 제거와 pglz/lz4/zstd 선택적 압축은 구조가 아닌 공학이다. 그러나 복제 대역폭과 아카이브 비용을 지배하는 WAL 볼륨을 실질적으로 줄인다. Database Internals(Petrov 2019, raw/dbms/textbooks/Database Internals.pdf)는 이 설계 공간을 조망하며 FPI를 범용 저장 장치에서 원자적 페이지 쓰기의 부재에 대한 표준 답안으로 위치 짓는다.

컴파일 타임 디스패치 대 플러그인 레지스트리. RmgrTable[]의 X-매크로 구성은 의도적으로 정적인 설계다. 내장 집합은 컴파일 타임에 고정되고 와이어 포맷은 문자 그대로 rmgrlist.h의 줄 순서다. 2020년대 커스텀 rmgr 추가(128–255 id, RegisterCustomRmgr)는 정적 코어에 작은 동적 레지스트리를 볼트로 고정한 것으로, 그래서 커스텀 등록에 그토록 많은 불변성이 따른다. shared_preload_libraries 초기화 중에 일어나야 하고, WAL이 읽히기 전이어야 하므로, 복구가 실행될 때 테이블은 사실상 다시 불변이다. SQLite의 VFS 레이어나 완전히 플러그인 기반 로그 레코드 레지스트리보다 좁은 확장성 계약이지만, 그 좁음이 핵심이다. 리소스 매니저는 크래시 복구에 참여하며, 잘못 매칭되거나 누락된 항목은 조용한 데이터 손실이다. 시스템은 유연성보다 WAL 스트림이 정확히 하나의 의미를 갖는다는 보장을 택한다. generic_xlog 기계가 압력 밸브다. 표준 페이지의 크래시 안전 편집만 필요한 익스텐션은 커스텀 redo 코드 없이 그것을 얻는다. 의미 단위가 아닌 바이트 수준 델타라는 비용을 치르고.

연구 프론티어. 세 가지 흐름을 짚을 만하다. 첫째, 로그 배송과 스케일 아웃. rmgr 계층이 WAL을 깔끔하고 자기 기술적인 스트림으로 만들기 때문에, 물리 복제와 논리 디코딩(rm_decode 콜백) 모두의 기판으로 쓰인다. Amazon Aurora 같은 시스템은 이를 더 밀고 나가 redo 로그 자체를 내구성 경계로 삼는다. “로그가 데이터베이스다”라고 WAL을 페이지를 지연 구체화하는 스토리지 계층으로 배송한다. 둘째, 비휘발성 메모리와 더 짧은 로그. 연구 계열은 영속 메모리가 엔진들로 하여금 FPI와 심지어 redo 로그를 줄이거나 없애도록 할 수 있는지 묻는다. 바이트 주소 지정 가능한 내구성이 FPI를 동기 부여한 torn-page 계산을 바꾸기 때문이다. 셋째, 병목으로서의 WAL. 단일 순서 로그는 직렬화 지점이다. 락 매니저가 멀티코어 병목이 되어 파티셔닝으로 해소된 것처럼(knowledge/research/dbms-papers/scalable-lock-manager.md), WAL 삽입 경로도 PostgreSQL에서 여러 삽입 락으로 파티셔닝된다(postgres-xlog-wal.md). ARIES가 가정하는 엄격한 전체 순서와 현대 하드웨어가 요구하는 병렬성 사이의 지속적인 긴장이다. rmgr 테이블 자체가 병목은 아니다. 이미 파싱된 레코드에 대한 순수 디스패치이기 때문이다. 그러나 이 확장성 질문의 바로 다음 단계에 위치한다.

  • 코드 (REL_18_STABLE, 커밋 273fe94, /data/hgryoo/references/postgres):
    • src/include/access/rmgrlist.hPG_RMGR X-매크로 목록 (22개 내장 리소스 매니저와 콜백 열).
    • src/include/access/rmgr.hRmgrIds enum, RM_MAX_ID / RM_MIN_CUSTOM_ID / RM_EXPERIMENTAL_ID, RmgrIdIsBuiltin / RmgrIdIsCustom.
    • src/backend/access/transam/rmgr.cRmgrTable[], RmgrStartup / RmgrCleanup, RmgrNotFound, RegisterCustomRmgr, pg_get_wal_resource_managers.
    • src/include/access/xlog_internal.hRmgrData 구조체, GetRmgr / RmgrIdExists 접근자.
    • src/include/access/xlogrecord.hXLogRecord 헤더, XLogRecordBlockHeader, XLogRecordBlockImageHeader, BKPBLOCK_* / BKPIMAGE_* / XLR_* 플래그 패밀리.
    • src/backend/access/transam/xlogutils.cXLogReadBufferForRedo / …Extended, XLogReadBufferExtended, log_invalid_page / XLogCheckInvalidPages.
    • src/backend/access/transam/generic_xlog.c + src/include/access/generic_xlog.hGeneric rmgr 델타 엔진 (GenericXLogStart / RegisterBuffer / Finish, computeDelta, applyPageRedo, generic_redo, generic_mask).
    • src/backend/access/heap/heapam_xlog.cheap_redo opcode 디스패치 (rmgr rm_redo 패턴의 예시).
    • src/backend/access/rmgrdesc/heapdesc.cheap_desc / heap_identify 디스크립터 쌍.
    • src/bin/pg_waldump/rmgrdesc.c — 동일한 rmgrlist.h에서 만들어진 프론트엔드 RmgrDescTable.
    • src/backend/access/transam/README — WAL 레코드 구성, 버퍼 등록, 전체 페이지 이미지 규칙에 대한 정식 서술.
  • 이론 / 교재:
    • knowledge/research/dbms-papers/aries.md — Mohan et al. 1992, ARIES. WAL 불변성, LSN/pageLSN, redo/undo, CLR, 리소스 매니저.
    • knowledge/research/dbms-papers/systemr.md — ARIES가 일반화한 System R 복구 계보.
    • knowledge/research/dbms-papers/scalable-lock-manager.md — 단일 직렬화 지점의 멀티코어 파티셔닝 (WAL 삽입 락 유추).
    • raw/dbms/textbooks/Database Internals.pdf (Petrov 2019) — 더 넓은 엔진 환경에서의 WAL, 전체 페이지 쓰기, 버퍼 관리, 복구.
  • 이 지식 베이스 내 교차 참조:
    • postgres-xlog-wal.md — WAL 삽입, 삽입 락, 세그먼트·플러시 기계 (이 문서가 위임하는 emit 측).
    • postgres-recovery-redo.md — 복구 드라이버 루프, 체크포인트, 재시작점 (이 문서가 위임하는 재생 드라이버).
    • postgres-xact.md — 트랜잭션 커밋·중단 레코드 (Transaction rmgr).
    • postgres-heap-am.md, postgres-nbtree.md — 이 문서가 예제로 쓴 힙과 B-트리 rmgr의 redo 루틴.
    • postgres-buffer-manager.md — 재생 중 XLogReadBufferExtended가 구동하는 공유 버퍼 계층.
    • postgres-logical-decoding.mdrm_decode 콜백 소비자.