콘텐츠로 이동

(KO) PostgreSQL I/O — 동기 버퍼 읽기에서 비동기 I/O로

목차:

이 서브시스템이 진화해야 했던 이유 (원래의 한계)

섹션 제목: “이 서브시스템이 진화해야 했던 이유 (원래의 한계)”

PostgreSQL은 오랫동안 데이터를 가장 단순한 방법으로 읽었다. 백엔드가 shared_buffers에 캐시되지 않은 페이지를 필요로 하면, ReadBuffer를 호출하고 버퍼 매니저가 victim 프레임을 할당한 뒤 스토리지 매니저가 릴레이션 세그먼트 파일에 단일 블로킹 pread(2)를 발행한다. 그러면 백엔드는 멈췄다 — 읽기가 완료되고 페이지 바이트가 프레임에 들어올 때까지 커널에 의해 스케줄에서 빠진다. 캐시 미스 하나, 시스템 콜 하나, 스톨 하나. 캐시 히트 경로의 메커니즘은 postgres-buffer-manager.md에 설명되어 있다.

이 설계는 정확하고, 이식 가능하며, 추론하기 쉽다. 20년간 충분히 통했던 이유는 운영체제 커널이라는 숨겨진 보조 덕분이었다. Linux(와 지원되는 모든 OS)는 자체적으로 readahead를 수행한다 — 순차적인 pread 오프셋 패턴을 감지하면 다음 청크를 투기적으로 페이지 캐시에 끌어오기 때문에, 파일이 워밍된 상태라면 seq 스캔이 물리 I/O에서 거의 블로킹되지 않는다. PostgreSQL이 순전히 동기 읽기를 발행하는 동안에도 말이다. PostgreSQL은 사실상 I/O 동시성을 커널 휴리스틱에 아웃소싱하고 있었다.

이 보조는 데이터베이스가 가장 많이 아파하는 지점에서 정확히 무너진다.

  • 랜덤 I/O. 비트맵 힙 스캔, 인덱스 스캔, redo 리플레이는 흩어진 블록을 건드린다. 커널 readahead는 랜덤 접근 패턴을 예측할 수 없으므로, 미스마다 스토리지까지 전체 왕복이 발생하고 그 시간 내내 백엔드는 유휴 상태다. 100 µs–10 ms 지연과 깊은 큐를 갖춘 장치에서, 단일 스레드로 직렬화된 동기 랜덤 읽기는 장치가 병렬로 처리할 수 있는 요청의 대부분을 허비한다.
  • 백엔드 하나 = 진행 중인 I/O 하나. 순차 스캔에서도 pread에서 블로킹된 프로세스는 요청 하나만 진행 중이다. 최신 NVMe는 피크 대역폭에 도달하려면 수십 ~ 수백 수준의 큐 깊이가 필요하다. 동기 백엔드는 그 큐를 절대 채울 수 없다.
  • 복구는 단일 스레드다. 스타트업 프로세스는 WAL 레코드를 하나씩 리플레이하고, 캐시에 없는 페이지를 수정하는 레코드를 만나면 먼저 동기로 읽어야 한다. 리플레이는 “블록 읽기(스톨), 변경 적용(저렴한 CPU)” 패턴을 반복하며 읽기 지연에 지배된다. redo 루프는 postgres-recovery-redo.md에 설명되어 있다.

데이터베이스가 원하는 해결책은 개념적으로 단순하다. 결과가 필요하기 전에 읽기를 시작해 스토리지 지연이 유용한 CPU 작업이나 다른 진행 중인 읽기와 겹치게 하는 것이다. 이것이 비동기 I/O다. 그러나 진짜 async I/O는 이식 가능하게 구현하기도 어렵고, “ReadBuffer는 핀된 유효한 페이지를 돌려준다”는 전체 계약을 가진 버퍼 풀에 꿰어 넣기도 어렵다. PostgreSQL은 단계적으로 여기에 도달했다. 각 단계는 더 어려운 배관이 아래에서 성숙하는 동안 일부 이점을 사들인다. 다음 네 시대가 그 상승을 추적하며, 메커니즘의 종점은 postgres-aio.md에 있다.

