PostgreSQL의 MVCC를 처음 보면 보통 이렇게 설명한다.
UPDATE가 발생하면 기존 행을 덮어쓰는 대신 새로운 버전을 만든다.
틀린 설명은 아니다.
실제로 PostgreSQL에서 UPDATE는 기존 튜플의 데이터를 그 자리에서 수정하는 방식보다는 새로운 튜플 버전을 생성하는 방식으로 동작한다.
그런데 MVCC를 여기까지만 이해하면 몇 가지 질문이 남는다.
- 새 버전과 이전 버전이 동시에 존재한다면 SELECT는 어떤 것을 가져올까?
- PostgreSQL은 내가 어떤 버전을 볼 수 있는지 어떻게 판단할까?
xmin,xmax만 비교하면 되는 걸까?- 오래된 버전은 언제 삭제될까?
- VACUUM은 정확히 무엇을 정리하는 걸까?
- UPDATE가 많으면 왜 테이블과 인덱스가 부풀까?
- HOT Update는 이 구조에서 정확히 무엇을 최적화하는 걸까?
조금 더 깊게 들어가 보면 PostgreSQL MVCC의 핵심은 단순한 행 버전 관리가 아니다.
더 정확히 말하면,
현재 트랜잭션이 어떤 튜플 버전을 볼 수 있는지를 판단하는 규칙
이 MVCC의 핵심에 가깝다.
PostgreSQL은 이 판단을 위해 각 튜플에 트랜잭션 정보를 저장하고, 쿼리가 가지고 있는 Snapshot과 비교한다.
대략적으로 보면 SELECT 중에는 이런 일이 반복된다.
Heap / Index에서 tuple 후보 발견
│
▼
Tuple Header 확인
│
┌───────┴────────┐
│ │
xmin xmax
│ │
└───────┬────────┘
▼
Transaction 상태 확인
│
▼
Snapshot과 비교
│
▼
지금 이 tuple이 보이는가?
┌───┴───┐
YES NO
│ │
반환 무시
이걸 이해하기 시작하면 VACUUM, HOT, Index Only Scan, 심지어 오래 열린 트랜잭션이 왜 위험한지까지 한 흐름으로 연결된다.
PostgreSQL에서 우리가 보는 행과 실제 튜플은 다르다
다음과 같은 테이블이 있다고 해보자.
CREATE TABLE users (
id bigint PRIMARY KEY,
name text NOT NULL
);
INSERT INTO users VALUES (1, 'Kim');
평범하게 조회하면:
SELECT * FROM users;
결과는 당연히 이렇다.
id | name
----+------
1 | Kim
SQL만 사용하고 있다면 PostgreSQL이 id, name 두 값만 저장하고 있는 것처럼 보인다.
하지만 실제 heap tuple에는 사용자 데이터 외에 MVCC를 위한 메타데이터가 붙어 있다.
PostgreSQL 소스에서는 이런 정보를 담고 있는 구조를 HeapTupleHeader 계열의 구조로 다룬다.
대표적으로 다음과 같은 정보가 중요하다.
t_xmin
t_xmax
t_cid
t_ctid
t_infomask
t_infomask2
SQL에서도 일부는 시스템 컬럼을 통해 볼 수 있다.
SELECT xmin, xmax, ctid, *
FROM users;
예를 들어:
xmin | xmax | ctid | id | name
------+------+-------+----+------
820 | 0 | (0,1) | 1 | Kim
같이 나올 수 있다.
여기서부터 PostgreSQL의 MVCC가 시작된다.
xmin
xmin은 현재 튜플 버전을 생성한 트랜잭션의 ID다.
예를 들어:
xmin = 820
이라면 이 튜플은 820번 트랜잭션에 의해 만들어졌다는 의미다.
INSERT뿐 아니라 UPDATE에 의해 새로운 버전이 생성될 때도 새로운 xmin이 기록된다.
하지만 여기서 중요한 점이 있다.
PostgreSQL은 단순히:
xmin이 현재 transaction보다 작음
→ 과거 데이터
→ 보임
이렇게 판단하지 않는다.
820번 트랜잭션이 실제로 성공했는지도 알아야 한다.
예를 들어:
Transaction 820
│
├─ COMMITTED
├─ ABORTED
└─ IN PROGRESS
세 상태는 완전히 다른 의미를 가진다.
xmin = 820인데 820번 트랜잭션이 rollback되었다면 해당 튜플은 정상적인 다른 트랜잭션에서는 보여서는 안 된다.
따라서 tuple visibility 판단에는 Transaction ID와 Transaction 상태가 둘 다 필요하다.
xmax
xmax는 조금 더 복잡하다.
입문 수준에서는 보통:
이 튜플을 삭제하거나 UPDATE한 트랜잭션 ID
라고 설명한다.
예를 들어 아직 아무도 이 튜플을 제거하지 않았다면 보통:
xmax = 0
처럼 보인다.
하지만:
UPDATE users
SET name = 'Lee'
WHERE id = 1;
이 실행되면 기존 Kim 튜플에는 UPDATE를 수행한 트랜잭션 정보가 xmax 쪽에 들어간다.
개념적으로:
OLD TUPLE
xmin = 820
xmax = 900
name = Kim
그리고 새 튜플은:
NEW TUPLE
xmin = 900
xmax = 0
name = Lee
가 된다.
다만 실제 PostgreSQL에서 xmax의 의미는 이보다 더 넓다.
나중에 보겠지만 row lock이 여러 개 걸리는 경우에는 xmax가 단순 Transaction ID가 아니라 MultiXact ID를 의미할 수도 있다.
그래서 내부 구현을 분석할 때는:
xmax != 0
이라는 사실만 보고
이 튜플은 삭제됨
이라고 단정하면 안 된다.
t_infomask 같은 상태 비트도 같이 확인해야 한다.
ctid
ctid는 현재 튜플이 heap 안에서 어디에 있는지를 나타낸다.
(0,1)
이라면 대략:
0번 heap page
1번 item
정도로 보면 된다.
중요한 점은 ctid가 row의 영구적인 ID가 아니라는 것이다.
UPDATE로 새 튜플 버전이 생성되면 새 tuple은 다른 위치에 저장될 수 있다.
예를 들어:
Kim
ctid = (0,1)
UPDATE
Lee
ctid = (0,2)
처럼 바뀔 수 있다.
그래서 ctid를 Primary Key처럼 사용하는 것은 안전하지 않다.
그런데 ctid는 단순히 현재 물리적 위치만 나타내는 데 끝나지 않는다.
UPDATE된 row의 old tuple은 새로운 tuple version을 가리키는 형태로 연결될 수 있다.
(0,1)
│
▼
(0,2)
│
▼
(0,3)
이 구조는 이후 HOT Update를 볼 때 다시 등장한다.
UPDATE는 실제로 무엇을 하는가
현재 users 테이블이 다음과 같다고 해보자.
SELECT xmin, xmax, ctid, *
FROM users;
xmin | xmax | ctid | id | name
------+------+-------+----+------
820 | 0 | (0,1) | 1 | Kim
이 상태에서:
UPDATE users
SET name = 'Lee'
WHERE id = 1;
를 실행한다.
일반 파일이나 메모리 값을 수정하는 방식으로 생각하면:
Kim
↓
Lee
처럼 Kim이라는 문자열이 있던 자리를 Lee로 덮어쓸 것 같다.
PostgreSQL은 기본적으로 그렇게 하지 않는다.
개념적으로는 다음 상태가 된다.
Tuple A
ctid = (0,1)
xmin = 820
xmax = 900
name = Kim
Tuple B
ctid = (0,2)
xmin = 900
xmax = 0
name = Lee
즉 UPDATE는 꽤 거칠게 표현하면:
기존 tuple의 lifetime 종료 표시
+
새 tuple INSERT
에 가깝다.
이 구조 덕분에 이전 데이터를 보고 있는 트랜잭션과 새로운 데이터를 봐야 하는 트랜잭션이 동시에 존재할 수 있다.
그런데 왜 두 행이 나오지 않을까
물리적으로 두 tuple version이 존재한다면:
SELECT * FROM users;
결과가:
1 | Kim
1 | Lee
두 줄이어야 할 것 같다.
하지만 실제로는 하나만 나온다.
이걸 결정하는 것이 Tuple Visibility다.
PostgreSQL executor가 heap tuple 후보를 찾았다고 해서 바로 반환하는 것이 아니다.
먼저:
이 tuple이 현재 Snapshot에서 visible한가?
를 확인한다.
PostgreSQL 코드까지 내려가면 이런 계열의 판단은 HeapTupleSatisfiesMVCC() 같은 함수와 연결된다.
함수 이름 그대로다.
이 heap tuple이 MVCC 규칙을 만족하는가?
를 판단한다.
PostgreSQL에서 MVCC를 깊게 볼 때 가장 중요한 지점 중 하나다.
Snapshot은 데이터베이스 복사본이 아니다
MVCC 설명에서 Snapshot을 흔히:
트랜잭션 시작 시점에 데이터베이스 사진을 찍는다.
라고 표현한다.
이해하기에는 좋은 비유다.
하지만 실제 내부 동작을 생각하면 오해할 수 있다.
100GB짜리 데이터베이스에서 트랜잭션 하나를 열 때마다 100GB를 복사할 수는 없다.
PostgreSQL Snapshot은 데이터 복사본이 아니라 visibility를 판단하기 위한 트랜잭션 상태의 경계 정보다.
단순화하면 Snapshot은 이런 정보를 가진다고 볼 수 있다.
xmin
xmax
현재 실행 중인 Transaction ID 목록
예를 들어 Snapshot을 얻었을 때:
xmin = 100
xmax = 110
in-progress:
103
105
108
이었다고 해보자.
이걸 아주 단순하게 표현하면:
100보다 오래된 영역
│
▼
┌──────────────────────────────┐
100 101 102 103 104 105 ... 109 110
▲ ▲ ▲
running running running
└──────────────────────────────┘
▲
│
snapshot 이후 영역
여기서 어떤 tuple의:
xmin = 105
라고 하자.
숫자만 보면:
105 < 110
이다.
그렇다고 이 tuple이 무조건 보이지는 않는다.
Snapshot이 생성되는 순간 105번 트랜잭션은 아직 진행 중이었다.
따라서 해당 Snapshot에서 105가 만든 tuple은 visible하지 않을 수 있다.
이게 MVCC를 단순 Transaction ID 비교로 이해하면 안 되는 이유다.
Snapshot은 어떻게 만들어질까
여기서 한 단계 더 내려가 볼 수 있다.
PostgreSQL은 Snapshot을 만들기 위해 현재 DB에서 어떤 트랜잭션들이 실행 중인지 알아야 한다.
PostgreSQL은 연결마다 backend process를 사용하고, 이 프로세스들의 transaction 관련 상태를 shared memory에 관리한다.
이 흐름에서 자주 등장하는 구조가 ProcArray다.
대략적으로 보면:
Backend 1
Transaction 120
Backend 2
Transaction 124
Backend 3
Transaction 없음
Backend 4
Transaction 129
같은 상태를 PostgreSQL이 알고 있다.
Snapshot을 구성할 때는 이런 현재 실행 중인 transaction 정보를 바탕으로 visibility 경계를 만든다.
PostgreSQL 소스를 따라가면 GetSnapshotData()라는 함수 이름을 만나게 된다.
개념적으로는:
GetSnapshotData()
│
▼
현재 backend들의
transaction 상태 확인
│
▼
현재 실행 중인 XID 수집
│
▼
snapshot xmin / xmax 결정
│
▼
Snapshot 생성
형태다.
즉 Snapshot 하나를 얻는 것도 timestamp 하나 읽는 수준의 단순한 작업은 아니다.
Transaction 상태는 어디서 확인할까
Tuple에:
xmin = 820
이 기록되어 있다고 하자.
820번 Transaction이 실제로 commit되었는지를 알아야 한다.
PostgreSQL은 Transaction의 commit/abort 상태를 별도로 관리한다.
예전 PostgreSQL 관련 자료에서는 이 영역을 흔히 CLOG라고 부른다.
현재 데이터 디렉터리에서는 pg_xact라는 이름을 볼 수 있다.
개념적으로는:
817 → COMMITTED
818 → ABORTED
819 → COMMITTED
820 → COMMITTED
821 → IN PROGRESS
같은 상태가 저장되어 있다고 보면 된다.
Tuple visibility 판단 중 PostgreSQL은 필요하면 이 정보를 확인한다.
그런데 여기서 또 문제가 생긴다.
모든 SELECT가 pg_xact를 확인한다면
테이블에 1천만 개의 row가 있다고 해보자.
각 tuple마다:
xmin 확인
→ pg_xact 확인
→ commit인지 확인
을 계속 반복한다면 비용이 꽤 크다.
PostgreSQL은 이 비용을 줄이기 위해 Hint Bit를 사용한다.
Tuple header의 t_infomask에는 Transaction 상태와 관련된 힌트가 기록될 수 있다.
예를 들어 개념적으로:
HEAP_XMIN_COMMITTED
같은 bit가 설정되어 있다면:
이 tuple을 생성한 transaction은 commit된 것으로 이미 확인했음
이라는 사실을 tuple 자체에서 빠르게 알 수 있다.
그러면 다음 접근에서 다시 pg_xact까지 내려갈 필요가 줄어든다.
첫 접근
tuple
│
├─ xmin = 820
│
▼
pg_xact 확인
│
▼
820 = COMMITTED
│
▼
Hint Bit 기록
다음 접근
tuple
│
├─ HEAP_XMIN_COMMITTED
│
▼
바로 판단
여기서 재미있는 부분이 하나 있다.
우리가 SQL 수준에서는:
SELECT ...
를 완전히 read-only 작업이라고 생각한다.
하지만 PostgreSQL 내부에서는 SELECT 도중 transaction 상태를 확인하고 hint bit를 설정하면서 page 상태가 바뀔 수 있다.
물론 실제 WAL 기록 여부나 page dirty 처리에는 설정과 상황에 따른 세부 조건이 있다.
중요한 점은:
SQL에서 데이터를 수정하지 않는 SELECT와 storage page가 절대로 변하지 않는다는 것은 같은 의미가 아니다.
라는 것이다.
READ COMMITTED와 REPEATABLE READ
이제 Snapshot을 알고 나면 두 Isolation Level의 차이가 훨씬 단순해진다.
READ COMMITTED
PostgreSQL의 기본 Isolation Level이다.
BEGIN;
SELECT * FROM users;
첫 SELECT가 시작될 때 사용할 Snapshot을 얻는다.
결과:
Kim
이 상태에서 다른 세션이:
UPDATE users
SET name = 'Lee'
WHERE id = 1;
COMMIT;
한다.
그리고 기존 transaction에서 다시:
SELECT * FROM users;
를 실행한다.
이번 statement에서는 새로운 Snapshot을 사용한다.
따라서:
Lee
가 보일 수 있다.
구조를 그리면:
Transaction A
SELECT
│
└── Snapshot #1
│
└── Kim
Transaction B
UPDATE Kim → Lee
COMMIT
Transaction A
SELECT
│
└── Snapshot #2
│
└── Lee
READ COMMITTED에서 중요한 기준은 transaction 전체가 아니라 statement 단위 Snapshot이다.
REPEATABLE READ
이번에는:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM users;
를 실행한다.
여기서 Snapshot을 하나 얻었다고 한다.
Snapshot #1
이후 다른 transaction이:
Kim → Lee
로 바꾸고 commit한다.
하지만 기존 transaction에서 다시 SELECT해도:
Kim
이 보인다.
왜냐하면 transaction이 계속 동일한 visibility 기준을 사용하기 때문이다.
Transaction A
SELECT
│
└── Snapshot #1
│
└── Kim
Transaction B
UPDATE Kim → Lee
COMMIT
Transaction A
SELECT
│
└── Snapshot #1
│
└── Kim
“REPEATABLE READ는 옛날 데이터를 캐시에 저장한다”가 아니다.
같은 Snapshot을 사용하기 때문에 같은 tuple version이 visible한 것이다.
같은 Transaction 내부에서는 Command ID도 필요하다
여기까지 보면 visibility는:
xmin
xmax
Snapshot
세 가지만 보면 되는 것처럼 보인다.
그런데 같은 Transaction 안에서 여러 statement를 실행하면 또 다른 문제가 생긴다.
BEGIN;
INSERT INTO users VALUES (2, 'Park');
SELECT * FROM users;
COMMIT;
INSERT와 SELECT는 같은 Transaction ID를 사용한다.
하지만 SELECT는 앞에서 실행한 INSERT 결과를 볼 수 있어야 한다.
그래서 PostgreSQL은 transaction 내부의 command 순서도 관리한다.
개념적으로:
Transaction 1000
Command 0
INSERT
Command 1
SELECT
Command 2
UPDATE
같은 식이다.
Tuple header에는 이와 관련된 Command ID 정보도 사용된다.
즉 visibility는 단순히:
트랜잭션 A가
트랜잭션 B보다 먼저 실행됐는가?
만의 문제가 아니다.
같은 Transaction 내부에서도:
이 tuple을 만드는 command가
현재 command보다 먼저 실행됐는가?
라는 순서가 필요하다.
UPDATE가 반복되면
다음 SQL을 실행한다고 해보자.
UPDATE users SET name = 'Lee' WHERE id = 1;
UPDATE users SET name = 'Park' WHERE id = 1;
UPDATE users SET name = 'Choi' WHERE id = 1;
겉으로 보이는 데이터는:
Choi
하나다.
하지만 heap 내부에는 한동안 여러 버전이 존재할 수 있다.
Version 1
Kim
xmin = 820
xmax = 900
Version 2
Lee
xmin = 900
xmax = 910
Version 3
Park
xmin = 910
xmax = 920
Version 4
Choi
xmin = 920
xmax = 0
이때 흔히 Kim, Lee, Park를 전부 Dead Tuple이라고 부른다.
하지만 정확히 말하면 UPDATE 직후부터 무조건 제거 가능한 tuple은 아니다.
이전 Tuple은 언제 정말 필요 없어질까
다음 상황을 보자.
T1
REPEATABLE READ
Kim을 보는 Snapshot 유지
그 상태에서:
T2
Kim → Lee
COMMIT
T3
Lee → Park
COMMIT
이 발생했다고 한다.
최신 transaction에서는:
Park
만 필요하다.
하지만 T1에서는 여전히:
Kim
이 보여야 한다.
따라서 PostgreSQL 입장에서 Kim tuple을 아직 없애면 안 된다.
중요한 기준은:
이 tuple이 최신 데이터인가?
가 아니다.
진짜 기준은:
살아 있는 어떤 Snapshot이라도
이 tuple을 볼 가능성이 있는가?
이다.
이 조건 때문에 MVCC에서는 garbage collection이 생각보다 까다롭다.
오래 열린 Transaction이 문제가 되는 이유
예를 들어 애플리케이션이:
BEGIN;
SELECT * FROM users;
까지 실행한 다음 실수로 COMMIT이나 ROLLBACK을 하지 않았다고 하자.
세션은 아무 SQL도 실행하지 않는다.
CPU 사용량도 거의 없다.
그래서 별 문제 없어 보일 수 있다.
하지만 오래된 Snapshot이 살아 있다면 상황이 달라진다.
그 사이 다른 transaction에서는 계속:
A → B
B → C
C → D
D → E
...
UPDATE가 발생한다.
그런데 오래된 transaction이 이전 tuple을 볼 가능성이 있기 때문에 VACUUM이 해당 영역을 정리하지 못할 수 있다.
오래된 Transaction
│
│
▼
Visibility Horizon 정체
│
▼
이전 Tuple 제거 지연
│
▼
Dead Tuple 증가
│
▼
Heap Bloat
│
▼
더 많은 Page 접근
│
▼
Buffer Cache 효율 저하
│
▼
I/O 증가
그래서 PostgreSQL 운영 중:
idle in transaction
상태가 오래 지속되는 connection을 주의해서 본다.
단순히 connection 하나가 놀고 있는 문제가 아닐 수 있다.
MVCC의 garbage collection 자체를 방해할 수 있기 때문이다.
VACUUM은 “삭제된 Row를 지우는 작업”인가
절반 정도만 맞다.
일반적인 VACUUM은 테이블 파일을 통째로 다시 작성해서 크기를 줄이는 작업이 아니다.
핵심은:
더 이상 어떤 Snapshot에서도 필요하지 않은 Tuple이 사용하던 공간을 재사용할 수 있게 만드는 것
에 가깝다.
예를 들어 heap page가 다음 상태라고 하자.
┌─────────────────────┐
│ Tuple A LIVE │
│ Tuple B DEAD │
│ Tuple C DEAD │
│ Tuple D LIVE │
│ Free Space │
└─────────────────────┘
VACUUM 이후 개념적으로:
┌─────────────────────┐
│ Tuple A LIVE │
│ Reusable Space │
│ Reusable Space │
│ Tuple D LIVE │
│ Free Space │
└─────────────────────┘
가 된다.
이 공간은 PostgreSQL이 이후 INSERT나 UPDATE에서 다시 사용할 수 있다.
그래서 일반적인 VACUUM을 했다고:
users 파일 10GB
→ VACUUM
→ 3GB
처럼 파일 크기가 바로 줄어드는 것은 아니다.
VACUUM FULL은 전혀 다른 작업이다
VACUUM FULL users;
는 일반 VACUUM과 성격이 상당히 다르다.
VACUUM FULL은 테이블을 다시 작성해서 실제 파일 크기를 줄일 수 있다.
대신:
- 작업 비용이 크고
- 강한 lock을 요구하며
- 운영 중인 큰 테이블에서는 부담이 크다.
그래서:
VACUUM
과:
VACUUM FULL
을 단순히 약한 버전 / 강한 버전으로 보면 안 된다.
실제 운영에서는 성격이 다른 작업으로 보는 편이 맞다.
모든 Tuple 정리를 VACUUM만 하는 것은 아니다
MVCC의 오래된 tuple이 정리되는 과정은 VACUUM 하나로 끝나지 않는다.
PostgreSQL은 heap page에 접근하면서 상황에 따라 Page Pruning을 수행할 수 있다.
특히 UPDATE로 version chain이 길어졌을 때:
Tuple A
│
▼
Tuple B
│
▼
Tuple C
│
▼
Tuple D
과거 version 중 명백하게 더 이상 필요 없는 것을 page 내부에서 정리할 수 있다.
그래서 PostgreSQL의 tuple cleanup을:
VACUUM이 와서
Dead Tuple을 모두 지운다
라고만 보면 실제 구현과 차이가 있다.
더 정확하게는:
Heap Pruning
+
VACUUM
+
Index Cleanup
여러 단계가 함께 동작한다.
HOT Update
PostgreSQL의 MVCC 구조에서 상당히 중요한 최적화가 하나 있다.
HOT(Heap-Only Tuple) Update다.
다음 테이블이 있다고 해보자.
CREATE TABLE users (
id bigint PRIMARY KEY,
email text,
last_login timestamptz
);
CREATE INDEX idx_users_email
ON users(email);
그리고 last_login을 계속 갱신한다.
UPDATE users
SET last_login = now()
WHERE id = 1;
PostgreSQL에서는 UPDATE마다 새로운 heap tuple version이 생긴다.
문제는 index다.
일반적인 상황에서 새 tuple을 만들 때마다 index에도 새 위치를 반영해야 한다면:
Heap Update
+
Index Update
가 계속 발생한다.
index가 여러 개라면 비용은 더 커진다.
하지만 last_login 변경이 해당 인덱스를 수정할 필요가 없는 상황이고, 같은 heap page에 새 tuple을 넣을 공간까지 있다면 PostgreSQL은 HOT Update를 사용할 수 있다.
HOT Chain
HOT Update가 발생하면 index가 모든 tuple version을 각각 가리킬 필요가 없다.
개념적으로:
B-Tree Index
│
▼
Tuple A
│
▼
Tuple B
│
▼
Tuple C
처럼 heap 내부에서 version chain을 따라갈 수 있다.
즉 index entry를 새로 만드는 비용을 줄이는 것이다.
이게 Heap-Only Tuple이라는 이름의 의미다.
다만 모든 UPDATE가 HOT이 되는 것은 아니다.
대략적으로는:
- 해당 UPDATE 때문에 새로운 index entry가 필요하지 않아야 하고
- 같은 heap page에 새로운 tuple을 저장할 공간이 있어야 한다.
같은 조건이 필요하다.
그래서 Fillfactor가 나온다
페이지를 처음부터 100% 가까이 채우면 같은 page에 새 tuple을 넣기 어려워진다.
예를 들어:
Page
████████████████████████████████
이미 꽉 찼다면 UPDATE 시 새로운 tuple version을 다른 page에 저장해야 할 가능성이 커진다.
그래서 UPDATE가 매우 많은 테이블에서는 fillfactor를 조정하기도 한다.
ALTER TABLE users
SET (fillfactor = 80);
그러면 page를 처음부터 일부러 완전히 채우지 않는다.
fillfactor = 100
[Tuple][Tuple][Tuple][Tuple][Tuple]
█████████████████████████████████
fillfactor = 80
[Tuple][Tuple][Tuple][Tuple][ ]
██████████████████████████░░░░░░
남겨둔 공간을 이후 UPDATE된 tuple version이 사용할 수 있다.
HOT Update 가능성을 높이는 데 도움이 될 수 있다.
물론 공짜는 아니다.
fillfactor를 낮추면 동일한 row 수를 저장하기 위해 더 많은 page가 필요하다.
따라서 읽기 중심 workload라면 불필요한 I/O가 늘 수 있다.
결국:
낮은 Fillfactor
→ UPDATE에는 유리할 수 있음
→ Storage Density는 낮아짐
높은 Fillfactor
→ 읽기 밀도에는 유리
→ UPDATE 공간은 줄어듦
이라는 trade-off다.
Index Scan도 Visibility 판단을 피할 수 없다
B-Tree Index가 있다고 해보자.
SELECT *
FROM users
WHERE email = '[email protected]';
Index Scan이 실행된다.
Index에서 조건에 맞는 entry를 찾는다.
B-Tree
[email protected]
│
▼
Heap Tuple
문제는 index가 찾은 tuple이 현재 Snapshot에서 visible하다는 보장이 없다는 점이다.
예전 version을 가리키고 있을 수도 있다.
그래서 PostgreSQL은 heap에 접근해 tuple visibility를 확인해야 하는 경우가 있다.
Index
│
▼
TID
│
▼
Heap
│
▼
MVCC Visibility Check
여기서 Index Only Scan 이야기가 연결된다.
Index Only Scan의 “Only”가 가능한 이유
다음 index가 있다고 해보자.
CREATE INDEX idx_users_email
ON users(email);
그리고:
SELECT email
FROM users
WHERE email = '[email protected]';
처럼 index 안의 정보만으로 결과를 만들 수 있는 query를 실행한다.
이론적으로는 heap에 갈 이유가 없다.
그런데 MVCC 때문에 문제가 생긴다.
index만 봐서는 해당 heap tuple이 현재 Snapshot에서 visible한지 완전히 판단하기 어렵다.
그렇다면 매번 heap에 가야 한다.
그러면:
Index Only Scan
이라는 이름이 무색해진다.
PostgreSQL은 이를 해결하기 위해 Visibility Map을 사용한다.
Visibility Map
Visibility Map은 heap page 단위로 visibility 관련 상태를 기록한다.
대표적으로 중요한 것이 all-visible 상태다.
어떤 page의 모든 tuple이 모든 현재 Snapshot에서 안전하게 visible하다고 판단할 수 있다면 해당 page를 all-visible로 표시할 수 있다.
그러면 Index Only Scan은:
Index Entry
│
▼
Visibility Map
│
├─ all-visible = YES
│ │
│ └── Heap 접근 생략
│
└─ all-visible = NO
│
└── Heap 확인
처럼 동작할 수 있다.
그래서 Index Only Scan의 성능은 index만 잘 만들어 놓는다고 끝나지 않는다.
VACUUM 상태와 Visibility Map도 영향을 준다.
VACUUM
│
▼
Visibility Map 관리
│
▼
all-visible page 증가
│
▼
Heap Fetch 감소
│
▼
Index Only Scan 효율 증가
PostgreSQL에서 VACUUM과 SELECT 성능이 연결되는 이유 중 하나다.
Free Space Map은 또 다른 문제를 푼다
Visibility Map과 비슷하게 들리는 구조가 하나 더 있다.
Free Space Map, FSM이다.
역할은 완전히 다르다.
새 tuple을 저장하려고 할 때 PostgreSQL은:
어느 heap page에 공간이 남아 있지?
를 알아야 한다.
매번 모든 page를 확인할 수는 없다.
FSM은 page별 free space 정보를 추적해서 PostgreSQL이 적절한 page를 찾는 데 사용한다.
정리하면:
Visibility Map
→ 이 page의 tuple을 visibility 관점에서 신뢰할 수 있는가?
Free Space Map
→ 이 page에 새 tuple이 들어갈 공간이 있는가?
서로 다른 문제를 해결한다.
하지만 둘 다 MVCC와 heap storage를 이해하다 보면 자연스럽게 만난다.
Index Bloat
MVCC의 비용은 heap에만 생기지 않는다.
일반 UPDATE로 새로운 tuple version이 생성되고 index entry까지 새로 만들어지면 시간이 지나면서:
Heap
Old Tuple
New Tuple
Index
Old Index Entry
New Index Entry
가 같이 쌓일 수 있다.
Old Tuple이 더 이상 필요 없어지고 VACUUM 대상이 되더라도 index 쪽의 죽은 entry도 정리해야 한다.
UPDATE가 많은 테이블에서:
- Heap Bloat
- Index Bloat
를 함께 보는 이유다.
그리고 HOT Update가 좋은 이유도 여기 있다.
HOT이 성공하면 불필요한 index entry 생성을 상당 부분 줄일 수 있기 때문이다.
Autovacuum은 단순 청소 기능이 아니다
PostgreSQL을 처음 운영하면 autovacuum을:
Dead Tuple 자동 삭제
정도로 생각하기 쉽다.
그런데 실제 역할은 훨씬 중요하다.
Autovacuum은 MVCC가 계속 정상적으로 굴러가기 위한 유지보수 시스템에 가깝다.
대표적으로:
- Dead Tuple 공간 회수
- Index Cleanup
- Visibility Map 관리
- 통계 갱신과 연계
- 오래된 Transaction ID Freeze
- Transaction ID Wraparound 방지
같은 역할이 연결된다.
특히 마지막 두 개는 단순 성능 문제가 아니다.
Transaction ID는 무한하지 않다
PostgreSQL의 일반 Transaction ID는 32-bit 공간을 사용한다.
즉 가능한 값의 공간이 유한하다.
대략:
0
1
2
...
4,294,967,295
범위를 생각할 수 있다.
그런데 Transaction ID는 계속 증가한다.
언젠가는 값이 다시 돌아오게 된다.
문제는 MVCC가 Transaction ID의 상대적 순서를 visibility 판단에 사용한다는 것이다.
새로운 Transaction ID가 숫자상으로 다시 작아졌다고 해서 아주 오래된 transaction으로 오해하면 안 된다.
그래서 PostgreSQL은 XID를 단순한 unsigned integer 순서로 비교하지 않는다.
현재 XID를 기준으로 대략 20억 범위 안에서 상대적인 과거와 미래를 판단하는 개념을 사용한다.
Freeze
아주 오래된 tuple을 계속:
이 tuple은 Transaction 123이 만들었다
라고 기억할 필요는 없다.
123번 transaction이 너무 오래되어 현재와 미래의 정상적인 snapshot에서 항상 committed된 과거로 취급해도 된다면 PostgreSQL은 해당 tuple을 frozen 상태로 취급할 수 있게 만든다.
이 과정에 VACUUM이 관여한다.
Old Tuple
xmin = 오래된 XID
│
▼
VACUUM / Freeze
│
▼
더 이상 일반적인 XID aging의 영향을
받지 않는 상태로 처리
그래서 VACUUM은:
디스크 공간 조금 아끼는 작업
이 아니다.
극단적으로는 PostgreSQL이 Transaction ID Wraparound 때문에 정상적인 transaction을 계속 실행할 수 없는 상황을 막는 역할까지 한다.
PostgreSQL이 wraparound 위험이 커지면 aggressive vacuum을 수행하는 것도 이 때문이다.
xmax가 Transaction ID가 아닐 수도 있다
앞에서 xmax를:
이 tuple을 UPDATE/DELETE한 Transaction ID
라고 설명했다.
내부 구현에서는 예외가 있다.
여러 transaction이 동시에 하나의 row에 lock을 걸 수 있기 때문이다.
예를 들어:
SELECT *
FROM users
WHERE id = 1
FOR SHARE;
같은 row lock을 여러 transaction이 잡을 수 있다.
Transaction 100
│
├────┐
│ │
Transaction 101
│
▼
Tuple
Tuple header에 무한히 여러 Transaction ID를 넣을 공간은 없다.
그래서 PostgreSQL은 MultiXact라는 별도 구조를 사용한다.
여러 transaction의 lock 정보를 하나의 MultiXact ID로 표현하고, xmax 필드가 이를 가리킬 수 있다.
따라서 실제 Tuple Header를 분석할 때는:
xmax 값
+
t_infomask
를 같이 봐야 한다.
이 지점부터 xmin/xmax만 이해하면 PostgreSQL MVCC를 안다라는 설명이 한계에 부딪힌다.
MVCC가 Lock을 없애주는 것은 아니다
MVCC를:
Lock 없이 동시성을 해결하는 기술
이라고 이해하면 안 된다.
MVCC가 특히 잘 해결하는 것은 reader와 writer가 불필요하게 서로 막는 상황이다.
Reader
│
├──── Old Version 읽기
│
Writer
│
└──── New Version 생성
둘이 동시에 진행할 수 있다.
하지만 두 writer가 같은 row를 수정하려고 하면 다르다.
Transaction A
UPDATE users
SET name = 'A'
WHERE id = 1;
Transaction B
UPDATE users
SET name = 'B'
WHERE id = 1;
두 UPDATE를 아무 제약 없이 동시에 처리할 수는 없다.
Row-level lock과 transaction wait가 필요하다.
그래서 PostgreSQL concurrency 구조는:
MVCC vs Lock
이 아니다.
실제로는:
MVCC
+
Lock
이다.
Snapshot과 Tuple Visibility로 read consistency를 만들고, 실제 write conflict는 lock으로 조정한다.
SERIALIZABLE은 Snapshot 하나로 해결되지 않는다
REPEATABLE READ에서 동일한 Snapshot을 유지한다면 가장 강한 일관성을 얻을 것 같지만 그렇지 않다.
대표적인 예가 Write Skew다.
당직 의사가 두 명 있다고 하자.
Doctor A = ON
Doctor B = ON
규칙:
최소 한 명은 항상 ON이어야 한다.
Transaction A:
현재 ON 인원 = 2
→ 내가 OFF가 되어도 1명 남음
→ A = OFF
Transaction B:
현재 ON 인원 = 2
→ 내가 OFF가 되어도 1명 남음
→ B = OFF
둘이 동일한 과거 Snapshot을 사용했다면 각자 판단은 정상이다.
하지만 결과는:
A = OFF
B = OFF
가 된다.
규칙이 깨졌다.
Snapshot isolation만으로 해결할 수 없는 anomaly다.
PostgreSQL의 SERIALIZABLE과 SSI
PostgreSQL의 SERIALIZABLE은 모든 SELECT에 무조건 강한 lock을 거는 방식이 아니다.
PostgreSQL은 SSI, Serializable Snapshot Isolation 방식을 사용한다.
핵심은 transaction 사이에서 serialization anomaly가 발생할 가능성이 있는 의존 관계를 추적하는 것이다.
개념적으로:
T1
│
│ rw-dependency
▼
T2
│
│ rw-dependency
▼
T3
같은 관계가 위험한 cycle을 만들 가능성이 있다면 PostgreSQL이 transaction 중 하나를 abort할 수 있다.
그래서 SERIALIZABLE 사용 중:
could not serialize access
due to read/write dependencies among transactions
같은 오류를 볼 수 있다.
이것은 DB가 망가졌다는 의미가 아니다.
오히려:
이 transaction들을 전부 commit하면
serializable 규칙을 보장할 수 없다
고 판단해서 하나를 실패시킨 것이다.
그래서 SERIALIZABLE transaction은 애플리케이션에서 retry를 고려하는 것이 중요하다.
MVCC 관점에서 UPDATE 하나를 다시 보면
이제 처음의 단순한 SQL로 돌아가 보자.
UPDATE users
SET name = 'Lee'
WHERE id = 1;
처음 보면 그냥 값 하나 바꾸는 SQL이다.
하지만 내부적으로 연결되는 것을 펼쳐보면 생각보다 많다.
조건에 맞는 Tuple 탐색
│
▼
현재 Snapshot에서 Visible한지 확인
│
▼
Tuple Lock
│
▼
Old Tuple Header 변경
│
▼
New Tuple Version 생성
│
├──────────┐
│ │
▼ ▼
Heap Index
변경 변경
│ │
└────┬─────┘
▼
Page Dirty
│
▼
WAL 기록
│
▼
Old Version 잔존
│
▼
Pruning / VACUUM 대상
│
┌─────┴──────┐
▼ ▼
Heap Index
Cleanup Cleanup
│
▼
Visibility Map / FSM
상태에도 영향
UPDATE users SET ... 한 줄 뒤에 꽤 많은 storage engine 동작이 숨어 있다.
그래서 UPDATE가 아주 많은 서비스에서는 단순히 SQL 실행 시간만 보고 PostgreSQL 성능을 판단하기 어렵다.
다음 같은 지표가 같이 중요해진다.
Dead Tuple
HOT Update 비율
Autovacuum 동작
Long-running Transaction
Heap Bloat
Index Bloat
Fillfactor
Visibility Map 상태
MVCC와 WAL은 다른 문제를 해결한다
MVCC를 깊게 보다가 WAL과 섞이기 쉽다.
둘은 역할이 다르다.
MVCC는:
동시에 여러 Transaction이 존재할 때
누가 어떤 Tuple Version을 볼 것인가?
를 해결한다.
WAL은:
서버가 갑자기 꺼졌을 때
이미 수행한 변경을 어떻게 복구할 것인가?
를 해결한다.
하지만 PostgreSQL 내부에서는 둘이 매우 가까이 붙어 있다.
UPDATE가:
새 Tuple 생성
Old Tuple Header 변경
Heap Page 변경
을 일으키면 이 변경을 crash recovery 상황에서 안전하게 처리할 수 있어야 하기 때문이다.
그래서 PostgreSQL 내부를 따라가다 보면 결국:
MVCC
│
├── Heap
│
├── Buffer Manager
│
├── WAL
│
├── VACUUM
│
├── Index
│
└── Transaction Manager
가 따로 떨어진 기능이 아니라 하나의 storage engine 안에서 서로 연결되어 있다는 걸 보게 된다.
결국 MVCC에서 중요한 것은 Version보다 Visibility다
처음에는 PostgreSQL MVCC를 이렇게 이해하기 쉽다.
UPDATE를 하면
기존 데이터를 남기고
새로운 버전을 만든다.
여기까지도 틀리지는 않는다.
하지만 PostgreSQL 내부를 조금 더 보면 내가 중요하다고 느낀 부분은 다른 쪽이었다.
Heap에는 서로 다른 시점에 만들어진 여러 tuple version이 존재할 수 있다.
각 tuple에는:
누가 나를 만들었는가
누가 나를 끝냈는가
에 대한 정보가 있다.
그리고 각 Transaction은 자신이 세상을 바라보는 기준인 Snapshot을 가진다.
PostgreSQL은 tuple을 만날 때마다:
이 Tuple은
현재 Snapshot이 바라보는 세계에
존재하는가?
를 판단한다.
보이면 반환한다.
보이지 않으면 heap에 실제로 존재하더라도 없는 것처럼 취급한다.
그리고 시간이 지나 어떤 살아 있는 Snapshot에서도 더 이상 볼 수 없는 과거가 되면 그제야 PostgreSQL은 해당 공간을 정리할 수 있다.
이렇게 생각하면 지금까지 따로 보였던 개념이 전부 연결된다.
MVCC
│
┌──────┴──────┐
│ │
Snapshot Tuple
│ │
│ ┌────┴────┐
│ xmin xmax
│
▼
Visibility
│
┌──────┴──────┐
│ │
Isolation Level Long Transaction
│
▼
VACUUM
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Pruning Freeze Visibility Map
│ │ │
▼ ▼ ▼
HOT Chain Wraparound Index Only Scan
│
▼
Index
│
▼
Bloat
결국 PostgreSQL에서 MVCC는 동시성 기능 하나를 따로 붙여 놓은 것이 아니다.
Heap에 row를 저장하는 방식부터 UPDATE 비용, Index 구조, VACUUM, Transaction ID 관리까지 PostgreSQL storage engine 전체 설계에 깊게 들어가 있다.
그래서 PostgreSQL 성능 문제를 깊게 파다 보면 이상할 정도로 자꾸 MVCC로 돌아오게 된다.
SELECT, UPDATE, DELETE를 SQL 문법으로만 보면 PostgreSQL은 꽤 단순한 데이터베이스처럼 보인다.
하지만 그 SQL 한 줄이 실제로:
Heap Page
Tuple Header
Snapshot
Transaction 상태
Visibility
Lock
WAL
Index
VACUUM
위에서 어떻게 실행되는지 보기 시작하면 전혀 다른 시스템처럼 보인다.
그때부터 PostgreSQL의 동작이 조금씩 이해되기 시작한다.
