콘텐츠로 이동

(KO) PostgreSQL 버퍼 매니저 — 공유 풀, Clock-Sweep 교체, WAL-Before-Flush 규칙

목차

데이터베이스는 전체를 메모리에 올리기엔 너무 크다. 그래서 엔진은 권위 있는 사본을 디스크에 두고 — PostgreSQL에서는 스토리지 매니저가 관리하는 fork별 세그먼트 파일에 보관하며, postgres-smgr-md.md를 참고 — 고정 크기 페이지들의 작업 부분집합을 RAM에 캐싱한다. 그 캐시를 소유하는 컴포넌트가 버퍼 매니저 (a/k/a 버퍼 풀 또는 페이지 캐시)다. Database Internals(Petrov, ch. 4 §“Buffer Management”)는 이 역할을 정확하게 정의한다. 버퍼 매니저는 “디스크에서 읽은 페이지를 메모리에 캐싱하는 역할을 하며” 영속 스토리지(디스크)와 엔진 상위 계층 사이의 중개자로 동작하므로, 상위 계층은 데이터를 페이지 ID로 지칭하고 파일을 직접 건드리지 않는다는 점이다. 튜플 읽기, 인덱스 탐색, WAL 로깅된 변경 모두 버퍼를 거친다.

버퍼 매니저는 두 가지 어려운 제약의 교차점에 서 있다. 설계 공간은 각 제약에 어떻게 답하느냐에 따라 결정된다.

  1. 캐시는 유한하므로 무엇을 교체할지 선택해야 한다. 백엔드가 요청한 페이지가 메모리에 없고 빈 프레임도 없다면, 이미 적재된 페이지 중 하나를 내보내야 한다. 교체 정책(어떤 페이지를 축출할지)이 첫 번째 축이다. 진정한 LRU는 접근마다 전역 리스트를 갱신해야 하는데, 하나의 풀을 공유하는 여러 CPU 위에서는 확장성이 나쁘다. 실제 엔진들은 CLOCK, CLOCK-Pro, LRU-K, 2Q, ARC 같은 근사 방식을 쓴다. 교과서는 “아직 사용 중인 페이지를 축출하는 것이 페이지 폴트이며, 주변 비용 모델이 곧 정책이 최소화하려는 것”이라고 정리한다.

  2. 캐시된 페이지는 제자리에서 수정되므로 축출은 복구와 맞물린다. 캐시에서 더티 상태이고 아직 디스크에 기록되지 않은 페이지는 커밋됐거나 미커밋된 상태를 담고 있으며, 크래시가 발생해도 살아남아야 한다. 두 번째 축은 복구 문헌에서 말하는 force/steal 정책이다([HABERMANN]/[MOHAN] — dbms-papers/aries.md 참고):

    • Force vs no-force: 트랜잭션의 더티 페이지를 커밋 전에 반드시 디스크에 내려야 하는가(force), 아니면 캐시에 남겨 두고 지연 기록을 허용하는가(no-force)?
    • Steal vs no-steal: 아직 커밋하지 않은 트랜잭션이 더티로 만든 페이지를 버퍼 매니저가 축출(디스크 기록)할 수 있는가(steal), 아니면 커밋 시까지 고정시켜야 하는가(no-steal)?

    PostgreSQL이 따르는 복구 방법 ARIES는 가장 성능이 좋고 가장 까다로운 조합을 선택한다. steal + no-force다. Steal이란 미커밋 트랜잭션의 변경이 디스크에 도달할 수 있음을 뜻하므로, 복구는 그것을 undo할 수 있어야 한다. No-force란 커밋된 트랜잭션의 변경이 아직 디스크에 없을 수 있으므로, 복구는 그것을 redo할 수 있어야 한다. 두 방향 모두 하나의 불변식으로 성립한다. Write-Ahead Logging(WAL) 규칙이다. 변경을 기술하는 로그 레코드가 변경된 데이터 페이지보다 먼저 안정 스토리지에 있어야 한다는 뜻이다. 버퍼 매니저는 이 계약의 no-force/steal 절반을 물리적으로 집행하는 컴포넌트다. 구체적으로, 데이터 페이지를 디스크에 쓰기 전 반드시 WAL을 그 페이지의 LSN까지 플러시하도록 강제한다. WAL 생성과 플러시 자체는 postgres-xlog-wal.md에서 다루고, 이 문서는 집행 시점을 다룬다.

두 가지 교과서적 포인트가 구현을 형성한다. 첫째, 버퍼 매니저는 다중 프로세스 엔진에서 공유 구조체이므로, 메타데이터 접근은 저렴하고 동시적이어야 한다. PostgreSQL 8.1 이전 전역 단일 락 BufMgrLock은 확장성 병목으로 악명 높았다. 둘째, 캐싱 단위는 페이지다(PostgreSQL BLCKSZ, 기본 8 KB; 페이지 내부 구조는 postgres-page-layout.md 참고). 페이지는 페이지 ID로 식별되며, 이 ID가 캐시의 해시 키가 된다. 아래 내용은 PostgreSQL이 이 두 축 — 교체 정책과 force/steal — 과 두 제약 — 동시성과 페이지 주소 지정 — 을 구체적인 자료구조로 변환한 결과다.

교과서가 모델을 제시하며, 이 절은 PostgreSQL, Oracle, InnoDB, SQL Server, CUBRID 같은 거의 모든 디스크 상주 버퍼 매니저가 어떤 형태로든 채택하는 공학적 관례를 정리한다. §“PostgreSQL의 접근법”에서 PostgreSQL의 구체적인 선택은 이 공유 공간 안에서 하나의 다이얼 세트로 읽힌다.

페이지 배열과 나란히 놓인 디스크립터 배열

섹션 제목: “페이지 배열과 나란히 놓인 디스크립터 배열”

풀은 두 개의 병렬 배열이다. 실제 바이트를 담는 페이지 크기 프레임의 연속 블록 하나, 그리고 프레임마다 하나씩 있는 버퍼 디스크립터 — 페이지 신원, 더티/유효 플래그, 핀 카운트, 교체 정책 부기를 담는 소형 메타데이터 레코드 — 의 병렬 배열 하나다. 버퍼는 이 배열들 안에서 인덱스로 이름 붙인다. 핫한 메타데이터(디스크립터)를 콜드한 8 KB 페이로드와 물리적으로 분리하면, 엔진이 페이지 데이터를 CPU 캐시로 끌어들이지 않고 디스크립터를 스캔하고 락을 걸고 CAS를 할 수 있다는 점이 핵심이다.

페이지 ID → 프레임 해시 테이블

섹션 제목: “페이지 ID → 프레임 해시 테이블”

페이지 ID가 주어지면, 엔진은 “이 페이지가 적재돼 있는가, 그렇다면 어느 프레임에?”를 O(1)로 답해야 한다. 모든 엔진은 페이지 신원(테이블스페이스/데이터베이스/릴레이션/fork/블록, 또는 그 압축값)을 키, 프레임 인덱스를 값으로 하는 매핑 해시 테이블을 유지한다. 이 테이블이 새 전역 병목이 되지 않도록, 테이블을 보호하는 락을 태그의 해시 기반으로 파티션 분할한다. 그러면 관련 없는 페이지를 건드리는 두 백엔드가 거의 충돌하지 않는다.

핀 카운트 vs 콘텐츠 락 — 두 개의 독립된 수호자

섹션 제목: “핀 카운트 vs 콘텐츠 락 — 두 개의 독립된 수호자”

백엔드가 버퍼에서 원하는 것은 두 가지이며, 성숙한 엔진은 이 둘을 분리한다.

  • (참조 카운트)은 “이 프레임을 내 발 밑에서 재활용하지 마라”는 의미다. 프레임의 신원을 보호한다. 핀은 오랫동안 유지될 수 있고(순차 스캔은 페이지 전체 처리 동안 핀을 유지) 비용이 싸다.
  • 콘텐츠 락(공유/배타)은 “내가 쓰는/읽는 동안 바이트를 건드리지 마라”는 의미다. 페이지 내용을 보호한다. 콘텐츠 락은 단기로 잡는다.

