3.2 MVCC와 스냅샷
PostgreSQL의 동시성 제어 핵심은 MVCC(Multi-Version Concurrency Control)입니다. 한 row가 여러 버전으로 디스크에 공존하고, 각 트랜잭션은 자기 스냅샷에 맞는 버전을 골라서 봅니다. 락은 가능한 한 잡지 않기 때문에 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않습니다.
핵심 아이디어
모든 row 갱신은 새 버전을 추가합니다. 기존 버전은 그대로 두고 새 row를 만들고, 이전 버전에 "이 트랜잭션에서 죽었음" 표시를 합니다. 같은 시점에 활성인 다른 트랜잭션은 죽은 버전을 계속 봅니다.
tuple 헤더의 xmin·xmax
1.5에서 본 heap tuple 헤더에는 xmin과 xmax 두 필드가 있습니다.
| 필드 | 의미 |
|---|---|
xmin | 이 row 버전을 만든 트랜잭션의 XID |
xmax | 이 row 버전을 무효화한 트랜잭션의 XID (없으면 0) |
INSERT/UPDATE/DELETE 패턴:
| 작업 | 결과 |
|---|---|
| INSERT | 새 tuple (xmin=현재 XID, xmax=0) |
| UPDATE | 새 tuple 추가 + 기존 tuple의 xmax = 현재 XID |
| DELETE | 기존 tuple의 xmax = 현재 XID (즉시 지우지 않음) |
이래서 DELETE 직후 디스크가 안 줄어듭니다. 실제 공간 회수는 VACUUM이 합니다.
직접 확인:
SELECT xmin, xmax, * FROM accounts WHERE id = 1;
-- xmin | xmax | id | balance
-- 50 | 0 | 1 | 1000
스냅샷
각 트랜잭션은 자기가 "어느 시점의 데이터를 봐야 하는지" 결정하는 스냅샷을 가집니다. 스냅샷은 다음으로 정의됩니다.
| 요소 | 의미 |
|---|---|
xmin | 이보다 작은 XID는 모두 완료된 것으로 봄 |
xmax | 이보다 큰 XID는 모두 미래 — 안 보임 |
xip (xip_list) | xmin~xmax 사이에서 아직 활성인 XID 목록 |
스냅샷 시점은 격리 수준에 따라 다릅니다.
- Read Committed (기본): 각 SQL 명령 시작 시점마다 새 스냅샷
- Repeatable Read / Serializable: 트랜잭션 첫 SQL 시점 기준 한 번만 스냅샷 잡고 끝까지 유지
SELECT pg_current_snapshot();
-- xmin:xmax:xip1,xip2,...
-- 4096:4100:4097,4098
가시성 판단 알고리즘
backend가 tuple 하나를 보고 "이걸 이 트랜잭션에 보여줘야 하는가?"를 결정하는 규칙:
visible(tuple, snapshot):
if tuple.xmin not committed:
return False # 아직 안 끝난 트랜잭션이 만들었음
if tuple.xmin > snapshot.xmax:
return False # 미래
if tuple.xmin in snapshot.xip:
return False # 동시에 진행 중
# 여기까지 왔으면 xmin은 통과 — 이제 xmax
if tuple.xmax == 0:
return True # 아직 살아있음
if tuple.xmax not committed:
return True # 죽이려던 트랜잭션이 안 끝남
if tuple.xmax > snapshot.xmax:
return True # 미래에 죽음
if tuple.xmax in snapshot.xip:
return True # 동시 진행 중이라 아직 죽음 미확정
return False # 이미 죽었음
이 규칙 덕분에 동시에 진행 중인 트랜잭션들이 서로 다른 row 버전을 보면서도 일관성을 유지할 수 있습니다.
ctid 체인
UPDATE는 새 tuple을 추가하면서 이전 tuple의 ctid(또는 t_ctid 필드)에 새 tuple 위치를 적습니다. 이게 update chain입니다.
이 체인이 길수록 SELECT가 같은 row를 찾기 위해 헤매는 거리가 길어집니다. HOT update가 같은 페이지 안에서 체인을 만들면 인덱스를 안 갱신해도 되어 매우 효율적입니다 (Part IV 4.3에서 자세히).
xmin horizon — VACUUM의 한계선
PostgreSQL이 dead tuple을 즉시 못 지우는 이유는 여전히 그 tuple을 봐야 하는 트랜잭션이 남아 있을 수 있어서입니다.
| 개념 | 의미 |
|---|---|
OldestXmin (=cluster xmin horizon) | 현재 active 트랜잭션 중 가장 오래된 스냅샷의 xmin |
| VACUUM이 지울 수 있는 tuple | xmax < OldestXmin 인 tuple만 |
긴 트랜잭션 한 개가 떠 있으면 OldestXmin이 그 자리에 묶여, 전체 클러스터의 vacuum이 멈춥니다. dead tuple이 쌓이고 BLOAT이 자랍니다.
-- 가장 오래된 xmin 추적
SELECT pid, state, xact_start, backend_xmin, query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY backend_xmin
LIMIT 5;
backend_xmin이 가장 작은 backend가 vacuum을 막고 있는 주범입니다.
replication slot도 xmin horizon을 잡는다. 로지컬 복제 슬롯이 hold하고 있는 catalog_xmin이 너무 오래 묶이면 vacuum이 멈춥니다. pg_replication_slots의 xmin·catalog_xmin 컬럼 주기 점검 필요합니다.
XID wraparound
XID는 32-bit 정수입니다. 약 21억 트랜잭션 후에 wrap합니다. PostgreSQL은 wrap 직전에 freeze를 통해 오래된 tuple의 xmin을 특수 값 FrozenTransactionId(= 2)로 바꿔, wrap 후에도 "이미 완료된 것"으로 인식하게 합니다.
| 한계 | 동작 |
|---|---|
autovacuum_freeze_max_age (기본 2억) | autovacuum이 강제로 freeze 시작 |
vacuum_freeze_table_age | aggressive vacuum 시점 |
| wraparound 임박 (XID < 1천만 남음) | EMERGENCY: 클러스터가 read-only로 강제 전환 |
운영 모니터링 필수 지표:
SELECT datname,
age(datfrozenxid) AS xid_age,
pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
ORDER BY xid_age DESC;
xid_age가 17억 가까이 가면 비상. autovacuum이 일을 못 하고 있다는 신호입니다.
운영자가 알아 둘 것
- DELETE/UPDATE가 디스크 공간을 안 줄이는 건 정상 — VACUUM이 공간 회수
idle in transaction한 세션이 클러스터 전체 vacuum을 막을 수 있음backend_xmin,pg_replication_slots.catalog_xmin은 BLOAT 진단 시 1순위 확인- 큰 테이블의 잦은 UPDATE는 BLOAT 폭증의 원인입니다. HOT update 가능하도록 인덱스를 최소화
정리
MVCC는 한 row의 여러 버전을 디스크에 공존시키고, 트랜잭션마다 자기 스냅샷에 맞는 버전을 보여 줍니다. 가시성은 tuple 헤더의 xmin·xmax와 (xmin, xmax, xip)로 정의된 스냅샷이 함께 판단하며, VACUUM이 dead tuple을 지울 수 있는 한계선은 클러스터 전체의 OldestXmin입니다. 긴 트랜잭션·잊혀진 prepared transaction·idle replication slot은 이 한계선을 붙잡아 vacuum을 막고, XID는 32-bit라 freeze로 wrap을 늦추므로 age(datfrozenxid) 모니터링이 핵심입니다.