6.2 성능 특성과 Tail Latency
Neon compute는 로컬 디스크에 데이터 파일을 두지 않습니다. shared_buffers와 로컬 파일 캐시(LFC)에 없는 페이지는 네트워크 너머의 pageserver에서 가져옵니다. 이 차이는 평균 지연보다 tail latency에서 더 크게 드러납니다. 또한 어떤 workload가 Neon에 적합한지를 결정합니다. 이 장에서는 읽기와 쓰기 경로를 계층별로 나눠 지연이 발생하는 지점을 설명합니다. 이어서 vanilla PostgreSQL과 공정하게 비교하는 벤치마크 절차를 정리합니다.
읽기 경로의 세 계층
| 계층 | 위치 | 크기 기준 | 미스 시 다음 단계 |
|---|---|---|---|
| shared_buffers | compute 메모리 | compute 설정값 | LFC |
| LFC (Local File Cache) | compute 로컬 SSD | compute RAM의 최대 75% | pageserver |
| Pageserver | 별도 노드 | 현재 상태와 설정된 history window 안의 이력 (layer 파일) | 필요 시 object storage에서 layer 다운로드 |
Pageserver와 object storage가 보관하는 이력은 무기한이 아닙니다. GC와 retention 정책이 허용하는 범위, 즉 현재 상태와 history window 안의 layer만 남습니다.
Vanilla PostgreSQL에서 shared_buffers 미스가 발생하면 OS page cache나 로컬 디스크에서 읽습니다. 이 지연은 저장장치 종류(OS cache, NVMe, HDD, 네트워크 블록 스토리지)와 queue 상태에 따라 마이크로초부터 밀리초 이상까지 벌어지므로 일반값을 인용하는 대신 대상 장비에서 직접 측정합니다. 같은 쿼리의 EXPLAIN (ANALYZE, BUFFERS)와 fio 결과를 함께 기록하면 비교 기준이 됩니다. Neon에서 LFC 미스가 발생하면 GetPage@LSN 요청을 pageserver로 보내 네트워크를 왕복합니다. Pageserver가 요청한 LSN의 페이지를 즉시 보유하고 있으면 빠르게 응답합니다. 그러나 최신 WAL을 아직 처리하지 않았거나 layer 파일이 로컬에 없으면 object storage에서 받아야 합니다. 이때 지연은 밀리초 단위로 늘어납니다. Xata의 비교 글은 이 부분을 "추가 indirection과 복잡성"으로 표현합니다. 또한 write-heavy workload에서 tail latency가 문제가 될 가능성을 지적합니다.
이 때문에 Autoscaling 알고리즘은 LFC working set을 20초 간격으로 추정해 compute 크기를 올립니다. Working set이 LFC에 들어가면 pageserver 왕복이 거의 사라집니다. LFC를 넘으면 순차 스캔마다 왕복이 반복됩니다. 상세는 2.5 Autoscaling과 Scale to Zero를 참고합니다.
Prefetch 의존성
Neon은 OS readahead의 도움을 받지 못합니다. 데이터 파일이 로컬에 없으므로 커널이 미리 읽어둘 대상도 없습니다. 그래서 compute의 PostgreSQL 패치에는 순차 스캔, 인덱스 스캔, vacuum 경로마다 prefetch가 들어갔습니다 (2.6 Postgres 패치와 제약). Prefetch가 잘 맞는 접근 패턴(순차 스캔/bitmap heap scan)은 pageserver 왕복을 묶어 보냅니다. 따라서 처리량이 유지됩니다. 반면 예측하기 어려운 random access는 LFC에 없는 페이지마다 왕복을 겪습니다. 큰 테이블에서 인덱스를 사용해 흩어진 row를 한 건씩 읽는 OLTP 패턴이 대표적입니다.
EXPLAIN (ANALYZE, BUFFERS)로 같은 쿼리를 두 번 실행해 비교하면 첫 실행과 두 번째 실행의 차이가 vanilla보다 크게 나타납니다. 첫 실행에는 LFC를 채우는 비용이 포함되기 때문입니다.
-- 두 번 실행해 첫 실행(LFC 미스)과 두 번째 실행(LFC 히트)의 차이를 봅니다
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*) FROM orders WHERE customer_id BETWEEN 1000 AND 2000;
Neon 확장은 LFC 상태를 확인하는 뷰를 제공합니다. 이름과 열은 버전에 따라 달라집니다. \dv neon*와 \df neon*로 현재 compute에서 확인합니다.
쓰기 경로와 quorum
쓰기에서는 로컬 fsync 대신 safekeeper quorum의 확인을 받은 뒤 commit을 완료합니다. Safekeeper 세 대 중 두 대가 WAL을 디스크에 기록했다고 응답하면 클라이언트에 COMMIT을 반환합니다. 이 왕복은 같은 리전 안에서 발생하는 네트워크 지연이므로 로컬 NVMe fsync보다 깁니다. 다만 동기 standby 한 대를 둔 vanilla 구성과 비슷한 수준입니다. 자세한 흐름은 2.2 Safekeeper와 WAL 내구성에 있습니다.
쓰기 지연을 키우는 두 번째 요인은 backpressure입니다. Compute가 pageserver의 처리 속도보다 빠르게 WAL을 생산하면 max_replication_write_lag, max_replication_flush_lag, max_replication_apply_lag 한도에 걸립니다. 이때 쓰기 backend가 대기합니다. 대량 적재, 큰 인덱스 생성, 대규모 UPDATE가 이런 상황을 만듭니다. 증상은 COMMIT 자체가 아니라 INSERT 문이 간헐적으로 멈추는 형태로 나타납니다.
-- compute 쪽에서 현재 backpressure 관련 설정과 WAL 위치를 확인합니다
SHOW max_replication_write_lag;
SHOW max_replication_flush_lag;
SHOW max_replication_apply_lag;
SELECT pg_current_wal_flush_lsn();
콜드스타트
Scale to zero 상태에서 첫 연결이 들어오면 compute가 다시 시작됩니다. 공식 문서는 재시작 시간을 고정된 수치로 제시하지 않습니다. Neon은 여러 자료에서 "수백 밀리초 수준"을 목표로 한다고 표현합니다. 이 값은 리전, compute 크기, 시점에 따라 다르므로 직접 측정합니다. 콜드스타트 뒤에는 shared_buffers와 LFC가 비어 있습니다. 따라서 첫 쿼리가 실행되는 몇 초 동안 pageserver 왕복이 집중됩니다. 사용자에게 보이는 지연은 "compute 기동 시간 + 캐시 워밍업 시간"의 합입니다.
지연에 민감한 서비스는 Launch 이상 플랜에서 scale to zero를 끄거나 최소 CU를 올려 둡니다. 개발용 branch는 콜드스타트를 감수하는 편이 비용에 유리합니다.
Workload 적합성
Branch 기반 개발/테스트, 간헐적으로 사용하는 내부 도구, 트래픽이 시간대별로 크게 달라 autoscaling 이득이 큰 서비스는 Neon 구조와 잘 맞습니다. 데이터가 LFC에 들어가는 규모의 OLTP도 적합합니다. 캐시가 데워진 뒤에는 지연이 vanilla와 크게 다르지 않습니다. 유휴 시간 비용도 사라집니다.
반대로 working set이 compute RAM의 몇 배에 이르는 random read OLTP는 비교 벤치마크 없이 옮기지 않습니다. 지속적인 대량 쓰기로 pageserver 처리량을 상회하는 적재 파이프라인도 마찬가지입니다. 수 밀리초 p99를 SLO로 둔 서비스에도 같은 기준을 적용합니다. 이 경우 compute 크기를 키워 LFC를 확보하는 방법이 있습니다. 다만 CU-hour 단가도 함께 오릅니다.
벤치마크 방법론
pgbench로 vanilla PostgreSQL과 Neon compute를 비교할 때 흔히 범하는 오류는 서로 다른 조건을 같은 그래프에 놓는 것입니다.
- 데이터 크기와 LFC의 관계를 먼저 정합니다.
pgbench -i -s 100은 약 1.5 GB이므로 4 GB RAM(1 CU)에서는 대부분 LFC에 들어갑니다. LFC 미스를 보려면 scale factor를 RAM의 몇 배로 잡습니다. - 워밍업 구간을 분리합니다. 첫 60초 결과는 버립니다.
pgbench -T 600처럼 충분히 긴 구간을 측정합니다. - 자원 조건을 같게 맞춥니다. RAM만 맞추는 것으로는 부족합니다. vCPU 수와 세대, PostgreSQL major 버전과 빌드,
shared_buffers를 비롯한 주요 설정, 클라이언트에서 서버까지의 RTT, vanilla 쪽 로컬 스토리지 사양을 모두 고정하고 기록합니다. Neon의 CU별 vCPU와 RAM은 compute 크기 문서에서 확인합니다. - Autoscaling을 고정합니다. 벤치마크 중 CU가 바뀌면 결과를 해석하기 어렵습니다. 따라서 최소와 최대를 같은 값으로 둡니다.
- 평균 대신 분포를 봅니다.
pgbench -r로 문장별 지연을 받습니다.--log로 per-transaction 로그를 남겨 p95/p99를 계산합니다.
# Neon compute (pooler 없는 직접 연결)
pgbench -i -s 300 "$NEON_DSN"
pgbench -c 16 -j 4 -T 60 "$NEON_DSN" > /dev/null # 워밍업, 결과 폐기
pgbench -c 16 -j 4 -T 600 -r --log --log-prefix=neon "$NEON_DSN"
# vanilla PostgreSQL, 같은 vCPU/RAM/설정/RTT 조건
pgbench -i -s 300 "$PG_DSN"
pgbench -c 16 -j 4 -T 60 "$PG_DSN" > /dev/null
pgbench -c 16 -j 4 -T 600 -r --log --log-prefix=vanilla "$PG_DSN"
--log-prefix를 지정하지 않으면 두 실행이 같은 pgbench_log.* 파일 이름을 공유합니다. 그 상태로 와일드카드를 정렬하면 두 시스템의 트랜잭션 지연이 한 분포에 섞여 백분위 비교가 무효가 됩니다. 실행별로 접두사를 나누거나 서로 다른 디렉터리를 씁니다.
로그 파일의 세 번째 열에는 마이크로초 단위 지연이 기록됩니다. 접두사별로 따로 정렬해 백분위를 구하면 tail 차이가 나타납니다.
for prefix in neon vanilla; do
sort -n -k3 "${prefix}"_log.* \
| awk -v p="$prefix" '{a[NR]=$3} END {print p, "p50", a[int(NR*0.50)], "p95", a[int(NR*0.95)], "p99", a[int(NR*0.99)]}'
done
Pooler 엔드포인트(-pooler)를 거치면 PgBouncer transaction mode의 지연이 추가됩니다. 따라서 직접 연결/pooler 연결을 따로 측정합니다.
지연을 관찰하는 지표
운영 중에는 벤치마크 대신 상시 지표로 tail latency 변화를 추적합니다. pg_stat_statements에서 mean_exec_time과 max_exec_time의 차이가 큰 쿼리를 찾습니다. 이런 쿼리는 LFC 미스를 겪는 후보입니다. 콘솔의 compute 지표에서는 LFC 히트율과 working set 크기 추정값을 함께 봅니다. 이 정보는 compute 크기를 올릴지/쿼리를 고칠지 판단하는 근거가 됩니다.
SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms,
round(max_exec_time::numeric, 2) AS max_ms, left(query, 60) AS query
FROM pg_stat_statements
ORDER BY max_exec_time - mean_exec_time DESC
LIMIT 10;
실패 사례
Read-only 분석 쿼리를 Neon branch에서 실행한 뒤 "vanilla보다 5배 느리다"고 결론 내린 사례를 가정해 봅니다. 대개 첫 실행 한 번만 측정한 것이 원인입니다. compute가 0.25 CU에서 시작해 autoscaling이 따라잡기 전에 측정이 끝난 경우도 있습니다. 같은 쿼리를 세 번 실행하고 두 번째부터 측정합니다. 그런 다음 compute 최소 크기를 데이터 크기에 맞게 올려 다시 측정하면 차이가 줄어듭니다. 그래도 남는 차이가 Neon 구조의 비용입니다.
연습 문제
- Part III의 로컬 환경에서
pgbench -i -s 50을 실행하고 같은 SELECT를 세 번 실행해 첫 실행과 나머지 실행의 시간 차이를 기록합니다. max_replication_write_lag를 작게 줄인 뒤 대량 INSERT를 실행해 backpressure로 멈추는 순간을 관찰합니다.- Neon 클라우드 Free 플랜에서 scale to zero 후 첫 연결까지 걸리는 시간을 10회 측정해 분포를 만듭니다.
심화 체크
- 우리 서비스의 working set 크기를 알고 있으며, 어떤 CU에서 LFC에 들어가는지 계산했는가?
- 벤치마크에서 워밍업 구간을 분리하고 autoscaling을 고정했는가?
- p99 SLO가 있다면 콜드스타트 뒤 첫 요청의 지연을 SLO 계산에 포함했는가?