이 둘을 혼동하면 모든 읽기에 무거운 락이 필요해진다. 분리하면 읽기 측이 핀+공유락으로 가시성을 확인하고, 콘텐츠 락을 놓은 뒤 핀 하나만 쥐고 튜플 데이터를 계속 읽을 수 있다.

정확한 LRU는 접근마다 전역 리스트를 재정렬해야 한다. 동시성 환경에서는 받아들일 수 없다. 지배적 근사법은 CLOCK (또는 second chance)이다. 프레임들이 원을 이루고, 각 프레임은 작은 사용 카운터를 갖는다. “핸드”가 원을 돌며 각 프레임에서 축출(사용 카운터 0이고 핀 없음)하거나 사용 카운터를 감소시키고 지나친다. 접근은 카운터를 올리고, 핸드 통과는 낮추므로, 최근/빈번하게 쓰인 페이지는 한 번의 핸드 통과에서 살아남는다. 카운터의 최댓값은 LRU 정확도와 핸드 비용 사이의 조정 가능한 트레이드오프다.

진짜 빈 프레임을 위한 작은 프리 리스트

섹션 제목: “진짜 빈 프레임을 위한 작은 프리 리스트”

시작 시점에 모든 프레임은 비어 있다. 릴레이션이 삭제되면 프레임이 다시 빈다. 프리 리스트는 알려진 빈 프레임을 담아, 그 경우 스윕 전체를 건너뛰고 바로 할당할 수 있게 한다. 프리 리스트는 clock 위에 쌓은 빠른 경로이지, 교체 정책 자체가 아니다.

대용량 스캔 한 번(풀보다 큰 테이블 순차 스캔, VACUUM, COPY)은 순진한 CLOCK 아래서 전체 작업 집합을 축출하고 두 번 다시 보지 않을 페이지를 캐싱한다. 관례는 링 버퍼 (a/k/a buffer ring / strategy)다. 대량 작업이 자신들끼리만 재사용하는 소수의 고정 프레임 집합이며, 나머지 풀은 건드리지 않는다.

모든 steal/no-force 엔진은 “캐시의 더티 페이지” → “디스크의 페이지 바이트” 경로 위에 하나의 관문을 둔다. 쓰기를 발행하기 전, 페이지의 LSN까지 로그를 디스크에 강제 플러시하는 것이다. 이 단일 규칙이 undo(steal)와 redo(no-force) 복구를 모두 성립시킨다. 버퍼 매니저가 데이터 페이지를 쓰는 유일한 컴포넌트이기 때문에, 집행 시점도 버퍼 매니저에 있다.

이론 / 관례PostgreSQL 이름
페이지 크기 캐시 프레임BufferBlocks 배열, 각 항목 BLCKSZ 바이트
프레임별 메타데이터 레코드BufferDesc (in BufferDescriptors[])
페이지 신원 / 캐시 키BufferTag = (spcOid, dbOid, relNumber, forkNum, blockNum)
더티 / 유효 / 핀 플래그 + 카운터BufferDesc.state — 하나의 atomic uint32 (18b refcount, 4b usagecount, 10b flags)
페이지ID → 프레임 해시 테이블buf_table.c 공유 해시, BufTableLookup/Insert/Delete
해시 파티션 락BufMappingPartitionLockNUM_BUFFER_PARTITIONS = 128
핀 / 참조 카운트PinBuffer / UnpinBuffer, PrivateRefCountArray 백업
콘텐츠 (읽기/쓰기) 락디스크립터별 LWLock content_lock, LockBuffer로 접근
CLOCK 사용 카운터BUF_USAGECOUNT, 최대 BM_MAX_USAGE_COUNT = 5
CLOCK 핸드StrategyControl->nextVictimBuffer, ClockSweepTick으로 전진
빈 프레임 프리 리스트StrategyControl->firstFreeBuffer + BufferDesc.freeNext
피해자 선택StrategyGetBufferGetVictimBuffer로 래핑
대량 작업 링BufferAccessStrategy (BAS_BULKREAD/BAS_BULKWRITE/BAS_VACUUM)
WAL-before-flush 규칙FlushBufferXLogFlush(BufferGetLSN(buf))

PostgreSQL의 버퍼 풀은 postmaster 시작 시점에 하나의 공유 메모리 세그먼트에서 고정 크기로 잘라낸 영역이다(postgres-architecture-overview.md §“Axis 2” 참고 — 풀은 런타임에 커지지 않는다). NBuffers(shared_buffers GUC, BLCKSZ 단위) 개의 프레임이 한 번만 할당되고, 그 곁에 세 개의 병렬 공유 배열이 만들어진다. 핵심 선택은 네 가지다. (1) 프레임의 변경 가능한 헤더 상태 전부 — 핀 카운트, 사용 카운트, 플래그 10비트 — 를 하나의 32비트 atomic 워드에 압축하여 일반적인 핀/언핀 연산에 스핀락이 필요 없게 한다. (2) 교체는 프리 리스트 빠른 경로를 앞세운 clock-sweep으로 한다. (3) 대량 작업은 소형 링 버퍼 안에서 돌아 캐시를 날리지 않는다. (4) 쓰기 경로는 영구 릴레이션이면 무조건 WAL 규칙을 집행한다.

BufferManagerShmemInit (buf_init.c)은 디스크립터, 페이지 블록, 버퍼별 I/O 조건 변수, 체크포인트 정렬용 스크래치 배열을 만들고, 모든 디스크립터를 초기 프리 리스트로 연결한다.

// BufferManagerShmemInit — storage/buffer/buf_init.c (condensed)
BufferDescriptors = (BufferDescPadded *)
ShmemInitStruct("Buffer Descriptors",
NBuffers * sizeof(BufferDescPadded), &foundDescs);
BufferBlocks = (char *)
TYPEALIGN(PG_IO_ALIGN_SIZE,
ShmemInitStruct("Buffer Blocks",
NBuffers * (Size) BLCKSZ + PG_IO_ALIGN_SIZE, &foundBufs));
// ... condensed: BufferIOCVArray, CkptBufferIds ...
for (i = 0; i < NBuffers; i++)
{
BufferDesc *buf = GetBufferDescriptor(i);
ClearBufferTag(&buf->tag);
pg_atomic_init_u32(&buf->state, 0);
buf->buf_id = i;
buf->freeNext = i + 1; /* link all buffers as unused */
LWLockInitialize(BufferDescriptorGetContentLock(buf), LWTRANCHE_BUFFER_CONTENT);
ConditionVariableInit(BufferDescriptorGetIOCV(buf));
}
GetBufferDescriptor(NBuffers - 1)->freeNext = FREENEXT_END_OF_LIST;
StrategyInitialize(!foundDescs); /* builds the mapping hash + control */

디스크립터 배열은 BufferDescPadded다. 각 BufferDesc는 64바이트 캐시 라인(BUFFERDESC_PAD_TO_SIZE)으로 패딩된다. 인접한 두 디스크립터에서 두 CPU가 스핀하더라도 같은 캐시 라인을 공유하지 않는다는 점이 중요하다. 페이로드 배열(BufferBlocks)은 PG_IO_ALIGN_SIZE에 정렬된다. 직접 I/O / 비동기 I/O가 경계를 가로지르지 않고 DMA로 복사할 수 있기 때문이다.

flowchart LR
  subgraph SHM["공유 메모리 세그먼트 하나 (부팅 시 한 번 크기 결정)"]
    direction TB
    DESC["BufferDescriptors[]<br/>NBuffers x BufferDescPadded<br/>(64바이트 캐시 라인 패딩)"]
    BLK["BufferBlocks[]<br/>NBuffers x BLCKSZ (8KB)"]
    CV["BufferIOCVArray[]<br/>프레임별 I/O 조건 변수"]
    HASH["buf_table 해시<br/>BufferTag -> buf_id"]
    CTL["BufferStrategyControl<br/>nextVictimBuffer, freelist 헤드/테일"]
  end
  DESC -- "buf_id로 인덱싱" --> BLK
  HASH -- "태그를 해석해" --> DESC
  CTL -- "clock 핸드가 순회" --> DESC