timeline
    title PostgreSQL I/O — 동기 읽기에서 비동기 I/O로
    section 9.0 이전 (기준선)
        동기 버퍼 읽기 : ReadBuffer -> 블로킹 pread(2)
                       : 미스 하나 = 스톨 하나
                       : 커널 readahead가 유일한 동시성
    section 9.0 (2010)
        posix_fadvise 프리패치 : PrefetchBuffer() bufmgr에 추가
                               : POSIX_FADV_WILLNEED 힌트 발행
                               : 비트맵 힙 스캔에 연결
                               : effective_io_concurrency로 제어
    section 15 (2022)
        복구 중 WAL 프리패치 : xlogprefetcher.c 룩어헤드
                             : redo가 건드릴 블록에 fadvise
                             : recovery_prefetch + maintenance_io_concurrency
    section 17 (2024)
        read stream 추상화 : read_stream.c 생산자/소비자
                           : 블록 번호 콜백 -> 벡터 읽기
                           : io_combine_limit 병합
                           : 적응형 룩어헤드 거리
                           : seq 스캔 / ANALYZE / VACUUM에 소급 적용
    section 18 (2025)
        비동기 I/O 서브시스템 : aio.c + PgAioHandle 상태 머신
                              : IoMethodOps vtable sync/worker/io_uring
                              : B_IO_WORKER 프로세스 (기본값)
                              : 백엔드별 io_uring 링 (Linux)
                              : read_stream이 진짜 비동기 완료 위에서 재구성

시대 0 — 동기 버퍼 읽기 (긴 기준선, 9.0 이전)

섹션 제목: “시대 0 — 동기 버퍼 읽기 (긴 기준선, 9.0 이전)”

무엇이었나. 초기 릴리스부터 8.x 라인까지 PostgreSQL이 사실상 변경 없이 유지했던 읽기 경로다. 이 형태는 REL_18에서도 폴백 형태로 여전히 존재하므로, 이후 모든 시대가 이것으로부터의 이탈로 정의되는 만큼 정확히 짚어둘 가치가 있다.

릴레이션 포크의 블록 N이 필요한 백엔드는 ReadBuffer(오늘날 src/backend/storage/buffer/bufmgr.cReadBufferExtended / ReadBuffer_common)를 호출한다. 버퍼 매니저는 다음을 수행한다.

  1. 버퍼 태그 (rel, fork, block)를 계산하고 파티션된 버퍼 매핑 해시를 탐색한다.
  2. 히트 시, 기존 프레임을 핀하고 반환한다 — I/O 없음.
  3. 미스 시, clock-sweep으로 victim 프레임을 선택(GetVictimBuffer)하고, dirty라면 먼저 플러시(WAL-before-flush 규칙 준수)한 다음 읽기를 발행한다.

읽기 자체는 스토리지 스택을 단일 블로킹 호출로 타고 내려간다. smgrreadmdreadFileRead → 세그먼트 파일 디스크립터에 대한 pread(2) 순서다(postgres-buffer-manager.md에서 핀/축출 메커니즘 참조). 호출 프로세스는 pread가 8 KB 페이지를 반환할 때까지 커널 스케줄러에 의해 대기 상태가 된다. 겹침이 없다. 백엔드는 읽기가 진행되는 동안 아무것도 하지 않고, 진행 중인 읽기는 정확히 하나다.

왜 이렇게 오래 살아남았나. 두 가지 이유가 있다. 첫째, 버퍼 풀이 워킹 셋을 흡수한다 — 캐시가 따뜻하면 대부분의 ReadBuffer 호출은 히트이고 스토리지를 전혀 건드리지 않는다. 둘째, 순차 접근에서 남는 미스에 대해서는 OS 페이지 캐시와 커널 readahead가 조용히 프리패치를 수행한다. PostgreSQL의 직렬 pread가 도달할 때쯤이면 다음 블록이 이미 커널 캐시에 있는 경우가 많다. PostgreSQL은 비동기 코드 한 줄 작성 없이 순차 읽기에서 비동기와 유사한 동작을 공짜로 얻었다.

어디서 무너졌나. 구조적 한계는 “왜 진화해야 했나” 절에 나열된 것들과 정확히 같다. 랜덤 I/O는 커널 readahead를 무력화하고, 프로세스 하나는 요청 하나만 진행할 수 있으며, 복구는 읽기-적용의 직렬 루프다. effective_io_concurrency 파라미터는 아직 존재하지 않았고, 엔진에는 “이 블록들이 곧 필요하다”고 표현할 메커니즘 자체가 없었다.

구조적 형태.

flowchart LR
    Q[백엔드<br/>스캔 실행 중] --> RB[ReadBuffer 블록 N]
    RB -->|캐시 미스| SMGR[smgrread -> mdread]
    SMGR --> PR[pread 블록 N<br/>BLOCKING]
    PR -. 백엔드 스케줄 밖<br/>유휴, 진행 중인<br/>요청 없음 .-> PR
    PR --> RET[페이지 반환]
    RET --> Q2[적용 / 블록 N 스캔]
    Q2 --> RB2[ReadBuffer 블록 N+1<br/>... 직렬 반복]

이 기준선은 REL_18에도 존재한다. io_method=sync가 환원되는 형태가 바로 이것이고, read stream이나 프리패치가 관여하지 않을 때 사용되는 경로다. 이 시대 이후의 모든 것은 현재 블록이 소비되기 전에 다음 읽기를 발행하는 것에 관한 이야기다.

