Split View: PostgreSQL 내부 완전 정복 — MVCC, VACUUM, WAL, Query Planner, Index, Partitioning, pgvector까지 (2025)
PostgreSQL 내부 완전 정복 — MVCC, VACUUM, WAL, Query Planner, Index, Partitioning, pgvector까지 (2025)
"Postgres is not a fashion. It's a philosophy." — Tom Lane (PostgreSQL major contributor, 20+ years)
2024년 Stack Overflow Developer Survey에서 PostgreSQL이 처음으로 MySQL을 제치고 1위를 차지했다. 이 반전은 우연이 아니다. 지난 10년간 Postgres는 "JSON을 MongoDB보다 잘 다루고, 벡터 검색을 Pinecone보다 잘하며, 분석도 ClickHouse 수준으로 한다"는 괴물로 진화했다.
PostgreSQL은 1986년 UC Berkeley의 Michael Stonebraker가 시작한 POSTGRES 프로젝트에서 출발했다. 1996년 SQL 지원을 더하면서 PostgreSQL이 되었고, 이후 30년간 ACID 완벽성, 확장성(extensibility), 표준 준수를 철학으로 삼아왔다. 이 글은 Postgres를 "그냥 쓴다"에서 "내부를 이해하고 튜닝한다"로 넘어가려는 사람을 위한 지도다.
1. MVCC — PostgreSQL의 심장
MVCC가 왜 혁명적이었나
전통적 DB는 읽기 잠금을 썼다: 트랜잭션 A가 읽는 동안 B는 쓸 수 없다. 이는 OLTP 성능 킬러였다.
MVCC(Multi-Version Concurrency Control)는 "각 트랜잭션에게 그 시점의 스냅샷을 보여준다"는 아이디어. 읽기와 쓰기가 서로 막지 않는다.
"Readers don't block writers, writers don't block readers."
Oracle vs PostgreSQL 구현의 차이
Oracle: Undo Segment에 이전 버전 저장, 현재 버전은 주 테이블에. PostgreSQL: 모든 버전을 테이블에 저장, dead tuple은 VACUUM으로 청소.
PostgreSQL의 접근은 단순하지만 대가가 있다: 테이블이 필연적으로 부풀어 오른다. 이것이 VACUUM의 운명이다.
Tuple의 속성 — xmin, xmax
PostgreSQL의 각 row(tuple)에는 숨겨진 시스템 컬럼이 있다:
t_xmin — 이 tuple을 만든 트랜잭션 ID
t_xmax — 이 tuple을 삭제/업데이트한 트랜잭션 ID (0이면 아직 살아있음)
t_cmin — 같은 트랜잭션 내 커맨드 순번
트랜잭션은 자신의 snapshot(시작 시 활성 XID 집합)을 기준으로 다음을 판단:
t_xmin < MyXid이고t_xmin이 커밋되었고t_xmax가 없거나 미커밋 → 보인다- 그 외 → 안 보인다
이렇게 잠금 없이 읽기 일관성이 보장된다.
UPDATE의 진실 — "제자리 갱신"은 없다
UPDATE users SET name = 'Alice' WHERE id = 1;
실제로는:
- 기존 tuple의 t_xmax에 현재 트랜잭션 ID 기록 (논리적 삭제)
- 새 tuple 삽입, t_xmin = 현재 TX ID
- 관련 인덱스도 모두 새 행 포인터 추가 (HOT update 예외 있음)
이 때문에 PostgreSQL의 UPDATE는 INSERT 급 비용이고, write amplification 문제를 낳는다. HOT(Heap-Only Tuple)는 인덱스 키가 변하지 않을 때 같은 페이지에 넣어 인덱스 갱신을 피한다.
2. VACUUM — 빠질 수 없는 숙명
VACUUM이 하는 일
- Dead tuple 재활용 — 테이블/인덱스 공간 회수
- Visibility Map 갱신 — Index-Only Scan 가능 영역 기록
- Free Space Map 갱신 — INSERT가 쓸 공간 추적
- XID Wraparound 방지 — XID를 frozen 상태로 마킹 (후술)
- 통계 수집 (ANALYZE와 함께)
VACUUM FULL vs VACUUM
- VACUUM — 공간을 재사용 가능하게 표시, 테이블 크기는 유지 (이후 INSERT에 활용)
- VACUUM FULL — 테이블 재작성, 크기 축소, AccessExclusiveLock 잡음 (서비스 정지 수준)
프로덕션에서는 pg_repack 익스텐션으로 온라인 리팩 가능.
XID Wraparound — 32비트의 저주
PostgreSQL의 트랜잭션 ID는 32비트(약 42억). 소진되면 이전/이후를 구분할 수 없어 DB가 읽기 전용으로 전환.
방지책:
- VACUUM이 오래된 tuple을 FrozenXID로 마킹 → 영원히 보이도록 고정
autovacuum_freeze_max_age(기본 2억) 넘으면 강제 vacuum freeze 발동
2020년대 들어 대형 서비스에서 wraparound 장애가 종종 보고되었고, PostgreSQL 17부터 64비트 XID 논의가 본격화. 18에서도 아직 32비트.
autovacuum 튜닝 — 대부분 이걸 못 해서 문제
기본값은 작은 DB 기준. 수천만 row 테이블에서는:
autovacuum_vacuum_scale_factor = 0.02 # 기본 0.2 → 2%로
autovacuum_naptime = 15s # 기본 1min → 더 자주
autovacuum_max_workers = 6 # CPU에 맞춰
autovacuum_vacuum_cost_limit = 2000 # 기본 200, I/O 허용량 ↑
Dead tuple 비율 모니터링이 핵심:
SELECT relname, n_dead_tup, n_live_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
FROM pg_stat_user_tables ORDER BY dead_pct DESC NULLS LAST;
3. WAL — Write-Ahead Log의 우아함
WAL의 원칙
"데이터 파일을 변경하기 전에, 변경 사항을 로그에 먼저 기록한다."
이 한 줄의 규칙이:
- 장애 복구(crash recovery)
- 복제(replication)
- Point-in-Time Recovery(PITR)
- Logical Decoding
모든 기능의 기반이다.
WAL 쓰기 흐름
- 트랜잭션이 데이터 변경
- WAL 레코드를 WAL 버퍼에 쓰기
- COMMIT 시 WAL을 디스크에 fsync
- 데이터 파일 자체는 나중에 CHECKPOINT가 처리
즉, 커밋 시 실제 데이터 페이지는 디스크에 없어도 된다. WAL만 영속이면 복구 가능.
CHECKPOINT
- 주기적으로 dirty page를 디스크에 flush
- 너무 자주 → I/O 폭발
- 너무 드물게 → WAL 크기 증가, 복구 시간 ↑
checkpoint_timeout(기본 5min)과max_wal_size(기본 1GB)로 제어
복제 — Physical vs Logical
Physical Replication (Streaming):
- WAL을 그대로 Standby에 전송
- 바이트 단위 복제 → 완전 동일
- 전체 클러스터 단위
- 동기/비동기/쿼럼 복제 가능
Logical Replication (10 이상):
- WAL을 디코딩해서 논리적 SQL 변경사항으로 변환
- 테이블/컬럼 단위 선택
- 다른 버전 PG 간 복제 가능
- Upgrade/마이그레이션에 필수 (앞 글 "Zero-Downtime DB Migration" 참조)
동기 복제 설정
synchronous_commit = on
synchronous_standby_names = 'FIRST 2 (replica1, replica2, replica3)'
3개 중 2개 ACK 기다림. 한 replica 장애에도 커밋 지속.
4. Query Planner — "왜 같은 쿼리가 갑자기 느려질까"
3단계 — Parse → Rewrite → Plan → Execute
- Parser: SQL → AST
- Rewriter: 뷰 펼치기, 룰 적용
- Planner: 여러 실행 계획 중 비용 최소 선택 ← 핵심
- Executor: 실행
비용 모델
Planner는 각 오퍼레이션에 "비용 단위"를 매긴다:
seq_page_cost = 1.0(순차 디스크 읽기)random_page_cost = 4.0(랜덤 읽기)cpu_tuple_cost = 0.01cpu_index_tuple_cost = 0.005cpu_operator_cost = 0.0025
SSD라면 random_page_cost = 1.1 정도로 낮추는 게 거의 항상 이득.
통계가 전부다
Planner는 pg_statistic의 통계로 카디널리티를 추정:
n_distinct— 고유값 수 추정most_common_vals— 상위 빈도값histogram_bounds— 분포
ANALYZE가 안 돌면 통계가 낡아서 Planner가 헛발질한다.
EXPLAIN ANALYZE 읽기
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
Index Scan using idx_user_status on orders
(cost=0.43..125.67 rows=12 width=80)
(actual time=0.045..0.312 rows=15 loops=1)
Index Cond: ((user_id = 123) AND (status = 'paid'::text))
Buffers: shared hit=8 read=0
읽는 순서:
- Node 타입: Index Scan, Seq Scan, Hash Join, Merge Join, Nested Loop
- cost: 추정 시작-종료
- rows: 추정 vs 실제 — 10배 이상 차이 나면 통계 문제
- Buffers: hit(캐시)/read(디스크) — 캐시 효율
- loops: Nested Loop 내부 반복 수
공통 문제 패턴
| 증상 | 원인 | 해결 |
|---|---|---|
| Seq Scan인데 행이 많다 | 통계 낡음 / 인덱스 없음 | ANALYZE / CREATE INDEX |
| Rows estimate가 1인데 실제 수만 | 상관관계 있는 WHERE | CREATE STATISTICS |
| Nested Loop인데 내부가 큼 | Planner 오판 | SET enable_nestloop = off 검증 |
| Sort 메모리 넘침 | work_mem 부족 | work_mem 상향 |
5. Index — 6가지 타입을 분간하라
B-Tree — 기본값이자 왕
- 범용:
=,<,>,BETWEEN,ORDER BY - 다중 컬럼 인덱스는 왼쪽부터 사용 가능
- PostgreSQL 12부터 중복 제거로 크기 30%+ 감소
Hash
=만 지원, 범위 불가- 10에서 WAL 로그 추가되어 비로소 crash-safe
- 실제로는 거의 안 씀 (B-Tree가 대부분 더 나음)
GiST — Generalized Search Tree
- 기하학(PostGIS), 범위 타입, 전문 검색
- 플러그인 가능한 구조 — 자기 자료형에 맞는 인덱스 작성 가능
- 예:
CREATE INDEX ON events USING GIST (during)(tsrange)
GIN — Generalized Inverted Index
- 다치 값(배열, JSONB, tsvector) 최적
- 각 "토큰"이 몇 번 문서에 나오는지 역인덱스
- JSONB 인덱스의 기본값
- 쓰기 비용 큼,
fastupdate로 완화
BRIN — Block Range Index
- 물리적으로 정렬된 거대 테이블(시계열, 로그) 전용
- 각 블록 범위의 min/max만 저장 → 극도로 작음
- 10TB 시계열에 BRIN 100MB도 안 됨
- 조건: 데이터가 insert 순서대로 정렬되어 있어야 함
HNSW (pgvector) — 벡터 검색의 표준
- PostgreSQL 자체는 아니고
pgvector익스텐션 (0.5.0+) - Hierarchical Navigable Small World 그래프
- ANN(Approximate Nearest Neighbor) 검색
- 2024년부터 사실상 RAG의 표준
CREATE EXTENSION vector;
CREATE TABLE items (id bigint, embedding vector(1536));
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
SELECT id FROM items ORDER BY embedding <=> '[...]'::vector LIMIT 10;
인덱스 선택 가이드
| 워크로드 | 추천 |
|---|---|
| 일반 OLTP 조회 | B-Tree |
| JSONB 필드 검색 | GIN (jsonb_path_ops) |
| 전문 검색(FTS) | GIN (tsvector) |
| 지리 좌표 | GiST (PostGIS) |
| 거대 시계열 append-only | BRIN |
| AI 임베딩 유사도 | HNSW (pgvector) |
| 범위 타입(tsrange) | GiST |
Partial & Expression Index
-- 조건부 인덱스
CREATE INDEX ON orders (user_id) WHERE status = 'paid';
-- 표현식 인덱스
CREATE INDEX ON users (lower(email));
-- Covering index (PG 11+)
CREATE INDEX ON orders (user_id) INCLUDE (total, created_at);
Partial은 인덱스 크기를 극적으로 줄인다.
6. Partitioning — 관계형 DB의 샤딩
Declarative Partitioning — PG 10부터
CREATE TABLE events (
id bigserial,
user_id bigint,
occurred_at timestamp
) PARTITION BY RANGE (occurred_at);
CREATE TABLE events_2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
파티셔닝 전략
- RANGE — 시간, 범위값 (가장 흔함)
- LIST — 카테고리 (country, region)
- HASH — 균등 분산 (샤딩)
파티션 프루닝
WHERE 절이 파티션 키를 참조하면 관련 파티션만 스캔.
WHERE occurred_at >= '2026-04-15'
→ events_2026_04 파티션만 스캔
자동 파티션 관리 — pg_partman
수동으로 매달 파티션을 만드는 건 자살 행위. pg_partman이 자동 생성/삭제.
Citus — Postgres를 수평 샤딩
2019년 Microsoft가 인수. 여러 Postgres 노드에 distributed table을 만들어:
- 샤딩 키로 자동 분배
- 분산 쿼리 실행
- 병렬 INSERT/SELECT
2024년에는 Citus가 Azure Cosmos DB for PostgreSQL의 엔진으로 자리. 자체 운영도 가능.
7. 연결 관리 — 왜 Postgres는 연결이 비싼가
프로세스 기반 모델
PostgreSQL은 연결당 프로세스 모델(스레드 아님). 장점:
- 격리 — 한 연결 크래시가 전체에 영향 없음
- 안정성 — 수십 년 검증
단점:
- 메모리 비용 — 연결당 10-20MB
- 연결 생성 비용 — 수 ms
- 1000+ 연결은 과부하
pgBouncer — 연결 풀링의 표준
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
pool_mode = transaction
default_pool_size = 20
max_client_conn = 1000
pool_mode:
session— 클라이언트 연결 = 서버 연결 (1:1, 풀 의미 없음)transaction— 트랜잭션 동안만 유지 (가장 흔함)statement— 쿼리 하나당 (매우 제한적)
Transaction Mode 주의사항
- Prepared Statement 비호환 — PG 17+ 에서
track_planner_stats개선 - SET LOCAL 만 허용,
SET은 안 됨 - LISTEN/NOTIFY 불가
- 애플리케이션 드라이버 설정 필요 (예:
PreparedStatementCacheSize=0)
PgCat & Supavisor
- PgCat — Rust로 쓴 pgBouncer 대안, 샤딩 지원
- Supavisor — Supabase가 만든 Elixir 기반, 수백만 연결
경험칙
- 풀 크기 =
core_count * 2 + spindle_count(~PostgreSQL 위키) - 보통 20-50 정도가 스위트 스팟
- 더 늘려도 throughput 안 늘고 latency만 증가
8. JSONB — "Postgres로 다 된다"의 진실
JSON vs JSONB
| 속성 | JSON | JSONB |
|---|---|---|
| 저장 | 텍스트 그대로 | 파싱된 바이너리 |
| 크기 | 작음 | 약간 큼 |
| 입력 속도 | 빠름 | 약간 느림 |
| 질의 속도 | 느림 | 빠름 |
| 인덱싱 | 불가(일부) | GIN 가능 |
| 키 순서 | 보존 | 비보존 |
| 중복 키 | 허용 | 마지막만 |
거의 항상 JSONB를 쓴다.
GIN 인덱스 전략
-- 범용 (크고 유연)
CREATE INDEX ON docs USING GIN (data);
-- 경로 연산자 (`@>`)만 (더 작음)
CREATE INDEX ON docs USING GIN (data jsonb_path_ops);
jsonb_path_ops가 30% 가량 작고 @> 질의에 충분. 다른 연산자(?, ?|, ?&)는 범용만 지원.
MongoDB 대체 가능한가?
- 스키마 유연성: JSONB가 근접
- 성능: 작은 데이터는 유사, 거대 문서는 MongoDB 우위
- 집계: Postgres의 SQL이 훨씬 강력
- Transaction: Postgres 승 (Mongo 4.0+로 따라왔지만 제한)
- 쓰기 확장: MongoDB의 샤딩이 더 성숙 (Citus 있지만)
결론: 규모가 극단적이지 않으면 Postgres로 "문서 + 관계 + 분석"을 한 DB에서 해결 가능.
9. AI 시대의 Postgres — pgvector와 pg_duckdb
pgvector (2023년 이후 폭발)
- 2021년 Andrew Kane가 만듦
- 2023년 HNSW 추가로 프로덕션 가능
- 2024년 pgvectorscale(Timescale)로 성능 10배 개선
- 2025년에는 Supabase, Neon, RDS 모두 기본 탑재
DiskANN & Binary Quantization
- DiskANN 인덱스(pgvectorscale) — 메모리보다 큰 벡터셋을 디스크에서 검색
- Binary Quantization — float32 → 1bit, 메모리 32배 감소
pg_duckdb (2024년 말)
DuckDB를 PostgreSQL 내부에 임베드. 분석 쿼리만 DuckDB로 넘김.
SELECT duckdb.query('
SELECT date_trunc(''hour'', ts), count(*)
FROM read_parquet(''s3://logs/*.parquet'')
GROUP BY 1
');
OLTP는 Postgres, OLAP은 DuckDB — 한 DB에서. 2025년 HTAP 전쟁의 다크호스.
AI 네이티브 PG 생태
- Neon — 서버리스, 브랜칭, copy-on-write
- Supabase — Postgres + pgvector + Realtime + Auth 번들
- Timescale — 시계열 + pgvector + AI 최적화
- MotherDuck + DuckDB — OLAP 분리
10. PostgreSQL 18 (2025) 신기능
AIO — Asynchronous I/O
- 지금까지는 동기 I/O 기반
- 18부터
io_uring(Linux) 지원 → 30-50% 빠른 순차 스캔 - 자동 read-ahead 개선
Direct I/O
- OS 페이지 캐시 우회, PG가 직접 관리 (
shared_buffers더 크게) io_direct = data옵션
UUIDv7 네이티브 지원
- 기존 UUIDv4는 랜덤 → 인덱스 파편화
- UUIDv7은 시간 정렬 → B-Tree 친화적
gen_uuid_v7()빌트인
Logical Replication 개선
- DDL 복제 (테이블 생성까지 복제)
- 양방향 복제 기반 마련 (BDR-like)
Skip Scan
- 다중 컬럼 인덱스에서 앞 컬럼 조건 없어도 활용
- "Loose Index Scan" — Oracle/MySQL에는 있었던 기능
11. 모니터링 — 꼭 봐야 할 지표
pg_stat_statements
모든 쿼리의 누적 통계. 가장 중요한 익스텐션.
SELECT query, calls, total_exec_time/1000 AS total_sec,
mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 20;
핵심 지표
| 지표 | 확인 | 경고선 |
|---|---|---|
| Cache hit ratio | pg_stat_database | 99%+ |
| Dead tuple % | pg_stat_user_tables | 테이블당 20% |
| Replication lag | pg_stat_replication | 1MB |
| Long-running TX | pg_stat_activity | 5분+ |
| Lock waits | pg_locks + pg_stat_activity | 30s+ |
| Connections | pg_stat_activity count | max_connections 80% |
| WAL generation | pg_stat_wal | 베이스라인 대비 |
| Autovacuum lag | n_dead_tup + last_vacuum | 24h+ |
Long Running Transaction의 저주
오래 열린 트랜잭션은:
- VACUUM을 막음 (그 TX가 볼 수 있는 tuple은 청소 불가)
- dead tuple 폭증 → 테이블 부풀음
idle_in_transaction_session_timeout설정으로 방어
12. 안티패턴 TOP 10
- ORM의 N+1 쿼리 — EXPLAIN 보고 놀라지 말고
INCLUDE/JOIN - BEGIN 후 아이들 — 장시간 방치, VACUUM 블록
- 많은 짧은 연결 — pgBouncer 없이 직결
- ANALYZE 안 함 — 통계 낡으면 Planner 헛발
- VACUUM FULL을 운영 중 — AccessExclusive 락, 서비스 중단
- 모든 컬럼 인덱싱 — 쓰기 속도 추락, 인덱스 유지비 ↑
- Prepared Statement + pgBouncer transaction mode — 비호환
- UUIDv4 PK 남용 — 인덱스 파편화 (→ UUIDv7)
SELECT *— Planner 최적화 기회 상실- 거대 JSONB 전체 UPDATE — TOAST 재작성 폭발
13. Postgres를 현명하게 쓰는 체크리스트
- autovacuum 튜닝 — scale_factor, cost_limit 조정
- pg_stat_statements 상시 활성화
- ANALYZE 주기 확인, 통계 타겟 조정 가능
- pgBouncer(transaction mode) 배포
-
random_page_costSSD 기준으로 조정 -
work_mem신중히 상향 (연결당 곱해짐) -
shared_buffersRAM의 25% - Replication lag 모니터링 — slot 쌓이는 것 확인
- Long-running TX 타임아웃 설정
- pg_stat_user_tables dead_pct 알람
- WAL 보관/아카이빙 — PITR 가능하게
- 주요 쿼리 EXPLAIN ANALYZE 베이스라인 저장
마치며 — "Postgres로 다 된다"의 참뜻
"Postgres everything" 밈은 사실이다. OLTP, OLAP(pg_duckdb), 벡터 검색(pgvector), 시계열(Timescale), 지리정보(PostGIS), 그래프(Apache AGE), 전문 검색(GIN), 심지어 메시지 큐(pg_later)까지.
하지만 이것은 "아무 워크로드나 Postgres에 밀어넣어라"는 뜻이 아니다. **"한 DB로 대부분의 일을 할 수 있어서, 운영 복잡성을 줄일 수 있다"**는 뜻이다. 극한 규모(수십 TB OLAP, 초당 100만 이벤트)에서는 전용 시스템이 여전히 필요하다.
Postgres를 다룬다는 건 MVCC의 tuple 모델, VACUUM의 비용, WAL의 역할, Planner의 통계 의존성을 이해하는 것이다. 이걸 모르고 "왜 느려졌지?"를 디버그하는 건 지도 없이 동굴 탐험하는 일이다.
다음 글 예고 — Elasticsearch/OpenSearch와 검색의 과학
Postgres가 "구조화된 세계의 왕"이라면, Elasticsearch/OpenSearch는 "비구조화된 텍스트의 왕"이다. 다음 글에서는:
- Inverted Index의 본질 — 왜 GIN보다 Lucene이 빠른가
- Lucene 내부 — Segment, Commit, Merge 정책
- BM25 vs TF-IDF — 점수 매기기의 수학
- Sharding & Replication — primary/replica, routing
- Ingest 파이프라인 — Logstash, Beats, OpenTelemetry → ES
- Query DSL의 숲 — bool, match, term, function_score
- Vector Search in ES — kNN, HNSW, hybrid retrieval
- 2021년 라이선스 사태 — Elastic vs AWS, OpenSearch 포크
- Operational pain — Heap, GC, split brain, circuit breakers
- Hybrid Search & RAG — BM25 + Vector = 답
검색의 역사와 미래를 한 번에 훑는 여정.
"The first rule of Postgres: never fight the Planner. Give it good statistics, and it will give you good plans. The second rule: know where the bodies are buried — MVCC, VACUUM, WAL. Everything else is commentary." — Bruce Momjian (PostgreSQL Global Development Group)
PostgreSQL Internals Deep Dive — MVCC, VACUUM, WAL, Query Planner, Index, Partitioning, pgvector (2025)
"Postgres is not a fashion. It's a philosophy." — Tom Lane (PostgreSQL major contributor, 20+ years)
In 2024 Stack Overflow Developer Survey, PostgreSQL finally overtook MySQL for #1. Not a coincidence. Over the last decade Postgres evolved into a monster: "handles JSON better than MongoDB, vector search better than Pinecone, analytics close to ClickHouse."
PostgreSQL started in 1986 as the POSTGRES project at UC Berkeley (Michael Stonebraker). SQL support in 1996 turned it into PostgreSQL, and for 30 years the philosophy has been ACID correctness, extensibility, standards compliance. This post is a map for those moving from "just using Postgres" to "understanding and tuning its internals."
1. MVCC — The Heart of PostgreSQL
Why MVCC was revolutionary
Traditional DBs used read locks: while transaction A reads, B cannot write. OLTP performance killer.
MVCC (Multi-Version Concurrency Control): "each transaction sees a snapshot at its point in time." Reads and writes don't block each other.
"Readers don't block writers, writers don't block readers."
Oracle vs PostgreSQL implementation
Oracle: old versions in Undo Segment, current version in the main table. PostgreSQL: all versions stored in the table, dead tuples cleaned by VACUUM.
Postgres's approach is simpler but has a cost: tables inevitably bloat. This is the fate of VACUUM.
Tuple attributes — xmin, xmax
Each row (tuple) has hidden system columns:
t_xmin — transaction ID that created this tuple
t_xmax — transaction ID that deleted/updated it (0 means still alive)
t_cmin — command sequence within the same transaction
A transaction uses its snapshot (active XIDs at start) to decide:
t_xmin < MyXid, committed, andt_xmaxabsent or uncommitted → visible- otherwise → invisible
Read consistency without locks.
The truth about UPDATE — no "in-place update"
UPDATE users SET name = 'Alice' WHERE id = 1;
Actually:
- Set current TX ID into t_xmax of old tuple (logical delete)
- Insert new tuple, t_xmin = current TX ID
- All related indexes also get new pointer entries (HOT update exception)
PostgreSQL UPDATE costs as much as INSERT, causing write amplification. HOT (Heap-Only Tuple) avoids index updates when index keys don't change by placing the new version on the same page.
2. VACUUM — The Inescapable Burden
What VACUUM does
- Reclaim dead tuples — table/index space
- Update Visibility Map — enables Index-Only Scan
- Update Free Space Map — tracks space for INSERT
- Prevent XID wraparound — mark tuples frozen
- Collect statistics (with ANALYZE)
VACUUM FULL vs VACUUM
- VACUUM — marks space reusable, table size unchanged
- VACUUM FULL — rewrites the table, shrinks size, takes AccessExclusiveLock (outage-level)
In production, use pg_repack extension for online repack.
XID Wraparound — the 32-bit curse
Transaction IDs are 32-bit (~4.2 billion). Exhausting them makes past/future indistinguishable and the DB goes read-only.
Defenses:
- VACUUM marks old tuples FrozenXID → visible forever
autovacuum_freeze_max_age(default 200M) triggers forced vacuum freeze
Wraparound incidents were reported at large services through the 2020s; 64-bit XID discussions intensified from PG 17. PG 18 is still 32-bit.
autovacuum tuning — where most people fail
Defaults assume small DBs. For tables with tens of millions of rows:
autovacuum_vacuum_scale_factor = 0.02 # default 0.2 → 2%
autovacuum_naptime = 15s # default 1min → more often
autovacuum_max_workers = 6 # match CPU
autovacuum_vacuum_cost_limit = 2000 # default 200, raise I/O budget
Dead-tuple ratio monitoring is key:
SELECT relname, n_dead_tup, n_live_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS dead_pct
FROM pg_stat_user_tables ORDER BY dead_pct DESC NULLS LAST;
3. WAL — The Elegance of Write-Ahead Log
The WAL principle
"Log changes before modifying data files."
That one rule underpins:
- Crash recovery
- Replication
- Point-in-Time Recovery (PITR)
- Logical Decoding
WAL write flow
- Transaction modifies data
- Write WAL record to WAL buffer
- On COMMIT, fsync WAL to disk
- Data pages themselves flushed later by CHECKPOINT
On commit, data pages need not be on disk yet. Recovery works as long as WAL is durable.
CHECKPOINT
- Periodically flushes dirty pages
- Too frequent → I/O spike
- Too rare → WAL grows, recovery time rises
- Controlled by
checkpoint_timeout(default 5min) andmax_wal_size(default 1GB)
Replication — Physical vs Logical
Physical Replication (Streaming):
- Sends WAL byte-identical to standby
- Whole-cluster scope
- Sync / async / quorum supported
Logical Replication (10+):
- Decodes WAL into logical SQL changes
- Table/column level selection
- Replication across different PG versions possible
- Essential for upgrade/migration (see "Zero-Downtime DB Migration")
Synchronous replication setup
synchronous_commit = on
synchronous_standby_names = 'FIRST 2 (replica1, replica2, replica3)'
Waits for 2 of 3 ACKs. Commits continue when one replica fails.
4. Query Planner — "Why did the same query suddenly get slow?"
Parse → Rewrite → Plan → Execute
- Parser: SQL → AST
- Rewriter: expand views, apply rules
- Planner: pick minimum-cost plan ← the core
- Executor: run it
Cost model
Cost units per operation:
seq_page_cost = 1.0(sequential)random_page_cost = 4.0(random)cpu_tuple_cost = 0.01cpu_index_tuple_cost = 0.005cpu_operator_cost = 0.0025
On SSD, lowering random_page_cost to 1.1 is almost always a win.
Statistics are everything
Planner estimates cardinalities from pg_statistic:
n_distinct— distinct count estimatemost_common_vals— top frequencieshistogram_bounds— distribution
Without ANALYZE, stats go stale and the Planner misfires.
Reading EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
Index Scan using idx_user_status on orders
(cost=0.43..125.67 rows=12 width=80)
(actual time=0.045..0.312 rows=15 loops=1)
Index Cond: ((user_id = 123) AND (status = 'paid'::text))
Buffers: shared hit=8 read=0
Read in order:
- Node type: Index Scan, Seq Scan, Hash Join, Merge Join, Nested Loop
- cost: estimated start-end
- rows: estimated vs actual — 10x gap means stats issue
- Buffers: hit (cache) / read (disk) — cache efficiency
- loops: Nested Loop inner iterations
Common problem patterns
| Symptom | Cause | Fix |
|---|---|---|
| Seq Scan with many rows | stale stats / no index | ANALYZE / CREATE INDEX |
| Row estimate 1, actual tens of thousands | correlated WHERE | CREATE STATISTICS |
| Nested Loop with large inner | planner misjudgment | verify with SET enable_nestloop = off |
| Sort spills to disk | low work_mem | raise work_mem |
5. Index — Master the 6 Types
B-Tree — the default and the king
- General purpose:
=,<,>,BETWEEN,ORDER BY - Multi-column index usable from the leftmost key
- Since PG 12, deduplication cuts size by 30%+
Hash
- Supports
=only, no range - Crash-safe only from PG 10 (WAL logging added)
- Rarely useful (B-Tree is almost always better)
GiST — Generalized Search Tree
- Geometry (PostGIS), range types, full text
- Pluggable — write your own index for your type
- e.g.
CREATE INDEX ON events USING GIST (during)(tsrange)
GIN — Generalized Inverted Index
- Optimal for multi-valued (arrays, JSONB, tsvector)
- Inverted index: each "token" → documents it appears in
- Default for JSONB indexing
- Costly writes, mitigated with
fastupdate
BRIN — Block Range Index
- Designed for physically sorted huge tables (time series, logs)
- Stores only min/max per block range → extremely small
- BRIN on 10TB time series can be under 100MB
- Requires data to be insert-ordered
HNSW (pgvector) — the vector search standard
- Not core, via
pgvectorextension (0.5.0+) - Hierarchical Navigable Small World graph
- ANN (Approximate Nearest Neighbor) search
- De facto RAG standard since 2024
CREATE EXTENSION vector;
CREATE TABLE items (id bigint, embedding vector(1536));
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
SELECT id FROM items ORDER BY embedding <=> '[...]'::vector LIMIT 10;
Index choice guide
| Workload | Choice |
|---|---|
| General OLTP lookup | B-Tree |
| JSONB field search | GIN (jsonb_path_ops) |
| Full-text search (FTS) | GIN (tsvector) |
| Geographic coords | GiST (PostGIS) |
| Huge append-only time series | BRIN |
| AI embedding similarity | HNSW (pgvector) |
| Range types (tsrange) | GiST |
Partial & Expression Index
-- Conditional index
CREATE INDEX ON orders (user_id) WHERE status = 'paid';
-- Expression index
CREATE INDEX ON users (lower(email));
-- Covering index (PG 11+)
CREATE INDEX ON orders (user_id) INCLUDE (total, created_at);
Partial indexes dramatically shrink index size.
6. Partitioning — Sharding in a Relational DB
Declarative Partitioning — from PG 10
CREATE TABLE events (
id bigserial,
user_id bigint,
occurred_at timestamp
) PARTITION BY RANGE (occurred_at);
CREATE TABLE events_2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
Partitioning strategies
- RANGE — time, ranges (most common)
- LIST — categories (country, region)
- HASH — even distribution (sharding)
Partition pruning
WHERE referencing the partition key → scan only relevant partitions.
WHERE occurred_at >= '2026-04-15'
→ scan only events_2026_04
Automated partition management — pg_partman
Creating partitions monthly by hand is suicide. pg_partman auto-creates/drops.
Citus — horizontal sharding for Postgres
Acquired by Microsoft in 2019. Distributed tables across multiple Postgres nodes:
- Auto distribution by shard key
- Distributed query execution
- Parallel INSERT/SELECT
In 2024, Citus became the engine of Azure Cosmos DB for PostgreSQL. Self-hosting also possible.
7. Connection Management — Why Postgres Connections Are Expensive
Process-based model
PostgreSQL uses one process per connection (not threads). Pros:
- Isolation — one crash doesn't affect others
- Stability — decades proven
Cons:
- Memory — 10-20MB per connection
- Connect cost — milliseconds
- 1000+ connections overload
pgBouncer — the connection pool standard
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
pool_mode = transaction
default_pool_size = 20
max_client_conn = 1000
pool_mode:
session— client conn = server conn (1:1, pool pointless)transaction— held only during a transaction (most common)statement— per query (very limited)
Transaction-mode caveats
- Prepared statement incompat — improved via
track_planner_statsin PG 17+ - Only
SET LOCALallowed,SETnot - LISTEN/NOTIFY unavailable
- Driver tuning needed (e.g.
PreparedStatementCacheSize=0)
PgCat & Supavisor
- PgCat — Rust pgBouncer alternative, sharding support
- Supavisor — Supabase's Elixir-based, millions of connections
Rules of thumb
- Pool size ~
core_count * 2 + spindle_count(PostgreSQL wiki) - 20-50 is typical sweet spot
- Raising more only increases latency, not throughput
8. JSONB — The Truth About "Postgres Does Everything"
JSON vs JSONB
| Property | JSON | JSONB |
|---|---|---|
| Storage | raw text | parsed binary |
| Size | smaller | slightly larger |
| Insert speed | faster | slightly slower |
| Query speed | slower | faster |
| Indexing | partial | GIN supported |
| Key order | preserved | not preserved |
| Duplicate keys | allowed | last wins |
Almost always JSONB.
GIN index strategy
-- General (large, flexible)
CREATE INDEX ON docs USING GIN (data);
-- Path operator (`@>`) only (smaller)
CREATE INDEX ON docs USING GIN (data jsonb_path_ops);
jsonb_path_ops is ~30% smaller and enough for @> queries. Other operators (?, ?|, ?&) need the general form.
MongoDB replacement?
- Schema flexibility: JSONB comes close
- Performance: small docs similar, giant docs favor Mongo
- Aggregation: Postgres SQL far more powerful
- Transactions: Postgres wins (Mongo 4.0+ caught up but with limits)
- Write scale: Mongo sharding more mature (Citus exists)
Verdict: unless scale is extreme, Postgres handles "document + relational + analytics" in one DB.
9. Postgres in the AI Era — pgvector & pg_duckdb
pgvector (exploded post-2023)
- 2021 by Andrew Kane
- 2023 HNSW addition made it production-ready
- 2024 pgvectorscale (Timescale) improved perf 10x
- 2025 bundled by default on Supabase, Neon, RDS
DiskANN & Binary Quantization
- DiskANN index (pgvectorscale) — search vector sets larger than memory
- Binary Quantization — float32 → 1bit, 32x memory reduction
pg_duckdb (late 2024)
Embeds DuckDB in PostgreSQL. Push analytics queries to DuckDB.
SELECT duckdb.query('
SELECT date_trunc(''hour'', ts), count(*)
FROM read_parquet(''s3://logs/*.parquet'')
GROUP BY 1
');
OLTP with Postgres, OLAP with DuckDB — in one DB. Dark horse of the 2025 HTAP war.
AI-native PG ecosystem
- Neon — serverless, branching, copy-on-write
- Supabase — Postgres + pgvector + Realtime + Auth bundle
- Timescale — time-series + pgvector + AI-optimized
- MotherDuck + DuckDB — OLAP offload
10. PostgreSQL 18 (2025) New Features
AIO — Asynchronous I/O
- Sync I/O until now
- From 18,
io_uring(Linux) support → 30-50% faster sequential scan - Improved auto read-ahead
Direct I/O
- Bypass OS page cache, PG manages directly (grow
shared_buffers) io_direct = dataoption
Native UUIDv7
- UUIDv4 is random → index fragmentation
- UUIDv7 is time-sorted → B-Tree friendly
gen_uuid_v7()built-in
Logical replication improvements
- DDL replication (even CREATE TABLE)
- Foundations for bidirectional (BDR-like)
Skip Scan
- Use multi-column index even without leading column predicate
- "Loose Index Scan" — Oracle/MySQL already had this
11. Monitoring — Must-Watch Metrics
pg_stat_statements
Cumulative stats per query. The most important extension.
SELECT query, calls, total_exec_time/1000 AS total_sec,
mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 20;
Key metrics
| Metric | Source | Threshold |
|---|---|---|
| Cache hit ratio | pg_stat_database | 99%+ |
| Dead tuple % | pg_stat_user_tables | 20% per table |
| Replication lag | pg_stat_replication | 1MB |
| Long-running TX | pg_stat_activity | 5 min+ |
| Lock waits | pg_locks + pg_stat_activity | 30s+ |
| Connections | pg_stat_activity count | 80% of max_connections |
| WAL generation | pg_stat_wal | vs baseline |
| Autovacuum lag | n_dead_tup + last_vacuum | 24h+ |
The curse of long-running transactions
Long-open TX:
- Blocks VACUUM (tuples visible to that TX can't be cleaned)
- Dead tuples explode → table bloat
- Defend with
idle_in_transaction_session_timeout
12. Top 10 Anti-Patterns
- ORM N+1 queries — don't be surprised by EXPLAIN, use
INCLUDE/JOIN - Idle after BEGIN — long idle, blocks VACUUM
- Many short connections — no pgBouncer, direct
- Not running ANALYZE — stale stats, Planner misfires
- VACUUM FULL in prod — AccessExclusive lock, outage
- Indexing every column — writes crash, index maintenance cost
- Prepared statements + pgBouncer transaction mode — incompatible
- UUIDv4 PK abuse — index fragmentation (→ UUIDv7)
SELECT *— lose planner optimization chances- Full UPDATE on huge JSONB — TOAST rewrite explosion
13. Checklist for Using Postgres Wisely
- Tune autovacuum — scale_factor, cost_limit
- Keep pg_stat_statements always on
- Verify ANALYZE cadence, adjust stats target
- Deploy pgBouncer (transaction mode)
- Lower
random_page_costfor SSD - Raise
work_memcarefully (multiplied per connection) - Set
shared_buffersto 25% of RAM - Monitor replication lag — watch slot buildup
- Set long-running TX timeout
- Alert on pg_stat_user_tables dead_pct
- WAL archiving — enable PITR
- Save EXPLAIN ANALYZE baselines of key queries
Closing — The Real Meaning of "Postgres Does Everything"
The "Postgres everything" meme is real. OLTP, OLAP (pg_duckdb), vector search (pgvector), time series (Timescale), geospatial (PostGIS), graph (Apache AGE), full text (GIN), even message queues (pg_later).
But this doesn't mean "dump every workload into Postgres." It means "one DB covers most work, cutting operational complexity." Extreme scale (tens of TB OLAP, 1M events/s) still needs specialized systems.
Using Postgres means understanding the MVCC tuple model, the cost of VACUUM, the role of WAL, the planner's dependence on statistics. Debugging "why is it slow?" without these is cave exploration without a map.
Next up — Elasticsearch/OpenSearch and the Science of Search
If Postgres is "king of the structured world," Elasticsearch/OpenSearch is "king of unstructured text." Next post:
- The essence of Inverted Index — why Lucene beats GIN
- Lucene internals — Segment, Commit, Merge policies
- BM25 vs TF-IDF — the math of scoring
- Sharding & Replication — primary/replica, routing
- Ingest pipelines — Logstash, Beats, OpenTelemetry → ES
- The Query DSL forest — bool, match, term, function_score
- Vector Search in ES — kNN, HNSW, hybrid retrieval
- The 2021 license fight — Elastic vs AWS, OpenSearch fork
- Operational pain — Heap, GC, split brain, circuit breakers
- Hybrid Search & RAG — BM25 + Vector = the answer
A sweep through the history and future of search.
"The first rule of Postgres: never fight the Planner. Give it good statistics, and it will give you good plans. The second rule: know where the bodies are buried — MVCC, VACUUM, WAL. Everything else is commentary." — Bruce Momjian (PostgreSQL Global Development Group)