콘텐츠로 이동

(KO) PostgreSQL Write-Ahead Log — 레코드 삽입, LSN, 그리고 내구성 척추

목차

write-ahead log (미리-쓰기 로그)는 하나의 질문에 답하기 위해 존재한다. 시스템이 중단됐을 때 진행 중이던 변경 가운데 어느 것이 실제로 내구 상태에 도달했는가, 그리고 어떻게 일관된 지점으로 되돌아갈 것인가. 이 질문에 대한 정석적 답이 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는 세 가지 원칙 위에 서 있다. PostgreSQL의 WAL은 이 원칙들을 충실하게 — 다만 선택적으로 — 구현한 사례다.

  1. 미리-쓰기 로깅. 페이지 변경을 기술하는 로그 레코드는 변경된 데이터 페이지보다 먼저 안정 저장소에 도달해야 한다. 이것이 이름이 된 불변성이다. 이 불변성 덕분에 버퍼 매니저는 더티 페이지를 메모리에 무한히 유지할 수 있고(no-force 정책), 커밋 전에도 더티 페이지를 기록할 수 있다(steal 정책). 잃어버린 작업의 redo와 커밋되지 않은 작업의 undo 모두 로그만으로 충분하기 때문이다.
  2. redo 중 역사 반복. 재시작 시 마지막 체크포인트부터 로그를 앞방향으로 재생한다. 최종적으로 중단된 트랜잭션의 변경까지 포함해 모든 기록된 변경을 다시 적용함으로써 크래시 직전의 정확한 페이지 상태를 재현한다. 그런 다음 실패한 트랜잭션들을 롤백한다.
  3. undo 중 변경 기록. undo 동작 자체도 로그에 기록된다(보상 로그 레코드, compensation log records). 복구 도중 크래시가 발생해도 롤백 진행이 사라지지 않는다.

로그와 데이터 페이지를 연결하는 단일 메커니즘이 **LSN(Log Sequence Number, 로그 시퀀스 번호)**이다. ARIES에서 모든 페이지는 그 페이지를 마지막으로 수정한 로그 레코드의 LSN을 저장한다(PageLSN). 두 가지 비교 연산이 이 구조에서 도출되어 전체 기계를 작동시킨다.

  • WAL 규칙, 비교로 강제. 페이지는 해당 페이지의 LSN까지 로그가 내구 상태에 있을 때만 플러시될 수 있다. 즉, flushedLSN >= PageLSN(page)가 페이지 기록의 사전 조건이다.
  • 멱등(idempotent) redo, 역시 비교로. 재생 중 LSN L에 있는 페이지 관련 로그 레코드는 PageLSN(page) >= L이면 건너뛴다. 해당 변경이 이미 반영되어 있기 때문이다. 이것이 재생을 재시작해도 안전한 이유다.

Database Internals(Petrov, 5장, “Transaction Processing and Recovery”)는 이를 운영적 로그(물리적/생리적 레코드, 바이트 단위 페이지 편집을 기술)와 논리적 로그(상위 수준 연산을 기록) 사이의 경계선으로 설명한다. PostgreSQL의 WAL은 생리적(physiological) 로그다. 레코드는 페이지를 이름으로 지정하고 특정 물리 편집을 재적용할 충분한 데이터를 담고 있지만, 편집 자체는 리소스 매니저가 이해하는 형태로 표현된다(예: 이 줄 포인터에 이 튜플 삽입). Database System Concepts(Silberschatz et al., 7판, 19장, “Recovery System”)도 같은 모델을 “지연(deferred)” vs. “즉시(immediate)” 수정이라는 이름으로 설명한다. PostgreSQL은 steal/no-force 방식의 즉시-수정 엔진이며, 이것이 정확히 ARIES가 설계 목표로 삼은 체제다.

WAL 구현자가 선택하는 설계 공간은 다음 네 가지 축으로 이루어진다.

  1. 레코드 세분성 — 물리적(바이트 범위), 논리적(연산), 생리적(페이지 범위 연산). PostgreSQL은 생리적 방식을 택하며 리소스 매니저별로 구분된다.
  2. Torn-page 방어 — 전력 차단으로 페이지가 절반만 기록된 상황에서 살아남는 방법. “페이지 쓰기는 원자적”이라는 ARIES 가정은 대부분의 저장 장치에서 거짓이므로 실제 엔진은 이에 대한 답을 가져야 한다. PostgreSQL의 답은 **전체 페이지 쓰기(full-page writes, FPI)**다.
  3. 그룹 커밋 — 각 커밋 트랜잭션이 개별 fsync를 발행하는지, 아니면 여러 트랜잭션이 하나의 fsync에 편승하는지. PostgreSQL은 일괄 처리한다.
  4. 비동기 내구성COMMIT이 레코드 fsync 전에 반환할 수 있는지. 제한된 손실 창을 허용하는 대신 레이턴시를 낮춘다. PostgreSQL은 synchronous_commit = off로 이를 제공한다.

이 문서의 나머지는 PostgreSQL이 위 네 가지 축을 어떻게 설정했는지를 다룬다.

ARIES 계열 엔진은 대부분 동일한 공학적 관행으로 수렴한다. 이 관행들을 먼저 정리해 두면, 다음 절에서 등장하는 PostgreSQL 고유 심볼들이 발명처럼 보이지 않고 공유된 플레이북 안의 선택으로 읽힌다.

로그는 하나의 논리적 추가-전용 스트림

섹션 제목: “로그는 하나의 논리적 추가-전용 스트림”

디스크 파일 레이아웃이 어떻든 로그는 엔진의 나머지 부분에 단일하고 계속 자라는 바이트 시퀀스로 제시된다. LSN은 그 스트림 안의 위치다. 단조 증가하며 재사용되지 않는다. LSN을 바이트 오프셋으로 만들면 “로그가 이 지점까지 내구 상태인가?”는 정수 하나의 비교가 되고, “두 레코드는 얼마나 떨어져 있는가?”는 뺄셈이 된다. 엔진은 재활용과 아카이빙을 위해 스트림을 고정 크기 세그먼트 파일로 분할하지만, 세그먼트 경계는 LSN 뒤에 감춰진 구현 세부사항이다.

레코드는 자기 기술적이고 타입이 있다

섹션 제목: “레코드는 자기 기술적이고 타입이 있다”

로그 레코드는 헤더(길이, 소유 트랜잭션, 이전 레코드 역방향 링크, 타입 태그, 체크섬)와 타입별 페이로드로 구성된다. 타입 태그는 복구 시점에 레코드를 올바른 redo 핸들러로 라우팅한다. 이것이 리소스 매니저 패턴이다. 로그 서브시스템은 레코드의 프레이밍, 위치 확인, 체크섬, 체이닝 방법을 알고 있지만, 의미는 서브시스템별 콜백에 위임한다(PostgreSQL의 rmgr 카탈로그 전체 — Heap, Btree, Xact, CLOG 등 — 는 형제 문서 postgres-wal-records-rmgr.md의 주제다. 이 문서에서 rmgr id는 레코드 헤더의 불투명한 태그로 취급된다).

고처리량 엔진은 백엔드가 I/O를 수행하는 동안 전역 로그 락을 잡도록 허용하지 않는다. 보편적 패턴은 두 단계로 나뉜다.

  1. 로그의 공간을 예약하는 짧은 임계 구역. 바이트 범위를 주장하고 레코드에 LSN을 할당한 뒤 바이트를 인메모리 로그 버퍼로 복사한다.
  2. 백그라운드 작성자가 구동하고 커밋 시 명시적 플러시 요청으로 작동하는, 버퍼를 디스크에 쓰고 플러시하는 별도의 비동기 경로.

예약 단계는 전역으로 직렬화되기 때문에 최대한 작아야 한다. 단일 스트림의 선두가 진행하는 지점이기 때문이다. 레코드 조립, 체크섬 계산, 버퍼 복사, fsync는 모두 이 구역 밖으로 밀려난다.

세 개의 수위 마커가 진행을 추적한다

섹션 제목: “세 개의 수위 마커가 진행을 추적한다”