그림 1 — 버퍼 풀의 공유 배열. BufferDescriptors[](메타데이터)는 BufferBlocks[](8 KB 페이로드)와 병렬로 존재하며, buf_id가 둘 다 인덱싱한다. buf_table 해시가 BufferTagbuf_id로 해석하고, BufferStrategyControl이 clock 핸드와 프리 리스트 포인터를 갖는다. 전부 고정 크기이며, BufferManagerShmemInit이 한 번만 생성한다.

버퍼 태그 — 페이지를 식별하는 것

섹션 제목: “버퍼 태그 — 페이지를 식별하는 것”

프레임의 신원은 BufferTag다. 어떤 카탈로그도 참조하지 않고 블록을 특정하는 다섯 필드로 구성된다. 페이지를 플러시하는 백엔드가 해당 릴레이션을 아직 가시 상태로 보지 않는 경우에도 작동해야 하므로 카탈로그 독립성이 중요하다.

// struct buftag — storage/buf_internals.h
typedef struct buftag
{
Oid spcOid; /* tablespace oid */
Oid dbOid; /* database oid */
RelFileNumber relNumber; /* relation file number */
ForkNumber forkNum; /* fork number (main / fsm / vm / init) */
BlockNumber blockNum; /* block number within the fork */
} BufferTag;

이 태그가 해시 키다. BufTableHashCode가 태그를 해싱하며, 그 해시의 하위 비트가 NUM_BUFFER_PARTITIONS(128)개 파티션 락 중 하나를 선택한다. 관련 없는 페이지를 건드리는 두 백엔드는 서로 다른 BufMappingPartitionLock을 잡으므로 직렬화되지 않는다.

// BufMappingPartitionLock / BufTableHashPartition — storage/buf_internals.h
static inline uint32
BufTableHashPartition(uint32 hashcode)
{
return hashcode % NUM_BUFFER_PARTITIONS;
}
static inline LWLock *
BufMappingPartitionLock(uint32 hashcode)
{
return &MainLWLockArray[BUFFER_MAPPING_LWLOCK_OFFSET +
BufTableHashPartition(hashcode)].lock;
}

여러 파티션을 동시에 잡아야 할 때는 파티션 번호 오름차순으로 잡는다. 교착 상태를 피하기 위함이며, 소스 내 README가 이를 명시한다. 매핑 해시 자체는 NBuffers + NUM_BUFFER_PARTITIONS개 항목으로 크기가 잡힌다. BufferAlloc이 이전 태그를 삭제하기 전에 새 태그를 먼저 삽입하는 작업이 파티션마다 동시에 일어날 수 있기 때문이다.

디스크립터의 변경 가능한 헤더가 atomic 워드 하나라는 것이 가장 중요한 마이크로 설계 결정이다.

// BufferDesc + state layout — storage/buf_internals.h (condensed)
// state = 18 bits refcount | 4 bits usagecount | 10 bits flags
#define BUF_REFCOUNT_BITS 18
#define BUF_USAGECOUNT_BITS 4
#define BUF_FLAG_BITS 10
typedef struct BufferDesc
{
BufferTag tag; /* ID of page contained in buffer */
int buf_id; /* buffer's index number (from 0) */
pg_atomic_uint32 state; /* flags + refcount + usagecount */
int wait_backend_pgprocno; /* backend waiting for sole pin */
int freeNext; /* link in freelist chain */
PgAioWaitRef io_wref; /* set iff async I/O is in progress */
LWLock content_lock; /* lock for the buffer *contents* */
} BufferDesc;

10개의 플래그 비트가 페이지의 생명주기를 인코딩한다.

// buffer flags — storage/buf_internals.h
#define BM_LOCKED (1U << 22) /* buffer header is spinlocked */
#define BM_DIRTY (1U << 23) /* data needs writing */
#define BM_VALID (1U << 24) /* data is valid */
#define BM_TAG_VALID (1U << 25) /* tag is assigned (has a hash entry) */
#define BM_IO_IN_PROGRESS (1U << 26) /* read or write in progress */
#define BM_IO_ERROR (1U << 27) /* previous I/O failed */
#define BM_JUST_DIRTIED (1U << 28) /* dirtied since write started */
#define BM_PIN_COUNT_WAITER (1U << 29) /* have waiter for sole pin */
#define BM_CHECKPOINT_NEEDED (1U << 30) /* must write for checkpoint */
#define BM_PERMANENT (1U << 31) /* permanent (WAL-logged) buffer */

refcount, usagecount, 플래그가 한 워드를 공유하기 때문에, 백엔드는 compare-and-swap 루프 하나로 버퍼를 핀할 수 있다. refcount를 올리고, usagecount를 올리고, BM_VALID를 검사하는 작업이 모두 스핀락 없이 이루어진다. 버퍼 헤더 스핀락BM_LOCKED 플래그 비트 자체다. LockBufHdr이 이 비트를 세팅하는 스핀이고, UnlockBufHdr이 플레인 쓰기로 클리어한다. 헤더 스핀락은 복잡한 멀티필드 갱신(태그 교체)을 보호하고, CAS 경로는 단순한 연산을 처리한다. 헤더 스핀락은 페이지 바이트를 보호하지 않는다. 그 역할은 content_lock이 담당한다.

§“DBMS 공통 설계”의 두 독립된 수호자가 이 설계에서 구체화된다. 소스 내 README가 규칙을 명확히 서술한다. 백엔드는 버퍼를 건드리기 전에 반드시 해야 하며, 핀은 프레임이 재활용되지 않도록 막는다. 핀 상태는 백엔드 내 소형 배열과 오버플로 해시로 추적된다. 같은 버퍼를 두 번 핀해도 두 번째 핀은 공유 메모리를 건드리지 않는다는 점이 중요하다.

// PrivateRefCountEntry / array — storage/buffer/bufmgr.c
typedef struct PrivateRefCountEntry
{
Buffer buffer;
int32 refcount;
} PrivateRefCountEntry;
#define REFCOUNT_ARRAY_ENTRIES 8 /* ~one cache line; overflow spills to a hash */

PinBuffer는 프라이빗 첫 번째 핀일 때만 공유 refcount(state 안)를 올리고, 이후 핀은 프라이빗으로 추적하며 현재 ResourceOwner에 등록하여 트랜잭션 종료 시(에러 포함) 해제되도록 한다.

// PinBuffer — storage/buffer/bufmgr.c (condensed)
ref = GetPrivateRefCountEntry(b, true);
if (ref == NULL) /* first pin by this backend */
{
ref = NewPrivateRefCountEntry(b);
old_buf_state = pg_atomic_read_u32(&buf->state);
for (;;)
{
if (old_buf_state & BM_LOCKED)
old_buf_state = WaitBufHdrUnlocked(buf);
buf_state = old_buf_state + BUF_REFCOUNT_ONE; /* take a shared pin */
if (strategy == NULL) /* bump usagecount, capped */
{
if (BUF_STATE_GET_USAGECOUNT(buf_state) < BM_MAX_USAGE_COUNT)
buf_state += BUF_USAGECOUNT_ONE;
}
else if (BUF_STATE_GET_USAGECOUNT(buf_state) == 0)
buf_state += BUF_USAGECOUNT_ONE; /* ring buffers cap at 1 */
if (pg_atomic_compare_exchange_u32(&buf->state, &old_buf_state, buf_state))
{
result = (buf_state & BM_VALID) != 0;
break;
}
}
}
else
result = (pg_atomic_read_u32(&buf->state) & BM_VALID) != 0;
ref->refcount++;
ResourceOwnerRememberBuffer(CurrentResourceOwner, b);

