(KO) PostgreSQL SLRU — 순환 가능한 메타데이터를 위한 단순 LRU 버퍼링
목차
- 이론적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 안내
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
이론적 배경
섹션 제목: “이론적 배경”PostgreSQL은 주어진 XID가 커밋됐는지, 중단됐는지, 아직 진행 중인지를 XID를 인덱스로 하는 소규모 파일 집합으로 관리한다. 이 파일들이 답해야 하는 질문은 단 하나다: “XID N에게 무슨 일이 있었는가?” 접근 패턴은 XID에 의한 무작위(random) 조회지만, 시간적 지역성(temporal locality)이 뚜렷하다. 가장 최근 페이지는 커밋할 때마다 읽히고 쓰이며, 오래된 페이지일수록 vacuum이 정리하면서 접근 빈도가 점점 낮아진다. 이 접근 패턴은 LRU 교체 정책을 갖는 단순 페이지 버퍼 캐시의 고전적인 사용 사례다.
Database System Concepts(Silberschatz et al., 7판, 13장 “Data Storage
Structures”)는 버퍼 관리의 일반 모델을 이렇게 정리한다: 고정 크기
인메모리 프레임 풀, 논리 페이지를 프레임에 매핑하는 페이지 테이블,
풀이 가득 찼을 때 가장 덜 유용한 프레임을 교체하는 정책(LRU, clock,
MRU), 그리고 교체 전 쓰기를 보장하는 더티 페이지 추적으로 구성된다.
ARIES(Mohan et al., ACM TODS 1992; dbms-papers/aries.md)는
WAL-before-data 불변식을 확립한다. 더티 페이지는 그 변경을
기술하는 WAL 레코드가 먼저 플러시되기 전까지 디스크에 내려가서는
안 된다는 원칙이다. 일반 힙 및 인덱스 페이지의 경우 storage/buffer/bufmgr.c의
메인 버퍼 매니저가 쓰기 시점에 페이지 LSN을 WAL 플러시 포인트와
비교해 이 불변식을 강제한다. 트랜잭션 상태 페이지도 동일한 불변식을
따르지만, 크기가 작고 수가 많으며 논리 주소 공간이 단조 증가한다는
특성이 있다. 따라서 힙 버퍼 매니저의 풍부한 기능 — 버퍼 디스크립터,
공유 버퍼 해시, 버퍼별 콘텐츠 락, 복잡한 clock-sweep 알고리즘 — 은
이 접근 패턴에서는 순수한 오버헤드가 된다.
SLRU(Simple LRU)는 PostgreSQL이 이 문제에 대한 답으로 만든 전용 페이지 버퍼 캐시다. 트랜잭션 메타데이터의 접근 패턴과 고정 논리 주소 공간을 활용해, 범용 버퍼 매니저에서 비용 대비 효과가 없는 기능을 모두 제거한 신중한 엔지니어링 결과물이다. 학문적으로 새로운 발상은 아니며, 이 접근 패턴에서 스스로를 정당화하지 못하는 기능을 빠짐없이 걷어낸 것이 핵심이다.
순환(wrap-around) 문제는 별도의 설계 과제다. TransactionId는
32비트 부호 없는 정수이므로, 약 20억 건의 트랜잭션 이후에는 0으로
되돌아온다. 상태 파일의 페이지 번호와 세그먼트 번호도 모듈러 산술로
처리해야 한다. SimpleLruTruncate는 클라이언트가 제공하는
PagePrecedes 콜백으로 낡은 세그먼트를 삭제한다. 이 콜백이 모듈러
비교 논리를 캡슐화하므로, SLRU 기반 코드 자체는 XID 의미론을 전혀
알 필요가 없다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”트랜잭션 상태 저장소는 관계형 엔진에서 거의 보편적인 서브시스템이다. 다만 엔진의 동시성 제어 프로토콜에 따라 형태가 크게 달라진다.
두 가지 원형 설계
섹션 제목: “두 가지 원형 설계”언두 로그 엔진(Oracle, SQL Server, MySQL/InnoDB)은 수정된 행의 *이전 이미지(before-image)*를 별도의 언두 세그먼트에 기록한다. “스냅샷 시점 T에서 이 행의 커밋된 상태는 무엇이었는가?”라는 질문에는 현재 버전에서 언두 체인을 거슬러 올라가며 적절한 버전을 찾아 답한다. 커밋 레코드가 행 버전 체인과 롤백 세그먼트의 트랜잭션 테이블에 내재되어 있으므로, 별도의 “XID N이 커밋됐는가?” 로그 파일이 필요 없다. CLOG와 동등한 구조는 롤백 테이블스페이스로 교체되는 소규모 인메모리 트랜잭션 테이블이지, 독립된 추가 전용(append-mostly) 파일이 아니다.
MVCC-with-commit-log 엔진(PostgreSQL, 일부 모드의 CUBRID)은 행의
최종 커밋된 버전을 제자리에(in-place) 또는 힙 버전으로 기록하고,
XID → {커밋됨, 중단됨, 진행 중}을 매핑하는 별도 파일을 유지한다.
가시성 판단에는 이 사이드 파일 조회가 필요하다. PostgreSQL의
pg_xact/(이전 명칭 pg_clog/)가 대표적인 예다. 언두 체인 순회
비용을 작고 뜨거우며 캐시 가능한 파일의 단일 랜덤 읽기로 대체한다는
트레이드오프다.
시간적 지역성과 “최신 편향” 접근 패턴
섹션 제목: “시간적 지역성과 “최신 편향” 접근 패턴”두 설계 모두에서 트랜잭션 상태 데이터는 강한 시간적 지역성을 보인다. 최근 커밋된 트랜잭션이 동시 실행 중인 읽기 요청으로부터 가장 자주 확인된다. 수천 번의 체크포인트 전에 커밋된 트랜잭션은 vacuum이 활동하거나 수명이 매우 긴 스냅샷이 활성화된 경우에만 등장한다. 트랜잭션 상태 데이터를 위한 캐시라면 어느 것이든 최신성(recency)에 맞게 조정되어야 한다. SLRU의 LRU 교체 정책과 “최신 페이지는 교체 대상에서 제외”라는 특별 규칙이 이 원칙의 직접적인 표현이다.
고정 크기 세그먼트 파일
섹션 제목: “고정 크기 세그먼트 파일”끝없이 커지는 단일 대형 파일 대신, 모든 프로덕션 엔진은 트랜잭션
상태 저장소를 고정 크기 세그먼트로 분할한다. 세그먼트 순환(rotation)은
체크포인터 친화적인 작업이며(한 번에 세그먼트 하나씩 sync), 잘라내기는
세그먼트 파일 전체를 삭제하는 디렉터리 엔트리 삭제 연산으로, 대형
파일에 구멍을 뚫는 것보다 훨씬 저렴하다. PostgreSQL의
SLRU_PAGES_PER_SEGMENT = 32 세그먼트는 각 256 KB(32페이지 × 8 KB)이며,
CLOG 기준 XID당 2비트 패킹으로 세그먼트당 100만 개의 트랜잭션 상태를
담는다.
”마지막 페이지 고정”이 있는 LRU
섹션 제목: “”마지막 페이지 고정”이 있는 LRU”순수 LRU는 가장 오랫동안 접근되지 않은 프레임을 교체한다. 추가 전용
워크로드에서 최신 페이지는 모든 커밋 트랜잭션이 기록하므로, 뱅크
내에서 다른 페이지 읽기가 없었다면 순수 LRU가 역설적으로 이 페이지를
교체 후보로 선택할 수 있다. PostgreSQL SLRU는 단 하나의 예외를 도입한다.
원자 변수 latest_page_number(uint64)에 기록된 페이지는 LRU 카운트와
무관하게 절대 교체 대상이 되지 않는다. 그 외의 모든 페이지는 뱅크 내에서
순수 LRU를 따른다.
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”SLRU는 SimpleLruInit을 호출하는 모든 코어 서브시스템이 인스턴스화할
수 있는 범용 기반 구조다. REL_18_STABLE 기준으로 다음 클라이언트가
존재한다.
| 클라이언트 | SlruCtl | 디렉터리 | 비고 |
|---|---|---|---|
| CLOG | XactCtl | pg_xact/ | XID당 2비트, WAL-before-data 활성화 |
| 서브트랜잭션 | SubTransCtl | pg_subtrans/ | 서브 XID별 부모 XID |
| 커밋 타임스탬프 | CommitTsCtl | pg_commit_ts/ | commit_timestamp GUC 조건부 |
| MultiXact 오프셋 | MultiXactOffsetCtl | pg_multixact/offsets/ | 멤버 파일 내 오프셋 |
| MultiXact 멤버 | MultiXactMemberCtl | pg_multixact/members/ | XID 목록 + 플래그 |
| NOTIFY | NotifyCtl | pg_notify/ | 비동기 알림; fsync 없음 |
| 직렬화 격리 | SerialSlruCtl | pg_serial/ | SSI SIREAD 잠금 요약 |
확장 모듈도 SimpleLruInit을 직접 호출해 자체 SLRU를 정의할 수 있다.
자료 구조
섹션 제목: “자료 구조”SLRU 상태는 포스트마스터 시작 시 고정 공유 메모리 세그먼트에 할당되는
공유(shared) 부분과, 각 백엔드가 로컬 메모리에 유지하면서 공유
상태에 접근하는 데 사용하는 비공유(unshared) 제어 구조체
(SlruCtlData)로 나뉜다.
// SlruSharedData — src/include/access/slru.htypedef struct SlruSharedData{ int num_slots; /* 전체 버퍼 슬롯 수 */ char **page_buffer; /* BLCKSZ 바이트 배열, 슬롯당 하나 */ SlruPageStatus *page_status; /* EMPTY / READ_IN_PROGRESS / VALID / WRITE_IN_PROGRESS */ bool *page_dirty; /* 쓰기 중 재더티 여부 */ int64 *page_number; /* 슬롯이 보유한 논리 페이지 번호 */ int *page_lru_count; /* 슬롯별 LRU 카운터 */ LWLockPadded *buffer_locks; /* 버퍼별 I/O LWLock */ LWLockPadded *bank_locks; /* 뱅크별 제어 LWLock */ int *bank_cur_lru_count; /* 뱅크별 LRU 시계 */ XLogRecPtr *group_lsn; /* 선택적 WAL 플러시 LSN (CLOG 전용) */ int lsn_groups_per_page; pg_atomic_uint64 latest_page_number; /* 최신 페이지; 절대 교체 불가 */ int slru_stats_idx; /* pgstat SLRU 카운터 인덱스 */} SlruSharedData;// SlruCtlData — src/include/access/slru.htypedef struct SlruCtlData{ SlruShared shared; uint16 nbanks; /* 총 뱅크 수 = num_slots / SLRU_BANK_SIZE */ bool long_segment_names; /* 15자리 16진수 vs 4~6자리 16진수 파일명 */ SyncRequestHandler sync_handler;/* SYNC_HANDLER_NONE이면 fsync 비활성화 */ bool (*PagePrecedes)(int64, int64); /* 잘라내기를 위한 모듈러 "더 오래됨" */ char Dir[64]; /* PGDATA 상대 서브디렉터리 */} SlruCtlData;뱅크 아키텍처
섹션 제목: “뱅크 아키텍처”버퍼는 SLRU_BANK_SIZE = 16개 슬롯으로 구성된 **뱅크(bank)**로 조직된다.
페이지는 pageno % nbanks에 따라 뱅크에 배정된다. 각 뱅크는 자체
bank_locks[bankno] LWLock과 bank_cur_lru_count 카운터를 갖는다.
이 구조가 가져오는 핵심 효과는 세 가지다.
- 범위 제한 LRU 탐색. 교체 대상 선택(
SlruSelectLRUPage)이 목표 뱅크의 슬롯 16개만 순회한다. 전역 스캔도 없고 해시 테이블 조회도 없다. - 분산 잠금. 서로 다른 뱅크 페이지에 대한 동시 접근은 서로 다른 LWLock에서 경쟁하므로, 높은 커밋률에서 핫 락 경합이 줄어든다.
- 뱅크별 LRU 카운트로 전역 캐시 무효화 방지.
bank_cur_lru_count[bankno]갱신은 해당 뱅크의 캐시 라인만 건드리며, 전역 카운터를 건드리지 않는다.
총 버퍼 수는 SLRU_BANK_SIZE의 배수여야 한다. GUC 파라미터(예:
transaction_buffers, multixact_offsets_buffers)가 총 수를 제어하며,
check_slru_buffers가 16의 배수 제약을 강제한다.
SimpleLruAutotuneBuffers는 shared_buffers / divisor를 16의 배수로
내림하여 기본값을 계산한다.
공유 메모리 레이아웃은 ShmemInitStruct로 단일 할당된다. 크기는
SimpleLruShmemSize가 미리 계산한다. SlruSharedData 헤더 이후에
슬롯별 병렬 배열(슬롯 인덱스로 접근)이 오고, 그 다음에 뱅크별 배열
(뱅크 인덱스로 접근), 그 다음에 선택적 group_lsn 배열, 마지막에
BLCKSZ * nslots 바이트의 페이지 버퍼 블록이 온다. 슬롯과 뱅크의
관계는 SlotGetBankNumber(slotno) = slotno >> SLRU_BANK_BITSHIFT로
결정된다. 슬롯 015가 뱅크 0, 슬롯 1631이 뱅크 1에 속한다.
flowchart TB
subgraph SHM["ShmemInitStruct(name, SimpleLruShmemSize(nslots, nlsns))"]
HDR["SlruSharedData 헤더<br/>num_slots, latest_page_number (atomic),<br/>lsn_groups_per_page, slru_stats_idx"]
subgraph PERSLOT["슬롯별 배열 — slotno로 인덱싱 (길이 nslots)"]
PS["page_status[]<br/>EMPTY / READ_IP / VALID / WRITE_IP"]
PD["page_dirty[]"]
PN["page_number[] (int64)"]
PL["page_lru_count[] (int)"]
BL["buffer_locks[] (LWLockPadded)<br/>슬롯당 I/O 락 하나"]
end
subgraph PERBANK["뱅크별 배열 — bankno로 인덱싱 (길이 nbanks)"]
BKL["bank_locks[] (LWLockPadded)"]
BC["bank_cur_lru_count[] (int)"]
end
GL["group_lsn[] (XLogRecPtr)<br/>nslots * lsn_groups_per_page<br/>nlsns > 0일 때만 존재 — CLOG 전용"]
subgraph BUFS["page_buffer[] -> BLCKSZ * nslots 바이트"]
PB0["슬롯 0: 8192바이트 페이지"]
PB1["슬롯 1: 8192바이트 페이지"]
PBN["... 슬롯 nslots-1"]
end
end
SEG["pg_xact / pg_subtrans / ... 세그먼트 파일<br/>SLRU_PAGES_PER_SEGMENT = 32페이지씩"]
PN -->|"pageno % nbanks"| BKL
BL -.->|"SlotGetBankNumber = slotno >> 4"| BKL
PB0 <-->|"SlruPhysicalReadPage / SlruPhysicalWritePage<br/>pread() / pwrite() at (pageno % 32) * BLCKSZ"| SEG
SimpleLruShmemSize의 크기 계산은 다음과 같이 전개된다.
// SimpleLruShmemSize — src/backend/access/transam/slru.cint nbanks = nslots / SLRU_BANK_SIZE;sz = MAXALIGN(sizeof(SlruSharedData));sz += MAXALIGN(nslots * sizeof(char *)); /* page_buffer[] */sz += MAXALIGN(nslots * sizeof(SlruPageStatus));/* page_status[] */sz += MAXALIGN(nslots * sizeof(bool)); /* page_dirty[] */sz += MAXALIGN(nslots * sizeof(int64)); /* page_number[] */sz += MAXALIGN(nslots * sizeof(int)); /* page_lru_count[] */sz += MAXALIGN(nslots * sizeof(LWLockPadded)); /* buffer_locks[] */sz += MAXALIGN(nbanks * sizeof(LWLockPadded)); /* bank_locks[] */sz += MAXALIGN(nbanks * sizeof(int)); /* bank_cur_lru_count[] */if (nlsns > 0) sz += MAXALIGN(nslots * nlsns * sizeof(XLogRecPtr)); /* group_lsn[] */return BUFFERALIGN(sz) + BLCKSZ * nslots; /* 페이지 내용은 맨 마지막 */SimpleLruInit은 if (!IsUnderPostmaster) 분기 안에서 실행 포인터
ptr을 같은 순서로 전진시키며 배열 포인터를 하나씩 잘라낸다. 크기
계산과 포인터 할당이 동일한 순서로 배열을 순회한다는 점이 이 레이아웃의
유일한 정확성 계약이다. 배열별 독립 할당이 없으므로, 순서가 어긋나면
두 배열이 조용히 겹쳐버린다.
뱅크 기하는 순수 비트 시프트로 정의된다. 교체 대상 탐색을 경계가 명확하고 캐시 친화적인 창 안으로 한정하기 위한 선택이다.
// bank geometry — src/backend/access/transam/slru.c#define SLRU_BANK_BITSHIFT 4#define SLRU_BANK_SIZE (1 << SLRU_BANK_BITSHIFT) /* 16 */#define SlotGetBankNumber(slotno) ((slotno) >> SLRU_BANK_BITSHIFT)페이지 생명주기와 네 가지 상태
섹션 제목: “페이지 생명주기와 네 가지 상태” SimpleLruZeroPage / SimpleLruReadPage | EMPTY ───► READ_IN_PROGRESS ───► VALID │ ▲ (더티) │ │ 쓰기 중 재더티 ▼ │ WRITE_IN_PROGRESS ───► VALID (클린)SLRU_PAGE_EMPTY — 슬롯이 사용되지 않는 상태.
SLRU_PAGE_READ_IN_PROGRESS — 백엔드가 디스크에서 페이지를 읽는 중이다.
buffer_locks[slotno]를 독점적으로 보유하고 뱅크 락을 해제한 상태다.
SLRU_PAGE_VALID — 페이지가 메모리에 있고 사용 가능한 상태다. 더티일 수
있다.
SLRU_PAGE_WRITE_IN_PROGRESS — 백엔드가 페이지를 디스크에 쓰는 중이다.
buffer_locks[slotno]를 독점적으로 보유한다. 쓰기 중에 다른 백엔드가
페이지를 다시 더티 상태로 만들면 page_dirty가 true로 재설정되어
두 번째 쓰기가 필요하다는 사실이 기록된다.
page_dirty 플래그는 page_status와 별개다. WRITE_IN_PROGRESS 상태이면서
동시에 더티일 수 있다. 이 경우 현재 쓰기가 완료된 후에도 추가 쓰기가
한 번 더 뒤따른다.
잠금 프로토콜
섹션 제목: “잠금 프로토콜”SLRU 인스턴스마다 두 계층의 LWLock을 사용한다.
-
뱅크 제어 락 (
bank_locks[bankno]) — 뱅크 내 슬롯의 모든 메타데이터 필드(page_status,page_dirty,page_number,page_lru_count,bank_cur_lru_count)를 보호한다. 이 필드를 검사하거나 수정하려면 반드시 이 락을 보유해야 한다(대부분의 경로에서 독점,SimpleLruReadPage_ReadOnly에서는 공유). -
버퍼별 I/O 락 (
buffer_locks[slotno]) — 진행 중인 I/O를 동기화한다. 읽기나 쓰기를 시작하기 전에 백엔드는 버퍼 락을 독점적으로 획득하고 뱅크 락을 해제한다. 같은 I/O를 기다리는 다른 백엔드는 버퍼 락을 공유 모드로 획득 시도하여 블록되고, I/O가 끝나면 뱅크 락을 재획득한다.
핵심 불변식은 이것이다: I/O 핫 경로에서 어떤 백엔드도 뱅크 락과 버퍼
락을 동시에 보유하지 않는다. 버퍼 락을 획득하려면 반드시 뱅크 락을
먼저 해제해야 하고, 뱅크 락 재획득은 항상 버퍼 락 해제보다 앞서 완료되기
때문에 데드락이 발생할 수 없다. SimpleLruWaitIO는 이 순서를 가장 명확하게
표현하는 함수다. 다른 백엔드가 진행 중인 I/O를 기다리는 방식이 여기에
담겨 있다.
// SimpleLruWaitIO — src/backend/access/transam/slru.cint bankno = SlotGetBankNumber(slotno);/* See notes at top of file */LWLockRelease(&shared->bank_locks[bankno].lock);LWLockAcquire(&shared->buffer_locks[slotno].lock, LW_SHARED);LWLockRelease(&shared->buffer_locks[slotno].lock);LWLockAcquire(&shared->bank_locks[bankno].lock, LW_EXCLUSIVE);/* If the slot is still io-in-progress, the I/O must have failed; * recover the page_status so the slot can be reused. */if (shared->page_status[slotno] == SLRU_PAGE_READ_IN_PROGRESS || shared->page_status[slotno] == SLRU_PAGE_WRITE_IN_PROGRESS){ if (LWLockConditionalAcquire(&shared->buffer_locks[slotno].lock, LW_SHARED)) { if (shared->page_status[slotno] == SLRU_PAGE_READ_IN_PROGRESS) shared->page_status[slotno] = SLRU_PAGE_EMPTY; else /* write_in_progress */ { shared->page_status[slotno] = SLRU_PAGE_VALID; shared->page_dirty[slotno] = true; } LWLockRelease(&shared->buffer_locks[slotno].lock); }}여기서 LW_SHARED로 버퍼 락을 획득하는 것은 순수한 랑데부(rendezvous)다.
I/O를 수행 중인 백엔드가 LW_EXCLUSIVE로 보유하고 있으므로, 기다리는
백엔드는 I/O가 끝나고 락이 해제될 때까지 블록된 후 즉시 락을 놓는다.
그 직후의 조건부 획득은 자기 수복(self-healing) 검사다. 버퍼 락을 획득할
수 있는 상태인데도 슬롯이 여전히 진행 중으로 표시되어 있다면, 원래 I/O가
오류로 종료된 것이다(트랜잭션 중단이 상태를 재설정하지 않은 채 락을 해제한
경우). 이 백엔드가 슬롯 상태를 복구한다.
latest_page_number는 유일한 예외다. LRU 교체 대상 선택 중 슬롯이
최신 페이지를 보유하는지 확인하는 것은 정확성이 아닌 힌트 검사이므로,
락 대신 원자 연산(pg_atomic_read_u64 / pg_atomic_write_u64)으로
읽고 쓴다.
// SimpleLruReadPage — src/backend/access/transam/slru.c// 뱅크 락을 독점 상태로 진입하고, 독점 상태로 반환한다.intSimpleLruReadPage(SlruCtl ctl, int64 pageno, bool write_ok, TransactionId xid){ for (;;) { slotno = SlruSelectLRUPage(ctl, pageno); /* 페이지 찾기 또는 피해자 선택 */
if (페이지가 이미 메모리에 있고 I/O 중이 아니면) { SlruRecentlyUsed(shared, slotno); pgstat_count_slru_page_hit(...); return slotno; }
/* 슬롯을 read-busy 표시, 버퍼 락 획득, 뱅크 락 해제 */ shared->page_status[slotno] = SLRU_PAGE_READ_IN_PROGRESS; LWLockAcquire(&shared->buffer_locks[slotno].lock, LW_EXCLUSIVE); LWLockRelease(banklock);
ok = SlruPhysicalReadPage(ctl, pageno, slotno); /* pread() */
/* 뱅크 락 재획득, 상태 갱신, 버퍼 락 해제 */ LWLockAcquire(banklock, LW_EXCLUSIVE); shared->page_status[slotno] = ok ? SLRU_PAGE_VALID : SLRU_PAGE_EMPTY; LWLockRelease(&shared->buffer_locks[slotno].lock);
if (!ok) SlruReportIOError(ctl, pageno, xid); return slotno; }}SimpleLruReadPage_ReadOnly는 먼저 공유 뱅크 락 스캔을 시도한다.
독점 락 없이 뱅크를 순회하며 페이지를 찾는다. 히트 시에는 배리어 없이
SlruRecentlyUsed로 LRU 카운트를 갱신한다. int 읽기·쓰기는 모든 지원
플랫폼에서 원자적이며, 약간 낡은 LRU 카운트는 교체 품질을 저하시킬 뿐
정확성을 해치지 않는다. 미스 시에는 독점 락으로 업그레이드하고
SimpleLruReadPage로 위임한다.
SimpleLruReadPage의 미스 경로 — 페이지가 상주하지 않아 디스크에서
가져와야 하는 경우 — 는 이 파일에서 잠금 흐름이 가장 복잡한 구간이다.
바깥 for (;;) 루프가 필요한 이유는, 뱅크 락을 놓은 사이에 슬롯 내용이
완전히 바뀔 수 있기 때문이다. I/O 대기 후 슬롯을 처음부터 다시 확인해야
하며, 그때는 히트로 전환될 수도 있다.
flowchart TB
START["SimpleLruReadPage(ctl, pageno, write_ok, xid)<br/>진입 시 뱅크 락 EXCLUSIVE 보유"] --> SEL["slotno = SlruSelectLRUPage(ctl, pageno)<br/>pageno % nbanks 뱅크의 슬롯 16개 스캔"]
SEL --> RES{"슬롯이 pageno 보유<br/>이고 != EMPTY?"}
RES -->|"예 — 히트"| BUSY{"READ_IN_PROGRESS,<br/>또는 WRITE_IN_PROGRESS<br/>이고 write_ok 아님?"}
BUSY -->|"예"| WAIT["SimpleLruWaitIO(ctl, slotno)<br/>뱅크 락 해제, 버퍼 락 SHARED 획득,<br/>해제, 뱅크 락 재획득"]
WAIT --> SEL
BUSY -->|"아니오"| HIT["SlruRecentlyUsed<br/>pgstat_count_slru_page_hit<br/>return slotno"]
RES -->|"아니오 — 미스, 피해자 선택됨"| MARK["page_number[slotno] = pageno<br/>page_status[slotno] = READ_IN_PROGRESS<br/>page_dirty[slotno] = false"]
MARK --> ACQ["LWLockAcquire buffer_locks[slotno] EXCLUSIVE<br/>then LWLockRelease(banklock) — 동시 보유 없음"]
ACQ --> IO["SlruPhysicalReadPage(ctl, pageno, slotno)<br/>pread(); 파일 없으면 복구 중 제로 반환"]
IO --> ZLSN["SimpleLruZeroLSNs(ctl, slotno)"]
ZLSN --> REACQ["LWLockAcquire(banklock) EXCLUSIVE<br/>page_status = ok ? VALID : EMPTY<br/>LWLockRelease(buffer_locks[slotno])"]
REACQ --> ERR{"읽기 성공?"}
ERR -->|"아니오"| RPT["SlruReportIOError(ctl, pageno, xid)<br/>ereport(ERROR)"]
ERR -->|"예"| DONE["SlruRecentlyUsed<br/>pgstat_count_slru_page_read<br/>return slotno"]
대기 후 재시도에서도 SlruSelectLRUPage를 다시 호출한다는 점에 주목할
필요가 있다. 다른 백엔드가 이미 페이지를 읽어 들인 경우, 다음 반복은
히트로 끝난다. 선택된 피해자가 더티였다면 SlruSelectLRUPage 자체가
SlruInternalWritePage로 플러시하고 돌아온다. 따라서 SimpleLruReadPage가
슬롯을 덮어쓸 때는 항상 클린 상태가 보장된다.
LRU 교체 대상 선택
섹션 제목: “LRU 교체 대상 선택”// SlruSelectLRUPage — src/backend/access/transam/slru.c// 뱅크 락을 보유한 채 진입하고 반환한다. 더티 피해자를 인라인으로 쓸 수 있다.static intSlruSelectLRUPage(SlruCtl ctl, int64 pageno){ /* 1. 이미 목표 페이지를 보유한 슬롯이 있으면 반환한다. */ /* 2. EMPTY 슬롯이 있으면 즉시 반환한다. */ /* 3. VALID 슬롯 중 (bank_cur_lru_count - page_lru_count)가 가장 큰 슬롯, 즉 가장 오래된 슬롯을 선택한다. latest_page_number 슬롯은 건너뛴다. */ /* 4. VALID 슬롯이 없으면 LRU I/O-busy 슬롯을 기다리고 재시도한다. */ /* 5. 선택된 피해자가 더티이면 SlruInternalWritePage로 플러시하고 반복한다. */}“가장 큰 델타” 계산은 정수 뺄셈의 암묵적 오버플로우를 활용하므로,
페이지의 나이가 INT_MAX 카운트를 넘지 않는 한 카운터 순환을 올바르게
처리한다. 동시 SlruRecentlyUsed 호출로 클록 왜곡이 생긴 경우,
SlruSelectLRUPage는 스캔 전에 bank_cur_lru_count를 미리 증가시켜
선택된 피해자가 다음 접근 시 반드시 신선한 것으로 표시되도록 보장한다.
“최근 사용 표시” 측면은 SlruRecentlyUsed가 담당한다. 공유 뱅크 락 하에서
안전하게 동작할 만큼 의도적으로 작게 유지된다(읽기 전용 빠른 경로가 이를
동시에 호출하기 때문이다). if 조건이 핵심이다. 해당 페이지가 이미 뱅크에서
가장 최근인 경우에는 증가를 건너뛴다. 불필요한 쓰기를 줄이는 것도 있지만,
더 중요한 것은 뱅크 카운터의 진행 속도를 늦춰 오래된 페이지의 카운트가
“순환”하여 최근 사용된 것처럼 보이는 상황을 방지한다는 점이다.
// SlruRecentlyUsed — src/backend/access/transam/slru.cint bankno = SlotGetBankNumber(slotno);int new_lru_count = shared->bank_cur_lru_count[bankno];/* Suppress useless increments; allows concurrent callers under * shared bank lock, since int reads/writes are atomic. */if (new_lru_count != shared->page_lru_count[slotno]){ shared->bank_cur_lru_count[bankno] = ++new_lru_count; shared->page_lru_count[slotno] = new_lru_count;}이 동시성 허용이 SimpleLruReadPage_ReadOnly를 현실적인 선택으로 만드는
이유다. 여러 읽기 요청이 두 int 저장에 동시에 경쟁할 수 있으며, 최악의
경우는 카운터가 낮은 값으로 “재설정”되는 것이다. SlruSelectLRUPage는
이를 방어적으로 수정한다(this_delta < 0 분기에서 페이지 카운트를
cur_count로 재설정). LRU 카운트의 정확성은 정확성 요건이 아니다.
교체 방향을 합리적인 피해자 쪽으로 유도할 뿐이며,
latest_page_number 예외가 카운터 노이즈와 무관하게 절대 교체되어서는
안 되는 페이지를 보호한다.
CLOG를 위한 WAL-before-data
섹션 제목: “CLOG를 위한 WAL-before-data”CLOG는 쓰기 전 WAL 플러시가 필요한 유일한 SLRU 클라이언트다. 커밋하는
트랜잭션은 clog.c의 TransactionIdSetPageStatus를 호출해 2비트 필드를
갱신하고, 커밋의 WAL LSN을 group_lsn[slotno * lsn_groups_per_page + group_offset]에 기록한다. SlruPhysicalWritePage가 CLOG 슬롯을 처리할
때, 슬롯의 group_lsn 배열을 스캔해 최대 LSN을 찾고, pwrite() 발행
전에 XLogFlush(max_lsn)을 호출한다.
// SlruPhysicalWritePage (WAL 경로) — src/backend/access/transam/slru.cif (shared->group_lsn != NULL){ XLogRecPtr max_lsn = 0; lsnindex = slotno * shared->lsn_groups_per_page; for (int lsnoff = 0; lsnoff < shared->lsn_groups_per_page; lsnoff++) { XLogRecPtr this_lsn = shared->group_lsn[lsnindex++]; if (max_lsn < this_lsn) max_lsn = this_lsn; } if (!XLogRecPtrIsInvalid(max_lsn)) { START_CRIT_SECTION(); XLogFlush(max_lsn); END_CRIT_SECTION(); }}다른 클라이언트는 SimpleLruInit에 nlsns = 0을 전달해 group_lsn을
NULL로 두므로, WAL 플러시 경로가 완전히 생략된다.
세그먼트 이름 체계: 짧은 이름과 긴 이름
섹션 제목: “세그먼트 이름 체계: 짧은 이름과 긴 이름”디스크 상에서 각 SLRU는 논리 페이지 번호를 세그먼트 파일에 매핑한다.
세그먼트는 SLRU_PAGES_PER_SEGMENT = 32페이지를 담는다. 세그먼트 번호는
pageno / 32다. 파일 이름은 세그먼트 번호의 16진수 표현이다.
- 짧은 이름(기본값): 4
6자리 16진수(0000FFFFFF). CLOG, subtrans, commit_ts가 사용. 최대 2²⁴개 세그먼트 지원. - 긴 이름(
long_segment_names = true): 15자리 16진수. multixact 멤버와 오프셋이 사용. 64비트 MultiXactId의 더 큰 주소 공간을 지원하기 위해 최대 2⁶⁰개 세그먼트를 지원한다.
SlruFileName이 이 선택을 인코딩한다.
// SlruFileName — src/backend/access/transam/slru.cstatic inline intSlruFileName(SlruCtl ctl, char *path, int64 segno){ if (ctl->long_segment_names) return snprintf(path, MAXPGPATH, "%s/%015" PRIX64, ctl->Dir, segno); else return snprintf(path, MAXPGPATH, "%s/%04X", ctl->Dir, (unsigned int) segno);}잘라내기와 PagePrecedes 콜백
섹션 제목: “잘라내기와 PagePrecedes 콜백”SimpleLruTruncate(ctl, cutoffPage)는 cutoffPage보다 엄밀히 오래된
페이지만 포함하는 모든 디스크 세그먼트를 삭제한다. “오래됨”의 기준은
클라이언트가 제공하는 ctl->PagePrecedes(a, b) 콜백으로 정의된다.
XID 순환을 올바르게 처리하려면 이 콜백 내부에 모듈러 산술이 들어가야 한다.
안전 불변식: latest_page_number는 cutoffPage보다 오래되어서는 안 된다.
오래된 경우 함수는 경고를 로그에 남기고 아무것도 삭제하지 않고 반환한다.
이는 호출자의 버그를 막는 최후 방어선이다.
인메모리 측에서는 커트오프보다 오래된 버퍼 페이지를 먼저 교체하거나
I/O 완료를 기다린 뒤, SlruScanDirectory에 SlruScanDirCbDeleteCutoff
콜백을 전달해 해당 세그먼트를 삭제한다. SlruMayDeleteSegment의
네 가지 경우 분석은 세그먼트가 커트오프나 순환 지점에 걸쳐 있는
경계 사례를 처리한다.
체크포인트 통합
섹션 제목: “체크포인트 통합”SimpleLruWriteAll은 모든 더티 버퍼를 디스크에 플러시한다. 뱅크 경계를
넘을 때마다 뱅크 락을 교체하며 모든 슬롯을 순회한다. SlruWriteAllData
구조체에 파일 디스크립터를 최대 MAX_WRITEALL_BUFFERS = 16개까지 배치해
같은 세그먼트의 여러 페이지를 쓸 때 open()/close() 오버헤드를
분산시킨다. 슬롯 스캔 후에는 fsync_fname(ctl->Dir, true)로 디렉터리를
sync하여 새 세그먼트 파일 디렉터리 엔트리의 내구성을 보장한다.
체크포인트 통계 측면에서, SLRU 체크포인트 중 쓰인 페이지마다
CheckpointStats.ckpt_slru_written과
PendingCheckpointerStats.slru_written이 증가한다.
각 SLRU 인스턴스는 초기화 시 pgstat_get_slru_index(name)로 통계
카운터 슬롯을 등록한다. 추적하는 카운터는 다음과 같다.
pgstat_count_slru_page_zeroed— 새 페이지 생성.pgstat_count_slru_page_hit— 버퍼에서 찾음.pgstat_count_slru_page_read— 디스크에서 로드.pgstat_count_slru_page_written— 디스크에 기록.pgstat_count_slru_page_exists—SimpleLruDoesPhysicalPageExist호출.pgstat_count_slru_flush—SimpleLruWriteAll호출.pgstat_count_slru_truncate—SimpleLruTruncate호출.
이 카운터들은 pg_stat_slru 뷰로 확인할 수 있다.
소스 코드 안내
섹션 제목: “소스 코드 안내”초기화 서브시스템
섹션 제목: “초기화 서브시스템”| 심볼 | 역할 |
|---|---|
SimpleLruShmemSize | nslots 버퍼 + nbanks 락 + group_lsn 배열(선택)에 필요한 공유 메모리 바이트 계산 |
SimpleLruAutotuneBuffers | GUC 기본값: shared_buffers / divisor를 SLRU_BANK_SIZE 배수로 내림 |
SimpleLruInit | 이름 붙은 공유 메모리 영역 할당 또는 부착; 슬롯·뱅크 배열과 LWLock 초기화; ctl->Dir, nbanks, PagePrecedes 설정 |
check_slru_buffers | GUC 검사 훅; SLRU_BANK_SIZE로 나누어 떨어지지 않는 값 거부 |
읽기/쓰기 API
섹션 제목: “읽기/쓰기 API”| 심볼 | 역할 |
|---|---|
SimpleLruZeroPage | 새로운 제로 페이지를 슬롯에 할당; latest_page_number로 설정 |
SimpleLruReadPage | 페이지를 찾거나 로드; 슬롯 번호 반환; 호출자는 뱅크 락 유지 |
SimpleLruReadPage_ReadOnly | 공유 락으로 낙관적 스캔 후 SimpleLruReadPage로 폴백 |
SimpleLruWritePage | fdata = NULL로 SlruInternalWritePage를 호출하는 외부 래퍼 |
SimpleLruWriteAll | 체크포인트 플러시: 모든 슬롯 순회, 더티 슬롯 쓰기, FD 배치 처리 |
SimpleLruDoesPhysicalPageExist | 버퍼에 로드하지 않고 디스크 존재 여부 확인 |
내부 헬퍼
섹션 제목: “내부 헬퍼”| 심볼 | 역할 |
|---|---|
SlruInternalWritePage | 핵심 쓰기: 슬롯을 WRITE_IN_PROGRESS로 표시, 뱅크 락 해제, SlruPhysicalWritePage 호출, 뱅크 락 재획득 |
SlruPhysicalReadPage | pread() 발행; 복구 중 파일 없는 경우 제로 반환 |
SlruPhysicalWritePage | group_lsn으로 WAL-before-data 강제; pwrite() 발행; sync 요청 큐 등록 |
SlruRecentlyUsed | page_lru_count[slotno] = ++bank_cur_lru_count[bankno] 기록; 공유 뱅크 락 하에서 안전 |
SlruSelectLRUPage | 뱅크 내 피해자 탐색; 더티 피해자 인라인 교체 |
SimpleLruWaitIO | 뱅크 락 해제 → 버퍼 락 공유 획득 → 버퍼 락 해제 → 뱅크 락 재획득 |
SlruReportIOError | 상태 정리 후 slru_errcause / slru_errno를 ereport(ERROR)로 변환 |
SimpleLruZeroLSNs | 슬롯의 group_lsn 배열 항목을 제로로 초기화 |
세그먼트 관리
섹션 제목: “세그먼트 관리”| 심볼 | 역할 |
|---|---|
SlruFileName | 세그먼트 경로 형식화; long_segment_names에 따라 짧은(4~6 16진수) 또는 긴(15 16진수) 이름 |
SimpleLruTruncate | 오래된 인메모리 페이지 교체 후 SlruScanDirectory(SlruScanDirCbDeleteCutoff) 호출 |
SlruMayDeleteSegment | 네 가지 경우의 모듈러 검사: 세그먼트의 첫 번째와 마지막 페이지 모두 커트오프보다 오래되어야 삭제 |
SlruInternalDeleteSegment | sync 요청 취소 후 unlink() 호출 |
SlruDeleteSegment | 외부 진입점: 버퍼에서 세그먼트를 먼저 교체한 후 SlruInternalDeleteSegment 호출 |
SlruScanDirectory | 디렉터리 엔트리 읽기, 이름 길이 + 16진수 패턴 필터링, 세그먼트별 콜백 호출 |
SlruScanDirCbReportPresence | 스캔 콜백: 커트오프보다 오래된 세그먼트가 있으면 true 반환 |
SlruScanDirCbDeleteCutoff | 스캔 콜백: SlruMayDeleteSegment 조건 만족 세그먼트 삭제 |
SlruScanDirCbDeleteAll | 스캔 콜백: 모든 세그먼트 무조건 삭제 |
SlruSyncFileTag | sync 핸들러: 세그먼트 파일을 열고 pg_fsync() 호출 |
자료 구조 (헤더)
섹션 제목: “자료 구조 (헤더)”| 심볼 | 역할 |
|---|---|
SlruPageStatus | 열거형: SLRU_PAGE_EMPTY, SLRU_PAGE_READ_IN_PROGRESS, SLRU_PAGE_VALID, SLRU_PAGE_WRITE_IN_PROGRESS |
SlruSharedData / SlruShared | 공유 메모리 상태: page_buffer, page_status, page_dirty, page_number, page_lru_count, buffer_locks, bank_locks, bank_cur_lru_count, group_lsn 배열 |
SlruCtlData / SlruCtl | 백엔드별 비공유 제어: 공유 포인터, nbanks, long_segment_names, sync_handler, PagePrecedes, Dir |
SimpleLruGetBankLock | 인라인: pageno % nbanks를 계산하고 &bank_locks[bankno].lock 반환 |
위치 힌트 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)
섹션 제목: “위치 힌트 (커밋 273fe94, REL_18_STABLE, 2026-06-05 기준)”| 심볼 | 파일 | 행 |
|---|---|---|
SlruSharedData | src/include/access/slru.h | 61 |
SlruCtlData | src/include/access/slru.h | 127 |
SlruPageStatus | src/include/access/slru.h | 47 |
SimpleLruGetBankLock | src/include/access/slru.h | 175 |
SLRU_BANK_BITSHIFT | src/backend/access/transam/slru.c | 142 |
SLRU_BANK_SIZE | src/backend/access/transam/slru.c | 143 |
SlotGetBankNumber | src/backend/access/transam/slru.c | 148 |
MAX_WRITEALL_BUFFERS | src/backend/access/transam/slru.c | 123 |
SLRU_PAGES_PER_SEGMENT | src/include/access/slru.h | 39 |
SimpleLruShmemSize | src/backend/access/transam/slru.c | 198 |
SimpleLruAutotuneBuffers | src/backend/access/transam/slru.c | 231 |
SimpleLruInit | src/backend/access/transam/slru.c | 252 |
SimpleLruZeroPage | src/backend/access/transam/slru.c | 375 |
SimpleLruWaitIO | src/backend/access/transam/slru.c | 445 |
SimpleLruReadPage | src/backend/access/transam/slru.c | 502 |
SimpleLruReadPage_ReadOnly | src/backend/access/transam/slru.c | 605 |
SlruInternalWritePage | src/backend/access/transam/slru.c | 652 |
SimpleLruWritePage | src/backend/access/transam/slru.c | 732 |
SlruPhysicalReadPage | src/backend/access/transam/slru.c | 804 |
SlruPhysicalWritePage | src/backend/access/transam/slru.c | 876 |
SlruReportIOError | src/backend/access/transam/slru.c | 1048 |
SlruRecentlyUsed | src/backend/access/transam/slru.c | 1123 |
SlruSelectLRUPage | src/backend/access/transam/slru.c | 1169 |
SimpleLruWriteAll | src/backend/access/transam/slru.c | 1322 |
SimpleLruTruncate | src/backend/access/transam/slru.c | 1408 |
SlruInternalDeleteSegment | src/backend/access/transam/slru.c | 1503 |
SlruDeleteSegment | src/backend/access/transam/slru.c | 1526 |
SlruMayDeleteSegment | src/backend/access/transam/slru.c | 1603 |
SlruScanDirectory | src/backend/access/transam/slru.c | 1791 |
SlruSyncFileTag | src/backend/access/transam/slru.c | 1831 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”REL_18_STABLE 브랜치의 커밋 273fe94를 기준으로 검증했다.
확인된 사항:
SlruSharedData레이아웃은SimpleLruShmemSize계산과 정확히 일치한다.SimpleLruInit(283~309행)의 필드 오프셋 진행이 page_buffer, page_status, page_dirty, page_number, page_lru_count, buffer_locks, bank_locks, bank_cur_lru_count, group_lsn(선택) 순서를 따른다.SLRU_BANK_SIZE = 16,SLRU_BANK_BITSHIFT = 4.SlotGetBankNumber(slotno)는(slotno) >> 4다. 142~143행에서 확인.SLRU_PAGES_PER_SEGMENT = 32(slru.h:39). 세그먼트 1개 = 32 × 8192 = 256 KB. CLOG 기준: 32페이지 × (8192 × 4 XID/바이트) = 1,048,576 XID/세그먼트.latest_page_number는pg_atomic_uint64(slru.h:115).SimpleLruZeroPage에서 원자적으로 쓰이고,SlruSelectLRUPage에서 배리어 없이 읽힌다. slru.c:1251의 주석이 이것이 올바른 이유를 설명한다.group_lsn은 CLOG(nlsns = CLOG_LSNS_PER_PAGE, clog.c:811)에서만 non-NULL이다. 다른 모든 클라이언트는nlsns = 0을 전달한다.- NOTIFY 클라이언트는
sync_handler = SYNC_HANDLER_NONE을 전달해 fsync를 비활성화한다(async.c:537~541 확인).SlruPhysicalWritePage경로는sync_handler == SYNC_HANDLER_NONE일 때RegisterSyncRequest를 건너뛴다. MAX_WRITEALL_BUFFERS = 16(slru.c:123). 16개를 초과하는 FD 처리는 slru.c:985의 폴백(fdata = NULL설정)으로 처리된다.
미해결 / 이 문서의 범위 밖:
SlruPagePrecedesUnitTests함수(slru.c:1697)와 각 클라이언트의PagePrecedes콜백이 계약을 어떻게 만족시키는지 —postgres-clog-commit-ts.md와postgres-multixact.md에서 다룬다.SimpleLruTruncate와 vacuum의 XID 지평(horizon) 계산 간의 상호작용 —postgres-vacuum.md에서 다룬다.- SSI 클라이언트(
SerialSlruCtl, predicate.c:814)는 XID가 아닌 페이지당 SIREAD 잠금 요약을 저장하므로 주소 공간 의미론이 CLOG와 다르다 —postgres-ssi-predicate-locking.md에서 다룬다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”Oracle과 SQL Server: 별도 상태 로그 없음
섹션 제목: “Oracle과 SQL Server: 별도 상태 로그 없음”Oracle은 트랜잭션 상태를 각 행의 ITL(Interested Transaction List) 슬롯에
내재된 **SCN(System Change Number)**과 롤백 세그먼트 트랜잭션 테이블에
저장한다. “XID N이 커밋됐는가?”는 별도 로그 파일이 아닌 롤백 세그먼트를
프로빙해 답한다. 롤백 세그먼트가 교체되면 Oracle은 “지연 블록 클린아웃
(delayed block cleanout)“으로 행 헤더를 지연 갱신한다. pg_xact/에
해당하는 구조물은 없다.
SQL Server도 tempdb의 행 버전 저장소를 사용하는 유사한 인-로우
버전 관리 방식으로, 버전 체인에 트랜잭션 상태가 내재되어 있다.
PostgreSQL 설계의 장점은 단순성과 예측 가능성이다. pg_xact/ SLRU는
압축적이고 균일한 로그이므로 vacuum이 힙 파일 수명과 독립적으로
잘라낼 수 있다. 단점은 모든 가시성 확인에 추가적인 SLRU 조회가
필요하다는 점이다. 다만 힙 튜플의 힌트 비트(HeapTupleHeaderData의
t_infomask)가 이 비용을 분산시킨다. 한 번 확인된 커밋 상태는
heapam_visibility.c의 HeapTupleHeaderSetHintBits로 튜플 헤더에
기록되어 이후 읽기는 SLRU를 우회한다.
CUBRID: 다른 교체 단위를 가진 커밋 로그
섹션 제목: “CUBRID: 다른 교체 단위를 가진 커밋 로그”CUBRID의 트랜잭션 상태 서브시스템도 공유 인메모리 캐시를 갖춘 로그 파일 방식을 사용하지만, 버퍼 풀이 별도 구조물이 아닌 범용 버퍼 매니저와 통합되어 있다. 통합된 교체 정책이 장점이다. 반면 높은 커밋률 워크로드에서는 중앙 버퍼 풀 락 경합이 발생하는데, SLRU의 뱅크 아키텍처가 회피하려는 바로 그 문제다.
연구: 힙 헤더 내 상태 비트맵
섹션 제목: “연구: 힙 헤더 내 상태 비트맵”Neumann et al.(“Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems”, SIGMOD 2015)과 HyPer/Umbra 계열은 트랜잭션 상태 비트맵을 각 버전의 헤더에 직접 내장해 사이드 파일 조회를 완전히 제거한다. 비트맵이 캐시 라인에 들어가는 메인 메모리 데이터베이스에서는 유효한 방법이다. 디스크 기반 데이터베이스에서는 사이드 파일 방식이 페이지당 메타데이터 오버헤드를 줄인다. PostgreSQL의 힌트 비트는 같은 아이디어의 부분적 적용이다. 트랜잭션 상태가 한 번 확인되면 튜플 헤더에 기록되어 이후 읽기가 SLRU를 건너뛴다.
확장성 관점
섹션 제목: “확장성 관점”PostgreSQL SLRU는 그 자체가 확장성 표면(extensibility surface)이다. 어떤
확장 모듈이든 SimpleLruInit을 커스텀 이름, 디렉터리, 버퍼 수,
PagePrecedes 콜백, sync 핸들러와 함께 호출해 자체 SLRU를 정의할 수 있다.
이는 PostgreSQL의 더 넓은 설계 철학 — 테이블 AM, 인덱스 AM, WAL rmgr,
백그라운드 워커 같은 내부 기계를 일급 확장 지점으로 노출한다는 방향 — 과
궤를 같이한다. 압축적이고 순환 가능한 상태 로그가 필요한 컬럼형이나
시계열 확장은 LRU 교체, WAL 플러시, 체크포인팅, 잘라내기 전체 스택을
재구현 없이 그대로 재사용할 수 있다.
코어 소스 파일 (REL_18_STABLE, 커밋 273fe94):
src/backend/access/transam/slru.c— 메인 구현 (1852행)src/include/access/slru.h— 공개 API 및 공유 메모리 레이아웃src/backend/access/transam/clog.c— CLOG 클라이언트;XactCtl정의src/backend/access/transam/subtrans.c— 서브트랜잭션 클라이언트src/backend/access/transam/commit_ts.c— commit_ts 클라이언트src/backend/access/transam/multixact.c— MultiXact 오프셋/멤버 클라이언트src/backend/commands/async.c— NOTIFY 클라이언트 (fsync 없음)src/backend/storage/lmgr/predicate.c— SSI SIREAD 잠금 클라이언트
이 KB 내 교차 참조:
postgres-xact.md— CLOG 쓰기를 유발하는 트랜잭션 생명주기postgres-clog-commit-ts.md— SLRU 클라이언트로서의 CLOG와 commit_tspostgres-multixact.md— SLRU 클라이언트로서의 MultiXactpostgres-mvcc-snapshots.md— CLOG 가시성 확인에서 SLRU를 읽는 경로postgres-xlog-wal.md— 쓰기 전 SLRU가 의존하는 WAL 인프라postgres-vacuum.md— SLRU 잘라내기를 유발하는 XID 지평 계산postgres-ssi-predicate-locking.md— SSI SerialSlruCtl 클라이언트postgres-buffer-manager.md— 대조: 힙·인덱스 범용 버퍼 매니저
교과서 및 논문:
- Database System Concepts, Silberschatz et al., 7판, 13장 (버퍼 관리)
- Database Internals, Petrov 2019 (버퍼 풀 교체 정책)
dbms-papers/aries.md— WAL-before-data 불변식 (Mohan et al. 1992)