삽입과 내구성이 분리되어 있으므로 엔진은 순서가 있는 진행 마커를 유지한다. 레코드가 버퍼로 삽입된 위치, 버퍼가 커널로 기록된 위치, 커널이 안정 매체로 플러시(fsync)된 위치가 그 세 가지다. Insert >= Write >= Flush 불변성은 항상 유지되며, 커밋은 Flush >= commit-LSN이 될 때까지 기다린다.

페이지는 LSN을 저장하며, 버퍼 매니저는 그것을 관문으로 쓴다

섹션 제목: “페이지는 LSN을 저장하며, 버퍼 매니저는 그것을 관문으로 쓴다”

모든 데이터 페이지는 그 페이지를 마지막으로 건드린 로그 레코드의 LSN을 저장한다. 버퍼 매니저는 더티 페이지를 추출/기록하기 전에 이 값을 확인하며, WAL 규칙을 페이지가 메모리를 떠나는 유일한 병목 지점에서 강제한다. 이것이 WAL 서브시스템과 스토리지 엔진 사이의 이음새다.

Torn page는 페이지 전체를 기록함으로써 방어한다

섹션 제목: “Torn page는 페이지 전체를 기록함으로써 방어한다”

하드웨어의 페이지 쓰기는 원자적이지 않으므로, 페이지가 체크포인트 이후 처음 수정될 때 엔진은 전체 페이지(전체 페이지 이미지, full-page image)를 로그에 기록한다. 재생 시 그 이미지를 복원하면 원래 쓰기가 torn 상태였든 아니든 알려진 양호한 페이지를 얻는다. 비용은 WAL 볼륨 증가이며, 완화책은 hole 제거와 압축이다.

ARIES / 교과서 개념PostgreSQL 이름
Log Sequence Number (스트림 내 위치)XLogRecPtruint64 바이트 오프셋 (xlogdefs.h)
단일 추가-전용 로그WAL / “xlog”; pg_wal/ 안의 물리 파일들
각 데이터 페이지의 PageLSNPageGetLSN / PageSetLSN (페이지 헤더 pd_lsn)
로그 레코드 헤더XLogRecord (xlogrecord.h)
레코드 타입 태그 → redo 핸들러xl_rmid (rmgr id) + xl_info
이전 레코드 역방향 링크xl_prev
레코드 체크섬xl_crc (CRC-32C)
예약-후-복사 삽입ReserveXLogInsertLocation 다음 CopyXLogRecordToWAL
인메모리 로그 버퍼WAL 버퍼들 (XLogCtl->pages, wal_buffers)
Insert / Write / Flush 수위 마커logInsertResult / logWriteResult / logFlushResult
전체 페이지 이미지 (torn-page 방어)전체 페이지 쓰기 (FPW / FPI), BKPBLOCK_HAS_IMAGE
그룹 커밋XLogFlush 그룹 커밋 루프 + CommitDelay
비동기 내구성synchronous_commit = off, XLogBackgroundFlush
세그먼트 파일WAL 세그먼트, 기본값 16 MB (DEFAULT_XLOG_SEG_SIZE)

PostgreSQL은 코드 안에서 WAL 서브시스템을 xlog라고 부른다. 설계 의도는 src/backend/access/transam/README에 간결하게 담겨 있다. “write AHEAD log의 기본 가정은 로그 항목이 그것이 기술하는 데이터 페이지 변경보다 먼저 안정 저장소에 도달해야 한다는 것이다.” 아래 내용은 그 가정을 어떻게 기계로 만들었는지를 설명한다.

LSN은 바이트 위치이며, 로그는 하나의 스트림이다

섹션 제목: “LSN은 바이트 위치이며, 로그는 하나의 스트림이다”

근본적인 결정은 다음과 같다. XLogRecPtr은 시간의 시작점부터 시작해 영원히 자라는 하나의 개념적 로그 안의 64비트 바이트 오프셋이다.

// XLogRecPtr — src/include/access/xlogdefs.h
typedef uint64 XLogRecPtr;
#define InvalidXLogRecPtr 0
#define XLogRecPtrIsValid(r) ((r) != InvalidXLogRecPtr)

값이 바이트 오프셋이므로 %X/%X 형식(LSN_FORMAT_ARGS 사용)의 사람이 읽을 수 있는 표현은 그 오프셋의 상위 32비트와 하위 32비트일 뿐이다. 0은 “유효하지 않음”으로 예약되어 있다. 부트스트랩은 의도적으로 첫 번째 실제 WAL 페이지를 한 세그먼트 안쪽에서 시작하므로, 실제 레코드는 오프셋 0에서 시작하지 않는다.

그 단일 스트림은 디스크에서 세그먼트 파일로 잘린다(기본 16 MB). 하지만 분할은 LSN에 보이지 않는다. 변환 매크로는 xlog_internal.h에 있다.

// segment math — src/include/access/xlog_internal.h
#define XLByteToSeg(xlrp, logSegNo, wal_segsz_bytes) \
logSegNo = (xlrp) / (wal_segsz_bytes)
#define XLogSegmentOffset(xlogptr, wal_segsz_bytes) \
((xlogptr) & ((wal_segsz_bytes) - 1))

WAL 파일 이름은 TLI + 상위-32 + 하위-32 세그먼트 번호를 16진수로 인코딩한다. 타임라인 ID, 그리고 세그먼트를 4-GB “로그 id”와 로그 내 세그먼트 인덱스로 분해한 형태다.

// XLogFileName — src/include/access/xlog_internal.h
snprintf(fname, MAXFNAMELEN, "%08X%08X%08X", tli,
(uint32) (logSegNo / XLogSegmentsPerXLogId(wal_segsz_bytes)),
(uint32) (logSegNo % XLogSegmentsPerXLogId(wal_segsz_bytes)));

LSN 산술이 흡수해야 하는 미묘함이 하나 있다. 각 WAL 페이지(8 KB, XLOG_BLCKSZ)는 헤더로 시작하므로, 파일의 모든 바이트가 레코드 데이터에 “사용 가능”한 것은 아니다. PostgreSQL은 모든 페이지 헤더를 제외한 사용 가능한 바이트 위치 공간으로 삽입 진행을 추적한 뒤, 핫 락 밖에서 실제 XLogRecPtr로 변환한다(XLogBytePosToRecPtr 참조). 페이지 헤더는 작지만 필수다.

// XLogPageHeaderData — src/include/access/xlog_internal.h
typedef struct XLogPageHeaderData
{
uint16 xlp_magic; /* magic value for correctness checks */
uint16 xlp_info; /* flag bits, see below */
TimeLineID xlp_tli; /* TimeLineID of first record on page */
XLogRecPtr xlp_pageaddr; /* XLOG address of this page */
uint32 xlp_rem_len; /* total len of remaining data for record */
} XLogPageHeaderData;

xlp_pageaddr는 페이지 자체의 LSN으로, 리더(§ 소스 코드 가이드)가 재활용됐지만 덮어쓰이지 않은 세그먼트를 감지할 때 활용하는 자기 검증 값이다. xlp_rem_len은 페이지 경계를 넘치는 레코드가 어떻게 이어지는지를 나타낸다. 다음 페이지의 헤더에 넘쳐흐른 레코드의 남은 바이트 수가 기록된다.

레코드 형식: 헤더, 블록, 데이터

섹션 제목: “레코드 형식: 헤더, 블록, 데이터”

WAL 레코드는 고정 헤더와 가변 길이의 블록 참조 및 데이터 청크로 구성된다. 헤더는 작고 엄격하다.

// 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;

다섯 개의 헤더 필드는 ARIES 레코드 헤더를 구체화한 것이다. xl_tot_len이 크기를 정하고, xl_xid가 트랜잭션에 연결하며, xl_prev가 이전 레코드에 역방향으로 연결한다(읽기 시 검증되는 체인). xl_rmid+xl_info는 레코드를 리소스 매니저의 redo 핸들러로 라우팅하며, xl_crc(CRC-32C)는 복구가 레코드를 적용하기 전에 신뢰할 수 있게 하는 무결성 검증값이다. 헤더 이후 — 블록 참조, 전체 페이지 이미지, “메인 데이터” — 는 xlogrecord.h의 헤더 주석에 문서화된 레이아웃을 따른다.

