(KO) PostgreSQL smgr & md — 스토리지 매니저 스위치와 자기 디스크 드라이버
목차
- 학술적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 가이드
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
학술적 배경
섹션 제목: “학술적 배경”모든 관계형 엔진은 한 가지 근본 문제를 풀어야 한다. “릴레이션 R의 페이지 N”이라는 논리 주소를 어떻게 디스크 위의 실제 바이트로 변환하는가. 이 역할을 맡는 계층이 **스토리지 매니저(storage manager)**다. 스토리지 매니저는 페이지 단위 버퍼 풀과 운영체제 사이에 위치한다. Database Internals(Petrov, 6장 “B-Tree Variants”)는 페이지 주소 지정의 관점에서 이 개념을 소개하고, Database System Concepts(Silberschatz, 7판, 10장 “Storage and File Structure”)는 고전적인 온디스크 구조 모델로 틀을 잡는다. 릴레이션은 고정 크기 페이지의 연속 배열이고, 버퍼 매니저는 페이지 단위로 읽거나 내보내며, 스토리지 계층은 물리적 위치 결정 메커니즘을 책임진다는 것이 핵심이다.
스토리지 계층이 담당할 일은 두 가지 설계 선택으로 결정된다.
-
I/O 단위. 사실상 모든 DBMS는 OS 페이지 크기와 같거나 배수인 고정 페이지 크기(4 KB–32 KB)를 선택한다. 커널 호출 한 번으로 데이터베이스 페이지 하나를 정확히 전송하기 위해서다. 버퍼 매니저와 스토리지 계층은 이 단위에 합의하며, 페이지 전송 경계에서 그보다 작은 단위는 다루지 않는다.
-
논리 주소를 물리 파일에 매핑하는 방식. 가장 단순한 방법은 릴레이션 하나를 파일 하나에 담는 것이다. 실제 엔진은 두 방향으로 확장한다. 하나는 릴레이션 포크(relation fork) — 릴레이션 하나에 여러 파일 시퀀스를 두어 빈 공간 맵이나 가시성 맵 같은 보조 메타데이터를 독립적으로 관리하는 방식이다. 다른 하나는 파일 세그먼테이션(file segmentation) — OS 파일 크기 제한을 피하기 위해 단일 포크를 고정 크기 파일 여러 개로 나누는 방식이다.
fsync 규율도 스토리지 매니저가 관리하는 영역이다. 커널 버퍼 캐시에 기록된 페이지는
내구성이 없다. 엔진은 언젠가 해당 파일에 fsync(2)를 호출해야 한다. 전략은 세
가지다. (a) 쓰기 경로에서 매번 동기적으로 fsync한다 — 올바르지만 느리다. (b) “더티
세그먼트”를 펜딩 목록에 쌓아 두고 체크포인트 시점에 백그라운드 프로세스가 소진한다
— PostgreSQL의 기본 방식이다. (c) O_DIRECT로 커널 버퍼 캐시를 우회해 엔진 자체
버퍼 풀만 캐시로 사용한다 — PG18의 비동기 I/O가 점차 실용화하는 경로다. PostgreSQL은
기본적으로 (b)를 쓰고, io_direct_flags 설정으로 (c)도 지원한다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”함수 포인터 테이블을 통한 디스패치
섹션 제목: “함수 포인터 테이블을 통한 디스패치”여러 스토리지 백엔드(디스크, WORM 주크박스, NVM, S3)를 지원하도록 설계된 엔진 대부분은 스토리지 계층을 **스토리지 매니저 식별자를 키로 하는 함수 포인터 테이블(vtable)**로 구현한다. 식별자는 카탈로그나 릴레이션 디스크립터에 저장된다. 디스패치는 페이지 연산 한 번에 간접 호출 하나를 더 유발하지만, 실제 I/O 비용이 압도적으로 크므로 사실상 무비용이다.
캐시된 핸들 객체
섹션 제목: “캐시된 핸들 객체”파일 열기는 비싼 연산이다. 엔진은 논리 릴레이션 식별자를 키로 하는 백엔드별 핸들
테이블에 열린 파일 디스크립터를 캐시한다. 핸들 객체는 릴레이션 로케이터, 각
세그먼트의 열린 파일 디스크립터, 그리고 현재 크기나 삽입 목표 블록 같은 소량의
메타데이터를 묶어 놓는다. 모든 I/O 전에 테이블 조회가 먼저 일어나며, 핸들이 이미
있으면 테이블 조회 하나로 open(2) 시스템 호출을 대체한다.
포크 기반 파일 레이아웃
섹션 제목: “포크 기반 파일 레이아웃”PostgreSQL 8.4 이후 표준 레이아웃은 다음과 같다.
$PGDATA/base/<dbOid>/<relfileNumber> ← 메인 포크, 세그먼트 0$PGDATA/base/<dbOid>/<relfileNumber>.1 ← 메인 포크, 세그먼트 1$PGDATA/base/<dbOid>/<relfileNumber>_fsm ← 빈 공간 맵 포크$PGDATA/base/<dbOid>/<relfileNumber>_vm ← 가시성 맵 포크$PGDATA/base/<dbOid>/<relfileNumber>_init ← 초기화 포크 (언로그드 테이블)각 포크는 독립적인 파일 시퀀스로, 포크마다 독립적으로 확장하고 축소할 수 있다. 포크 다중화는 FSM과 VM을 메인 파일 내부에 복잡하게 멀티플렉싱하지 않고 구현할 수 있는 최소 확장이다.
펜딩 옵스 목록을 통한 지연 fsync
섹션 제목: “펜딩 옵스 목록을 통한 지연 fsync”커널 캐시에 페이지를 쓰면 fsync 의무가 생긴다. 다음 체크포인트가 완료되기 전에
해당 세그먼트를 fsync해야 한다. 쓰기 경로에서 fsync를 동기적으로 호출하면 정확하지만
느리다. 표준 DBMS 패턴은 (테이블스페이스, 데이터베이스, 릴레이션 파일 번호, 포크, 세그먼트)를 키로 하는 펜딩 옵스 목록에 의무를 기록해 두고, 백그라운드
체크포인터가 체크포인트 창 안에서 목록을 소진하도록 위임하는 방식이다. 쓰기 경로는
블로킹되지 않는다.
이론 ↔ PostgreSQL 매핑
섹션 제목: “이론 ↔ PostgreSQL 매핑”| 이론 개념 | PostgreSQL 이름 |
|---|---|
| 스토리지 매니저 vtable | smgr.c의 f_smgr 구조체; 단일 항목 smgrsw[0] = md.c |
| 릴레이션 핸들 / 캐시된 FD | SMgrRelationData / SMgrRelation |
| 논리 주소 (릴레이션, 페이지) | RelFileLocator + ForkNumber + BlockNumber |
| 포크 | ForkNumber: MAIN_FORKNUM, FSM_FORKNUM, VISIBILITYMAP_FORKNUM, INIT_FORKNUM |
| 파일 세그먼트 | MdfdVec — 열린 세그먼트 하나당 하나; md_seg_fds[] 배열 |
| 세그먼트 크기 한도 | RELSEG_SIZE 블록 (기본 131 072 → 8 KB/블록 기준 1 GB) |
| 펜딩 fsync 의무 | RegisterSyncRequest에 전달되는 FileTag 항목 |
| 지연 fsync 실행자 | ProcessSyncRequests를 호출하는 체크포인터 프로세스 |
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”smgr 스위치 (smgr.c)
섹션 제목: “smgr 스위치 (smgr.c)”smgr.c는 의도적으로 얇다. 두 개의 자료구조와 약 20개의 순수 디스패치 래퍼 함수로
구성된다.
// f_smgr — smgr.c (the vtable)typedef struct f_smgr{ void (*smgr_init) (void); void (*smgr_open) (SMgrRelation reln); void (*smgr_close) (SMgrRelation reln, ForkNumber forknum); void (*smgr_create) (SMgrRelation reln, ForkNumber forknum, bool isRedo); bool (*smgr_exists) (SMgrRelation reln, ForkNumber forknum); void (*smgr_unlink) (RelFileLocatorBackend rlocator, ForkNumber forknum, bool isRedo); void (*smgr_extend) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, const void *buffer, bool skipFsync); void (*smgr_zeroextend) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, int nblocks, bool skipFsync); bool (*smgr_prefetch) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, int nblocks); uint32 (*smgr_maxcombine) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum); void (*smgr_readv) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, void **buffers, BlockNumber nblocks); void (*smgr_startreadv) (PgAioHandle *ioh, SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, void **buffers, BlockNumber nblocks); void (*smgr_writev) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, const void **buffers, BlockNumber nblocks, bool skipFsync); void (*smgr_writeback) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, BlockNumber nblocks); BlockNumber (*smgr_nblocks) (SMgrRelation reln, ForkNumber forknum); void (*smgr_truncate) (SMgrRelation reln, ForkNumber forknum, BlockNumber old_blocks, BlockNumber nblocks); void (*smgr_immedsync) (SMgrRelation reln, ForkNumber forknum); void (*smgr_registersync) (SMgrRelation reln, ForkNumber forknum); int (*smgr_fd) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, uint32 *off);} f_smgr;
static const f_smgr smgrsw[] = { /* magnetic disk — the only entry */ { .smgr_init = mdinit, .smgr_open = mdopen, .smgr_readv = mdreadv, .smgr_startreadv = mdstartreadv, /* ... */ }};README의 설명이 솔직하다. “현재는 자기 디스크 매니저 하나만 남아 있다. 스토리지 매니저를 다시 여러 개 도입하고 싶은 누군가를 위해 스위치 계층을 유지한다. 스위치 계층을 제거해도 절약되는 것은 없다. 스토리지 접근 연산 자체가 간접 C 함수 호출 하나보다 훨씬 비싸기 때문이다.”
SMgrRelation: 백엔드별 핸들
섹션 제목: “SMgrRelation: 백엔드별 핸들”물리 릴레이션에 대한 모든 접근은 SMgrRelation 핸들을 거친다. smgropen()은
RelFileLocatorBackend(테이블스페이스 OID + 데이터베이스 OID + 릴레이션 파일 번호
- 백엔드 번호)를 키로 하는 백엔드별 해시 테이블에서 핸들을 찾거나 새로 만든다.
// SMgrRelationData — src/include/storage/smgr.htypedef struct SMgrRelationData{ RelFileLocatorBackend smgr_rlocator; /* hash key — must be first */
BlockNumber smgr_targblock; /* current insertion target block */ BlockNumber smgr_cached_nblocks[MAX_FORKNUM + 1]; /* cached fork sizes */
int smgr_which; /* selector into smgrsw[] — always 0 */
/* md.c private fields */ int md_num_open_segs[MAX_FORKNUM + 1]; struct _MdfdVec *md_seg_fds[MAX_FORKNUM + 1];
/* pinning */ int pincount; dlist_node node; /* link in unpinned_relns list when pincount==0 */} SMgrRelationData;smgr_cached_nblocks 필드는 포크별 크기 캐시다. smgr 캐시 무효화 신호
(PROCSIGNAL_BARRIER_SMGRRELEASE 또는 CacheInvalidateSmgr)가 오면 무효화된다.
smgrnblocks_cached()의 주석은 분명하다. “파일 크기 변경에 대한 공유 무효화
메커니즘이 없으므로 현재는 복구 중에만 캐시 값을 사용한다.”
수명과 핀. 트랜잭션 내에서 만들어진 핸들은 기본적으로 언핀(unpinned) 상태다.
unpinned_relns 이중 연결 리스트에 올라가고, AtEOXact_SMgr()(트랜잭션 종료)
시점에 파기된다. relcache는 자신이 보유한 핸들을 smgrpin()으로 핀해 항목이
살아 있는 동안 파기되지 않도록 보호한다. 트랜잭션 바깥에서 동작하는 체크포인터나
보조 프로세스는 적절한 시점에 smgrdestroyall()을 직접 호출해 수명을 관리한다.
신호 기반 해제. 백엔드는 언제든지 PROCSIGNAL_BARRIER_SMGRRELEASE를 받아 열린
파일 디스크립터를 전부 즉시 닫도록 지시받을 수 있다.
ProcessBarrierSmgrRelease()는 smgrreleaseall()을 호출하고, 이 함수는 전체 해시
테이블을 순회하며 각 항목에 smgrrelease()를 부른다. 핸들 객체는 테이블에 남는다.
닫히는 것은 OS 파일 디스크립터뿐이다.
// smgropen — smgr.cSMgrRelationsmgropen(RelFileLocator rlocator, ProcNumber backend){ RelFileLocatorBackend brlocator; SMgrRelation reln; bool found;
HOLD_INTERRUPTS();
if (SMgrRelationHash == NULL) { HASHCTL ctl; ctl.keysize = sizeof(RelFileLocatorBackend); ctl.entrysize = sizeof(SMgrRelationData); SMgrRelationHash = hash_create("smgr relation table", 400, &ctl, HASH_ELEM | HASH_BLOBS); dlist_init(&unpinned_relns); }
brlocator.locator = rlocator; brlocator.backend = backend; reln = (SMgrRelation) hash_search(SMgrRelationHash, &brlocator, HASH_ENTER, &found); if (!found) { reln->smgr_targblock = InvalidBlockNumber; for (int i = 0; i <= MAX_FORKNUM; ++i) reln->smgr_cached_nblocks[i] = InvalidBlockNumber; reln->smgr_which = 0; /* md.c only */ reln->pincount = 0; dlist_push_tail(&unpinned_relns, &reln->node); smgrsw[reln->smgr_which].smgr_open(reln); }
RESUME_INTERRUPTS(); return reln;}릴레이션 포크
섹션 제목: “릴레이션 포크”ForkNumber 열거형(src/include/common/relpath.h)은 포크 네 가지를 정의한다.
| 포크 | 값 | 파일 접미사 | 용도 |
|---|---|---|---|
MAIN_FORKNUM | 0 | (없음) | 힙 또는 인덱스 페이지 |
FSM_FORKNUM | 1 | _fsm | 빈 공간 맵 |
VISIBILITYMAP_FORKNUM | 2 | _vm | 가시성 맵 |
INIT_FORKNUM | 3 | _init | 언로그드 테이블 초기화용 |
MAX_FORKNUM은 INIT_FORKNUM = 3이다. SMgrRelationData 구조체는
md_num_open_segs, md_seg_fds, smgr_cached_nblocks를 각각 [MAX_FORKNUM + 1]
크기 배열로 할당한다. 대부분의 코드는 MAIN_FORKNUM을 전달한다. smgr.h의 편의
래퍼 smgrread와 smgrwrite는 이를 고정값으로 사용한다.
md.c: 자기 디스크 드라이버
섹션 제목: “md.c: 자기 디스크 드라이버”md.c는 논리 주소 (SMgrRelation, ForkNumber, BlockNumber)를 해당 세그먼트 파일에
대한 POSIX pread/pwrite 호출로 변환한다. 내부 상태는 전적으로
SMgrRelationData 필드(md_num_open_segs, md_seg_fds)와 MdfdVec 배열 할당을
위한 MemoryContext MdCxt에 담긴다.
MdfdVec: 세그먼트별 파일 디스크립터 래퍼
섹션 제목: “MdfdVec: 세그먼트별 파일 디스크립터 래퍼”// _MdfdVec — md.ctypedef struct _MdfdVec{ File mdfd_vfd; /* fd number in fd.c's virtual-fd pool */ BlockNumber mdfd_segno; /* segment number, from 0 */} MdfdVec;File은 fd.c의 가상 파일 디스크립터(VFD, virtual file descriptor) 풀에 대한
정수 핸들이다. 실제 OS fd가 아니다. VFD 계층(src/backend/storage/file/fd.c)은 OS
파일 디스크립터 풀을 LRU 순서로 관리하며, max_files_per_process 한계에 가까워지면
파일을 닫고 필요 시 다시 열어 준다. MdfdVec는 퇴출로 무효화될 수 있는 원시 int가
아니라 안정적인 VFD 인덱스를 저장한다.
디스크 위의 세그먼트 파일 레이아웃
섹션 제목: “디스크 위의 세그먼트 파일 레이아웃”릴레이션 포크가 RELSEG_SIZE 블록(기본 131 072, 8 KB/블록 = 1 GB)을 초과하면
여러 세그먼트 파일에 걸쳐진다. 명명 규칙은 다음과 같다.
base/<dbOid>/<relfileNumber> ← MAIN_FORKNUM 세그먼트 0base/<dbOid>/<relfileNumber>.1 ← 세그먼트 1base/<dbOid>/<relfileNumber>.2 ← 세그먼트 2 ...base/<dbOid>/<relfileNumber>_fsm ← FSM_FORKNUM 세그먼트 0그림 2 — smgr 스위치 → md.c → 포크/세그먼트 파일 매핑
flowchart TD
CALL["smgrreadv / smgrwritev / smgrextend<br/>(rlocator, forknum, blocknum)"]
SW["smgrsw[reln->smgr_which]<br/>smgr_which == 0 (md.c only)"]
GETSEG["_mdfd_getseg<br/>targetseg = blocknum / RELSEG_SIZE"]
CALL --> SW
SW -->|mdreadv / mdwritev / mdextend| GETSEG
GETSEG -->|forknum == MAIN_FORKNUM 0| MAIN
GETSEG -->|forknum == FSM_FORKNUM 1| FSM
GETSEG -->|forknum == VISIBILITYMAP_FORKNUM 2| VM
GETSEG -->|forknum == INIT_FORKNUM 3| INIT
subgraph MAIN["MAIN fork — relpath() base name"]
M0["<relfileNumber><br/>segment 0 — blocks 0 .. RELSEG_SIZE-1"]
M1["<relfileNumber>.1<br/>segment 1 — next RELSEG_SIZE blocks"]
M2["<relfileNumber>.2<br/>segment 2 ..."]
M0 --> M1 --> M2
end
subgraph FSM["FSM fork (forkNames[1] = 'fsm')"]
F0["<relfileNumber>_fsm[.N]"]
end
subgraph VM["VM fork (forkNames[2] = 'vm')"]
V0["<relfileNumber>_vm[.N]"]
end
subgraph INIT["INIT fork (forkNames[3] = 'init', unlogged)"]
I0["<relfileNumber>_init[.N]"]
end
M0 -.->|seekpos = BLCKSZ * blocknum % RELSEG_SIZE| FILEIO["FileReadV / FileWriteV<br/>on MdfdVec.mdfd_vfd"]
F0 -.-> FILEIO
V0 -.-> FILEIO
I0 -.-> FILEIO
그림 2 — 논리 주소 (rlocator, forknum, blocknum)은 단일 smgrsw[0](md.c) vtable
항목을 거친다. _mdfd_getseg()는 blocknum을 RELSEG_SIZE로 나눠 세그먼트를
결정하고, 포크가 relpath()로 만든 파일 기본 이름을 선택한다(접미사는
forkNames[]에서: 없음 / _fsm / _vm / _init). 각 포크는 RELSEG_SIZE 블록
(기본 8 KB BLCKSZ 기준 1 GB)으로 제한되는 독립적인 세그먼트 벡터다. .1, .2,
…는 넘쳐흐른 세그먼트다.
_mdfd_segpath() 헬퍼가 경로 문자열을 만든다.
// _mdfd_segpath — md.cstatic MdPathStr_mdfd_segpath(SMgrRelation reln, ForkNumber forknum, BlockNumber segno){ RelPathStr path; MdPathStr fullpath;
path = relpath(reln->smgr_rlocator, forknum); if (segno > 0) sprintf(fullpath.str, "%s.%u", path.str, segno); else strcpy(fullpath.str, path.str); return fullpath;}md_seg_fds[forknum] 배열은 MdCxt에서 할당된 크기 조절 가능한 palloc
슬라이스다(_fdvec_resize() 이용). 불변 조건: md_num_open_segs[f]는 포크 f의
열린 세그먼트 수를 셈한다. 그 인덱스 너머의 세그먼트는 디스크에 존재할 수 있지만
아직 열리지 않은 것이다. 최적화의 결과로, 대부분의 릴레이션은 세그먼트가 하나이고
md_num_open_segs는 1에 머문다.
세그먼트 찾기: _mdfd_getseg()
섹션 제목: “세그먼트 찾기: _mdfd_getseg()”모든 읽기/쓰기 경로는 _mdfd_getseg()를 호출해 블록 번호를 MdfdVec에 매핑한다.
// _mdfd_getseg — md.c (condensed)static MdfdVec *_mdfd_getseg(SMgrRelation reln, ForkNumber forknum, BlockNumber blkno, bool skipFsync, int behavior){ BlockNumber targetseg = blkno / ((BlockNumber) RELSEG_SIZE);
/* fast path: segment already open */ if (targetseg < reln->md_num_open_segs[forknum]) return &reln->md_seg_fds[forknum][targetseg];
/* open segments from last-open up to targetseg */ for (nextsegno = reln->md_num_open_segs[forknum]; nextsegno <= targetseg; nextsegno++) { /* ... open or create based on 'behavior' flags ... */ v = _mdfd_openseg(reln, forknum, nextsegno, flags); } return &reln->md_seg_fds[forknum][targetseg];}behavior 플래그(EXTENSION_FAIL, EXTENSION_RETURN_NULL, EXTENSION_CREATE,
EXTENSION_CREATE_RECOVERY, EXTENSION_DONT_OPEN)는 네 가지 호출자 요건을
깔끔하게 인코딩한다.
세그먼트 내 블록 오프셋
섹션 제목: “세그먼트 내 블록 오프셋”블록 번호가 주어지면 해당 세그먼트 파일 안의 바이트 오프셋은 항상 다음 공식으로 계산된다.
seekpos = (off_t) BLCKSZ * (blocknum % RELSEG_SIZE)이 계산은 mdreadv, mdwritev, mdextend, mdprefetch, mdwriteback에 동일한
형태로 반복된다.
읽기 경로: mdreadv와 mdstartreadv
섹션 제목: “읽기 경로: mdreadv와 mdstartreadv”mdreadv()는 버퍼 매니저가 호출하는 동기 읽기 진입점이다. FileReadV()를 통한
벡터화된 preadv(2) 호출로 변환된다. 벡터(struct iovec[])는
buffers_to_iovec()이 만드는데, 연속된 버퍼 포인터를 단일 iovec 요소로 병합해
시스템 호출 횟수를 최소화한다.
// mdreadv — md.c (condensed)voidmdreadv(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, void **buffers, BlockNumber nblocks){ while (nblocks > 0) { struct iovec iov[PG_IOV_MAX]; int iovcnt; MdfdVec *v; BlockNumber nblocks_this_segment;
v = _mdfd_getseg(reln, forknum, blocknum, false, EXTENSION_FAIL | EXTENSION_CREATE_RECOVERY); seekpos = (off_t) BLCKSZ * (blocknum % ((BlockNumber) RELSEG_SIZE)); nblocks_this_segment = Min(nblocks, RELSEG_SIZE - (blocknum % RELSEG_SIZE)); iovcnt = buffers_to_iovec(iov, buffers, nblocks_this_segment);
/* retry loop for short reads */ for (;;) { nbytes = FileReadV(v->mdfd_vfd, iov, iovcnt, seekpos, WAIT_EVENT_DATA_FILE_READ); if (nbytes == size_this_segment) break; /* ... handle short read or EOF error ... */ } nblocks -= nblocks_this_segment; buffers += nblocks_this_segment; blocknum += nblocks_this_segment; }}mdstartreadv() 는 PG18 비동기 경로다. FileReadV()를 직접 호출하는 대신
PgAioHandle을 설정하고 iovec를 채운 다음 FileStartReadV()를 호출해 AIO
서브시스템(storage/aio/)에 I/O를 비동기로 제출한다. 완료 콜백 md_readv_complete와
md_readv_report가 핸들에 등록된다.
// mdstartreadv — md.c (condensed)voidmdstartreadv(PgAioHandle *ioh, SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, void **buffers, BlockNumber nblocks){ v = _mdfd_getseg(reln, forknum, blocknum, false, EXTENSION_FAIL | EXTENSION_CREATE_RECOVERY); iovcnt = buffers_to_iovec(iov, buffers, nblocks);
if (!(io_direct_flags & IO_DIRECT_DATA)) pgaio_io_set_flag(ioh, PGAIO_HF_BUFFERED);
pgaio_io_set_target_smgr(ioh, reln, forknum, blocknum, nblocks, false); pgaio_io_register_callbacks(ioh, PGAIO_HCB_MD_READV, 0);
FileStartReadV(ioh, v->mdfd_vfd, iovcnt, seekpos, WAIT_EVENT_DATA_FILE_READ);}AIO 계층은 워커 프로세스에서 I/O를 실행할 수 있다. smgr_aio_reopen()은 워커에서
파일 디스크립터를 재열기 위한 콜백이다. 워커는 I/O를 발행한 백엔드의 VFD 테이블을
공유하지 않으므로 이 콜백이 필요하다.
쓰기 경로: mdwritev와 지연 fsync
섹션 제목: “쓰기 경로: mdwritev와 지연 fsync”mdwritev()는 FileWriteV()로 동기 벡터화 쓰기를 수행한 뒤, skipFsync가 false이면
register_dirty_segment()를 호출한다.
// register_dirty_segment — md.c (condensed)static voidregister_dirty_segment(SMgrRelation reln, ForkNumber forknum, MdfdVec *seg){ FileTag tag; INIT_MD_FILETAG(tag, reln->smgr_rlocator.locator, forknum, seg->mdfd_segno);
if (!RegisterSyncRequest(&tag, SYNC_REQUEST, false)) { /* queue full — fsync immediately */ FileSync(seg->mdfd_vfd, WAIT_EVENT_DATA_FILE_SYNC); }}RegisterSyncRequest()(storage/sync/sync.c)는 FileTag를 체크포인터의 펜딩
동기화 테이블에 추가한다. 체크포인트 시점에 ProcessSyncRequests()가 테이블을
순회하며 각 항목에 FileSync()를 발행하고 테이블을 비운다. 큐가 가득 차면
백엔드가 인라인 동기 fsync로 폴백한다.
확장 경로: mdextend와 mdzeroextend
섹션 제목: “확장 경로: mdextend와 mdzeroextend”mdextend()는 EOF 이상의 위치에 블록 하나를 기록한다. blocknum % RELSEG_SIZE로
seekpos를 계산하고 FileWrite()로 쓴 뒤 더티 세그먼트를 등록한다.
mdzeroextend()는 한 번에 N개 블록을 0으로 채워 확장한다.
file_extend_method == FILE_EXTEND_METHOD_POSIX_FALLOCATE일 때 큰 확장에는
FileFallocate()(posix_fallocate(2) 경유)를, 그렇지 않으면 FileZero()(0
쓰기)를 사용한다. 성공 후 두 함수 모두 smgr_cached_nblocks를 갱신한다.
축소: mdtruncate
섹션 제목: “축소: mdtruncate”mdtruncate()는 마지막 세그먼트부터 역순으로 처리한다. 새 EOF를 완전히 초과하는
세그먼트에는 FileTruncate(v->mdfd_vfd, 0, ...)로 디스크 공간을 회수하고, 닫은
뒤 md_seg_fds 배열을 줄인다. 경계 세그먼트는 정확한 바이트 위치까지 잘라낸다.
세그먼트 0(segno==0)은 절대 언링크하지 않는다. 0으로 잘라 빈 파일로 남겨 두어
다음 체크포인트 전에 릴레이션 파일 번호가 재사용되는 것을 막는다.
// mdunlink path — md.c (condensed; non-redo, main fork)// 1. Truncate first segment to zero (reclaim disk space)ret = do_truncate(path.str);// 2. Register post-checkpoint unlink requestregister_unlink_segment(rlocator, forknum, 0);// Additional segments: truncate + unlink immediatelyfor (segno = 1; ; segno++) { do_truncate(segpath.str); register_forget_request(rlocator, forknum, segno); unlink(segpath.str);}이 지연 언링크 절차는 릴레이션이 삭제된 뒤 해당 파일 번호가 즉시 재사용되고, 다음 체크포인트 이전에 크래시가 발생할 경우 WAL 재현이 잘못된 파일을 재생성하는 시나리오를 막는다.
크기 계산: mdnblocks
섹션 제목: “크기 계산: mdnblocks”mdnblocks()는 마지막으로 열린 세그먼트부터 시작해 _mdnblocks()로 각 세그먼트를
탐색하고, RELSEG_SIZE보다 짧은 세그먼트를 만날 때까지 진행한다. 공식은 다음과
같다.
total_blocks = segno * RELSEG_SIZE + 마지막_세그먼트의_블록_수중요한 부작용이 있다. mdnblocks()를 호출하면 모든 활성 세그먼트가 열리고
md_seg_fds 배열에 추가된다. mdtruncate()가 계약 상 mdnblocks() 호출 이후에
실행되어야 하는 이유가 여기 있다.
인터럽트 규율
섹션 제목: “인터럽트 규율”두 파일 전체에서 가장 눈에 띄는 비명시적 패턴은 모든 진입점을 감싸는
HOLD_INTERRUPTS() / RESUME_INTERRUPTS() 쌍이다. smgr.c 파일 헤더에 이유가
명시되어 있다. 인터럽트 처리 과정에서 PROCSIGNAL_BARRIER_SMGRRELEASE가 발동되어
smgrreleaseall()을 호출할 수 있다. smgr.c의 대부분이 재진입 불가능하므로,
해시 테이블이나 열린 파일 디스크립터를 참조하는 함수는 실행 구간 동안 인터럽트를
막아야 한다.
비동기 I/O 타깃: pgaio_io_set_target_smgr
섹션 제목: “비동기 I/O 타깃: pgaio_io_set_target_smgr”PG18은 공식적인 AIO 타깃(target) 개념을 도입한다. smgr.c는 자신을
aio_smgr_target_info 구조체로 PGAIO_TID_SMGR 타깃에 등록하며, 두 가지 콜백을
제공한다.
smgr_aio_reopen()— AIO 워커에서 파일 디스크립터를 재열기 위해 호출된다. 워커는 I/O를 발행한 백엔드의 VFD 테이블을 상속하지 않는다.smgr_aio_describe_identity()— 오류 메시지용 사람이 읽을 수 있는 타깃 설명을 반환한다.
// pgaio_io_set_target_smgr — smgr.c (condensed)voidpgaio_io_set_target_smgr(PgAioHandle *ioh, SMgrRelationData *smgr, ForkNumber forknum, BlockNumber blocknum, int nblocks, bool skip_fsync){ PgAioTargetData *sd = pgaio_io_get_target_data(ioh); pgaio_io_set_target(ioh, PGAIO_TID_SMGR);
sd->smgr.rlocator = smgr->smgr_rlocator.locator; sd->smgr.forkNum = forknum; sd->smgr.blockNum = blocknum; sd->smgr.nblocks = nblocks; sd->smgr.is_temp = SmgrIsTemp(smgr); sd->smgr.skip_fsync = skip_fsync && !SmgrIsTemp(smgr);}그림 1 — smgr + md 계층 구조 개요
flowchart TD
BM["버퍼 매니저<br/>(bufmgr.c)"]
SMGR["smgr.c<br/>디스패치 + SMgrRelation 해시"]
MD["md.c<br/>세그먼트 파일 + fsync 펜딩 옵스"]
VFD["fd.c<br/>가상 파일 디스크립터 풀"]
OS["OS 커널<br/>페이지 캐시 / io_uring"]
SYNC["sync.c<br/>펜딩 동기화 테이블"]
CKPT["체크포인터 프로세스<br/>ProcessSyncRequests"]
AIO["storage/aio/<br/>PgAioHandle"]
BM -->|smgrreadv / smgrwritev| SMGR
BM -->|smgrstartreadv| SMGR
SMGR -->|mdreadv / mdwritev| MD
SMGR -->|mdstartreadv| MD
MD -->|FileReadV / FileWriteV| VFD
MD -->|FileStartReadV| AIO
VFD -->|pread / pwrite| OS
AIO -->|io_uring / 워커| OS
MD -->|RegisterSyncRequest| SYNC
SYNC -->|체크포인트 시점| CKPT
CKPT -->|FileSync| VFD
그림 1 — 버퍼 매니저에서 smgr, md를 거쳐 OS 커널까지의 계층 구조. 동기 경로(왼쪽)와 PG18 비동기 경로(md.c 오른쪽)는 같은 SMgrRelation 핸들과 세그먼트 파일 상태를 공유한다.
소스 코드 가이드
섹션 제목: “소스 코드 가이드”smgr.c — 스위치 계층
섹션 제목: “smgr.c — 스위치 계층”f_smgr(구조체) — vtable 정의;smgrsw[]에 md.c 단일 항목을 담는다.SMgrRelationHash—RelFileLocatorBackend를 키로 하는 백엔드별HTAB*.unpinned_relns— 언핀된SMgrRelationData객체의 이중 연결 리스트.AtEOXact_SMgr이 파기한다.smgrinit— 모든 vtable 항목의smgr_init호출;on_proc_exit에smgrshutdown등록.smgropen— 해시 조회 또는 삽입;smgr_which = 0설정, 캐시 필드 초기화,smgr_open호출.smgrpin/smgrunpin— 핸들을unpinned_relns에 넣거나 뺀다. relcache가 자신이 보유한 핸들을 핀한다.smgrrelease— 모든 포크 FD를 닫고 캐시된 블록 수를 무효화한다. 핸들은 테이블에 남는다.smgrclose—smgrrelease의 동의어 (예전 구분의 잔재).smgrdestroy(정적) — FD 닫기 + 해시에서 제거; 언핀된 핸들에만 호출된다.smgrdestroyall— 언핀된 핸들 전체 파기;AtEOXact_SMgr이 호출한다.smgrreleaseall— 모든 핸들 릴리스(파기는 아님);ProcessBarrierSmgrRelease가 호출한다.smgrcreate/smgrextend/smgrzeroextend/smgrreadv/smgrstartreadv/smgrwritev/smgrwriteback/smgrnblocks/smgrnblocks_cached/smgrtruncate/smgrimmedsync/smgrregistersync—HOLD_INTERRUPTS,smgrsw[reln->smgr_which]로 디스패치,RESUME_INTERRUPTS의 얇은 래퍼들.smgrdounlinkall— 버퍼를 내리고, sinval을 보내고, 각 포크에smgr_unlink호출.pgaio_io_set_target_smgr— AIO용PgAioTargetData채우기.smgr_aio_reopen— AIO 워커 재열기 콜백.AtEOXact_SMgr— 트랜잭션 종료 정리 훅.
md.c — POSIX 드라이버
섹션 제목: “md.c — POSIX 드라이버”MdfdVec(구조체) —{File mdfd_vfd, BlockNumber mdfd_segno}.MdCxt—MdfdVec배열을 위한MemoryContext.mdinit—MdCxt생성.mdopen— 모든 포크의md_num_open_segs를 0으로 초기화 (실제 파일 열기 없음).mdclose— 열린 세그먼트 FD를 역순으로 닫고 배열을 줄인다.mdopenfork(정적) — 열려 있지 않은 경우 포크의 세그먼트 0을 연다._mdfd_getseg(정적) —blocknum→MdfdVec*매핑; 없는 세그먼트를 순차적으로 연다._mdfd_openseg(정적) — 세그먼트 하나를 열고 배열을 키우고 항목을 채운다._mdfd_segpath(정적) — 세그먼트 파일 경로 문자열 생성._fdvec_resize(정적) — 세그먼트 배열 palloc/repalloc (축소 불가 — 크리티컬 섹션 내 할당 금지).mdcreate—O_CREAT|O_EXCL로 세그먼트 0 열기; 더티 등록.mdexists—EXTENSION_RETURN_NULL로mdopenfork호출.mdunlink/mdunlinkfork(정적) — 메인 포크: 0으로 잘라 지연 언링크; 다른 포크: 즉시 언링크.mdextend— EOF 이상 위치에 블록 하나 쓰기;smgr_cached_nblocks갱신.mdzeroextend—fallocate또는pwritev로 N개 블록 0 채우기.mdreadv— 동기 벡터화 읽기; 짧은 읽기 재시도 루프.mdstartreadv—PgAioHandle을 통한 비동기 벡터화 읽기.mdwritev— 동기 벡터화 쓰기; 더티 세그먼트 등록.mdwriteback—FileWriteback(커널 라이트백 힌트; fsync 없음).mdnblocks— 모든 세그먼트 순회; 부작용으로 전부 열린다.mdnblocks_cached(smgr.c 내) — 복구 중에smgr_cached_nblocks를 반환한다.mdtruncate— 마지막 세그먼트부터 역순 처리; 초과 세그먼트 비활성화 및 닫기.mdregistersync— 비활성 세그먼트 포함 모두 열어 각각을 더티로 등록.mdimmedsync— 모든 세그먼트에 즉시FileSync.mdfd— AIO 워커가 사용하는 원시 OS fd + 오프셋 반환.register_dirty_segment(정적) —RegisterSyncRequest(SYNC_REQUEST).register_unlink_segment(정적) —RegisterSyncRequest(SYNC_UNLINK_REQUEST).register_forget_request(정적) —RegisterSyncRequest(SYNC_FORGET_REQUEST).buffers_to_iovec(정적) — 연속 버퍼 포인터를 최소 iovec 수로 병합.
위치 힌트 테이블 (2026-06-05 기준, 커밋 273fe94)
섹션 제목: “위치 힌트 테이블 (2026-06-05 기준, 커밋 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
f_smgr (typedef struct) | smgr.c | 88 |
smgrsw[] | smgr.c | 128 |
SMgrRelationHash | smgr.c | 160 |
smgrinit | smgr.c | 188 |
smgropen | smgr.c | 240 |
smgrpin | smgr.c | 296 |
smgrunpin | smgr.c | 311 |
smgrdestroy | smgr.c | 323 |
smgrrelease | smgr.c | 350 |
smgrclose | smgr.c | 374 |
smgrdestroyall | smgr.c | 386 |
smgrreleaseall | smgr.c | 412 |
smgrcreate | smgr.c | 481 |
smgrextend | smgr.c | 620 |
smgrzeroextend | smgr.c | 649 |
smgrreadv | smgr.c | 721 |
smgrstartreadv | smgr.c | 753 |
smgrwritev | smgr.c | 791 |
smgrwriteback | smgr.c | 805 |
smgrnblocks | smgr.c | 819 |
smgrnblocks_cached | smgr.c | 847 |
smgrtruncate | smgr.c | 875 |
smgrregistersync | smgr.c | 940 |
smgrimmedsync | smgr.c | 974 |
AtEOXact_SMgr | smgr.c | 1017 |
ProcessBarrierSmgrRelease | smgr.c | 1027 |
pgaio_io_set_target_smgr | smgr.c | 1038 |
smgr_aio_reopen | smgr.c | 1064 |
SMgrRelationData (typedef struct) | smgr.h | 35 |
_MdfdVec (typedef struct) | md.c | 81 |
MdCxt | md.c | 87 |
register_dirty_segment (프로토) | md.c | 138 |
_fdvec_resize (프로토) | md.c | 144 |
_mdfd_segpath (프로토) | md.c | 147 |
_mdfd_openseg (프로토) | md.c | 149 |
_mdfd_getseg (프로토) | md.c | 151 |
mdinit | md.c | 180 |
mdexists | md.c | 193 |
mdcreate | md.c | 212 |
mdunlink | md.c | 327 |
mdunlinkfork | md.c | 364 |
mdextend | md.c | 477 |
mdzeroextend | md.c | 542 |
mdopenfork | md.c | 665 |
mdopen | md.c | 703 |
mdclose | md.c | 714 |
mdprefetch | md.c | 737 |
mdmaxcombine | md.c | 834 |
mdreadv | md.c | 848 |
mdstartreadv | md.c | 986 |
mdwritev | md.c | 1060 |
mdwriteback | md.c | 1165 |
mdnblocks | md.c | 1224 |
mdtruncate | md.c | 1291 |
mdregistersync | md.c | 1380 |
mdimmedsync | md.c | 1431 |
mdfd | md.c | 1484 |
register_dirty_segment | md.c | 1508 |
register_unlink_segment | md.c | 1552 |
register_forget_request | md.c | 1568 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
smgrsw[]는 REL_18_STABLE 기준으로 항목이 정확히 하나(md.c)다. README에 “자기 디스크 매니저 하나만 남아 있다”고 명시되어 있다.NSmgr = lengthof(smgrsw) = 1.smgr_which필드는smgropen()내에서 항상 0으로 설정된다. -
smgrclose()는 소멸자가 아닌smgrrelease()의 동의어다.smgr.c주석의 설명이 분명하다. “이 호출 이후 SMgrRelation 참조를 사용해서는 안 된다. 하지만smgropen()이 반환한 참조를 추적하지 않으므로 다른 참조가 남아 있는지 알 수 없다. 따라서 현재로서는smgrrelease()와 동의어다.” 파기를 원하면 내부 함수smgrdestroy()또는smgrdestroyall()을 써야 한다. -
smgr_cached_nblocks는 복구 중에만 신뢰할 수 있다.smgrnblocks_cached()는 복구 밖에서는 캐시 내용과 무관하게InvalidBlockNumber를 반환한다. 주석에 “파일 크기 변경에 대한 공유 무효화 메커니즘이 없기 때문”이라고 적혀 있다. -
메인 포크 세그먼트 0은 즉시 언링크되지 않는다.
mdunlinkfork()는 해당 파일을 0으로 잘라 빈 파일로 남기고register_unlink_segment(…, 0)으로 체크포인트 이후 삭제를 요청한다. 세그먼트 1 이상만 즉시 언링크된다. 376번 줄의 조건문으로 확인했다. -
HOLD_INTERRUPTS가 모든 smgr 진입점에 있다.smgr.c파일 헤더에 이유가 명시되어 있다.smgropen,smgrrelease,smgrdestroy,smgrextend,smgrnblocks,smgrdounlinkall에서 직접 확인했다. -
mdwriteback()은 fsync를 하지 않는다.FileWriteback()을 호출하는데, 이는 Linux의sync_file_range()또는posix_fadvise(POSIX_FADV_DONTNEED)힌트로 커널에 더티 페이지 라이트백을 요청하지만 내구성을 보장하지는 않는다. 1168번 줄의Assert((io_direct_flags & IO_DIRECT_DATA) == 0)은 O_DIRECT 경로에서는 이 함수가 호출되지 않음을 보여 준다. -
mdzeroextend()는 9블록 이상일 때만posix_fallocate를 쓴다. 595번 줄:if (numblocks > 8 && file_extend_method != FILE_EXTEND_METHOD_WRITE_ZEROS)— 일부 파일시스템에서 지연 할당을 방해하지 않기 위한 임계값이다. -
mdstartreadv()는zero_damaged_pages복구 경로를 구현하지 않는다.mdreadv()의 939번 줄 주석에 “해당 로직은 mdstartreadv()에 구현하지 않기로 결정했다”고 명시되어 있다.mdreadv()의Assert(false)는 이 코드 경로가 향후 제거될 예정임을 알린다.
미해결 사항
섹션 제목: “미해결 사항”-
RELSEG_SIZE소스 위치. (2026-06-05 해결.)RELSEG_SIZE는 configure 시점에--with-segsize옵션으로부터pg_config.h에 생성된다. 템플릿은src/include/pg_config.h.in이며(#undef RELSEG_SIZE, “RELSEG_SIZE는 디스크 파일 하나에 허용되는 최대 블록 수…RELSEG_SIZE를 바꾸려면 initdb가 필요하다”라는 주석 포함), 기본값--with-segsize=1은 기본 8 KBBLCKSZ기준 131 072 블록 → 1 GB를 산출한다. 체크아웃된 트리에서#define을 찾을 수 없는 것은pg_config.h가 빌드 아티팩트이지 커밋된 파일이 아니기 때문이다. -
mdwritev()의 세그먼트 경계 교차.mdwritev()(1092번 줄)는nblocks_this_segment != nblocks일 때elog(ERROR, "write crosses segment boundary")를 발생시킨다. 버퍼 매니저가 세그먼트를 넘는 쓰기를 절대 발행하지 않는다는 보장이 있는가?smgrmaxcombine()이RELSEG_SIZE - segoff를 반환하는 것이 그 메커니즘으로 보이는데, smgr 수준에서 이런 쓰기를 막는 부분인지 확인이 필요하다. -
smgr_aio_reopen의 인터럽트 규율. 이 함수는!INTERRUPTS_CAN_BE_PROCESSED()를 단언한다(1076번 줄). AIO 워커 인프라가 콜백 호출 전에 이를 보장하는가?storage/aio/에서pgaio_io_get_target_data호출 지점을 추적하면 확인할 수 있다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”-
다른 엔진의 VFS / 플러그인 가능 스토리지 계층. MySQL/InnoDB의
fil0fil.cc는 릴레이션 단위가 아닌 테이블스페이스 단위 세분성을 가진 유사한 파일 시스템 추상화를 구현한다. 세그먼트는 64페이지 익스텐트를 묶은 테이블스페이스 파일이다. PostgreSQL의 릴레이션별 파일 세분성은 DROP TABLE을 포크당unlink(2)한 번으로 처리할 수 있게 하는 반면, InnoDB의 테이블스페이스 패킹은 파일 내부에 페이지 수준 공간 관리를 요구한다. -
플러그인 가능 스토리지 매니저와 클라우드 스토리지.
f_smgrvtable 구조는 대안 백엔드를 고려해 설계되었다.pg_directio같은 프로젝트와 NVM/mmap기반 스토리지 논의가 이 인터페이스를 재검토하고 있다. PG18 AIO 리팩터링(storage/aio/)은 비POSIX 스토리지 매니저를 현실화하기 위한 첫 번째 의미 있는 단계다.mdstartreadv/smgr_aio_reopen이 I/O 워커가 다른 프로세스의 파일 디스크립터로 작업할 수 없다는 문제를 추상화하기 때문이다. -
Direct I/O와 io_uring.
O_DIRECT는 커널 페이지 캐시를 우회한다.io_uring(Linux 5.1 이상)은 호출당 시스템 콜 오버헤드 없이 진정한 비동기 제출을 가능하게 한다. PostgreSQL 18의io_method=io_uring경로는mdstartreadv→FileStartReadV→io_uring_prep_readv로 이어진다. 설계는storage/aio/README(PG18)에 기술되어 있다. 관련 선행 기술로는 Microsoft SQL Server의 “Scatter-Gather I/O”와 AIX/Solaris의 Oracle 비동기 I/O가 있다. 자세한 내용은postgres-aio.md참고. -
쓰기 앞 스토리지(Zheap / 플러그인 가능 힙). 테이블 AM API (
postgres-table-am.md)는 커스텀 힙이 스토리지 경로를 완전히 재정의하는 방법을 정의한다. 컬럼형 AM(예: Hydra, Citus columnar)은 자체 내부 스토리지에 자체mdreadv유사 구현을 제공하고 smgr 경로를 우회할 수 있다. 단, 인덱스 포크에 대해서는 여전히 smgr 경로를 사용한다. -
fsync 사고 (2018년). 커밋
9ccbe8f7(PostgreSQL 11)은 리눅스 커널 버그(그리고 PostgreSQL에서 오랫동안 이어진 동작)가 발견된 뒤data_sync_elevel메커니즘을 추가하고 fsync 오류 처리를 변경했다.fsync(2)실패 시 OS가 페이지의 “더티” 플래그를 제거하더라도 데이터는 내구적이지 않은데, PostgreSQL이 이를 신뢰하고 재시작 후 손상된 데이터를 그대로 서비스하는 문제였다.register_dirty_segment/ProcessSyncRequests경로가 이 시점에 점검되고 강화되었다. LWN의 “PostgreSQL’s fsync() surprise”(2018) 참고.
분석한 소스 파일
섹션 제목: “분석한 소스 파일”src/backend/storage/smgr/smgr.c(REL_18_STABLE, 커밋 273fe94)src/backend/storage/smgr/md.c(REL_18_STABLE, 커밋 273fe94)src/backend/storage/smgr/READMEsrc/include/storage/smgr.hsrc/include/common/relpath.h
관련 문서 (이 KB)
섹션 제목: “관련 문서 (이 KB)”postgres-buffer-manager.md—smgrreadv/smgrwritev를 호출하는 쪽postgres-page-layout.md— smgr이 전송하는 8 KB 페이지 형식postgres-aio.md—mdstartreadv와 연결되는 PG18 비동기 I/O 서브시스템postgres-xlog-wal.md— WAL이 fsync에smgrimmedsync/smgrregistersync사용postgres-table-am.md— smgr 위의 테이블 AM 인터페이스postgres-heap-am.md—smgrextend를 호출하는 구체적인 힙 AM
- Database System Concepts, Silberschatz 외, 7판, 10장 “Storage and File Structure”
- Database Internals, Petrov, 6장 (온디스크 페이지 관리와 파일 추상화)