시대 1 — 비트맵 힙 스캔을 위한 posix_fadvise 프리패치 (9.0)

섹션 제목: “시대 1 — 비트맵 힙 스캔을 위한 posix_fadvise 프리패치 (9.0)”

무엇이 바뀌었나. PostgreSQL 9.0은 “이 블록이 곧 필요하다”고 말할 수 있는 최초의 엔진 내 메커니즘을 추가했다 — src/backend/storage/buffer/bufmgr.cPrefetchBuffer()다. 이 함수는 페이지를 shared_buffers로 읽어 들이지 않는다. 대신 동일한 태그 조회를 수행하고 미스 시 smgrprefetchmdprefetchFilePrefetch를 호출한다. 이를 지원하는 플랫폼에서는 posix_fadvise(fd, offset, len, POSIX_FADV_WILLNEED)를 발행한다(현재도 src/backend/storage/file/fd.c에서 확인 가능). 이 advisory 호출은 커널에 “이 블록을 페이지 캐시에 올려라”고 알린다. 커널은 백엔드가 작업을 계속하는 동안 물리 읽기를 비동기로 시작한다. 나중에 백엔드가 해당 블록에 대한 실제 동기 ReadBuffer를 발행하면, 데이터가 (바라건대) 이미 OS 캐시에 올라와 있어 pread가 거의 즉시 반환된다.

왜. 동기를 부여한 워크로드는 비트맵 힙 스캔이었다. 비트맵 인덱스 스캔은 방문할 힙 블록의 정렬된 비트맵을 생성한다 — 알려진, 유한한, 랜덤 블록 번호 집합이다. 프리패치에 완벽한 케이스다. 익스큐터는 어떤 블록을 건드릴지 미리 정확히 알지만 블록들이 흩어져 있어 커널 readahead는 무용지물이다. 현재 블록을 처리하는 동안 다음 블록에 PrefetchBuffer를 발행하면, 스캔은 블록마다 직렬로 스톨하지 않고 장치에서 여러 읽기를 동시에 진행할 수 있다.

파라미터. 9.0은 effective_io_concurrency를 도입했다. 비트맵 힙 스캔이 얼마나 앞서 프리패치할지를 제한하는 GUC로, 스토리지의 실효 큐 깊이 힌트다. 값이 1이면 “한 블록 앞서 프리패치”; 높은 값은 더 많은 읽기가 겹치도록 허용한다. RAID 배열과 SSD처럼 다수의 동시 요청을 처리하는 장치에서 특히 중요하다. 이 변수는 현재도 bufmgr.c에 선언되어 있다.

int effective_io_concurrency = DEFAULT_EFFECTIVE_IO_CONCURRENCY;

유지보수 경로(vacuum류 작업)를 위한 형제 파라미터 maintenance_io_concurrency는 사용자 쿼리와 다른 깊이를 쓸 수 있도록 나중에 추가되었다.

구조적 형태 변화: 소유하지 않고 힌트만. 이것이 시대 1과 시대 2의 결정적 속성이다. PostgreSQL은 여전히 비동기 읽기를 소유하지 않는다. 커널에 힌트를 줄 뿐이다. 커널이 페이지 캐시와 실제 I/O를 소유한다. 엔진은 진행 중인 읽기에 대한 핸들이 없고, 특정 읽기를 기다리거나 실패 여부를 알 방법도 없다. WILLNEED를 발행하고 실제 읽기가 도착할 때쯤 페이지가 따뜻하길 바랄 뿐이다. 완료 경로는 시대 0과 같다 — 블로킹 pread, 다만 이제는 보통 OS 캐시에 히트한다.

flowchart LR
    subgraph Era0[시대 0: 동기]
      A0[블록 N 스캔] --> R0[ReadBuffer N<br/>블로킹 pread] --> A0b[N 적용]
      A0b --> A0c[ReadBuffer N+1<br/>블로킹 pread]
    end
    subgraph Era1[시대 1: fadvise 프리패치]
      A1[블록 N 스캔] --> P1[PrefetchBuffer N+1..N+k<br/>fadvise WILLNEED]
      P1 --> RA1[ReadBuffer N<br/>OS 캐시 히트 가능성 높음]
      RA1 --> A1b[N 적용]
      P1 -. 커널이 N+1..N+k를<br/>백그라운드에서<br/>읽어옴 .-> KC[(OS 페이지 캐시)]
      KC -. 따뜻함 .-> RA1
    end

앞으로 이어진 한계. 프리패치가 advisory이므로 효과를 예측할 수 없다. 커널이 실제 읽기 전에 프리패치한 페이지를 축출할 수도 있고, posix_fadvise가 없는 플랫폼에서는 힌트가 no-op이며, 이중 읽기(커널이 읽고, 축출하고, 다시 읽음)가 발생할 수 있다. 9.0에서는 비트맵 힙 스캔에만 적용되었다. 순차 스캔, 인덱스 스캔, ANALYZE, VACUUM은 여전히 완전 동기로 실행되었다. 이 경로들에 프리패치를 일반화하는 것이 바로 시대 3과 4가 하는 일이다. 현재 프리패치 진입점은 postgres-buffer-manager.md에 설명되어 있다.