Fixed-size header (XLogRecord struct)
XLogRecordBlockHeader struct (block ref 0)
XLogRecordBlockHeader struct (block ref 1)
...
XLogRecordDataHeader[Short|Long]
block data
block data
...
main data

수정된 각 페이지는 XLogRecordBlockHeader로 기술된다. 작은 블록 id로 식별되며, 선택적으로 전체 페이지 이미지 헤더와 페이지의 relfilelocator/forknum/blocknumber가 뒤따른다.

// 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 (excl. page image) */
/* If BKPBLOCK_HAS_IMAGE, an XLogRecordBlockImageHeader follows */
/* If BKPBLOCK_SAME_REL is not set, a RelFileLocator follows */
/* BlockNumber follows */
} XLogRecordBlockHeader;

fork_flags 바이트는 fork 번호(하위 4비트)와 상위 비트 플래그를 다중화한다. BKPBLOCK_HAS_IMAGE(이 블록은 전체 페이지 이미지를 포함), BKPBLOCK_WILL_INIT(redo가 페이지를 처음부터 재구성하므로 기존 내용 불필요), BKPBLOCK_SAME_REL(relfilelocator 생략, 이전 블록과 동일) 세 가지다. 하나의 레코드는 최대 XLR_MAX_BLOCK_ID(32)개의 블록을 참조할 수 있다.

flowchart TB
  subgraph REC["one XLOG record (contiguous bytes in the stream)"]
    direction TB
    HDR["XLogRecord header<br/>xl_tot_len, xl_xid, xl_prev,<br/>xl_info, xl_rmid, xl_crc"]
    B0["XLogRecordBlockHeader 0<br/>id, fork_flags, data_length<br/>(+ image hdr if FPI)<br/>(+ RelFileLocator + BlockNumber)"]
    B1["XLogRecordBlockHeader 1<br/>(BKPBLOCK_SAME_REL -> no locator)"]
    DH["XLogRecordDataHeaderShort/Long<br/>(main-data length)"]
    BD0["block 0 data / full-page image"]
    BD1["block 1 data"]
    MD["main data (rmgr-specific)"]
  end
  HDR --> B0 --> B1 --> DH --> BD0 --> BD1 --> MD

그림 1 — 스트림 안에서 WAL 레코드 하나의 레이아웃. 헤더는 고정 크기이며 시작 위치에서 MAXALIGN 정렬된다. 그 이후는 비정렬 밀집 구조다. 블록 참조가 먼저 나오고(각각 선택적으로 전체 페이지 이미지를 포함), 그 다음에 short/long 메인 데이터 헤더, 그리고 블록 데이터와 rmgr 메인 데이터가 뒤따른다. xl_rmidredo 핸들러는 이 레이아웃을 역방향으로 분해할 줄 안다. (xlogrecord.h 헤더 주석에서 가져온 레이아웃.)

레코드 구성: Begin / Register / Insert

섹션 제목: “레코드 구성: Begin / Register / Insert”

호출자는 위의 바이트 레이아웃을 직접 구성하지 않는다. 세 종류의 호출로 의도를 선언하면 어셈블러가 패킹을 담당한다. README의 예제를 따라가면 다음과 같다.

// the WAL-logged action recipe — src/backend/access/transam/README
XLogBeginInsert();
XLogRegisterBuffer(0, lbuffer, REGBUF_STANDARD);
XLogRegisterBuffer(1, rbuffer, REGBUF_STANDARD);
XLogRegisterData(&xlrec, SizeOfFictionalAction);
XLogRegisterBufData(0, tuple->data, tuple->len);
recptr = XLogInsert(RM_FOO_ID, XLOG_FOOBAR_DO_STUFF);
PageSetLSN(dp, recptr); /* stamp the page with the record's LSN */

XLogRegisterBuffer는 공유 버퍼가 수정되었음을 기록한다. 해당 페이지에 전체 페이지 이미지가 필요한지는 어셈블러가 결정한다. 호출자는 관여하지 않는다. 이 함수는 버퍼가 배타적으로 잠겨 있고 더티 상태임을 단언한다. WAL 규칙의 사전 조건이다. 호출자가 REGBUF_NO_CHANGE를 전달하지 않는 한 그렇다.

// XLogRegisterBuffer — src/backend/access/transam/xloginsert.c
void
XLogRegisterBuffer(uint8 block_id, Buffer buffer, uint8 flags)
{
registered_buffer *regbuf;
Assert(begininsert_called);
#ifdef USE_ASSERT_CHECKING
if (!(flags & REGBUF_NO_CHANGE))
Assert(BufferIsExclusiveLocked(buffer) && BufferIsDirty(buffer));
#endif
regbuf = &registered_buffers[block_id];
BufferGetTag(buffer, &regbuf->rlocator, &regbuf->forkno, &regbuf->block);
regbuf->page = BufferGetPage(buffer);
regbuf->flags = flags;
/* ... condensed ... */
regbuf->in_use = true;
}

XLogRegisterData는 rmgr 고유 “메인 데이터”(논리적 페이로드 — 예: 삽입을 redo하기 위한 튜플 헤더)를 추가한다. XLogRegisterBufData등록된 블록과 연관된 데이터를 추가한다. 해당 블록의 전체 페이지 이미지를 찍는 경우 이미지에 이미 포함되어 있으므로 어셈블러가 이 데이터를 버릴 수 있다.

최종 XLogInsert(rmid, info)는 공개 진입점이다. 두 헬퍼 — 어셈블과 삽입 — 를 감싸는 재시도 루프이며, 삽입 경로에서 전체 페이지 쓰기 상태가 아래에서 변경된 것을 감지하면 재시도한다.

// XLogInsert — src/backend/access/transam/xloginsert.c
XLogRecPtr
XLogInsert(RmgrId rmid, uint8 info)
{
XLogRecPtr EndPos;
if (!begininsert_called)
elog(ERROR, "XLogBeginInsert was not called");
/* ... info-mask validation, bootstrap shortcut ... */
do
{
XLogRecPtr RedoRecPtr;
bool doPageWrites;
XLogRecPtr fpw_lsn;
XLogRecData *rdt;
int num_fpi = 0;
/* values needed to decide on full-page writes; rechecked under lock */
GetFullPageWriteInfo(&RedoRecPtr, &doPageWrites);
rdt = XLogRecordAssemble(rmid, info, RedoRecPtr, doPageWrites,
&fpw_lsn, &num_fpi, &topxid_included);
EndPos = XLogInsertRecord(rdt, fpw_lsn, curinsert_flags, num_fpi,
topxid_included);
} while (EndPos == InvalidXLogRecPtr);
XLogResetInsertion();
return EndPos;
}

XLogRecordAssemble은 등록된 버퍼와 데이터를 순회하며 XLogRecData 체인 — (data, len) 조각들의 연결 리스트 — 과 스크래치 버퍼 안의 레코드 헤더를 구성하고, 아직 알 수 없는 xl_prev만 빼고 나머지 전체의 CRC를 계산한다. 함수가 EndPos == InvalidXLogRecPtr을 반환하는 것은 간접적이다. 삽입 단계가 invalid를 반환해서 재조립을 강제하는 방식이다. XLogInsert의 반환값은 레코드의 LSN이며, 호출자가 PageSetLSN으로 페이지에 찍는다.

XLogRecordAssemble 안에서 전체 페이지 이미지를 삽입할지 결정하는 것은 블록별로 이루어진다. 규칙은 다음과 같다. 전체 페이지 쓰기가 켜져 있고, 이번이 마지막 체크포인트 이후 해당 페이지의 첫 번째 수정이라면(page_lsn <= RedoRecPtr) 백업한다.

// XLogRecordAssemble — needs_backup decision — xloginsert.c
else if (!doPageWrites)
needs_backup = false;
else
{
/* page LSN is the first data on every page passed to XLogInsert */
XLogRecPtr page_lsn = PageGetLSN(regbuf->page);
needs_backup = (page_lsn <= RedoRecPtr);
if (!needs_backup)
{
if (*fpw_lsn == InvalidXLogRecPtr || page_lsn < *fpw_lsn)
*fpw_lsn = page_lsn;
}
}

