PostgreSQL: MVCC
※ PostgreSQL: MVCC.
※ Version: PostgreSQL 17.
안녕하세요. 듀스트림입니다.
아니.. 이걸 포스팅 안했더라고요?
그래서 작성하는 오늘의 포스팅은 PostgreSQL의 MVCC 관련 내용입니다.
1. PostgreSQL MVCC란?
PostgreSQL은 고성능의 동시성 처리를 위해 MVCC (Multi-Version Concurrency Control) 방식을 채택했습니다.
이 방식은 트랜잭션이 데이터베이스의 '스냅샷'을 통해 데이터를 읽고, 동시에 다른 트랜잭션이 동일한 데이터에 대해 변경을 가하더라도 서로 간섭받지 않도록 하는 아키텍처입니다.
핵심 원리
- 각 트랜잭션은 자신이 '시작할 당시의 트랜잭션 ID' 상태를 스냅샷으로 보관합니다.
- 데이터를 변경하면 기존 튜플을 직접 수정하지 않고, 새로운 버전의 튜플을 생성합니다.
- 각 튜플에는 이를 만든(삭제한) 트랜잭션 ID (xmin, xmax)가 메타정보로 저장됩니다.
13.1. Introduction
13.1. Introduction # PostgreSQL provides a rich set of tools for developers to manage concurrent access to data. Internally, data consistency …
www.postgresql.org
2. 트랜잭션 ID(XID)의 부여와 증가 원리
PostgreSQL은 트랜잭션마다 고유한 32비트 정수형 XID(Transaction ID)를 부여합니다.
이 값은 전역적으로 증가하며, 서버 인스턴스의 PGPROC 구조체나 pg_control에서 현재 값을 관리합니다.
- txid_current() 함수를 통해 현재 트랜잭션의 ID 확인 가능합니다.
(이 함수를 호출하면 현재 트랜잭션에 XID가 할당되지 않은 경우에도 강제로 XID를 할당합니다.) - 최대값은 2³² - 1 (약 42억 9천만)입니다.
- 현재 트랜잭션 기준으로 약 21억 개(2³¹)의 과거 XID만을 유효 범위로 간주합니다.
- 이를 초과하면 XID가 wraparound되어 가시성 오류가 발생할 수 있으므로, VACUUM을 통해 오래된 XID는 Frozen 처리로 관리되어야 합니다.
- 트랜잭션이 첫 DML(INSERT/UPDATE/DELETE/SELECT FOR UPDATE 등)을 수행할 때 XID가 증가합니다.
- 읽기 전용(SELECT) 트랜잭션은 XID를 소비하지 않습니다.
XID Wraparound의 문제
XID가 순환되면, 오래된 트랜잭션과 새로운 트랜잭션의 구분이 어려워져 데이터의 가시성 문제가 발생할 수 있습니다.
이러한 문제를 방지하기 위해 PostgreSQL은 다음과 같은 보호 장치를 가지고 있습니다.
- VACUUM [FREEZE]: VACUUM 작업을 통해 오래된 튜플의 xid를 FrozenTransactionId(Frozen Bit)로 설정하여 wraparound를 방지합니다.
- AUTOVACUUM: autovacuum_freeze_max_age 등의 설정을 통해 원하는 시점에 자동으로 VACUUM 작업이 수행되도록 구성할 수 있습니다.
XID Wraparound 기준 요약
항목 | 값 |
XID 전체 범위 | 0 ~ 4,294,967,295 (2³² - 1) |
wraparound 기준 | ±2,147,483,648 (2³¹) |
안전 비교 가능 범위 | 현재 XID 기준으로 앞뒤 약 21억까지 |
판단 방식 | (xmin - 현재XID) → signed 32비트 해석 |
※ signed 32bit?
- signed는 "부호 있는"을 의미합니다. 즉, 이 데이터는 양수와 음수를 모두 표현할 수 있습니다.
- 양수와 음수를 모두 표현하려면, 한 비트(최상위 비트)가 부호 비트로 사용됩니다.
- 그래서 양수와 음수를 표현할 수 있는 값의 범위가 반반 나뉩니다.
범위 | 값 |
최소값 | -2,147,483,648 (−2³¹) |
최대값 | 2,147,483,647 (2³¹ − 1) |
총 개수 | 4,294,967,296 (2³²) |
3. 튜플의 구조: xmin, xmax, t_infomask
PostgreSQL의 각 튜플(row)은 다음과 같은 메타데이터를 포함합니다.
필드 | 설명 |
xmin | 이 튜플을 생성한 트랜잭션의 XID |
xmax | 이 튜플을 삭제하거나 갱신한 트랜잭션의 XID |
t_infomask | 각종 상태 비트 (힌트 비트, 락 상태, frozen 등) |
HeapTupleHeaderData 구조체 (C 코드)
typedef struct HeapTupleHeaderData {
...
TransactionId t_xmin; // 생성 트랜잭션
TransactionId t_xmax; // 삭제 트랜잭션
uint16 t_infomask; // 상태 플래그
...
} HeapTupleHeaderData;
4. 트랜잭션 가시성(Visibility) 판단 방식
기본 개념
- 언제 생성되었는지 (누가 INSERT 했는지 → xmin)
- 언제 삭제되었는지 (누가 DELETE 했는지 → xmax)
튜플이 보이기 위한 조건
순서 | 조건 | 의미 |
1 | xmin은 현재 트랜잭션보다 과거이고 | 튜플이 내 트랜잭션보다 먼저 만들어졌어야 함 |
2 | xmin 트랜잭션은 커밋되었고 | 만든 트랜잭션이 성공적으로 끝났어야 함 |
3 | xmax가 비어 있거나(=삭제 안 됨), 또는 xmax 트랜잭션이 커밋되지 않았거나 내가 한 것 |
삭제되지 않은 튜플이어야 함 |
가시성 조건 흐름
순서 | 조건 | 의미 |
1 | xmin < 현재 트랜잭션 | 미래 튜플이면 → 가시하지 않음 |
2 | xmin 커밋 상태 | 실패 트랜잭션이면 → 가시하지 않음 |
3 | xmax 없음 or 아직 미커밋 | 삭제되지 않았으므로 → 가시함 |
4 | xmax 커밋 상태 | 삭제된 것으로 간주 → 가시하지 않음 |
※ 이 흐름은 성공 가능성이 낮은 조건부터 먼저 걸러내는 방식
- xmin > 내 XID → 이 튜플은 아직 미래에 생성될 것이므로 바로 skip
- xmin 커밋 안 됨 → 아직 트랜잭션 진행 중이면 visible하지 않음
- xmax 존재 여부 → 삭제 여부는 마지막에 확인
XID 가시성 판단 공식
TransactionIdPrecedes(xid1, xid2) ⇨ ((int32)(xid1 - xid2)) < 0
// xid1이 xid2보다 과거인지 판단할 때 사용합니다.
// 튜플의 xmin이 현재 트랜잭션보다 과거면 → 가시함
// (xid1 - xid2)가 signed 32비트 정수로 처리되기 때문에, 2³¹만큼 차이가 나면 양수에서 음수로 넘어가게 됩니다.
- (xmin - 현재XID) 를 32비트 signed 정수로 해석했을 때 음수이면 → 가시함
- 양수이면 → 미래로 간주 → 가시하지 않음
XID 가시성 판단 예제 표
현재 XID | 튜플의 xmin | 계산 결과(signed) | Visibility | 설명 |
11 | 100 | +89 | ✅ | 정상 과거 |
11 | 2³¹ - 1 | +2,147,483,636 | ❌ | wraparound 범위 초과 |
11 | 2³² - 6 | -17 | ✅ | wraparound 발생 후지만 과거 |
100 | 90 | -10 | ✅ | 정상 과거 |
100 | 2³¹ | +2,147,483,548 | ❌ | wraparound 기준 초과 |
100 | 4,294,967,295 | -101 | ✅ | wraparound 직전 ID, 과거로 간주 |
5. 힌트 비트 (Hint Bit)
힌트 비트는 t_infomask에 기록되며, 트랜잭션의 상태가 pg_xact 파일을 매번 조회하지 않도록 캐시된 정보입니다.
비트 플래그 | 의미 |
HEAP_XMIN_COMMITTED | xmin 트랜잭션이 커밋됨 |
HEAP_XMIN_INVALID | xmin 트랜잭션이 실패(abort)함 |
HEAP_XMAX_COMMITTED | xmax 트랜잭션이 커밋됨 (즉, 이 튜플은 삭제됨) |
HEAP_XMAX_INVALID | xmax 트랜잭션이 실패함 |
HEAP_XMIN_FROZEN | xmin이 FrozenTransactionId로 취급됨 (21억 초과 대비) |
힌트 비트가 필요한 이유
각 트랜잭션이 튜플을 읽을 때마다 pg_xact에서 트랜잭션 상태(커밋/롤백 여부)를 확인하면 I/O 부담이 큽니다.
그래서 PostgreSQL은 힌트 비트(Hint Bit)를 사용해 "이 트랜잭션은 이미 커밋됐다"는 사실을 튜플 헤더에 표시해둡니다.
→ 다음번 SELECT에서는 pg_xact 확인 없이 빠르게 처리 가능해집니다.
힌트 비트의 특징
- 트랜잭션이 해당 튜플을 읽는 순간 비트를 설정함 (SELECT 등)
- WAL에는 기본적으로 기록되지 않음 → 장애 시 손실 가능
- wal_log_hints = on 이면 WAL에 기록하여 복제에서도 동기화 가능
6. MVCC와 복제, WAL, 힌트 비트의 상호작용
스탠바이(Hot Standby) 환경에서 힌트 비트는 WAL에 기록되지 않으면 전파되지 않기 때문에 다음과 같은 설정이 필요합니다.
wal_log_hints
- 힌트 비트를 WAL에 강제 기록
- pg_rewind 사용을 위해도 필요함
Streaming Replication 연계
- 기본적으로 힌트 비트는 스탠바이에서 새로 설정해야 하며, 이로 인해 WAL에서 반복적으로 동일 페이지가 재기록됨
- 힌트 비트는 복제 환경에서 checkpoint 이후 full page write 발생 가능
7. 프로즌 비트 (Frozen Bit)
트랜잭션 ID는 32비트로, 약 21억 개의 트랜잭션 후 wraparound가 발생합니다.
이를 방지하기 위해 PostgreSQL은 오래된 튜플의 xmin 값을 아예 "영구 커밋" 상태로 마킹합니다.
동작 방식
- VACUUM FREEZE 또는 autovacuum이 오래된 튜플을 탐색
- 해당 튜플의 t_infomask에 HEAP_XMIN_FROZEN 비트를 설정하거나,
- xmin 값을 FrozenTransactionId(값: 2)로 설정
관련 설정값
- vacuum_freeze_min_age
- vacuum_freeze_table_age
- autovacuum_freeze_max_age
8. pg_xact: 트랜잭션 상태 저장소
pg_xact 디렉토리는 각 트랜잭션 ID에 대한 상태(COMMITTED, ABORTED 등)를 2비트로 저장합니다.
VACUUM이나 힌트 비트 설정 없이도 PostgreSQL은 이 파일을 참조하여 트랜잭션의 상태를 확인할 수 있습니다.
그러나 pg_xact 조회는 디스크 I/O를 유발하므로, 힌트 비트를 적극 사용하는 것이 성능상 유리합니다.
9. FSM(Free Space Map) & Visibility Map
VACUUM에 등장할 내용이지만 이해를 돕기 위해 간략하게 작성합니다.
FSM (Free Space Map)
- FSM은 테이블 블록마다 남은 여유 공간을 추적합니다.
- PostgreSQL은 INSERT 시 새로운 페이지를 할당하기 전 FSM을 참고하여 빈 공간이 있는 페이지를 재사용합니다.
- FSM은 VACUUM이 정리된 페이지의 여유 공간을 반영하여 업데이트합니다.
Visibility Map (VM)
- VM은 각 페이지가 모든 튜플이 visible하고 frozen 상태인지 여부를 기록합니다.
- VACUUM은 VM을 참고하여 이미 모두 frozen된 페이지는 건너뜁니다.
- Index Only Scan은 VM을 통해 heap 접근 없이 인덱스만으로 결과를 반환할 수 있는지를 판단합니다.
10. 팁
- 힌트 비트로 인해 SELECT만 수행해도 디스크 write가 발생할 수 있음 → Write spike 주의
- ORDER BY로 인한 테이블 풀스캔 + 힌트 비트 update로 예상치 못한 IOPS 증가 가능
- 힌트 비트를 미리 설정하려면 자주 사용하는 테이블에 대해 VACUUM 주기적으로 실행
- 힌트 비트는 VM(Visibility Map)과 연계되어 index-only scan 가능성 결정에도 영향
다음 주제는 자연스럽게 VACUUM으로 하겠습니다.
오늘은 여기까지~