시대 2 — 복구 중 WAL 프리패치 (15)

섹션 제목: “시대 2 — 복구 중 WAL 프리패치 (15)”

무엇이 바뀌었나. PostgreSQL 15는 랜덤 사용자 쿼리 다음으로 가장 아팠던 곳인 크래시 복구, PITR, 스탠바이 리플레이에 프리패치 아이디어를 적용했다. 새로운 메커니즘은 src/backend/access/transam/xlogprefetcher.c(XLogPrefetcherLsnReadQueue)에 있다. 메커니즘은 postgres-recovery-redo.md에 문서화되어 있다. 여기서는 등장했는지와 어떤 형태를 추가했는지를 추적한다.

왜. Redo는 단일 스레드이며 읽기가 병목이다. 스타트업 프로세스는 WAL 레코드 하나를 디코딩하고, 수정할 블록을 찾고, 캐시에 없는 블록을 동기로 읽고, 변경을 적용하고, 계속 진행한다. 캐시에 없는 블록마다 직렬 스톨이 발생한다. 비트맵 스캔과 달리 룩어헤드가 전혀 없었다 — 스타트업 프로세스는 어떤 블록이 필요한지를 필요한 그 순간에야 알았다. 바쁜 프라이머리를 따라잡으려는 스탠바이나 긴 크래시 복구 창에서, 이 직렬 읽기-적용 루프는 데이터베이스가 복구되는 속도와 레플리카 지연을 결정하는 병목이다.

어떻게. WAL 스트림 자체가 완벽한 프리패치 오라클이다. 가까운 미래에 어떤 블록이 어떤 순서로 건드려질지가 정확히 기록되어 있기 때문이다. 프리패처는 리플레이 위치보다 앞서 레코드를 디코딩(아직 적용되지 않은 WAL에 대한 룩어헤드 윈도우)하고, 다가오는 각 레코드가 참조하는 블록을 추출하며, 해당 블록들에 대한 읽기 힌트를 발행해 스타트업 프로세스가 이전 레코드를 적용하는 동안 커널이 블록들을 캐시에 끌어오게 한다. 레코드가 생성하려는 블록(예: 릴레이션 확장이나 전체 페이지를 덮어쓰는 full-page 이미지)은 낭비적인 I/O를 피하기 위해 프리패치하지 않도록 필터를 유지한다.

파라미터. 두 GUC가 이를 제어하며, 둘 다 REL_18에도 남아 있다.

  • recovery_prefetchtry(기본값), on, off. xlogprefetcher.c의 헤더에 동시성 파라미터와 결합된 게이팅 조건이 있다.
  • maintenance_io_concurrency — 여기서는 룩어헤드 깊이로 재사용된다. 프리패처는 이 수만큼의 동시 읽기보다 앞서지 않는다.
/* from xlogprefetcher.c */
int recovery_prefetch = RECOVERY_PREFETCH_TRY;
/* ... enabled when recovery_prefetch != OFF && maintenance_io_concurrency > 0 */

새 뷰 pg_stat_recovery_prefetch는 카운터(prefetch, hit, skip_*)를 노출해 운영자가 룩어헤드가 실제로 읽기를 절약하는지 확인할 수 있게 한다.

구조적 형태 변화. 시대 2는 시대 1과 같은 힌트만, 소유하지 않음 모델이다. advisory 프리패치 힌트를 발행할 뿐 실제 비동기 읽기를 소유하지 않는다. 그러나 두 가지를 일반화한다. 첫째, 프리패치를 익스큐터에서 꺼내 복구/redo 서브시스템으로 옮겨 사용자 쿼리를 넘어서 패턴이 유효함을 증명한다. 둘째, 더 중요하게는 명시적이고 재사용 가능한 룩어헤드 엔진(LsnReadQueue)을 도입한다. 미래 작업을 디코딩하는 생산자와 그것을 적용하는 소비자, 그 사이의 유계 거리다. 이 생산자/소비자-유계-거리 구조가 바로 PG17이 read stream으로 일반화할 형태다. WAL 프리패치는 돌이켜보면 read stream 추상화를 위한 리허설이었다.

앞으로 이어진 한계. 두 가지 큰 한계가 있다. (1) 여전히 커널 advisory 프리패치다 — 핸들 없음, 실제 완료 없음, 시대 1과 같은 불예측성. (2) 룩어헤드 엔진이 복구 전용으로 특화되어 있다. 익스큐터의 순차 스캔과 인덱스 스캔에는 여전히 동등한 메커니즘이 없었다. 두 한계 모두 다음 두 시대에서 해소된다.

