PostgreSQL 운영 글을 쓰다 보면 디스크 읽기가 느린 환경에서 순차 스캔과 VACUUM이 CPU는 놀면서 I/O 대기만 쌓이는 경우를 자주 만난다. PostgreSQL 18이 정식으로 나오면서 이 대기를 줄일 수 있는 비동기 I/O 서브시스템이 들어왔고, 리눅스에서는 io_uring까지 쓸 수 있게 됐다. RDS가 아니라 EC2나 온프레미스에서 직접 운영하는 클러스터라면 설정 한두 줄로 체감되는 자리라 정리해 둔다.
결론부터 말하자면, PostgreSQL 18에서 io_method를 io_uring으로 바꾸면 백엔드가 읽기 요청을 여러 개 미리 큐에 넣어 처리한다. 순차 스캔, 비트맵 힙 스캔, VACUUM처럼 큰 덩어리를 읽는 작업에서 I/O 대기가 줄어든다. 다만 io_uring 값은 PostgreSQL을 --with-liburing으로 빌드한 경우에만 쓸 수 있고, 기본값은 worker다.
1. io_method 세 가지 값과 io_uring을 쓰려면
비동기 I/O(asynchronous I/O)는 디스크 읽기를 요청한 뒤 결과를 기다리지 않고 다음 작업을 이어 하다가, 데이터가 준비되면 받아 가는 방식이다. 17까지의 PostgreSQL은 힙 블록을 하나씩 동기적으로 읽어 매번 디스크 응답을 기다렸다. 18의 io_method는 이 읽기를 비동기로 돌리는 세 가지 모드를 고른다.
sync: 비동기로 처리할 수 있는 I/O도 종전처럼 동기로 실행한다.worker: 별도 I/O 워커 프로세스가 비동기로 처리한다. 기본값이다.io_uring: 리눅스 커널의 io_uring 인터페이스로 백엔드가 직접 비동기 I/O를 제출한다. 빌드에--with-liburing(meson은-Dliburing)이 들어가 있어야 한다.
이 파라미터는 서버 기동 시점에만 설정된다. 실행 중에 SET으로 바꿀 수 없고 postgresql.conf에 적은 뒤 재시작해야 한다.
내 빌드가 io_uring을 지원하는지부터 봐야 한다. PGDG 패키지나 직접 빌드한 바이너리가 liburing을 켜고 컴파일됐는지는 pg_config로 확인한다.
$ /usr/pgsql-18/bin/pg_config --configure | tr ' ' '\n' | grep -i uring '--with-liburing'
이 줄이 안 나오면 그 바이너리로는 io_uring을 못 쓴다. io_method=io_uring으로 적어 두고 재시작하면 서버가 기동에 실패하면서 로그에 해당 방식이 이 빌드에서 지원되지 않는다는 메시지를 남긴다. 그럴 땐 liburing을 켠 패키지를 쓰거나 소스를 다시 빌드해야 한다. 참고로 io_uring은 리눅스 전용이라 macOS나 윈도우에서는 worker나 sync만 가능하다.
2. 16에서 18로, pg_upgrade --swap으로 올리기
비동기 I/O는 18 기능이니 먼저 메이저 업그레이드가 필요하다. 18에는 pg_upgrade에 --swap 옵션이 새로 생겼다. 기존 데이터 디렉터리의 파일을 복사하거나 clone, link하지 않고 디렉터리 자체를 맞바꾼다. 공식 릴리스 노트는 이 방식을 가장 빠를 수 있는 모드라고 적는다.
맞바꾸기 전에 항상 --check로 먼저 검사한다. 이 단계는 실제 데이터를 건드리지 않는다.
$ /usr/pgsql-18/bin/pg_upgrade \
--old-datadir /var/lib/pgsql/16/data \
--new-datadir /var/lib/pgsql/18/data \
--old-bindir /usr/pgsql-16/bin \
--new-bindir /usr/pgsql-18/bin \
--check
Performing Consistency Checks
-----------------------------
Checking cluster versions ok
Checking database user is the install user ok
...
*Clusters are compatible*
검사를 통과하면 --check를 빼고 --swap을 붙여 실제로 올린다.
$ /usr/pgsql-18/bin/pg_upgrade \
--old-datadir /var/lib/pgsql/16/data \
--new-datadir /var/lib/pgsql/18/data \
--old-bindir /usr/pgsql-16/bin \
--new-bindir /usr/pgsql-18/bin \
--swap
...
Upgrade Complete
----------------
여기서 걸리는 지점이 하나 있다. --swap은 구 데이터 디렉터리의 파일을 신 클러스터로 옮겨 버리기 때문에, 업그레이드가 끝나면 예전 16 클러스터로 되돌아갈 수 없다. link 모드도 구 클러스터를 못 쓰게 만들지만 swap은 디렉터리 구조 자체를 바꾸므로 더 돌이키기 어렵다. 그래서 나는 --swap을 쓰기 전에 베이스 백업이나 스냅샷을 반드시 하나 떠 두고 시작한다. 되돌릴 여지를 남기고 싶으면 --swap 대신 --link나 복사 모드가 맞는 상황도 있다.
3. io_uring 켜고 실제로 올라왔는지 보기
18로 올라온 클러스터에서 postgresql.conf에 다음을 적는다.
io_method = io_uring # worker 모드에서 워커 수를 조정하려면 아래를 쓴다(io_uring에서는 영향 없음) # io_workers = 3 # 한 프로세스가 동시에 던질 수 있는 I/O 개수. -1은 shared_buffers 등에서 자동 산정 # io_max_concurrency = -1 # I/O를 묶는 최대 크기. 기본 128kB # io_combine_limit = 128kB
io_workers는 기본값 3이고 io_method=worker일 때만 의미가 있다. io_uring을 고르면 워커 프로세스 대신 백엔드가 직접 제출하므로 이 값은 무시된다. 적어 두고 다음 재시작으로 반영한다.
$ sudo systemctl restart postgresql-18 $ psql -c "SHOW io_method;" io_method ----------- io_uring (1 row)
값이 io_uring으로 보이면 기동에 성공한 것이다. 실행 중인 비동기 I/O 핸들은 18에 새로 생긴 pg_aios 뷰로 볼 수 있다. 부하가 없을 때는 비어 있는 게 정상이고, 큰 순차 스캔이나 VACUUM을 돌리는 동안 조회하면 진행 중인 I/O가 잡힌다.
$ psql -c "SELECT pid, io_method, operation, state FROM pg_aios LIMIT 5;"
누적 통계는 pg_stat_io에서 본다. 18에서 비동기 I/O 관련 집계가 이 뷰로 들어왔다. 읽기 바이트와 대기 지표를 업그레이드 전후로 비교하면 효과를 가늠할 수 있다.
$ psql -c "SELECT backend_type, object, context, reads, read_bytes FROM pg_stat_io WHERE reads > 0 ORDER BY reads DESC LIMIT 5;"
4. 순차 스캔과 VACUUM 읽기가 달라지는 지점
io_uring을 켰다고 모든 쿼리가 빨라지지는 않는다. 효과가 나오는 건 디스크에서 많은 블록을 연속으로 읽는 작업이다. 캐시에 다 올라온 작은 테이블은 애초에 디스크를 안 치므로 차이가 거의 없다.
순차 스캔의 선읽기 정도는 effective_io_concurrency가, VACUUM 같은 유지보수 작업은 maintenance_io_concurrency가 좌우한다. 18의 비동기 I/O는 fadvise()가 없는 환경에서도 이 값들을 0보다 크게 줄 수 있게 해 준다. 읽기 지연이 큰 스토리지라면 세션 단위로 올려 보고 체감을 확인한다.
$ psql
=# SET effective_io_concurrency = 32;
=# EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE amount > 0;
QUERY PLAN
----------------------------------------------------------------
Aggregate (cost=... rows=1 ...)
-> Seq Scan on orders (cost=... rows=... )
Filter: (amount > 0)
...
I/O Timings: shared read=...
Execution Time: ...
나는 같은 쿼리를 io_method=sync와 io_uring 두 번 돌려 I/O Timings의 read 값을 비교한다. 단 비교 전에 OS 페이지 캐시를 비우지 않으면 두 번째 실행이 캐시를 타서 공정하지 않다. 측정값은 스토리지 대역폭과 테이블 크기에 크게 좌우되는데, NVMe보다 EBS gp3처럼 지연이 있는 볼륨에서 비동기 전환의 이득이 더 또렷하게 보인다. 환경에 따라 다르지만 디스크 바운드 순차 스캔에서 읽기 대기가 눈에 띄게 줄어드는 수준을 기대할 수 있다.
VACUUM도 같은 원리다. maintenance_io_concurrency를 올리고 큰 테이블을 수동 VACUUM 돌리면서 pg_stat_progress_vacuum으로 진행을 보면 읽기 단계가 빨라지는 걸 확인할 수 있다.
5. 덤으로 따라오는 B-tree skip scan
18로 올리면 비동기 I/O와 별개로 B-tree skip scan도 같이 들어온다. 복합 인덱스에서 앞쪽 컬럼에 조건이 없어도 뒤쪽 컬럼 조건으로 인덱스를 탈 수 있게 해 준다. 17까지는 (tenant_id, created_at) 인덱스가 있을 때 WHERE created_at > ...처럼 tenant_id 조건이 빠지면 인덱스를 못 쓰고 순차 스캔으로 떨어지는 경우가 많았다.
=# CREATE INDEX idx_orders_tc ON orders (tenant_id, created_at); =# EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE created_at > now() - interval '1 day';
18에서는 선두 컬럼인 tenant_id의 서로 다른 값을 옵티마이저가 건너뛰며(skip) 인덱스를 훑어, 같은 쿼리가 순차 스캔 대신 인덱스 스캔으로 풀릴 수 있다. 다만 선두 컬럼의 고유값이 매우 많으면 건너뛸 지점도 많아져 이득이 줄어든다. 그래서 켜졌는지 믿지 말고 EXPLAIN으로 실제 계획이 인덱스를 타는지 눈으로 확인하는 편이 맞다. 전용 인덱스를 새로 만들지 않고도 기존 복합 인덱스의 쓸모가 넓어지는 자리라 반갑다.
io_uring으로 바꾼 뒤에는 커널 쪽도 한 번 봐 두면 좋다. io_uring은 커널 기능이라 오래된 커널에서는 비활성화됐거나 보안 정책으로 막혀 있을 수 있다. 운영 서버라면 worker 모드로 시작해 안정성을 확인한 다음 io_uring으로 넘어가는 순서가 더 마음 편하다.