MVCC — xmin, xmax, ctid로 이해하는 다중 버전 동시성
PostgreSQL MVCC의 핵심인 xmin(삽입 트랜잭션 ID), xmax(삭제/업데이트 트랜잭션 ID), ctid(물리 위치)의 역할, 튜플 버전 체인 구조, 스냅샷 기반 가시성 판단 알고리즘을 실무 쿼리와 함께 설명합니다.
지난 글에서 테이블 상속 구조를 살펴봤다. 이번에는 PostgreSQL의 동시성 제어의 심장부인 MVCC(Multi-Version Concurrency Control)를 xmin, xmax, ctid라는 시스템 컬럼으로 분해해 이해한다.
MVCC의 핵심 아이디어
PostgreSQL은 UPDATE와 DELETE 시 기존 튜플을 즉시 지우지 않는다. 대신 새 버전의 튜플을 추가하고, 어떤 트랜잭션이 어느 버전을 볼 수 있는지를 트랜잭션 ID(XID)로 판단한다.
이 방식의 장점:
- 읽기가 쓰기를 차단하지 않음: SELECT가 UPDATE를 기다리지 않는다.
- 쓰기가 읽기를 차단하지 않음: UPDATE가 SELECT를 기다리지 않는다.
- 과거 버전 조회 가능: 스냅샷 격리에서 트랜잭션 시작 시점의 데이터를 본다.
튜플 시스템 컬럼
각 튜플(행)은 사용자 컬럼 외에 숨겨진 시스템 컬럼을 가진다.
| 컬럼 | 의미 |
|---|---|
xmin | 이 버전을 삽입한 트랜잭션 ID |
xmax | 이 버전을 삭제/업데이트한 트랜잭션 ID (0=없음) |
ctid | 튜플의 물리적 위치 (페이지번호, 슬롯번호) |
cmin | 같은 트랜잭션 내 INSERT의 명령 순번 |
cmax | 같은 트랜잭션 내 DELETE의 명령 순번 |
-- 시스템 컬럼 직접 조회
SELECT xmin, xmax, ctid, id, name, age
FROM person
WHERE id = 1;
-- xmin | xmax | ctid | id | name | age
-- ------+------+-------+----+-------+-----
-- 102 | 0 | (0,3) | 1 | Alice | 32
버전 체인 예시
-- Txn 100: INSERT
BEGIN; -- xid = 100
INSERT INTO person (id, name, age) VALUES (1, 'Alice', 30);
COMMIT;
-- 튜플 (0,1): xmin=100, xmax=0
-- Txn 101: UPDATE
BEGIN; -- xid = 101
UPDATE person SET age = 31 WHERE id = 1;
COMMIT;
-- 튜플 (0,1): xmin=100, xmax=101 ← Dead
-- 튜플 (0,2): xmin=101, xmax=0 ← Live
-- Txn 102: UPDATE again
BEGIN; -- xid = 102
UPDATE person SET age = 32 WHERE id = 1;
COMMIT;
-- 튜플 (0,2): xmin=101, xmax=102 ← Dead
-- 튜플 (0,3): xmin=102, xmax=0 ← Live
ctid가 (페이지, 슬롯) 형태로 변경되는 것이 버전 체인을 나타낸다. Live 튜플의 ctid는 자기 자신을 가리키고, Dead 튜플의 ctid는 다음 버전을 가리킨다.
스냅샷과 가시성 판단
트랜잭션이 시작되면 스냅샷을 획득한다. 스냅샷은 세 값으로 표현된다.
xmin : xmax : xip_list
100 : 103 : 101,102
xmin: 이 값보다 작은 XID는 모두 커밋됨 (볼 수 있음)xmax: 이 값 이상의 XID는 모두 미시작 (볼 수 없음)xip_list: 그 사이에서 아직 진행 중인 트랜잭션 목록
-- 현재 스냅샷 조회
SELECT pg_current_snapshot();
-- 100:103:101,102
-- 특정 XID가 내 스냅샷 기준 커밋됐는지
SELECT pg_snapshot_xmin(pg_current_snapshot()); -- 100
SELECT pg_snapshot_xmax(pg_current_snapshot()); -- 103
가시성 알고리즘
튜플이 보이려면 두 조건을 모두 만족해야 한다.
- xmin이 커밋됨: xmin < 스냅샷 xmin, 또는 xmin이 xip에 없고 xmin < 스냅샷 xmax
- xmax가 미커밋(또는 0): xmax = 0, 또는 xmax > 스냅샷 xmax, 또는 xmax가 xip에 있음
-- 가시성 확인 유틸리티 (pageinspect 확장)
CREATE EXTENSION pageinspect;
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('person', 0));
t_infomask의 비트 플래그로 xmin 커밋 여부, xmax 커밋 여부를 확인할 수 있다.
UPDATE는 DELETE + INSERT
PostgreSQL의 UPDATE는 내부적으로 기존 튜플을 Dead로 표시 + 새 튜플 삽입이다.
-- UPDATE 전
SELECT ctid, xmin, xmax, age FROM person WHERE id = 1;
-- (0,3) | 102 | 0 | 32
-- UPDATE 실행
UPDATE person SET age = 33 WHERE id = 1;
-- UPDATE 후
SELECT ctid, xmin, xmax, age FROM person WHERE id = 1;
-- (0,4) | 103 | 0 | 33
-- 이전 위치 (Dead 튜플, VACUUM 전까지 남음)
-- (0,3): xmin=102, xmax=103
이 때문에 UPDATE가 많으면 Dead 튜플이 쌓이고, VACUUM이 이를 회수한다.
HOT Update — 같은 페이지면 ctid 체인
같은 힙 페이지 내에 업데이트가 가능하면 인덱스를 수정하지 않고 ctid 체인만 연결한다. 이를 HOT(Heap Only Tuple) Update라 한다.
-- HOT 통계 확인
SELECT n_tup_upd, n_tup_hot_upd,
round(n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0) * 100, 1) AS hot_pct
FROM pg_stat_user_tables
WHERE relname = 'person';
hot_pct가 높을수록 인덱스 업데이트 비용이 절감된다. 빈번하게 UPDATE하는 컬럼에 인덱스를 많이 걸면 HOT이 불가능해져 성능이 저하된다.
트랜잭션 ID 래핑 (XID Wraparound)
XID는 32비트 정수다. 약 21억 건을 넘으면 래핑(Wraparound) 위험이 발생한다. 오래된 트랜잭션이 “미래”로 보여 가시성 오류가 생긴다.
-- 가장 오래된 XID 확인
SELECT datname,
age(datfrozenxid) AS xid_age,
datfrozenxid
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
age(datfrozenxid)가 2억을 넘으면 경고, 20억이면 강제 종료. VACUUM FREEZE로 오래된 튜플의 xmin을 FrozenXID로 교체해 예방한다.
-- 강제 freeze
VACUUM FREEZE VERBOSE person;
정리
PostgreSQL MVCC는 “잠금 없는 읽기”를 위해 모든 변경을 새 버전 튜플로 추가하는 방식으로 동작한다. xmin은 이 버전을 만든 트랜잭션, xmax는 이 버전을 폐기한 트랜잭션, ctid는 현재 버전의 물리 주소다. 스냅샷 기준으로 xmin이 커밋됐고 xmax가 미커밋이면 그 튜플이 보인다. Dead 튜플은 VACUUM이 회수한다 — 이것이 다음 글의 주제다.
지난 글: 테이블 상속 — INHERITS와 파티셔닝의 뿌리
다음 글: VACUUM과 Dead 튜플 — 더티 공간 회수의 원리
읽어주셔서 감사합니다. 😊