무엇이 바뀌었나. PostgreSQL 17은 새로운 src/backend/storage/aio/ 디렉토리에 read_stream.c를 도입했다 — 재사용 가능한 생산자/소비자 헬퍼로, “룩어헤드 및 프리패치”를 두 개의 특화된 호출 지점(비트맵 스캔, 복구)에서 꺼내 모든 스캔이 채택할 수 있는 추상화로 일반화했다. 전체 메커니즘은 postgres-aio.md에 있다. 아크 관점의 핵심은 PG17이 어떤 블록이 필요한지어떻게 읽는지로부터 분리했다는 것이다.

형태. 소비자는 read_stream_begin_relation()(또는 _begin_smgr_relation())으로 스트림을 생성하고, 호출될 때마다 소비자가 원하는 다음 블록 번호(또는 스트림 종료를 위한 InvalidBlockNumber)를 반환하는 콜백을 전달한다. 소비자는 이후 루프에서 read_stream_next_buffer()만 호출하면 순서대로 핀된 버퍼를 받는다 — 정확히 ReadBuffer 계약이지만 읽기가 이미 겹쳐진다. 내부적으로 스트림은 다음을 수행한다.

  • 다가오는 블록을 발견하기 위해 소비보다 앞서 콜백을 실행하고, 유계 룩어헤드 거리를 유지한다(WAL 프리패처가 개척한 생산자/소비자-유계-거리 구조와 동일).
  • 이웃 블록들을 최대 io_combine_limit(PG17의 새 GUC, 기본값 128 KB)의 단일 벡터 읽기로 병합한다. 인접한 블록들의 연속이 여러 번의 읽기 대신 하나의 preadv형 I/O가 된다.
  • effective_io_concurrency / maintenance_io_concurrency에서 파생된 max_ios동시성을 제한해 여러 읽기가 동시에 진행될 수 있게 한다.
  • 최근 히트/미스 이력에 따라 룩어헤드 거리를 조정한다. 블록이 shared_buffers에 계속 히트하면 윈도우를 줄이고(캐시된 데이터를 프리패치할 이유 없음), 계속 미스하면 max_ios 방향으로 윈도우를 키운다.
/* read_stream.h — 소비자 대면 API (REL_18) */
extern ReadStream *read_stream_begin_relation(int flags, ...);
extern Buffer read_stream_next_buffer(ReadStream *stream, void **per_buffer_data);
#define READ_STREAM_SEQUENTIAL 0x02 /* 힌트: 순차 접근 */

왜 또 다른 호출 지점이 아닌 추상화인가. PG16까지 코드베이스에는 두 개의 독립적인 프리패치 메커니즘(익스큐터 비트맵 프리패치, 복구 WAL 프리패치)이 있었고, 프리패치가 없는 스캔 목록(순차 스캔, ANALYZE 샘플링, VACUUM 힙 패스)이 길었다. 각각에 fadvise 룩어헤드를 수작업으로 코딩하는 것은 유지 불가능했고 여전히 “힌트만, 소유하지 않음”의 약점을 남겼다. 룩어헤드, 병합, 동시성을 소유하는 단일 헬퍼는 모든 소비자가 잘 튜닝된 하나의 엔진에서 이익을 얻게 한다. 결정적으로, 나중에 실제 비동기 I/O로 재구성할 하나의 초크포인트를 프로젝트에 제공한다. 이것이 전략적 수이다. PG17의 read stream은 내부적으로 여전히 구 프리패치-후-동기-읽기 메커니즘 위에서 실행됐지만, 전체 엔진을 안정적인 API 뒤에 두어 PG18이 소비자를 건드리지 않고 아래를 교체할 수 있게 했다.

소급 적용. PG17은 명백한 순차 소비자들을 스트림으로 전환했다. 순차 힙 스캔, ANALYZE 블록 샘플링, VACUUM 힙 스캔의 일부가 read_stream_* 호출로 이동했다(힙-AM 참조는 여전히 src/backend/access/heap/heapam.c, heapam_handler.c, vacuumlazy.c에 있다). seq 스캔의 경우 병합이 많은 8 KB 읽기를 소수의 큰 벡터 읽기로 바꾸고, 룩어헤드가 순전히 커널 readahead에 의존하지 않고 장치를 바쁘게 유지한다.

구조적 형태 변화: 오라클과 엔진을 분리.