RedoRecPtr은 가장 최근 체크포인트의 redo 지점 LSN이다. LSN이 이미 이것을 초과하는 페이지는 체크포인트 이후 수정(따라서 이미지 촬영)되었으므로 새 이미지가 필요 없다. 여기서 반환되는 fpw_lsn은 이미지를 찍지 않은 페이지들 중 가장 낮은 LSN이다. XLogInsertRecord가 삽입 락 아래에서 이 값을 재확인해 체크포인트가 슬며시 끼어들어 해당 페이지에 이제 백업이 필요한지 감지하고, 재조립 루프를 트리거한다.

페이지 이미지를 찍을 때 PostgreSQL은 WAL 비용을 두 가지 방법으로 줄인다. 표준 페이지 레이아웃에서는 pd_lowerpd_upper 사이의 사용되지 않는 가운데 영역인 hole(홀, 전체가 0으로 채워진 미사용 구간)을 생략하고, 그 오프셋과 길이만 기록한다.

// XLogRecordAssemble — hole removal — xloginsert.c
if (regbuf->flags & REGBUF_STANDARD)
{
uint16 lower = ((PageHeader) page)->pd_lower;
uint16 upper = ((PageHeader) page)->pd_upper;
if (lower >= SizeOfPageHeaderData && upper > lower && upper <= BLCKSZ)
{
bimg.hole_offset = lower;
cbimg.hole_length = upper - lower;
}
/* ... condensed ... */
}

wal_compression이 켜져 있으면 hole이 제거된 이미지를 PGLZ / LZ4 / ZSTD로 압축하며, 사용된 알고리즘이 bimg_info에 기록된다. 이미지 헤더에는 redo가 실제로 이미지를 복원해야 할 때 BKPIMAGE_APPLY가 표시된다(wal_consistency_checking용으로만 기록된 이미지와 구별하기 위해서다).

flowchart TD
  START["block registered<br/>in XLogRecordAssemble"] --> Q1{REGBUF_FORCE_IMAGE?}
  Q1 -- yes --> BK["take full-page image"]
  Q1 -- no --> Q2{REGBUF_NO_IMAGE<br/>or doPageWrites off?}
  Q2 -- yes --> NOBK["no image;<br/>log only block data"]
  Q2 -- no --> Q3{"page_lsn &lt;= RedoRecPtr?<br/>first change since checkpoint"}
  Q3 -- yes --> BK
  Q3 -- no --> REC["record page_lsn as candidate fpw_lsn;<br/>no image this time"]
  BK --> Q4{REGBUF_STANDARD?}
  Q4 -- yes --> HOLE["omit pd_lower..pd_upper hole"]
  Q4 -- no --> FULL["image whole BLCKSZ"]
  HOLE --> COMP{wal_compression on?}
  FULL --> COMP
  COMP -- yes --> CZ["compress image (PGLZ/LZ4/ZSTD)"]
  COMP -- no --> RAW["store raw image"]

그림 2 — XLogRecordAssemble 안에서 블록별 전체 페이지 이미지 결정 흐름. 핵심 분기는 page_lsn <= RedoRecPtr이다. 체크포인트 이후 페이지가 처음 건드려지면 전체 이미지를 기록해서 재생 시 torn page를 복원할 수 있도록 한다. 같은 체크포인트 주기 안의 이후 변경들은 증분 편집만 기록한다. Hole 제거와 압축이 이미지의 WAL 공간 비용을 줄인다. (xloginsert.c의 로직.)

XLogInsertRecord는 조립된 체인이 공유 메모리를 만나는 곳이다. 이 함수 자체의 주석이 두 단계 계약을 명시한다. 핵심은 1단계가 전역적으로 직렬화되며 아주 짧고, 2단계는 동시에 실행된다는 점이다.

  1. WAL에서 적절한 크기의 공간을 예약한다. 예약된 공간의 현재 선두는 Insert->CurrBytePos에 유지되며 insertpos_lck로 보호된다.
  2. 레코드를 예약된 WAL 공간으로 복사한다 … 이 작업은 여러 프로세스에서 동시에 수행될 수 있다.

예약은 핫 경로에서 가장 뜨거운 지점이므로 스핀락 아래에서 몇 가지 산술 연산으로 줄였다. CurrBytePos는 “사용 가능한 바이트 위치”(페이지 헤더 제외)이므로 N바이트 예약은 본질적으로 CurrBytePos += N이다.

// ReserveXLogInsertLocation — src/backend/access/transam/xlog.c
SpinLockAcquire(&Insert->insertpos_lck);
startbytepos = Insert->CurrBytePos;
endbytepos = startbytepos + size;
prevbytepos = Insert->PrevBytePos;
Insert->CurrBytePos = endbytepos;
Insert->PrevBytePos = startbytepos;
SpinLockRelease(&Insert->insertpos_lck);
*StartPos = XLogBytePosToRecPtr(startbytepos);
*EndPos = XLogBytePosToEndRecPtr(endbytepos);
*PrevPtr = XLogBytePosToRecPtr(prevbytepos);

바이트 위치를 XLogRecPtr로 변환하는 작업(페이지 헤더를 다시 더하는 연산)은 스핀락 해제 후 XLogBytePosToRecPtr이 담당한다. 스핀락은 정수 두 개를 증가시키는 동안만 유지된다. 이것이 로그의 선두가 전진하는 유일한 직렬화 지점이며, PostgreSQL은 이를 최소화하는 데 상당한 노력을 기울인다(주석에 사이클을 아끼기 위해 함수가 pg_attribute_always_inline이라고 명시되어 있다).

예약 전에 XLogInsertRecordWAL 삽입 락 가운데 하나를 획득하고 전체 페이지 쓰기 상태를 재확인한다.

// XLogInsertRecord — under the insertion lock — xlog.c
WALInsertLockAcquire();
if (RedoRecPtr != Insert->RedoRecPtr)
{
Assert(RedoRecPtr < Insert->RedoRecPtr);
RedoRecPtr = Insert->RedoRecPtr;
}
doPageWrites = (Insert->fullPageWrites || Insert->runningBackups > 0);
if (doPageWrites &&
(!prevDoPageWrites ||
(fpw_lsn != InvalidXLogRecPtr && fpw_lsn <= RedoRecPtr)))
{
/* a page now needs backup that the caller didn't back up. Start over. */
WALInsertLockRelease();
END_CRIT_SECTION();
return InvalidXLogRecPtr; /* -> XLogInsert re-assembles */
}
ReserveXLogInsertLocation(rechdr->xl_tot_len, &StartPos, &EndPos,
&rechdr->xl_prev);

이것이 재조립 트리거다. RedoRecPtr은 삽입 락을 잡고 있는 동안에만 권위 있는 값이므로, 조립과 삽입 사이에 체크포인트가 이 값을 전진시키면 FPI 결정이 무효가 될 수 있다. 공간이 예약되고 xl_prev가 채워지면 헤더의 CRC를 최종화한다(본문 CRC는 조립 중 계산됐다). 그런 다음 바이트를 CopyXLogRecordToWAL로 WAL 버퍼에 복사한다.

“예약”(스핀락)과 “복사”(삽입 락)를 분리한 핵심 이유는 복사가 병렬로 실행되게 하기 위해서다. NUM_XLOGINSERT_LOCKS = 8개가 있다. 백엔드는 하나를 선택한다. 캐시 친화성을 위해 마지막에 사용한 것을 선호하며, 경합이 생기면 다른 것으로 이동한다.

// WALInsertLockAcquire — xlog.c
if (lockToTry == -1)
lockToTry = MyProcNumber % NUM_XLOGINSERT_LOCKS;
MyLockNo = lockToTry;
immed = LWLockAcquire(&WALInsertLocks[MyLockNo].l.lock, LW_EXCLUSIVE);
if (!immed)
lockToTry = (lockToTry + 1) % NUM_XLOGINSERT_LOCKS; /* try another next time */

각 락은 LWLock 이상의 정보를 담는다. insertingAt 원자 변수와 lastImportantAt LSN이 있다.

// WALInsertLock — xlog.c
typedef struct
{
LWLock lock;
pg_atomic_uint64 insertingAt;
XLogRecPtr lastImportantAt;
} WALInsertLock;