strategy 분기를 주목할 필요가 있다. 일반 핀은 usagecount를 올린다(BM_MAX_USAGE_COUNT까지). 링 버퍼를 통한 핀은 usagecount를 1로 제한한다. 링 페이지가 스캔 이후에도 남아 있을 만큼 보호를 축적하지 못하게 하는 것이다. UnpinBuffer는 거울 이미지다. 프라이빗 카운트를 낮추고, 0이 될 때만 공유 refcount를 CAS로 낮추며, 클린업 대기자가 있으면(BM_PIN_COUNT_WAITER) 깨운다.

콘텐츠 락은 디스크립터별 LWLock으로, LockBuffer로 공유 또는 배타 모드로 잡는다.

// LockBuffer — storage/buffer/bufmgr.c (condensed)
if (mode == BUFFER_LOCK_UNLOCK)
LWLockRelease(BufferDescriptorGetContentLock(buf));
else if (mode == BUFFER_LOCK_SHARE)
LWLockAcquire(BufferDescriptorGetContentLock(buf), LW_SHARED);
else if (mode == BUFFER_LOCK_EXCLUSIVE)
LWLockAcquire(BufferDescriptorGetContentLock(buf), LW_EXCLUSIVE);

README의 접근 규칙은 둘을 묶는다. 페이지를 스캔하려면 핀 (공유 또는 배타) 콘텐츠 락을 동시에 잡아야 한다. 튜플이 가시적이라고 판단한 뒤에는 콘텐츠 락을 놓고 핀만 쥔 채 바이트를 계속 읽을 수 있다(규칙 #2). 핀과 락이 별개의 프리미티브여야 하는 이유가 바로 여기 있다.

ReadBufferReadBuffer_commonBufferAlloc이 룩업-또는-폴트 경로다. BufferAlloc은 먼저 공유 파티션 락 아래 매핑 해시를 탐색한다. 히트하면 핀하고 반환한다. 미스라면 페이지를 폴트-인해야 한다. 피해자 프레임을 확보하고, 배타 파티션 락 아래 해시를 재확인하여(다른 백엔드가 경쟁해 같은 페이지를 폴트-인했을 수 있다), 깨끗한 미스라면 새 태그를 설치한다.

// BufferAlloc — storage/buffer/bufmgr.c (condensed)
LWLockAcquire(newPartitionLock, LW_SHARED);
existing_buf_id = BufTableLookup(&newTag, newHash);
if (existing_buf_id >= 0) /* HIT */
{
buf = GetBufferDescriptor(existing_buf_id);
valid = PinBuffer(buf, strategy);
LWLockRelease(newPartitionLock);
*foundPtr = true;
if (!valid) *foundPtr = false; /* read still in flight */
return buf;
}
LWLockRelease(newPartitionLock);
/* MISS: get a recyclable frame (this may flush a dirty victim, see below) */
victim_buffer = GetVictimBuffer(strategy, io_context);
victim_buf_hdr = GetBufferDescriptor(victim_buffer - 1);
LWLockAcquire(newPartitionLock, LW_EXCLUSIVE);
existing_buf_id = BufTableInsert(&newTag, newHash, victim_buf_hdr->buf_id);
if (existing_buf_id >= 0) /* lost the race; use theirs */
{
UnpinBuffer(victim_buf_hdr);
StrategyFreeBuffer(victim_buf_hdr);
/* ... pin existing_buf_hdr, release lock, return ... */
}
/* won the race: stamp the victim with the new tag */
victim_buf_state = LockBufHdr(victim_buf_hdr);
victim_buf_hdr->tag = newTag;
victim_buf_state |= BM_TAG_VALID | BUF_USAGECOUNT_ONE;
if (relpersistence == RELPERSISTENCE_PERMANENT || forkNum == INIT_FORKNUM)
victim_buf_state |= BM_PERMANENT;
UnlockBufHdr(victim_buf_hdr, victim_buf_state);
LWLockRelease(newPartitionLock);
*foundPtr = false; /* caller must do the read I/O */
return victim_buf_hdr;

BufferAlloc은 태그가 찍힌 핀된 프레임을 반환하되, 미스일 때는 내용이 유효하지 않다(*foundPtr = false). 호출자가 실제 읽기를 수행하고 StartBufferIO / TerminateBufferIO 프로토콜로 BM_VALID를 설정한다. BM_PERMANENT는 릴레이션 영속성을 기반으로 여기서 한 번만 설정된다. FlushBuffer가 나중에 WAL 규칙 적용 여부를 결정할 때 이 비트를 참조한다.

flowchart TD
  A["ReadBuffer(rel, blk)"] --> B["BufferTag + 해시 계산<br/>파티션 락 선택"]
  B --> C{"BufTableLookup<br/>(공유 파티션 락)"}
  C -- "히트" --> D["PinBuffer; 락 해제"]
  D --> E{"BM_VALID?"}
  E -- "yes" --> Z["핀된 버퍼 반환"]
  E -- "no" --> Y["진행 중인 읽기 대기"]
  C -- "미스" --> F["GetVictimBuffer<br/>(clock sweep / ring)"]
  F --> G["BufTableInsert<br/>(배타 파티션 락)"]
  G -- "경합 실패" --> H["UnpinBuffer + StrategyFreeBuffer<br/>승자의 버퍼 사용"]
  G -- "경합 승리" --> I["태그 스탬프, BM_TAG_VALID 설정<br/>로깅 릴레이션이면 BM_PERMANENT"]
  I --> J["smgr로 블록 읽기<br/>BM_VALID 설정"]
  J --> Z
  H --> Z

그림 2 — ReadBuffer/BufferAlloc 룩업-또는-폴트 흐름. 히트는 공유 파티션 락 아래 핀하고 반환한다. 미스는 피해자 프레임을 확보하고(clock sweep, 더티 프레임이면 플러시 가능), 배타 파티션 락 아래 재확인한다. 다른 백엔드가 같은 페이지를 동시에 폴트-인한 경합이 발생하면, 진 쪽이 피해자를 해제하고 이긴 쪽의 버퍼를 사용한다.

Clock-sweep 교체: GetVictimBuffer와 StrategyGetBuffer

섹션 제목: “Clock-sweep 교체: GetVictimBuffer와 StrategyGetBuffer”

미스가 프레임을 필요로 하면, GetVictimBuffer(bufmgr.c)가 정책을 구동하되 선택 자체는 StrategyGetBuffer(freelist.c)에 위임한다. StrategyGetBuffer는 먼저 전략 링(있다면)을 시도하고, 프리 리스트를 확인한 뒤, clock sweep을 돌린다.

// StrategyGetBuffer — storage/buffer/freelist.c (condensed, clock-sweep arm)
trycounter = NBuffers;
for (;;)
{
buf = GetBufferDescriptor(ClockSweepTick()); /* advance hand, return frame */
local_buf_state = LockBufHdr(buf);
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0)
{
if (BUF_STATE_GET_USAGECOUNT(local_buf_state) != 0)
{
local_buf_state -= BUF_USAGECOUNT_ONE; /* second chance: decay */
trycounter = NBuffers;
}
else
{
/* Found a usable buffer (unpinned, usage 0) */
if (strategy != NULL)
AddBufferToRing(strategy, buf);
*buf_state = local_buf_state;
return buf; /* returned with hdr spinlock held! */
}
}
else if (--trycounter == 0)
{
UnlockBufHdr(buf, local_buf_state);
elog(ERROR, "no unpinned buffers available");
}
UnlockBufHdr(buf, local_buf_state);
}

핸드 자체는 ClockSweepTick이 전진시키는 단일 atomic 카운터다.

// ClockSweepTick — storage/buffer/freelist.c (condensed)
victim = pg_atomic_fetch_add_u32(&StrategyControl->nextVictimBuffer, 1);
if (victim >= NBuffers)
{
victim = victim % NBuffers; /* wrap the index */
if (victim == 0) /* we caused a wraparound */
{
/* take buffer_strategy_lock just long enough to bump completePasses */
// ... CAS nextVictimBuffer back into range, completePasses++ ...
}
}
return victim;

프리 리스트 빠른 경로는 락 없이 먼저 확인되고, 비어 있지 않을 때만 buffer_strategy_lock 스핀락을 잡아 헤드 항목을 꺼낸다.