flowchart TB
    subgraph Before[PG17 이전: 소비자별 특화 프리패치]
      BHS[비트맵 힙 스캔<br/>자체 fadvise 루프] --> K1[(커널<br/>readahead)]
      REC[복구 redo<br/>자체 LsnReadQueue] --> K1
      SEQ[seq 스캔 / ANALYZE / VACUUM<br/>프리패치 없음] --> K1
    end
    subgraph After[PG17: 하나의 read_stream 엔진]
      CB1[seq 스캔 콜백] --> RS[ReadStream<br/>룩어헤드 + 병합<br/>+ max_ios]
      CB2[ANALYZE 콜백] --> RS
      CB3[VACUUM 콜백] --> RS
      RS --> COMB[벡터 읽기<br/>최대 io_combine_limit]
      COMB --> K2[(PG17에서는<br/>여전히 동기 읽기<br/>기반)]
    end

앞으로 이어진 한계 — 하지만 이제 격리됨. PG17에서 read stream은 I/O를 더 잘 정리하는 도구이지만, 스택 하단은 여전히 같다. 실제 읽기는 근본적으로 동기(fadvise형 힌팅 포함)인데, PostgreSQL에 아직 1급 비동기 I/O 엔진이 없었기 때문이다. 시대 1–2와의 결정적 차이는 이 한계가 이제 한 곳에 집중되어 있다는 점이다. PG18의 역할은 그 하단을 실제 비동기 완료로 교체하는 것이다. 소비자는 read_stream_next_buffer만 호출하므로 공짜로 그 혜택을 받는다.

시대 4 — 비동기 I/O 서브시스템 (18)

섹션 제목: “시대 4 — 비동기 I/O 서브시스템 (18)”

무엇이 바뀌었나. PostgreSQL 18은 이전 세 시대가 모두 대역을 맡았던 것, 즉 서버가 종단 간 소유하는 1급 비동기 I/O 서브시스템을 추가했다. 핵심은 src/backend/storage/aio/aio.c와 관련 파일들에 있다. 이제 PostgreSQL은 posix_fadvise 힌트를 발행하고 바라는 것이 아니라, 진짜 비동기 읽기를 직접 발행하고, 공유 메모리의 명시적 상태 머신으로 추적하며, 직접 완료한다. 전체 메커니즘은 postgres-aio.md에 있다. 이 절은 구조적 도약을 추적하고 각 부분을 이전 시대가 필요로 했던 이유와 연결한다.

중심 객체: PgAioHandle. 시대 1–2에서는 진행 중인 읽기에 대한 핸들이 전혀 없었지만, PG18은 각 작업을 공유 메모리에 있는 PgAioHandle로 표현하고, 8단계 생명주기를 거쳐 진행시킨다.

IDLE -> HANDED_OUT -> DEFINED -> STAGED -> SUBMITTED
-> COMPLETED_IO -> COMPLETED_SHARED -> COMPLETED_LOCAL

백엔드는 핸들을 획득(pgaio_io_acquire)하고, 버퍼 매니저와 하위 레이어가 완료 콜백을 등록하며 pgaio_io_start_readv()로 작업을 정의한다. 핸들은 플러그인형 메서드로 staged 및 submitted된다. 핸들이 공유 메모리에 있으므로 PgAioWaitRef(재사용된 핸들과 원본을 구별하는 세대 카운터 포함)로 어떤 프로세스도 I/O를 기다릴 수 있다 — 커널 힌트 모델이 절대 표현할 수 없었던 것이다. 완료 콜백은 함수 포인터가 아닌 작은 정수 ID로 식별된다. EXEC_BACKEND ASLR이 공유 메모리 함수 포인터를 금지하기 때문이다. 에러는 압축된 PgAioResult에 담겨 발행 백엔드에서 다시 발생된다.

플러그인형 엔진: IoMethodOps vtable. 결정적 설계 선택은 비동기 I/O 메커니즘이 vtable(IoMethodOps) 뒤에 추상화된다는 것이다. PGC_POSTMASTER GUC인 io_method로 선택한다. 세 가지 구현이 제공된다.

  • sync — 실제 비동기 I/O 없음. IO를 submit하면 인라인으로 동기 수행된다. 시대 0이 폴백/디버그 메서드로 재탄생한 것으로, 추상화가 건전함을 증명한다. 같은 read_stream 소비자가 하단이 동기든 비동기든 수정 없이 실행된다.
  • worker기본값. 발행 백엔드가 공유 메모리 submission 큐로 IO를 전용 I/O 워커 프로세스 풀에 넘긴다. 워커가 (블로킹) preadv를 수행하고 완료를 신호한다. 커널 비동기 API가 필요 없이 추가 프로세스만 있으면 되므로 모든 플랫폼에서 실제 겹침을 제공한다. 새 프로세스 타입은 B_IO_WORKER(src/include/miscadmin.h), 수는 io_workers GUC로 설정하며, 구현은 method_worker.c다.
  • io_uring — Linux 5.1+ 이상에서 커널의 io_uring 인터페이스를 사용해 진정한 인커널 비동기 submission/completion을 제공한다. 백엔드당 하나의 링이 공유 메모리에 상주하므로 모든 백엔드가 완료를 드레인할 수 있다(method_io_uring.c). 가장 낮은 오버헤드 경로다. 추가 프로세스, preadv 스레드 홉 없이 커널이 비동기 작업을 수행한다.