insertingAt은 플러셔가 페이지를 기록해도 안전한지 아는 방법이다. 페이지 경계를 넘는 백엔드는 자신이 얼마나 삽입했는지 공개하므로, WaitXLogInsertionsToFinish는 자신이 기록하려는 페이지까지의 삽입만 기다리고 더 앞의 삽입은 무시할 수 있다. lastImportantAt은 각 락 아래의 마지막 중요한 레코드의 LSN을 추적한다(XLOG_MARK_UNIMPORTANT로 플래그되지 않은 레코드). 체크포인터가 체크포인트할 가치가 있는 무언가가 일어났는지 판단하는 데 사용한다.

일부 레코드 타입들 — XLOG_SWITCH(세그먼트 경계 강제)와 체크포인트-redo 레코드 — 은 모든 삽입자를 동결해야 한다. 이들은 WALInsertLockAcquireExclusive로 여덟 개 락을 모두 획득한다. 그래서 삽입 경로가 WalInsertClass를 기준으로 분기하는 것이다.

flowchart TB
  A["XLogInsert(rmid, info)"] --> B["XLogRecordAssemble<br/>build XLogRecData chain + header,<br/>decide full-page images, body CRC"]
  B --> C["XLogInsertRecord"]
  C --> D["START_CRIT_SECTION"]
  D --> E["WALInsertLockAcquire<br/>(1 of 8 LWLocks)"]
  E --> F{RedoRecPtr stale<br/>or FPI now needed?}
  F -- yes --> G["release lock, return Invalid<br/>-> retry assemble"]
  F -- no --> H["ReserveXLogInsertLocation<br/>spinlock: CurrBytePos += size<br/>-> StartPos/EndPos/xl_prev"]
  H --> I["finalize header CRC"]
  I --> J["CopyXLogRecordToWAL<br/>(concurrent; into WAL buffers)"]
  J --> K["update lastImportantAt"]
  K --> L["WALInsertLockRelease<br/>END_CRIT_SECTION"]
  L --> M["bump shared LogwrtRqst.Write<br/>if crossed a page boundary"]
  M --> N["return EndPos (LSN)"]

그림 3 — 삽입 경로. 전역적으로 직렬화되는 작업은 H 단계의 스핀락-보호 CurrBytePos += size뿐이다. 레코드 조립, CRC, WAL 버퍼로의 복사는 모두 전역 선두 밖에서 여덟 개 삽입 LWLock에 걸쳐 병렬화된다. 재시도 간선(F→G)은 조립과 삽입 사이에 체크포인트가 RedoRecPtr을 전진시킨 경우를 처리한다. (xlog.cXLogInsertRecord 흐름.)

내구성: 쓰기, 플러시, 세 개의 수위 마커

섹션 제목: “내구성: 쓰기, 플러시, 세 개의 수위 마커”

레코드를 WAL 버퍼에 복사하면 삽입된 것이지 내구 상태가 된 것이 아니다. XLogCtlData에 원자 변수로 유지되는 세 개의 단조 수위 마커가 그 간격을 추적한다.

// XLogCtlData watermarks — src/backend/access/transam/xlog.c
pg_atomic_uint64 logInsertResult; /* last byte + 1 inserted to buffers */
pg_atomic_uint64 logWriteResult; /* last byte + 1 written out */
pg_atomic_uint64 logFlushResult; /* last byte + 1 flushed */

Insert >= Write >= Flush 순서는 명시적 메모리 배리어로 유지된다. RefreshXLogWriteResult는 읽기 배리어 뒤에서 Write보다 먼저 Flush를 읽고, XLogWrite는 쓰기 배리어 뒤에서 Flush보다 먼저 Write를 공개한다. 따라서 어느 관찰자든 자신이 본 Write 값보다 뒤처진 Flush 값을 항상 보게 된다.

XLogFlush(record)는 내구성 요청이다. “로그를 최소한 record까지 내구 상태로 만들어라.” 처음 두 줄이 빠른 경로다. 복구 중이면 쓰는 게 아니라 읽는 것이고, 이미 플러시되어 있으면 완료다.

// XLogFlush — src/backend/access/transam/xlog.c
if (!XLogInsertAllowed())
{
UpdateMinRecoveryPoint(record, false);
return;
}
if (record <= LogwrtResult.Flush) /* already durable */
return;

그렇지 않으면 WALWriteLock을 획득하기 위해 루프를 돌지만, 그룹 커밋 묘수가 있다. LWLockAcquireOrWait를 사용하므로 다른 백엔드가 이미 플러시 중이면 이 백엔드는 기다렸다가 재확인한다. 이미 작업이 완료되어 있는 경우가 많다. 플러시를 수행할 때는 진행 중인 삽입이 도달한 위치까지 의도적으로 플러시한다(WaitXLogInsertionsToFinish). 나중 트랜잭션들의 레코드도 이번 fsync에 편승시킨다.

// XLogFlush — group commit core — xlog.c
insertpos = WaitXLogInsertionsToFinish(WriteRqstPtr);
if (!LWLockAcquireOrWait(WALWriteLock, LW_EXCLUSIVE))
continue; /* someone may have flushed it already */
RefreshXLogWriteResult(LogwrtResult);
if (record <= LogwrtResult.Flush) /* yes they did */
{
LWLockRelease(WALWriteLock);
break;
}
/* optional CommitDelay sleep to gather more followers */
WriteRqst.Write = insertpos;
WriteRqst.Flush = insertpos;
XLogWrite(WriteRqst, insertTLI, false);

실제 쓰기 전의 pg_usleep(CommitDelay)는 명시적인 그룹 커밋 조정 장치다. 짧은 일시 정지로 더 많은 커미터가 배치에 합류할 시간을 주며, 레이턴시를 처리량과 교환한다.

XLogWrite는 실제 8 KB 버퍼 페이지들의 pg_pwrite를 수행한다. 물리적으로 인접한 페이지들을 하나의 시스템 콜로 병합하고, 그런 다음 fsync를 수행한다.

// XLogWrite — the write — xlog.c
from = XLogCtl->pages + startidx * (Size) XLOG_BLCKSZ;
nbytes = npages * (Size) XLOG_BLCKSZ;
/* ... loop ... */
written = pg_pwrite(openLogFile, from, nleft, startoffset);

fsync 자체는 issue_xlog_fsync가 담당하며, wal_sync_method에 따라 디스패치한다. 메서드가 open_sync/open_datasync이면 이미 write() 자체가 동기화했으므로 no-op이다.

// issue_xlog_fsync — xlog.c
if (!enableFsync ||
wal_sync_method == WAL_SYNC_METHOD_OPEN ||
wal_sync_method == WAL_SYNC_METHOD_OPEN_DSYNC)
return;
switch (wal_sync_method)
{
case WAL_SYNC_METHOD_FSYNC:
if (pg_fsync_no_writethrough(fd) != 0) msg = ...; break;
case WAL_SYNC_METHOD_FDATASYNC:
if (pg_fdatasync(fd) != 0) msg = ...; break;
/* ... */
}
if (msg) ereport(PANIC, ...); /* a failed WAL fsync is unrecoverable */

WAL fsync 실패는 PANIC이다. 로그가 내구 상태임을 증명할 수 없으면 시스템은 계속 동작할 수 없다.

flowchart LR
  subgraph MEM["in shared memory"]
    INS["records copied into<br/>WAL buffers (XLogCtl->pages)<br/>watermark: logInsertResult"]
  end
  INS -->|"XLogWrite: pg_pwrite<br/>whole 8KB pages"| KERN["kernel page cache<br/>watermark: logWriteResult"]
  KERN -->|"issue_xlog_fsync<br/>(fsync/fdatasync)"| DISK["stable storage<br/>watermark: logFlushResult"]
  COMMIT["COMMIT / XLogFlush(commitLSN)"] -.->|"waits until<br/>Flush >= commitLSN"| DISK
  BUF["buffer manager:<br/>flush dirty page"] -.->|"waits until<br/>Flush >= PageLSN"| DISK