// StrategyGetBuffer — freelist.c (free-list arm, condensed)
if (StrategyControl->firstFreeBuffer >= 0)
{
while (true)
{
SpinLockAcquire(&StrategyControl->buffer_strategy_lock);
if (StrategyControl->firstFreeBuffer < 0) { SpinLockRelease(...); break; }
buf = GetBufferDescriptor(StrategyControl->firstFreeBuffer);
StrategyControl->firstFreeBuffer = buf->freeNext; /* pop head */
buf->freeNext = FREENEXT_NOT_IN_LIST;
SpinLockRelease(&StrategyControl->buffer_strategy_lock);
local_buf_state = LockBufHdr(buf);
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0
&& BUF_STATE_GET_USAGECOUNT(local_buf_state) == 0)
return buf; /* clean + unused: take it */
UnlockBufHdr(buf, local_buf_state); /* else discard, retry */
}
}

두 가지 확장성 특성이 도출된다. clock 핸드는 atomic fetch-add 하나다. 여러 백엔드가 동시에 sweep하더라도 전역 락을 잡지 않는다. 프레임 순서가 약간 어긋날 수 있지만 무해하다. 프레임별 검사는 그 프레임의 헤더 스핀락 아래에서만 이루어진다. buffer_strategy_lock 스핀락은 프리 리스트 팝이나 clock 래랩 기록 시에만 닿는다. sweep 자체에서는 건드리지 않는다. BM_MAX_USAGE_COUNT = 5는 핫한 페이지가 버틸 수 있는 sweep 횟수를 제한한다. 5번 핀된 페이지가 0까지 감소하려면 핸드가 5번 지나야 하므로, 피해자를 찾기 위한 최악의 sweep이 제한된다.

flowchart LR
  H["nextVictimBuffer<br/>(clock 핸드)"] --> F0
  subgraph RING["원으로 본 BufferDescriptors"]
    direction LR
    F0["프레임 i<br/>핀됨? -> 건너뜀<br/>usage>0 -> usage--<br/>usage=0 -> 축출"]
    F1["프레임 i+1"]
    F2["프레임 i+2"]
    F3["..."]
  end
  F0 --> F1 --> F2 --> F3 -. "0으로 래랩,<br/>completePasses++" .-> F0

그림 3 — Clock-sweep 교체. 핸드(nextVictimBuffer)가 ClockSweepTick마다 프레임 하나씩 전진한다. 핀된 프레임은 건너뛰고, 핀 없고 usage > 0이면 usage를 감소시키는 “세컨드 찬스”를 준다. 핀 없고 usage 0인 프레임이 피해자다. 핸드는 전역 락에 멈추지 않는다 — 단일 atomic 카운터다.

GetVictimBuffer는 선택을 교체 작업으로 래핑한다. 선택된 프레임을 스핀락을 쥔 채로 핀하고(PinBuffer_Locked), 프레임이 더티라면 재활용 전에 기록한다. 이 시점이 쓰기 경로와 WAL 규칙이 들어오는 곳이다.

BufferAccessStrategy는 대량 작업이 자신들끼리 재사용하는 프레임 번호의 백엔드 프라이빗 링이다(palloc으로 할당, 공유 메모리 아님).

// BufferAccessStrategyData — storage/buffer/freelist.c
typedef struct BufferAccessStrategyData
{
BufferAccessStrategyType btype;
int nbuffers;
int current; /* most recently returned ring slot */
Buffer buffers[FLEXIBLE_ARRAY_MEMBER];/* the ring; 0 = slot not yet filled */
} BufferAccessStrategyData;

GetAccessStrategy가 타입별로 링 크기를 결정한다(README가 각각을 설명한다).

전략타입링 크기사용처
대량 읽기BAS_BULKREAD256 KB (+IO 동시성)대형 순차 스캔
대량 쓰기BAS_BULKWRITE16 MB (shared_buffers의 1/8 상한)COPY IN, CREATE TABLE AS
VacuumBAS_VACUUMvacuum_buffer_usage_limit (기본 2 MB)VACUUM
일반BAS_NORMAL— (NULL 반환)그 외 모든 것

전략이 활성화되면 StrategyGetBuffer는 먼저 GetBufferFromRing을 호출한다. current를 전진시켜 해당 슬롯에 이미 있는 프레임을 다시 제공하되, 핀이 없고 usagecount가 ≤ 1인 경우(다른 누가 건드리지 않았음)에만 그렇게 한다. 슬롯이 비어 있거나 버퍼가 탈취됐으면 NULL을 반환하고 호출자가 일반 clock sweep으로 넘어간다. 새로 확보한 프레임을 AddBufferToRing으로 슬롯에 기록한다.

// GetBufferFromRing — storage/buffer/freelist.c (condensed)
if (++strategy->current >= strategy->nbuffers)
strategy->current = 0;
bufnum = strategy->buffers[strategy->current];
if (bufnum == InvalidBuffer)
return NULL; /* slot empty: get a normal buffer */
buf = GetBufferDescriptor(bufnum - 1);
local_buf_state = LockBufHdr(buf);
if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0
&& BUF_STATE_GET_USAGECOUNT(local_buf_state) <= 1)
return buf; /* reuse this ring buffer */
UnlockBufHdr(buf, local_buf_state);
return NULL; /* stolen: get a normal buffer */

WAL 규칙과의 상호작용이 미묘한 부분이다. 링 버퍼가 더티 상태이고 재사용하면 WAL 플러시가 강제되는 경우, BAS_BULKREAD는 WAL 대기로 막히느니 다른 피해자를 고른다. StrategyRejectBuffer가 더티 프레임을 링에서 제거하면 호출자가 새 프레임을 가져간다. 반면 VACUUM과 대량 쓰기는 본래 쓰기 중심이므로 페이지를 링에 유지하고 WAL 플러시 비용을 지불한다. 이 결정은 GetVictimBuffer에서 이루어진다.

// GetVictimBuffer — storage/buffer/bufmgr.c (the strategy/WAL interaction, condensed)
if (buf_state & BM_DIRTY)
{
content_lock = BufferDescriptorGetContentLock(buf_hdr);
if (!LWLockConditionalAcquire(content_lock, LW_SHARED)) /* avoid deadlock */
{
UnpinBuffer(buf_hdr);
goto again;
}
if (strategy != NULL)
{
buf_state = LockBufHdr(buf_hdr);
lsn = BufferGetLSN(buf_hdr);
UnlockBufHdr(buf_hdr, buf_state);
if (XLogNeedsFlush(lsn) && StrategyRejectBuffer(strategy, buf_hdr, from_ring))
{
LWLockRelease(content_lock);
UnpinBuffer(buf_hdr);
goto again; /* pick a different victim */
}
}
FlushBuffer(buf_hdr, NULL, IOOBJECT_RELATION, io_context);
LWLockRelease(content_lock);
ScheduleBufferTagForWriteback(&BackendWritebackContext, io_context, &buf_hdr->tag);
}

FlushBuffer는 더티 데이터 페이지가 내구적이 되는 관문이며, WAL 규칙이 물리적으로 집행되는 곳이다. 순서는 이렇다. I/O를 클레임하고(BM_IO_IN_PROGRESS 설정으로 다른 백엔드가 같은 페이지를 쓰지 못하게 막는다), 헤더 스핀락 아래 페이지 LSN을 읽고, 그 LSN까지 WAL을 플러시하고, 페이지를 기록한 뒤 더티 비트를 클리어한다.

