본문으로 건너뛰기

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 헤더에는 xminxmax 두 필드가 있습니다.

필드의미
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이 지울 수 있는 tuplexmax < 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_slotsxmin·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_ageaggressive 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)을 봅니다.