그림 4 — 내구성 파이프라인과 세 개의 수위 마커. 삽입이 WAL 버퍼를 채우고(logInsertResult), XLogWrite가 페이지를 커널로 밀어낸다(logWriteResult). issue_xlog_fsync가 안정 매체로 강제한다(logFlushResult). COMMIT과 더티 페이지 플러시 모두 각자 관심 있는 LSN까지 Flush가 도달하기를 기다린다. WAL 규칙과 커밋 내구성이 같은 관문으로 표현되는 이유다. (수위 마커는 XLogCtlData에서; 파이프라인은 XLogFlush/XLogWrite/issue_xlog_fsync에서.)

엔진의 나머지가 LSN을 어떻게 관문으로 쓰는가

섹션 제목: “엔진의 나머지가 LSN을 어떻게 관문으로 쓰는가”

이 서브시스템이 “척추”라는 이름을 얻는 것은 README가 명시하는 두 개의 관문 때문이다.

관문 1 — 버퍼 매니저(WAL 규칙). “bufmgr가 더티 페이지를 기록하기 전에 xlog가 그 페이지의 LSN까지 디스크에 플러시되어 있음을 보장해야 한다.” 페이지의 LSN은 삽입 시 PageSetLSN(dp, recptr)으로 찍혔다. 버퍼 매니저는 PageGetLSN으로 그 값을 읽고 기록 전에 그 값까지 XLogFlush를 호출한다. 메커니즘 자체는 postgres-buffer-manager.md에 있고, 이 문서는 버퍼 매니저가 읽는 LSN을 소유한다. README의 주의사항에 주목할 필요가 있다. 이 검사는 공유 버퍼 매니저에만 존재한다. 임시 테이블 작업(로컬 버퍼 매니저)을 WAL 로그에 기록하면 안 되는 정확한 이유가 여기에 있다.

관문 2 — 커밋(내구성 = 플러시). 동기 COMMIT은 본질적으로 XLogFlush(commit_record_LSN)이다. 커밋 레코드의 삽입과 내구성 대기는 postgres-xact.md가 소유한다. 이 문서가 기여하는 것은 “트랜잭션이 내구 상태”라는 사실이 “Flush 수위 마커가 커밋 LSN을 통과했다”는 사실로 귀결된다는 점이다. synchronous_commit = off(비동기 커밋) 아래에서 백엔드는 플러시를 건너뛰고 LSN을 공유 asyncXactLSN에 기록한 뒤 반환한다. 이후 XLogBackgroundFlush — walwriter의 주기적 플러시 — 가 제한된 수의 wal_writer_delay 주기 안에 내구 상태로 만든다. README는 abort 레코드는 절대 강제 플러시되지 않는다고 명시한다. 크래시 후에는 커밋 레코드가 없으면 abort로 간주하기 때문이다.

체크포인터(RedoRecPtr, FPI 기준점을 전진시킴)와 세그먼트 재활용은 postgres-checkpoint.md가 소유한다. 이 모든 레코드들을 재시작 시 재생하는 것 — redo 루프, 일관성 지점, 타임라인 처리 — 은 postgres-recovery-redo.md가 소유한다. 이 문서는 의도적으로 레코드가 디스크에서 내구 상태가 되는 지점에서 멈춘다.

심볼을 서브시스템별로 묶었다. 파일은 /data/hgryoo/references/postgres/ 아래에 있다.

  • XLogRecPtr (xlogdefs.h) — LSN; 논리적 로그 안의 uint64 바이트 오프셋. InvalidXLogRecPtr은 0; LSN_FORMAT_ARGS%X/%X 출력 형식으로 분할한다.
  • XLogRecord (xlogrecord.h) — 고정 레코드 헤더: xl_tot_len, xl_xid, xl_prev, xl_info, xl_rmid, xl_crc. SizeOfXLogRecord가 MAXALIGN 관련 크기다.
  • XLogRecordBlockHeader / XLogRecordBlockImageHeader / XLogRecordBlockCompressHeader (xlogrecord.h) — 블록별 참조 프레이밍과 전체 페이지 이미지 서브헤더. 플래그 BKPBLOCK_HAS_IMAGE, BKPBLOCK_WILL_INIT, BKPBLOCK_SAME_REL; 이미지 플래그 BKPIMAGE_APPLY, BKPIMAGE_HAS_HOLE, BKPIMAGE_COMPRESS_*.
  • XLR_MAX_BLOCK_ID (32)와 예약된 블록 id들 XLR_BLOCK_ID_DATA_SHORT/LONG/ORIGIN/TOPLEVEL_XID (xlogrecord.h).
  • XLogRecordMaxSize (xlogrecord.h) — 레코드 하나의 상한 ~1 GB. 리더가 단일 청크로 할당할 수 있는 크기로 제한된다.

세그먼트 / 페이지 레이아웃 (헤더)

섹션 제목: “세그먼트 / 페이지 레이아웃 (헤더)”
  • XLogPageHeaderData / XLogLongPageHeaderData (xlog_internal.h) — 8 KB WAL 페이지 헤더(xlp_magic, xlp_info, xlp_tli, xlp_pageaddr, xlp_rem_len)와 각 세그먼트의 첫 번째 페이지에 사용되는 long 변형. XLOG_PAGE_MAGIC이 버전 스탬프다.
  • XLByteToSeg / XLogSegmentOffset / XLogSegNoOffsetToRecPtr (xlog_internal.h) — LSN ↔ 세그먼트 산술.
  • XLogFileName / XLogFilePath / IsXLogFileName (xlog_internal.h) — %08X%08X%08X 세그먼트 파일 명명.
  • WalSegMinSize / WalSegMaxSize / DEFAULT_XLOG_SEG_SIZE (마지막은 pg_config_manual.h) — 세그먼트 크기 범위(1 MB–1 GB)와 16 MB 기본값.
  • XLogRecData (xlog_internal.h) — 어셈블러가 구성하는 (next, data, len) 조각 리스트.
  • XLogBeginInsert — 레코드 시작; 등록 작업 공간 초기화.
  • XLogRegisterBuffer / XLogRegisterBlock — 수정된 페이지 선언(공유 풀 안팎). 어셈블러가 FPI 여부 결정.
  • XLogRegisterData / XLogRegisterBufData — 메인 데이터 / 블록별 데이터 추가.
  • XLogEnsureRecordSpace — 비정상적으로 큰 레코드를 위해 기본 한도(블록 참조 5개, 데이터 청크 20개)를 늘림.
  • XLogSetRecordFlagsXLOG_MARK_UNIMPORTANT 등 설정.
  • XLogInsert — 공개 진입점; 조립→삽입 재시도 루프.
  • XLogRecordAssemble — 헤더 + 블록 참조 + 데이터를 XLogRecData 체인으로 패킹. 블록별 FPI 결정(hole 제거, 압축), 본문 CRC 계산.
  • XLogInsertRecord — 두 단계 삽입(예약, 복사). 삽입 락 아래; FPI 재확인 재시도; XLOG_SWITCH / 체크포인트-redo를 위한 WalInsertClass 분기.
  • ReserveXLogInsertLocation / ReserveXLogSwitch — 스핀락-보호 CurrBytePos 증가로 LSN 범위와 xl_prev 할당.
  • XLogBytePosToRecPtr / XLogBytePosToEndRecPtr / XLogRecPtrToBytePos — 페이지 헤더를 더하거나 빼는 사용 가능 바이트 위치 ↔ XLogRecPtr 변환. 스핀락 밖에서 실행.
  • CopyXLogRecordToWAL — 체인을 XLogCtl->pages로 복사.
  • WALInsertLockAcquire / WALInsertLockAcquireExclusive / WALInsertLockRelease / WALInsertLockUpdateInsertingAt — 8개 삽입 락과 전체 락 경로.
  • WALInsertLock (구조체) / NUM_XLOGINSERT_LOCKS (8) — insertingAtlastImportantAt이 있는 락 배열.
  • XLogCtlInsert (구조체) — CurrBytePos/PrevBytePos, insertpos_lck, RedoRecPtr, fullPageWrites, runningBackups.
  • XLogCtlData (구조체) — 전체 공유 WAL 상태: 수위 마커들, pages, xlblocks, InsertTimeLineID.
  • XLogFlush — 주어진 LSN까지 로그를 내구 상태로 만듦; 그룹 커밋 루프(LWLockAcquireOrWait, CommitDelay).
  • XLogWrite — 전체 페이지 pg_pwrite, 병합; 세그먼트 전환; 세그먼트 끝 fsync + 아카이버 알림 + 체크포인트 트리거.
  • XLogBackgroundFlush — walwriter의 주기적 쓰기/플러시. 비동기 커밋 따라잡기 포함.
  • issue_xlog_fsyncwal_sync_method에 따른 fsync/fdatasync 디스패치; 실패 시 PANIC.
  • RefreshXLogWriteResult (매크로) — Write/Flush 수위 마커의 배리어-적용 읽기.
  • WaitXLogInsertionsToFinish — 각 락의 insertingAt을 사용해 목표 LSN을 통과하도록 진행 중인 삽입자 대기.
  • GetXLogInsertRecPtr — 현재 삽입 선두(CurrBytePos 읽기).