// FlushBuffer — storage/buffer/bufmgr.c (condensed)
if (!StartBufferIO(buf, false, false)) /* false = for output; someone else won */
return;
// ... condensed: error-context setup, smgropen if reln == NULL ...
buf_state = LockBufHdr(buf);
recptr = BufferGetLSN(buf); /* page's LSN, read under hdr lock */
buf_state &= ~BM_JUST_DIRTIED; /* detect concurrent re-dirtying */
UnlockBufHdr(buf, buf_state);
/*
* Force XLOG flush up to buffer's LSN. This implements the basic WAL
* rule that log updates must hit disk before any of the data-file changes
* they describe do. ... skip the flush if the buffer isn't permanent.
*/
if (buf_state & BM_PERMANENT)
XLogFlush(recptr);
bufBlock = BufHdrGetBlock(buf);
bufToWrite = PageSetChecksumCopy((Page) bufBlock, buf->tag.blockNum);
smgrwrite(reln, BufTagGetForkNum(&buf->tag), buf->tag.blockNum, bufToWrite, false);
// ... condensed: I/O stats ...
TerminateBufferIO(buf, true, 0, true, false); /* clear BM_DIRTY (unless re-dirtied) */

설계를 담는 세 가지 세부 사항이 있다.

  • **if (buf_state & BM_PERMANENT) XLogFlush(recptr)**가 WAL 규칙 그 자체다. 영구(WAL 로깅) 릴레이션이면, smgrwrite 전에 페이지 LSN까지 로그를 디스크에 강제한다. 언로그드 릴레이션은 어차피 크래시 시 손실되므로 플러시를 건너뛴다. 소스 내 주석에 따르면 가짜 언로그드-GiST LSN을 플러시하면 WAL 삽입 포인트를 넘어서 플러시하려 할 수도 있어서 BM_PERMANENT 가드가 최적화가 아니라 필수다. smgrwrite는 OS 페이지 캐시에 쓸 뿐, 반드시 플래터에 도달하지는 않는다. 데이터 페이지의 내구성은 나중에 체크포인터의 smgrimmedsync/fsync가 강제한다. WAL 규칙이 요구하는 것은 로그가 데이터 쓰기보다 앞서야 한다는 것뿐이며, 그 조건은 충족된다.
  • StartBufferIO가 false를 반환하는 경우는 다른 백엔드가 이미 이 페이지를 플러시했음을 의미한다. FlushBuffer는 단순히 반환한다. BM_IO_IN_PROGRESS가 페이지별 I/O 래치이며, 대기자들은 WaitIO에서 프레임의 조건 변수로 슬립한다.
  • **BM_JUST_DIRTIED**는 쓰기 전에 클리어되고 TerminateBufferIO에서 재확인된다. 쓰기 도중 페이지가 다시 더티해진다면(합법적이다 — 힌트 비트 갱신은 공유 콘텐츠 락 아래 발생한다), 더티 비트를 클리어하지 않으므로 페이지는 나중에 다시 기록된다.
sequenceDiagram
    participant V as GetVictimBuffer / checkpointer
    participant FB as FlushBuffer
    participant HDR as buffer header
    participant WAL as WAL (XLogFlush)
    participant SMGR as smgr (data file)

    V->>FB: 더티 피해자 플러시
    FB->>HDR: StartBufferIO -> BM_IO_IN_PROGRESS 설정
    alt 다른 백엔드가 이미 플러시 중
        HDR-->>FB: false -> 반환 (작업 없음)
    end
    FB->>HDR: LockBufHdr; recptr = BufferGetLSN; BM_JUST_DIRTIED 클리어
    opt buffer is BM_PERMANENT
        FB->>WAL: XLogFlush(recptr)
        Note right of WAL: 페이지 LSN까지 로그 디스크 저장<br/>페이지 쓰기 이전에
    end
    FB->>SMGR: smgrwrite(page) (+ checksum copy)
    FB->>HDR: TerminateBufferIO -> BM_JUST_DIRTIED 아니면 BM_DIRTY 클리어
    Note right of HDR: 쓰기 중 재더티화됐으면<br/>페이지는 더티 유지 -> 나중에 재기록

그림 4 — FlushBuffer와 WAL-before-flush 규칙. 플러셔가 BM_IO_IN_PROGRESS로 페이지를 클레임하고, LSN을 읽고, 영구 릴레이션이면 smgrwrite 전에 XLogFlush(recptr)로 로그를 디스크에 강제한다. BM_JUST_DIRTIED가 힌트 비트에 의한 동시 더티화를 감지해 재더티화된 페이지가 잘못 클린 표시되지 않게 한다.

수정자가 MarkBufferDirty를 호출(배타 콘텐츠 락과 핀 아래)하면 쓰기 경로가 시작된다. 이 함수는 CAS로 두 플래그 비트를 설정할 뿐이다.

// MarkBufferDirty — storage/buffer/bufmgr.c (condensed)
Assert(BufferIsPinned(buffer));
Assert(LWLockHeldByMeInMode(BufferDescriptorGetContentLock(bufHdr), LW_EXCLUSIVE));
for (;;)
{
if (old_buf_state & BM_LOCKED)
old_buf_state = WaitBufHdrUnlocked(bufHdr);
buf_state = old_buf_state | BM_DIRTY | BM_JUST_DIRTIED;
if (pg_atomic_compare_exchange_u32(&bufHdr->state, &old_buf_state, buf_state))
break;
}

FlushBuffer가 나중에 읽는 페이지 LSN은 여기서 설정되지 않는다. LSN은 WAL 기계가 설정한다(XLogInsert가 레코드의 끝 LSN을 반환하면, 수정하는 접근 방법이 페이지 헤더에 스탬프를 찍는다. postgres-xlog-wal.md 참고). MarkBufferDirty는 바이트가 디스크와 달라졌다는 사실만 기록한다.

클린업 락: 튜플 제거에는 단독 핀이 필요

섹션 제목: “클린업 락: 튜플 제거에는 단독 핀이 필요”

일부 작업(VACUUM 압축, 페이지 조각 정리)은 다른 백엔드가 페이지 안을 가리키는 포인터를 갖지 않음을 보장해야 한다. README 규칙 #5는 배타 콘텐츠 락 refcount == 1 확인을 요구한다. LockBufferForCleanup은 루프를 돈다. 배타 락을 잡고, 헤더 스핀락 아래 핀 카운트를 확인한다. 1이면 완료다. 1이 아니면 BM_PIN_COUNT_WAITER로 자신을 등록하고 락을 놓은 뒤 슬립한다. 다른 백엔드의 UnpinBuffer(또는 TerminateBufferIO)가 WakePinCountWaiter로 깨운다. 버퍼당 대기자가 하나뿐이라는 제약이 있지만, PostgreSQL은 하나의 릴레이션에 두 개의 VACUUM을 동시에 실행하지 않으므로 충분하다.

임시 테이블 페이지는 공유 풀에 들어가지 않는다. 세션 전용이고 WAL 로깅되지 않으므로, localbuf.c가 관리하는 백엔드별 배열(LocalBufferDescriptors, LocalRefCount)에 산다. LocalBufferAllocGetLocalVictimBuffer는 공유 로직의 거울이지만, 락이 없다(스핀락도, LWLock도 없다 — 접근자가 하나뿐이므로). 로컬 배열 위의 단순 clock sweep을 사용한다. 같은 BufferDesc 구조체를 재사용하지만 락과 대부분의 플래그 비트는 비활성 상태다. 임시 테이블의 빠른 경로가 공유 풀 경합에서 완전히 분리된다는 점이 요점이다.

심볼 이름에 닻을 내리고, 줄 번호에는 의존하지 않는다. git grep -n '<symbol>' src/backend/storage/buffer/로 심볼을 재위치시켜라. 위치 힌트 테이블의 줄 번호는 updated: 커밋 기준의 힌트다.

공유 풀 자료구조 (storage/buf_internals.h)