/* guc_tables.c — 엔진을 전환하는 파라미터 (REL_18) */
{"io_method", PGC_POSTMASTER, RESOURCES_IO, ...}, /* sync|worker|io_uring */
{"io_workers", ...}, /* B_IO_WORKER 프로세스 수 (worker 메서드) */
{"io_max_concurrency", ...}, /* 백엔드당 최대 진행 중인 IO 수 */

왜 io_uring이 아닌 worker가 기본값인가. 이식성과 안전성 때문이다. io_uring은 Linux 전용이고 보안 및 커널 버전 이력이 불안정했으므로 범용 기본값이 될 수 없다. worker 메서드는 프로세스와 공유 메모리만 있으면 되므로 모든 지원 플랫폼에서 즉시 실제 비동기 I/O를 제공하며, io_uring은 사용할 수 있는 이들을 위한 옵트인이다. sync는 디버깅과 비동기가 불필요한 환경을 위해 이전 동작을 유지한다. 15년간 fadvise로 힌트를 발행했던 “하나의 OS 기본 요소에 엔진 전체를 걸지 않는다”는 본능과 같다 — 하지만 이제 추상화가 내부에 있으므로 소비자는 어떤 메서드가 활성화됐는지 볼 수 없다.

성과: read_stream 재구성. PG17이 모든 것을 read_stream_next_buffer 뒤에 숨긴 이유가 여기서 드러난다. PG18에서 read stream은 더 이상 fadvise 힌트를 발행하고 동기 읽기를 수행하지 않는다. PgAioHandle을 획득하고 선택된 메서드로 진짜 비동기 읽기를 submit한 다음, 완료가 도착할 때 버퍼를 돌려준다. PG17에서 스트림을 채택한 모든 소비자 — 순차 스캔, ANALYZE, VACUUM — 는 소비자 코드 한 줄 변경 없이 PG18에서 실제 비동기 I/O를 얻는다. 내기는 성과를 냈다. 하나의 초크포인트, 아래에서 교체됐다.

구조적 형태 변화: 힌트에서 소유된 완료로.

flowchart TB
    subgraph PG17[PG17 read stream: 힌트 + 동기 읽기]
      C17[read_stream_next_buffer] --> RS17[ReadStream<br/>룩어헤드, 병합]
      RS17 --> H17[fadvise WILLNEED]
      H17 --> KC17[(OS 페이지 캐시)]
      RS17 --> SR17[동기 preadv<br/>캐시 히트 가능성 높음]
    end
    subgraph PG18[PG18 read stream: 소유된 비동기 I/O]
      C18[read_stream_next_buffer] --> RS18[ReadStream<br/>룩어헤드, 병합]
      RS18 --> AH[PgAioHandle 획득<br/>start_readv + 콜백]
      AH --> VT{IoMethodOps<br/>vtable}
      VT -->|worker| WK[B_IO_WORKER 프로세스<br/>블로킹 preadv]
      VT -->|io_uring| IU[io_uring 링<br/>커널 비동기]
      VT -->|sync| SY[인라인 동기<br/>폴백]
      WK --> CB[완료 콜백<br/>공유 버퍼 채움]
      IU --> CB
      SY --> CB
      CB --> C18
    end

이것이 최종적으로 무엇을 고치는가. 기준선의 모든 한계가 이제 엔진 내에서 해결 가능하다. 랜덤 읽기는 advisory 힌트가 아닌 실제 핸들로 다수 동시에 발행할 수 있다. 단일 백엔드가 NVMe 큐를 채우기 위해 io_max_concurrency개의 읽기를 진행 중으로 유지할 수 있다. 읽기 경로는 더 이상 커널 readahead 휴리스틱이나 posix_fadvise 존재 여부에 의존하지 않는다. 완료가 관찰되므로 에러가 올바르게 전파되고 이중 읽기가 사라진다. 복구의 WAL 프리패치(시대 2)와 비트맵/순차 소비자들은 시간이 지남에 따라 모두 특화된 fadvise 루프를 유지하는 대신 같은 소유된-비동기 기반 위로 마이그레이션할 수 있다.

쓰기 측에 대한 참고. 위의 시대들은 사용자 가시적 지연이 존재했고 PG18 작업이 집중된 읽기 경로를 추적한다. 동일한 PgAioHandle 메커니즘이 쓰기도 처리하도록 설계되어 있어(pgaio_io_start_writev), 체크포인터/백그라운드-라이터 플러시가 비동기 기반 위에서 실행될 수 있다. 세부 사항은 postgres-aio.md에 있다. 이 문서가 추적하는 아크는 읽기이지만, 서브시스템 자체는 범용이다.