읽기 측 (xlogreader.c) — 프레이밍만; redo는 별도 문서

섹션 제목: “읽기 측 (xlogreader.c) — 프레이밍만; redo는 별도 문서”
  • XLogReadRecord / XLogNextRecord — 리더의 디코드 큐에서 다음 레코드 가져오기.
  • DecodeXLogRecord — 원시 레코드를 헤더 + 블록 참조 + 데이터로 분해(XLogRecordAssemble의 역연산).
  • ValidXLogRecordHeader / ValidXLogRecord — 헤더(xl_prev 역방향 링크 포함) 검증 및 레코드를 신뢰하기 전 CRC-32C 재확인.
  • XLogRecGetBlockTag / XLogRecGetBlockTagExtended / XLogRecGetData — redo 핸들러가 사용하는 접근자(완전성을 위해 문서화; redo 측은 postgres-recovery-redo.md).

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
XLogRecPtr (typedef)src/include/access/xlogdefs.h21
InvalidXLogRecPtrsrc/include/access/xlogdefs.h28
LSN_FORMAT_ARGSsrc/include/access/xlogdefs.h44
XLogRecord (struct)src/include/access/xlogrecord.h41
XLogRecordMaxSizesrc/include/access/xlogrecord.h74
XLogRecordBlockHeader (struct)src/include/access/xlogrecord.h103
XLR_MAX_BLOCK_IDsrc/include/access/xlogrecord.h241
XLOG_PAGE_MAGICsrc/include/access/xlog_internal.h34
XLogPageHeaderData (struct)src/include/access/xlog_internal.h36
XLogLongPageHeaderData (struct)src/include/access/xlog_internal.h61
WalSegMinSizesrc/include/access/xlog_internal.h88
XLByteToSegsrc/include/access/xlog_internal.h117
XLogFileNamesrc/include/access/xlog_internal.h166
XLogRecData (struct)src/include/access/xlog_internal.h312
DEFAULT_XLOG_SEG_SIZEsrc/include/pg_config_manual.h20
NUM_XLOGINSERT_LOCKSsrc/backend/access/transam/xlog.c151
WALInsertLock (struct)src/backend/access/transam/xlog.c368
XLogCtlInsert (struct end)src/backend/access/transam/xlog.c446
XLogCtlData (struct end)src/backend/access/transam/xlog.c555
XLogRecPtrToBufIdxsrc/backend/access/transam/xlog.c592
UsableBytesInPagesrc/backend/access/transam/xlog.c598
XLogInsertRecordsrc/backend/access/transam/xlog.c748
ReserveXLogInsertLocationsrc/backend/access/transam/xlog.c1111
WALInsertLockAcquiresrc/backend/access/transam/xlog.c1374
WALInsertLockAcquireExclusivesrc/backend/access/transam/xlog.c1419
XLogBytePosToRecPtrsrc/backend/access/transam/xlog.c1861
XLogWritesrc/backend/access/transam/xlog.c2304
XLogFlushsrc/backend/access/transam/xlog.c2780
XLogBackgroundFlushsrc/backend/access/transam/xlog.c2968
issue_xlog_fsyncsrc/backend/access/transam/xlog.c8744
XLogBeginInsertsrc/backend/access/transam/xloginsert.c149
XLogRegisterBuffersrc/backend/access/transam/xloginsert.c242
XLogRegisterDatasrc/backend/access/transam/xloginsert.c364
XLogRegisterBufDatasrc/backend/access/transam/xloginsert.c405
XLogInsertsrc/backend/access/transam/xloginsert.c474
XLogRecordAssemblesrc/backend/access/transam/xloginsert.c548
XLogReadRecordsrc/backend/access/transam/xlogreader.c390
ValidXLogRecordsrc/backend/access/transam/xlogreader.c1204
DecodeXLogRecordsrc/backend/access/transam/xlogreader.c1682
XLogRecGetBlockTagExtendedsrc/backend/access/transam/xlogreader.c2017
  • LSN은 논리적 로그 안의 64비트 바이트 오프셋이며, 0은 유효하지 않음을 의미한다. xlogdefs.h에서 typedef uint64 XLogRecPtr#define InvalidXLogRecPtr 0으로 확인. 헤더 주석은 너비가 64비트인 이유를 “절대 오버플로우하지 않도록”이라고 밝히며, 부트스트랩이 첫 번째 세그먼트를 건너뛰므로 실제 레코드는 0에서 시작하지 않는다고 명시한다.

  • REL_18 기준 WAL 삽입 락은 정확히 8개다. xlog.c에서 #define NUM_XLOGINSERT_LOCKS 8. 컴파일 타임 상수이며 GUC가 아니다. 변경하려면 소스를 편집해야 한다. 각 락은 WALInsertLockPadded 유니언으로 캐시 라인 단위로 패딩된다.

  • 삽입의 전역 직렬화 부분은 스핀락-보호 정수 증가뿐이다. ReserveXLogInsertLocation에서 확인: Insert->insertpos_lck 아래에서 startbytepos = Insert->CurrBytePos; endbytepos = startbytepos + size; Insert->CurrBytePos = endbytepos;를 수행하고 해제한다. XLogBytePosToRecPtr 변환은 해제 후에 실행된다. 예약은 페이지 헤더를 제외한 사용 가능한 바이트 위치를 추적하므로 증가는 단순 덧셈이다.

  • XLogInsert는 전체 페이지 쓰기 상태가 변경되면 전체 조립-삽입 주기를 재시도한다. XLogInsertdo { ... } while (EndPos == InvalidXLogRecPtr) 루프가 XLogInsertRecordInvalidXLogRecPtr을 반환할 때마다 XLogRecordAssemble을 재실행한다. WALINSERT_NORMAL 분기에서 호출자가 백업하지 않은 페이지에 이미지가 이제 필요한 경우 이 일이 발생한다. 두 함수 모두에서 확인.

  • 체크포인트 이후 페이지의 첫 번째 수정은 전체 페이지 이미지로 기록된다. XLogRecordAssemble에서 needs_backup = (page_lsn <= RedoRecPtr) 확인. RedoRecPtr은 최근 체크포인트의 redo 지점이다. 표준 페이지의 경우 pd_lower..pd_upper hole을 생략한다. wal_compression이 켜져 있으면 선택적으로 PGLZ/LZ4/ZSTD로 압축한다.

  • WAL fsync 실패는 PANIC이다. issue_xlog_fsync에서 확인: 모든 동기화 메서드 분기에서 실패 시 msg를 설정하며, 뒤따르는 if (msg) ereport(PANIC, ...)이 백엔드를 종료한다. wal_sync_methodopen/open_datasync이거나 enableFsync가 꺼져 있으면 no-op이다.

  • 세 개의 내구성 수위 마커는 Insert ≥ Write ≥ Flush 순서의 원자 변수이며 명시적 메모리 배리어로 유지된다. XLogCtlData에서 logInsertResult, logWriteResult, logFlushResultpg_atomic_uint64임을 확인했다. XLogWrite의 공개 순서(pg_atomic_write_u64(logWriteResult); pg_write_barrier(); pg_atomic_write_u64(logFlushResult))와 RefreshXLogWriteResult의 대칭 배리어 읽기도 확인했다.

  • 커밋 내구성과 버퍼 매니저 WAL 규칙은 Flush 수위 마커에 대한 같은 관문이다. 경계에서 확인: XLogFlushrecord <= LogwrtResult.Flush일 때 조기 종료하고, README는 bufmgr가 더티 페이지 기록 전에 “적어도 해당 페이지의 LSN까지” 플러시해야 한다고 명시한다. 둘 다 Flush >= target-LSN으로 귀결된다. bufmgr와 커밋 메커니즘 자체는 postgres-buffer-manager.md / postgres-xact.md에 있다(의도적으로 여기서 재검증하지 않음).

  • 레코드는 읽기 시 신뢰하기 전에 CRC 검증을 거친다(CRC-32C). ValidXLogRecord에서 확인: 본문, 그 다음 헤더(xl_crc 제외) 순서로 CRC를 재계산하고 불일치 시 거부한다. xl_prevValidXLogRecordHeader에서 별도로 검증된다.

  • 기본 WAL 세그먼트 크기는 16 MB이며 1 MB에서 1 GB까지 조정 가능하다. DEFAULT_XLOG_SEG_SIZE = 16*1024*1024 (pg_config_manual.h). WalSegMinSize/WalSegMaxSizeIsValidWalSegSize(2의 거듭제곱)가 제한을 설정한다(xlog_internal.h).

  1. 많은 코어를 가진 하드웨어에서 NUM_XLOGINSERT_LOCKS = 8은 여전히 적절한 숫자인가? 코어 수가 늘어나는 동안 여러 릴리스에 걸쳐 8로 유지됐다. 스케일러블 락 매니저 작업(dbms-papers/scalable-lock-manager.md)은 헤비웨이트 락 테이블을 대상으로 하며 WAL 삽입과는 별개의 경합 지점이다. 조사 방향: 고코어 박스에서 삽입 처리량 대 락 수를 벤치마크하고, GUC로 만들자는 제안이 있었는지 pgsql-hackers 아카이브를 확인한다.

  2. CopyXLogRecordToWAL은 레코드가 페이지 경계를 넘을 때 정확히 무엇을 하며, AdvanceXLInsertBuffer 페이지 초기화와 어떻게 상호작용하는가? 이 문서는 복사를 블랙박스로 취급하고 호출 지점만 인용했다. xlp_rem_len / XLP_FIRST_IS_CONTRECORD 연속 레코드 처리 기계와 WAL 버퍼 페이지 초기화 경로(AdvanceXLInsertBuffer, WALBufMappingLock)는 별도의 심층 탐구가 필요하다. 조사 방향: CopyXLogRecordToWAL (xlog.c ~1227)과 AdvanceXLInsertBuffer를 전체적으로 읽는다.

  3. 실제로 전체 페이지 쓰기가 WAL 볼륨에서 차지하는 비중은 얼마이며, 언제 비활성화해도 안전한가? 메커니즘은 검증됐지만 비용/이익(특정 스토리지 스택에서 FPI 볼륨 대 torn-page 위험, wal_compression 및 체크포인트 간격과의 상호작용)은 이 문서가 다루지 않는 튜닝 질문이다. 조사 방향: 대표 워크로드에 pg_waldump --stats 실행; postgres-checkpoint.md가 생기면 교차 참조한다.

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

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”
  • ARIES (Mohan et al., TODS 1992) — PostgreSQL이 구현하는 이론. PostgreSQL은 역사 반복 redo와 PageLSN/WAL-규칙 기계를 채택하지만, 일반 트랜잭션 롤백에 ARIES 스타일의 보상 로그 레코드를 통한 undo를 사용하지 않는다. MVCC 덕분에 중단된 트랜잭션의 튜플은 단순히 보이지 않게 두고 vacuum이 회수하므로, 페이지 수준 undo 패스가 없다. “ARIES undo 대 MVCC no-undo”를 집중 비교하면 PostgreSQL이 ARIES의 어느 부분을 유지하는지 명확해질 것이다. (dbms-papers/aries.md 참조.)

  • CUBRID의 WAL — CUBRID도 ARIES 기반이지만 undo가 있는 고전적 물리/생리적 로그를 유지한다. undo와 redo 로그 레코드를 모두 기록하고 롤백 및 복구 시 undo 패스를 수행한다. 스토리지가 no-overwrite MVCC가 아니라 update-in-place 방식이기 때문이다. CUBRID의 log_append/LOG_LSA를 PostgreSQL의 XLogInsert/XLogRecPtr과 나란히 비교하면 no-overwrite-heap 결정이 어떻게 로그에 undo가 필요한지 여부에까지 전파되는지 드러날 것이다. (CUBRID 복구 분석은 knowledge/code-analysis/cubrid/ 참조.)

  • 코어별 / 분산 로깅 — 단일 직렬화 로그 선두(CurrBytePos 아래 하나의 스핀락)는 알려진 확장성 상한이다. 멀티 스트림 WAL, 코어별 로그 파티션(예: “Scalable Logging through Emerging Non-Volatile Memory”와 로그 버퍼 경합에 관한 Aether/ELEDA 연구) 같은 연구 방향이 단일 선두 제거를 탐구한다. PostgreSQL의 8개 삽입 락과 바이트 위치 트릭이 이미 그 이익의 대부분을 포착하는지, 어디서 부족한지를 추적하는 노트가 자연스러운 후속 작업이 될 것이다.

  • Constant-time recovery / 즉시 재시작 — SQL Server의 Accelerated Database Recovery와 Hekaton 스타일 영속 로깅 같은 엔진은 복구 시간을 로그 길이에서 분리한다. PostgreSQL의 복구는 마지막 체크포인트 이후 재생된 WAL에 비례해 선형이다. 설계 긴장(체크포인트 빈도 대 FPI 볼륨 대 복구 시간)은 postgres-checkpoint.mdpostgres-recovery-redo.md가 이어받아야 할 프론티어다.