섹션 제목: “공유 풀 자료구조 (storage/buf_internals.h)”
  • struct buftag (BufferTag) — (spc, db, rel, fork, block) 캐시 키; InitBufferTag, BufferTagsEqual, ClearBufferTag 연산.
  • struct BufferDesc — 프레임별 메타데이터: tag, buf_id, atomic state, freeNext, content_lock, io_wref.
  • BufferDescPaddedBufferDesc를 감싸는 캐시 라인 패딩 유니온.
  • BUF_REFCOUNT_BITS / BUF_USAGECOUNT_BITS / BUF_FLAG_BITSBUF_STATE_GET_* 접근자 — 압축 워드 레이아웃.
  • BM_* 플래그 매크로 — BM_DIRTY, BM_VALID, BM_TAG_VALID, BM_IO_IN_PROGRESS, BM_JUST_DIRTIED, BM_PIN_COUNT_WAITER, BM_PERMANENT, BM_LOCKED.
  • BM_MAX_USAGE_COUNT — clock-sweep 사용 상한 (= 5).
  • BufMappingPartitionLock / BufTableHashPartition — 파티션 락 선택.
  • LockBufHdr / UnlockBufHdrBM_LOCKED를 통한 헤더 스핀락.
  • BufferManagerShmemInit — 세 공유 배열 할당 + 연결.
  • BufferManagerShmemSize — shmem 세그먼트 크기 계산.
  • BufTableHashCode, BufTableLookup, BufTableInsert, BufTableDelete — tag→buf_id 공유 해시 테이블 API.
  • StrategyGetBuffer — 링 → 프리 리스트 → clock sweep 피해자 선택.
  • ClockSweepTick — atomic clock 핸드 전진, 래랩 처리.
  • StrategyFreeBuffer — 깨끗한 프레임을 프리 리스트에 반환.
  • BufferStrategyControl — 공유 nextVictimBuffer, 프리 리스트 헤드/테일, buffer_strategy_lock, bgwriter 알림.
  • GetAccessStrategy / GetAccessStrategyWithSize — 링 구성.
  • GetBufferFromRing / AddBufferToRing / StrategyRejectBuffer — 링 재사용과 더티 버퍼 거부.
  • StrategySyncStart / StrategyNotifyBgWriter — bgwriter 조율.

읽기 / 핀 / 락 / 쓰기 경로 (bufmgr.c)

섹션 제목: “읽기 / 핀 / 락 / 쓰기 경로 (bufmgr.c)”
  • ReadBuffer / ReadBufferExtended / ReadBuffer_common — 공개 진입점.
  • BufferAlloc — 룩업-또는-폴트; 파티션 락 아래 태그 설치.
  • GetVictimBuffer — 선택을 교체 + 전략/WAL 검사로 래핑.
  • PinBuffer / PinBuffer_Locked / UnpinBuffer / UnpinBufferNoOwner — 참조 카운팅; PrivateRefCountArray / PrivateRefCountEntry.
  • LockBuffer / ConditionalLockBuffer — 콘텐츠 락.
  • LockBufferForCleanup / ConditionalLockBufferForCleanup / WakePinCountWaiter — 단독 핀 클린업 프로토콜.
  • MarkBufferDirtyBM_DIRTY | BM_JUST_DIRTIED 설정.
  • FlushBuffer — WAL 규칙: XLogFlush(BufferGetLSN(buf))smgrwrite.
  • StartBufferIO / TerminateBufferIO / WaitIO / AbortBufferIOBM_IO_IN_PROGRESS I/O 래치 프로토콜.
  • InvalidateBuffer / InvalidateVictimBuffer — 버퍼 매핑 제거.
  • BufferSync — 체크포인트 시 모든 더티 버퍼 플러시.

로컬 (임시 테이블) 버퍼 (localbuf.c)

섹션 제목: “로컬 (임시 테이블) 버퍼 (localbuf.c)”
  • LocalBufferAlloc, GetLocalVictimBuffer, MarkLocalBufferDirty, FlushLocalBuffer — 세션 전용, 락 없는 거울 구현.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
struct buftagstorage/buf_internals.h106
struct BufferDescstorage/buf_internals.h258
BM_MAX_USAGE_COUNTstorage/buf_internals.h87
BufMappingPartitionLockstorage/buf_internals.h198
BufferManagerShmemInitstorage/buffer/buf_init.c67
BufferManagerShmemSizestorage/buffer/buf_init.c161
BufTableHashCodestorage/buffer/buf_table.c78
BufTableLookupstorage/buffer/buf_table.c90
BufTableInsertstorage/buffer/buf_table.c118
BufTableDeletestorage/buffer/buf_table.c148
BufferStrategyControlstorage/buffer/freelist.c30
ClockSweepTickstorage/buffer/freelist.c107
StrategyGetBufferstorage/buffer/freelist.c195
StrategyFreeBufferstorage/buffer/freelist.c362
GetAccessStrategystorage/buffer/freelist.c540
GetBufferFromRingstorage/buffer/freelist.c736
StrategyRejectBufferstorage/buffer/freelist.c839
ReadBufferstorage/buffer/bufmgr.c758
BufferAllocstorage/buffer/bufmgr.c2009
InvalidateBufferstorage/buffer/bufmgr.c2187
GetVictimBufferstorage/buffer/bufmgr.c2354
MarkBufferDirtystorage/buffer/bufmgr.c2956
PinBufferstorage/buffer/bufmgr.c3076
PinBuffer_Lockedstorage/buffer/bufmgr.c3186
UnpinBufferstorage/buffer/bufmgr.c3268
FlushBufferstorage/buffer/bufmgr.c4293
LockBufferstorage/buffer/bufmgr.c5609
LockBufferForCleanupstorage/buffer/bufmgr.c5689
WaitIOstorage/buffer/bufmgr.c5968
StartBufferIOstorage/buffer/bufmgr.c6047
TerminateBufferIOstorage/buffer/bufmgr.c6104
LocalBufferAllocstorage/buffer/localbuf.c118
GetLocalVictimBufferstorage/buffer/localbuf.c223