REL_18(커밋 273fe94, PG 18.x) 시점에서 PostgreSQL의 읽기 경로는 이전 시대의 코드가 모두 내부에서 볼 수 있는 계층형 스택이다.

  • 소비자read_stream_next_buffer()를 호출하고 순서대로 핀된 버퍼를 받는다 — 순차 스캔, ANALYZE, VACUUM 및 늘어나는 다른 것들. 읽기가 어떻게 일어나는지 모른다. 엔진과 API: src/backend/storage/aio/read_stream.c, postgres-aio.md에 문서화.
  • read stream은 각 소비자의 블록 번호 콜백을 병합된 벡터 읽기(최대 io_combine_limit)로 변환하고, effective_io_concurrency / maintenance_io_concurrency로 동시성을 제한하며, 히트/미스 이력에 따라 룩어헤드를 조정한다.
  • AIO 코어(src/backend/storage/aio/aio.c)는 각 읽기를 io_method로 선택된 IoMethodOps vtable을 거쳐 PgAioHandle로 발행한다. worker(기본값, method_worker.cB_IO_WORKER 프로세스 사용), io_uring(Linux, method_io_uring.c), 또는 sync(시대 0 폴백).
  • 버퍼 매니저(src/backend/storage/buffer/bufmgr.c)는 여전히 프레임 할당, 핀 관리, clock-sweep 축출, WAL-before-flush 규칙을 소유한다 — postgres-buffer-manager.md 참조. 시대 1의 PrefetchBuffer / effective_io_concurrency도 남아 있다.
  • 복구(src/backend/access/transam/xlogprefetcher.c)는 여전히 recovery_prefetch + maintenance_io_concurrency로 게이팅된 시대 2 WAL 프리패처를 탑재하고 있으며, 관찰 가능성을 위한 pg_stat_recovery_prefetch도 함께 — postgres-recovery-redo.md 참조.

결과: 동기 기준선(시대 0)은 io_method=sync로만 남는다. advisory 힌트 모델(시대 1–2)은 아직 마이그레이션되지 않은 곳에 남아 있다. 전략적 추상화(시대 3)는 PG18의 도약(시대 4)이 모든 스캔을 재작성하지 않고 기반을 교체할 수 있게 만든 것이다. PostgreSQL은 20년간 커널에서 빌려 쓰던 I/O 동시성 기본 요소를 마침내 직접 소유한다.

다음 단계 (PG19, 방금 릴리스된 전방 메모). PG18 서브시스템은 의도적으로 읽기 경로를 먼저 착지시키고 쓰기, 더 많은 소비자, direct I/O 통합은 후속 작업으로 남겼다. PG19 방향의 자연스러운 흐름은 추가 읽기 지점을 read stream으로 마이그레이션하고, 쓰기 경로(체크포인터/bgwriter)로 소유된-비동기를 확장하며, io_uring과 direct I/O를 성숙시키는 것이다. 이것은 전방 포인터로만 다루며, 현재 REL_18 동작이 아니다.

릴리스 노트 / 프로젝트 문서

  • PostgreSQL 릴리스 노트: 9.0(PrefetchBuffer / effective_io_concurrency, 비트맵 힙 스캔 프리패치), 15(복구 프리패치, recovery_prefetch, pg_stat_recovery_prefetch), 17(read stream, io_combine_limit, seq 스캔/ANALYZE/VACUUM 전환), 18(비동기 I/O, io_method, io_workers, I/O 워커 프로세스, io_uring).
  • src/backend/storage/aio/README.md (AIO 설계 개요, 인트리).

현재 상태 모듈 문서 (메커니즘 — 여기서 재도출하지 않음)

  • postgres-aio.md — PgAioHandle 생명주기, IoMethodOps vtable, worker / io_uring / sync 메서드, read_stream 내부.
  • postgres-buffer-manager.md — 프레임 할당, 핀/축출, clock-sweep, WAL-before-flush, PrefetchBuffer.
  • postgres-recovery-redo.md — redo 루프, XLogPrefetcher / LsnReadQueue, 복구 프리패치 게이팅.

핵심 소스 파일 (REL_18, 커밋 273fe94에서 관찰)

  • src/backend/storage/aio/aio.c — AIO 코어, 핸들 상태 머신.
  • src/backend/storage/aio/read_stream.c — read stream 생산자/소비자.
  • src/backend/storage/aio/method_worker.c — worker 메서드, B_IO_WORKER.
  • src/backend/storage/aio/method_io_uring.c — io_uring 메서드 (Linux).
  • src/backend/storage/buffer/bufmgr.cReadBuffer, PrefetchBuffer, effective_io_concurrency.
  • src/backend/storage/file/fd.cFilePrefetch / posix_fadvise.
  • src/backend/access/transam/xlogprefetcher.c — 복구 중 WAL 프리패치.