PostgreSQL

PostgreSQL: MVCC

dewstream 2025. 5. 16. 08:00

※ 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 커밋 상태 삭제된 것으로 간주 → 가시하지 않음

 

※ 이 흐름은 성공 가능성이 낮은 조건부터 먼저 걸러내는 방식

  1. xmin > 내 XID → 이 튜플은 아직 미래에 생성될 것이므로 바로 skip
  2. xmin 커밋 안 됨 → 아직 트랜잭션 진행 중이면 visible하지 않음
  3. 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으로 하겠습니다.

 

오늘은 여기까지~