트리 내 설계 문서:

  • src/backend/access/transam/README — “Write-Ahead Log Coding”, “Constructing a WAL record”, “Writing Hints”, “Asynchronous Commit”.

소스 파일 (REL_18_STABLE, 커밋 273fe94):

  • src/backend/access/transam/xlog.c — 삽입/예약/쓰기/플러시/fsync, 공유 WAL 상태, 삽입 락.
  • src/backend/access/transam/xloginsert.c — 레코드 구성과 조립, 전체 페이지 이미지 로직.
  • src/backend/access/transam/xlogreader.c — 레코드 디코드와 검증 (읽기 측).
  • src/include/access/xlogrecord.hXLogRecord와 블록 참조 형식.
  • src/include/access/xlog_internal.h — 페이지/세그먼트 레이아웃, 파일 명명, XLogRecData, rmgr 메서드 테이블.
  • src/include/access/xlogdefs.hXLogRecPtr/LSN 정의.
  • src/include/pg_config_manual.hDEFAULT_XLOG_SEG_SIZE.

논문과 교과서:

  • knowledge/research/dbms-papers/aries.md — Mohan et al., ARIES (TODS 1992): WAL/PageLSN/역사 반복 모델.
  • knowledge/research/dbms-papers/scalable-lock-manager.md — 락 수 열린 질문 맥락(헤비웨이트 락, WAL 삽입 아님).
  • Database Internals (Petrov), 5장 — 운영적 vs. 논리적 로그, 생리적 로깅.
  • Database System Concepts (Silberschatz et al., 7판), 19장 — 복구, steal/no-force, 즉시 수정.

교차 참조 (메커니즘이 다른 문서에 있음 — 여기서 중복하지 않음):

  • postgres-recovery-redo.md — redo 루프, 일관성, 타임라인.
  • postgres-wal-records-rmgr.md — 리소스 매니저 카탈로그와 레코드별 형식.
  • postgres-xact.md — 커밋 레코드 삽입과 커밋 시점 플러시.
  • postgres-checkpoint.mdRedoRecPtr 전진, 세그먼트 재활용.
  • postgres-buffer-manager.md — PageLSN을 관문으로 사용하는 더티 페이지 플러시.
  • postgres-architecture-overview.md — 축 3, WAL 내구성 척추.