3.2 MVCC와 스냅샷
PostgreSQL의 동시성 제어 핵심은 MVCC(Multi-Version Concurrency Control)다. 한 row가 여러 버전으로 디스크에 공존하고, 각 트랜잭션은 자기 스냅샷에 맞는 버전을 골라서 봅니다. 락은 가능한 한 안 잡는다 — 그래서 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않습니다.
핵심 아이디어
모든 row 갱신은 새 버전을 추가합니다. 기존 버전은 그대로 두고 새 row를 만들고, 이전 버전에 “이 트랜잭션에서 죽었음” 표시를 합니다. 같은 시점에 활성인 다른 트랜잭션은 죽은 버전을 계속 봅니다.
sequenceDiagram
participant T1 as Tx 100
participant T2 as Tx 101
participant Heap
T1->>Heap: SELECT * FROM accounts WHERE id=1
Heap-->>T1: (balance=1000, xmin=50, xmax=0)
T2->>Heap: UPDATE accounts SET balance=900 WHERE id=1
Note over Heap: 새 버전 추가<br/>(balance=900, xmin=101, xmax=0)<br/>이전 버전: xmax=101 표시
Heap-->>T2: UPDATE 1
T1->>Heap: SELECT * FROM accounts WHERE id=1
Note over T1,Heap: T1은 자기 스냅샷 기준<br/>여전히 1000을 본다
Heap-->>T1: (balance=1000)
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입니다.
flowchart LR
V1["tuple v1<br/>xmin=50, xmax=101<br/>balance=1000"]
V2["tuple v2<br/>xmin=101, xmax=105<br/>balance=900"]
V3["tuple v3<br/>xmin=105, xmax=0<br/>balance=950"]
V1 -->|t_ctid| V2 -->|t_ctid| V3
classDef dead fill:#f3f4f6,stroke:#4b5563,color:#1f2937
classDef live fill:#d1fae5,stroke:#047857,color:#064e3b
class V1,V2 dead
class V3 live
이 체인이 길수록 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을 막고 있는 주범입니다.
catalog_xmin이 너무 오래 묶이면 vacuum이 멈춥니다. pg_replication_slots의 xmin·catalog_xmin 컬럼 주기 점검 필요합니다.XID wraparound
XID는 32-bit 정수입니다. 약 21억 트랜잭션 후에 wrap합니다. PostgreSQL은 wrap 직전에 freeze를 통해 오래된 tuple의 xmin을 특수 값 FrozenTransactionId(= 2)로 바꿔, wrap 후에도 “이미 완료된 것"으로 인식하게 합니다.
flowchart LR
C["현재 XID 카운터"]
F["freeze 한계<br/>(XID - autovacuum_freeze_max_age)"]
W["wraparound 위험 한계"]
E["EMERGENCY<br/>read-only mode"]
C --> F --> W --> E
classDef ok fill:#d1fae5,stroke:#047857,color:#064e3b
classDef warn fill:#fed7aa,stroke:#c2410c,color:#7c2d12
classDef bad fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d
class C ok
class F,W warn
class E bad
| 한계 | 동작 |
|---|---|
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)3개로 정의 - VACUUM이 지울 수 있는 한계는 클러스터 전체의
OldestXmin - 긴 트랜잭션·잊혀진 prepared transaction·idle replication slot은 모두 vacuum 차단 요인
- XID는 32-bit, freeze로 wrap을 막음. 모니터링 핵심은
age(datfrozenxid)
다음 절(3.3)에서는 이 MVCC 위에 얹힌 격리 수준(Read Committed, Repeatable Read, Serializable)을 봅니다.