각 항목은 커밋 273fe94 현재 소스에서 외부 자료 없이 확인 가능한 사실이다. 미해결 질문은 큐레이터가 기록한 공백으로 이어진다.

  • 버퍼 state 워드는 18비트 refcount + 4비트 usagecount + 10비트 플래그이며, 합이 32임을 컴파일 타임 assert로 검증한다. buf_internals.h에서 확인(BUF_REFCOUNT_BITS/BUF_USAGECOUNT_BITS/BUF_FLAG_BITSStaticAssertDecl(... == 32)). 플래그 비트는 비트 22부터 시작한다(BM_LOCKED = 1U << 22).

  • BM_MAX_USAGE_COUNT는 5이며, GUC가 아닌 하드코딩이다. buf_internals.h에 주석(정확도/속도 트레이드오프 설명)과 함께 BUF_USAGECOUNT_BITS에 맞는지 확인하는 static assert가 있다. 튜닝하려면 재컴파일이 필요하다.

  • NUM_BUFFER_PARTITIONS는 128이다. storage/lwlock.h에 정의되어 있으며(버퍼 파일이 아님), BufTableHashPartition이 이 값을 소비한다. 2의 제곱이어야 한다.

  • clock 핸드는 pg_atomic_fetch_add_u32로 전진하는 단일 atomic nextVictimBuffer이며, buffer_strategy_lock 스핀락은 clock 래랩이나 프리 리스트 팝에서만 사용한다. ClockSweepTickStrategyGetBuffer(freelist.c)에서 확인. 동시 sweep은 버퍼를 약간 순서 어긋나게 반환할 수 있으나, 소스 내 주석이 이를 무해하다고 명시한다.

  • WAL 규칙은 FlushBuffer에서 if (buf_state & BM_PERMANENT) XLogFlush(recptr)로 집행되며, recptr = BufferGetLSN(buf)는 버퍼 헤더 스핀락 아래, smgrwrite 전에 읽는다. FlushBuffer(bufmgr.c)에서 확인. 언로그드 릴레이션은 설계상 플러시를 건너뛴다. BM_PERMANENT 가드가 단순 최적화가 아니라 필수임은 소스 내 주석이 가짜 LSN GiST 위험을 근거로 명시한다.

  • 링 크기: BAS_BULKREAD = 256 KB 기본값(IO 동시성만큼 증가), BAS_BULKWRITE = 16 MB, BAS_VACUUM = 2048 KB; 모두 shared_buffers의 1/8 상한. GetAccessStrategyGetAccessStrategyWithSize(freelist.c)에서 확인. BAS_NORMAL은 NULL을 반환한다(링 없음). BAS_VACUUM의 실효 크기는 GetAccessStrategy가 아닌 호출 지점의 vacuum_buffer_usage_limit GUC로 결정된다.

  • 더티 링 버퍼를 플러시하는 대신 거부하는 것은 BAS_BULKREAD뿐이다. StrategyRejectBuffer(freelist.c)에서 확인: BAS_BULKREAD 외 타입은 false를 반환한다. vacuum과 대량 쓰기 링은 더티 페이지 재사용 시 WAL 플러시 비용을 치르며, 이는 README와 일치한다.

  • 백엔드는 버퍼의 첫 번째 핀을 공유 메모리에 추적하고, 이후 핀은 8항목 배열(REFCOUNT_ARRAY_ENTRIES) + 오버플로 해시로 프라이빗 추적한다. bufmgr.c(PrivateRefCountEntry, PinBuffer)에서 확인. ResourceOwnerRememberBuffer가 각 핀을 리소스 오너에 묶어 abort 시 정리한다.

  • LockBufferForCleanup은 버퍼당 정확히 하나의 핀 카운트 대기자를 지원한다. bufmgr.c에서 확인되며 README도 명시한다. 하나의 릴레이션에 동시 VACUUM이 금지되므로 충분하다.

  1. AIO와 축출의 상호작용. REL_18은 BufferDescio_wref와 별도의 비동기 I/O 서브시스템(storage/aio)을 추가했다. 읽기 경로가 비동기로 동작할 수 있으며, TerminateBufferIO(..., release_aio)가 AIO가 잡은 핀을 해제한다. 읽기가 여전히 진행 중인 버퍼에서 GetVictimBufferLockBufferForCleanup이 어떻게 상호작용하는지는 이 문서에서 부분적으로만 추적했다. 조사 경로: storage/aio/README.mdWaitReadBuffers/StartReadBuffers 배치 경로 읽기, 향후 postgres-aio.md와 교차 참조.

  2. 프리 리스트의 장기적 역할. README는 프리 리스트가 진짜 빈 프레임(릴레이션 삭제)에 의해서만 채워짐을 명시한다. 콜드 페이지에 대해서는 현재 알고리즘이 그렇게 하지 않는다. 워밍업된 캐시에서 프리 리스트는 사실상 불필요한 오버헤드인가, 아니면 삭제가 많은 워크로드에서 의미가 있는가. 조사 경로: StrategyFreeBuffer 호출 지점 계측.

  3. Bgwriter / checkpointer 분업. BufferSync(체크포인트)와 BgBufferSync(백그라운드 라이터)는 모두 더티 버퍼를 기록하지만 다른 스케줄과 트리거로 동작한다. 이 문서는 버퍼별 FlushBuffer 메커니즘을 다루고, 언제 각각이 실행될지 결정하는 정책은 다루지 않는다. 조사 경로: 전용 postgres-checkpointer-bgwriter.md.

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

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

포인터만 제공하며 분석하지 않는다. 각 항목은 후속 문서의 시작점이며, 깊이는 의도적으로 얕다.

  • CUBRID의 페이지 버퍼. CUBRID는 유사한 핀/래치 분리 방식과 자체 LRU/clock 하이브리드로 pgbuf에서 페이지를 캐싱한다. 그러나 CUBRID의 구버전 스토리지는 out-of-place(undo 로그)이므로, 버퍼 매니저가 MVCC와 상호작용하는 방식이 PostgreSQL의 in-place 힙과 다르다(cubrid-mvcc.md 참고). 두 교체 정책과 WAL 플러시 관문을 나란히 비교하면 steal/no-force에 본질적인 것과 PostgreSQL 특유의 것을 가를 수 있다.

  • second-chance를 넘는 CLOCK 변형. PostgreSQL의 고정 BM_MAX_USAGE_COUNT=5 CLOCK은 LRU를 조야하게 근사한다. CLOCK-Pro(Jiang & Zhang, USENIX 2005), LRU-K(O’Neil et al., SIGMOD 1993), 2Q, ARC(Megiddo & Modha, FAST 2003)는 모두 스캔 저항성과 빈도 인식을 개선한다. PG의 링 버퍼 전략은 단순 CLOCK 위에 손으로 얹은 스캔 저항 패치다. 더 스마트한 기본 정책이 링을 얼마나 대체할 수 있는지는 열린 경험적 질문이다.

  • 이중 버퍼링 문제. PostgreSQL은 shared_buffers 아래에 OS 페이지 캐시를 두므로, 핫한 페이지가 두 번 캐싱될 수 있다. Direct I/O와 REL_18 비동기 I/O 서브시스템(storage/aio)은 OS 캐시를 우회하는 방향으로 나아간다. Database Internals ch. 4가 이 트레이드오프를 논의한다. PG의 이중 버퍼링 비용에 대한 측정 연구가 shared_buffers 크기 설정 경험칙을 수치로 뒷받침할 것이다.

  • 고코어 수에서의 버퍼 관리. 압축 state CAS 경로와 파티션 분할 매핑 락은 PG의 8.1 이전 BufMgrLock 병목에 대한 답이다. 수백 개 코어에서도 유효한지는 확장 가능한 락 매니저 연구(dbms-papers/scalable-lock-manager.md)와 아키텍처 개요의 공유 메모리 명제와 연결된다.

  • WAL-before-flush vs 섀도 페이징. ARIES steal/no-force(dbms-papers/aries.md)는 복구 설계 공간의 한 지점이다. 섀도 페이징 엔진과 CoW 스토어(LMDB 등)는 쓰기 증폭과 단편화 비용을 대신 지불하면서 WAL-before-flush 결합을 완전히 피한다. 이 비교는 PG의 버퍼 매니저가 XLogFlush-before-smgrwrite 관문을 왜 들고 있는지를 명확히 한다.

  • src/backend/storage/buffer/README — 버퍼 접근 규칙(핀 vs 콘텐츠 락), 내부 잠금(BufMappingLock 파티션, buffer_strategy_lock, 헤더별 스핀락, BM_IO_IN_PROGRESS), clock-sweep 알고리즘, 링 전략, 백그라운드 라이터.

PostgreSQL 소스 (/data/hgryoo/references/postgres 아래, REL_18 273fe94)

섹션 제목: “PostgreSQL 소스 (/data/hgryoo/references/postgres 아래, REL_18 273fe94)”
  • src/backend/storage/buffer/bufmgr.c
  • src/backend/storage/buffer/freelist.c
  • src/backend/storage/buffer/buf_init.c
  • src/backend/storage/buffer/buf_table.c
  • src/backend/storage/buffer/localbuf.c
  • src/include/storage/buf_internals.h
  • src/include/storage/bufmgr.h
  • src/include/storage/lwlock.h (for NUM_BUFFER_PARTITIONS)

교과서 챕터 (knowledge/research/dbms-general/ 아래)

섹션 제목: “교과서 챕터 (knowledge/research/dbms-general/ 아래)”
  • Database Internals(Petrov), Ch. 4 §“Buffer Management” (≈ line 3419), §“Buffer manager” (≈ line 791) — 페이지 캐시 역할, 축출, force/steal, 이중 버퍼링 트레이드오프.

논문 (knowledge/research/dbms-papers/ 아래)

섹션 제목: “논문 (knowledge/research/dbms-papers/ 아래)”
  • ARIES(Mohan et al., 1992) — aries.md. FlushBuffer가 집행하는 steal/no-force 복구 방법과 WAL 규칙.
  • postgres-smgr-md.mdFlushBuffer가 쓰기를 위임하는 스토리지 매니저.
  • postgres-xlog-wal.md — WAL 생성, 페이지 LSN, XLogFlush.
  • postgres-page-layout.md — 버퍼가 담는 8 KB 페이지 내부 구조.
  • postgres-architecture-overview.md — 풀이 잘려 나오는 고정 크기 공유 메모리 세그먼트(Axis 2)와 WAL 척추(Axis 3).