PostgreSQL의 MVCC 비용을 다른 데이터베이스 엔진의 버전 관리, 정리, 장애 방식과 비교한다.
Postgres를 좋아하지 않는 사람들을 따른다면, 아마 Postgres에 관해 가장 먼저 배우게 될 것은 MVCC가 나쁘다는 사실일 것이다. 40년 된 설계 실수다. 그 징후는 어디에나 있다. 크기가 두 배로 부풀어 오른 테이블, 32비트 트랜잭션 카운터 한계, 끝나지 않는 VACCUM과의 싸움, 데드 튜플 악몽. 근거도 있다. Uber는 2016년에 쓰기 증폭을 측정했고 그 때문에 MySQL로 떠났다. Andy Pavlo의 데이터베이스 그룹은 MVCC를 PostgreSQL에서 가장 싫어하는 부분이라고 불렀다. 실제 문제다. Postgres는 최악이다.
이 가운데 어느 것도 과장이 아니지만, 결국 이는 실제 설계 선택으로 귀결된다. 팽창, 증폭된 쓰기, vacuum 돌봄은 모두 결함이 아니라 결정에서 비롯되며, 아래에서 실제 PostgreSQL 19 beta2 인스턴스로 각각을 재현해 직접 피해가 일어나는 모습을 볼 수 있다. 그러나 커뮤니티에서 커뮤니티로 퍼지는 평결은 언제나 한 질문 앞에서 멈춘다. 무엇과 비교해서인가? 다른 모든 엔진은 대신 무엇을 하며, _그것_의 비용은 무엇인가?
MVCC는 선택 사항이 아니기 때문이다. 읽기가 쓰기를 막지 않기를 원하는 모든 데이터베이스는 어딘가에 여러 행 버전을 보관해야 하며, 그렇게 하는 모든 엔진은 같은 네 가지 질문에 답한다.
PostgreSQL의 답은 다음과 같다. 테이블 안, 이전에서 새것으로, 물리적 위치, 나중에 백그라운드 프로세스. 비평가들이 나열하는 모든 비용은 이 네 가지 답에서 따라온다. 그리고 모든 대안은 다른 답의 조합이며, 청구서는 작성자, 과거를 읽는 독자, tempdb, 캐시, 컴팩터 중 누군가에게 보내진다. 그중 하나는 PostgreSQL 설계가 첫날부터 공짜로 가져온 단 하나의 특성을 얻기 위해 수년간의 엔지니어링을 쏟았다. 모두 점심시간 내내 트랜잭션이 열려 있으면 서로 다르게 실패한다.
전체 메커니즘을 원한다면 PostgreSQL MVCC, Byte by Byte에서 pageinspect로 이를 살펴본다. 짧게 말하면 PostgreSQL의 UPDATE는 행을 절대 수정하지 않는다. 힙에 행의 완전한 새 사본을 쓰고, 이전 버전의 t_xmax를 찍은 뒤, 두 버전을 모두 디스크에 둔다. 가시성은 읽기 시점에 튜플별로 결정된다. 정리는 다른 누군가의 문제이며, 구체적으로는 VACUUM의 문제다.
아래에서 네 가지 혐의를 하나씩 재현한다.
Uber 불만의 핵심이다. 테이블의 모든 인덱스는 행의 물리적 위치(페이지 번호와 슬롯, 즉 ctid)를 가리키고, UPDATE는 새로운 물리적 행을 만들기 때문에 모든 인덱스에는 새 위치를 가리키는 새 항목이 필요하다. 건드리지 않은 열의 인덱스까지도 그렇다.
동일한 100만 행 테이블 사본 두 개를 준비한다. 하나는 기본 키만, 다른 하나는 추가 보조 인덱스 네 개를 둔다.
CREATE TABLE accounts (
id bigint PRIMARY KEY,
email text NOT NULL,
status text NOT NULL,
balance numeric NOT NULL,
created_at timestamptz NOT NULL,
last_seen timestamptz
);
INSERT INTO accounts
SELECT g, 'user' || g || '@example.com', 'active', 100, now(), now()
FROM generate_series(1, 1000000) g;
CREATE INDEX ON accounts (email);
CREATE INDEX ON accounts (status);
CREATE INDEX ON accounts (balance);
CREATE INDEX ON accounts (created_at);
CREATE TABLE accounts_lean (LIKE accounts);
ALTER TABLE accounts_lean ADD PRIMARY KEY (id);
INSERT INTO accounts_lean SELECT * FROM accounts;
이제 last_seen만 건드리며 100,000개 행을 갱신한다. last_seen은 어느 테이블에서도 어떤 인덱스에도 없다는 점에 주목하라. 깨끗한 측정 구간을 위해 CHECKPOINT와 pg_stat_reset_shared('wal') 뒤에 생성된 WAL(pg_stat_wal)을 측정한다.
UPDATE accounts_lean SET last_seen = now()
WHERE id > 100000 AND id <= 200000;
wal_records | wal_fpi | wal_bytes | wal_pretty
-------------+---------+-----------+------------
302510 | 1419 | 38218531 | 36 MB
UPDATE accounts SET last_seen = now()
WHERE id > 100000 AND id <= 200000;
wal_records | wal_fpi | wal_bytes | wal_pretty
-------------+---------+-----------+------------
709440 | 1981 | 72394573 | 69 MB
논리적 변경은 같고, 타임스탬프 100,000개다. 간결한 테이블은 행당 약 3.0개의 WAL 레코드를 만들었다. 인덱스가 있는 테이블은 7.1개를 만들었다. 힙 갱신에 더해 기본 키에 새 항목 하나, 그리고 바꾼 열을 인덱싱하지 않는 _각 네 개의 보조 인덱스_에 새 항목 하나씩이다. 행이 이동했고 모두 물리적 위치를 가리키므로, 어쨌든 모두 다시 작성됐다.
이것이 Uber가 측정한 증폭이며, 더 커진다. WAL이 많다는 것은 체크포인트 작업, 전체 페이지 쓰기, 모든 복제본으로 전송되는 바이트도 더 많다는 뜻이다. 인덱스가 많은 테이블에서 단일 열을 갱신하는 일은 유용한 변경 바이트당 할 수 있는 일 중 가장 비싼 축에 든다.
PostgreSQL의 완화책은 HOT 갱신(Heap-Only Tuples)이다. 인덱싱된 열이 바뀌지 않고 새 버전이 같은 페이지에 들어가면 인덱스는 그대로 둔다. 위에서는 두 조건 모두 실패했다. 페이지가 꽉 차 있어 모든 새 버전이 다른 페이지에 놓였기 때문이다. 테이블에 여유 공간을 주고 다시 시도한다.
ALTER TABLE accounts SET (fillfactor = 70);
VACUUM FULL accounts;
UPDATE accounts SET last_seen = now()
WHERE id > 200000 AND id <= 300000;
wal_records | wal_bytes | n_tup_hot_upd (this batch)
-------------+-----------+----------------------------
455812 | 51 MB | 41,996 of 100,000
나아졌지만 숫자를 보라. HOT는 100%가 아니라 42%다. 각 페이지에는 30%의 빈 공간과 약 80개 행이 있다. 페이지에서 처음 약 30개 갱신이 예비 공간을 소비하면, 그 페이지의 나머지 행은 인덱스를 포함해 다른 곳으로 흘러간다. 기회적 가지치기가 페이지 내 데드 버전을 재활용한 뒤 같은 배치를 두 번째로 실행한다.
wal_records | wal_bytes | n_tup_hot_upd (this batch)
-------------+-----------+----------------------------
304990 | 22 MB | 66,314 of 100,000
HOT는 66%, 행당 WAL 레코드는 3.0개로 간결한 테이블 비용에 거의 돌아왔다. 이것이 완화책의 정직한 모습이다. HOT는 기회적으로 작동하고 요청 즉시가 아니라 정상 상태에서 나타나며, 조용히 테이블 크기의 30%를 예비 공간으로 요구한다. 이는 작동하며, 실제 OLTP 테이블은 적절히 선택한 fillfactor로 90% 이상의 HOT 비율을 흔히 본다. 그러나 기본 동작은 행당 7.1개 레코드이며, 비평가들은 그 기본값을 설명하고 있다.
모든 UPDATE는 INSERT에 지연된 DELETE를 더한 것이다. 아무것도 하지 않으면 데드 버전이 쌓인다.
VACUUM (FULL, ANALYZE) accounts_lean; -- reset: 89 MB, 0% dead
\timing on
BEGIN;
UPDATE accounts_lean SET balance = balance + 1; -- all 1M rows
ROLLBACK;
SELECT pg_size_pretty(pg_relation_size('accounts_lean'));
SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent)
FROM pgstattuple('accounts_lean');
BEGIN
Time: 0.032 ms
UPDATE 1000000
Time: 1590.112 ms (00:01.590)
ROLLBACK
Time: 0.124 ms
pg_size_pretty
----------------
178 MB
tuple_count | dead_tuple_count | dead_pct
-------------+------------------+----------
1000000 | 1000000 | 47
문 하나, 심지어 커밋되지도 않은 트랜잭션 하나가 테이블을 두 배로 키웠다. 백만 개의 데드 튜플이 vacuum을 기다리며, VACUUM Is a Lie에서 다루듯 그 과정에서 커진 인덱스는 저절로 줄어들지 않는다. 이것이 팽창 러닝머신이다. 저장소 설계는 쓰기 속도로 쓰레기가 만들어지고 vacuum 속도로 수집됨을 보장하며, 두 속도를 맞추는 일은 PostgreSQL이 기본 autovacuum 설정을 출발점으로 하여 사용자에게 넘기는 운영 업무다.
실험을 계속한다. 두 번째 세션에서 트랜잭션을 열고 그저... 스냅샷을 붙든다.
-- session B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM accounts_lean;
-- go to lunch
세션 A로 돌아와 100,000개 행을 갱신하고 vacuum을 실행한다.
INFO: finished vacuuming "mvcc_critics.public.accounts_lean":
tuples: 14 removed, 1099945 remain,
100000 are dead but not yet removable
removable cutoff: 718, which was 1 XIDs old when operation ended
“죽었지만 아직 제거할 수 없음.” 세션 B의 스냅샷이 여전히 그 버전을 필요로 할 수 있으므로 vacuum은 이를 보존해야 한다. 이 테이블뿐만 아니라 세션 B가 읽지 않은 모든 테이블에도 xmin 지평선이 뒤로 밀린다. PostgreSQL은 독자를 취소하지도, 실패하게 하지도 않는다. MVCC 수준에서는 엄밀히 사실이지만, 운영 환경은 흔히 외부에서 이 취소를 다시 추가한다. idle_in_transaction_session_timeout과 statement_timeout은 점심시간 트랜잭션이 xmin 지평선을 붙들기 전에 죽이기 위해 정확히 존재한다. 트랜잭션이 유휴 상태인 동안 데이터베이스 전체의 쓰레기 수집은 조용히 멈춘다. 모든 Postgres 운영자는 결국 장기 실행 트랜잭션과 오래된 복제 슬롯을 찾는 법을 배운다.
모든 튜플 헤더는 생성자 트랜잭션 ID를 32비트에 저장한다. 40억 트랜잭션 뒤 카운터가 순환하므로 PostgreSQL은 XID 공간을 되찾기 위해 몇 달간 건드리지 않은 페이지까지 다시 쓰며 오래된 튜플을 주기적으로 _동결_해야 한다. 너무 뒤처지면 데이터베이스는 자신을 보호하기 위해 쓰기 수락을 멈춘다. 2015년 순환 장애를 기록한 Sentry나, 그 뒤 이 사실을 다시 배운 여러 회사를 보라. 그러나 시계가 다 하기 전에 계속 돌도록 하기 위해 건드리지 않은 페이지를 주기적으로 다시 써야 하는 것은 PostgreSQL뿐이다.
공정하게 말하면 Oracle의 카운터도 유한하다. SCN은 수십 년간 48비트였고, 데이터베이스 링크가 카운터를 앞으로 점프시킬 수 있음이 발견된 뒤 Oracle DBA들은 2019년에 SCN 여유 공간 패치를 적용했다. 궁극적 해결책은 SCN을 64비트로 넓히는 것이었고(12.2 이후), 데이터베이스 링크 전반의 버전 호환성 조정이라는 비용을 치렀다.
그러므로 혐의는 성립한다. 기본적으로 증폭된 쓰기, 쓰기 속도로 만들어지고 튜닝해야 하는 프로세스가 수집하는 쓰레기, 유휴 스냅샷 하나에 인질로 잡힌 정리, 영원히 감아야 하는 32비트 시계. PostgreSQL의 MVCC는 구체적이고 입증 가능한 네 가지 방식으로 나쁘다.
Oracle과 MySQL의 InnoDB는 네 질문에 정반대로 답한다. 버전은 테이블 밖에 살고, 체인은 새것에서 이전 것으로 향하며, 정리는 부분적으로 작성자의 문제다. 대부분의 “Postgres MVCC는 나쁘다” 글이 암묵적으로 해결책으로 내세우는 설계이므로 가장 면밀히 볼 가치가 있다.
InnoDB의 UPDATE는 클러스터드 인덱스 내부에서 행을 제자리에서 수정한다(InnoDB에서 테이블은 기본 키 순서의 B-트리 그 자체다). 덮어쓰기 전에 이전 값을 별도 저장 영역인 undo 로그로 복사하고, 행에 그 undo 레코드를 가리키는 포인터를 찍는다. 모든 행은 이를 위해 DB_TRX_ID(마지막으로 접촉한 트랜잭션)와 DB_ROLL_PTR(undo 포인터)라는 숨겨진 시스템 열 두 개를 지닌다. Oracle의 구조는 세부적으로 다르지만(undo 세그먼트, 블록 수준 일관 읽기) 형태는 동일하다. 테이블은 최신 버전만 보관하고, 이력은 그에 매달린 델타 체인이다.
더 오래된 스냅샷을 가진 독자는 현재 행을 찾아 DB_TRX_ID가 너무 새롭다는 것을 알아차리고, 볼 자격이 있는 버전을 재구성할 때까지 undo 체인을 뒤로 걸으며 델타를 적용한다. Oracle은 블록 단위로 이 작업을 수행하여 메모리에서 블록 전체의 일관된 이미지를 재구성한다.
네 혐의에 대해 이 설계가 사는 것을 항목별로 보자.
pgstattuple도 없다. undo는 고정 크기 링(Oracle)에서 재활용되거나 계속 제거된다(InnoDB).ROWID를 저장하지만, Oracle은 제자리 갱신과 행 이동 중에 그 주소를 안정적으로 유지한다. 따라서 last_seen 갱신은 네 보조 인덱스를 건드리지 않는다. 이 단일 결정이 Uber가 측정한 쓰기 증폭 차이의 대부분이다.네 개 중 네 개다. 사건 종결인가?
“제자리”라는 말에는 한 가지 단서가 있다. 새 버전이 블록에 들어갈 때만 가능하다. Oracle은 삽입 시 PCTFREE(기본값 10%)로 여유 공간을 남긴다. 갱신이 거기에 들어가면 행은 제자리에서 확장되고 인덱스는 건드리지 않는다. 들어가지 않으면 Oracle은 행을 다른 블록으로 옮기고 원래 슬롯에 전달 포인터를 남긴다. ROWID는 그대로이므로 인덱스 역시 유지보수가 필요 없다.
비용은 읽기로 이동한다. 인덱스 조회는 포인터를 따라 두 번째 블록으로 간다. 확장이 많은 갱신이 충분히 쌓이면 이동된 행이 생기고 테이블은 결국 재구성된다(ALTER TABLE ... MOVE). PostgreSQL은 확장 비용을 쓰기 증폭으로 내고, Oracle은 읽기 증폭과 유지보수로 낸다.
여기에 청구서가 있다.
롤백 비용은 트랜잭션이 든 비용만큼이다. 실험을 기억하라. PostgreSQL은 백만 행 UPDATE를 _0.124밀리초_에 롤백했다. 힙 버전 관리 시스템에서 중단은 “아무것도 하지 말라, 이전 버전은 이미 제자리에 있다”를 뜻하기 때문이다. (엉망을 만드는 데 1.59초, 그것을 포기하는 데 0.124밀리초가 들었다. 엉망은 vacuum을 위해 남지만, _당신의 트랜잭션_은 끝났다.) undo 기반 엔진에서 롤백은 체인을 걸으며 모든 변경을 행마다 되돌리는 일이다. 대략 정방향과 같은 양의 작업이며 때로는 더 많다. 모든 MySQL 운영자는 결국 장기 실행 UPDATE를 죽이고, 데이터베이스가 실행 시간보다 더 오래 되돌리는 모습을 지켜본다. undo를 적용해야 하므로 죽일 수 없다. 트랜잭션 중간에 충돌하면 시스템이 완전히 정상화되기 전 복구가 같은 일을 떠안는다. 바로 이를 피하기 위해 SQL Server는 나중에 Postgres식 버전 저장소를 키웠지만, 너무 앞서갔다.
독자도 읽기 시점에 그 이력의 비용을 낸다. PostgreSQL에서 이전 행 버전은 힙에 그냥 놓여 있으므로 읽기 비용은 다른 무엇을 읽는 것과 같다. 최근 커밋된 튜플의 첫 독자는 힌트 비트를 설정하려 pg_xact를 우회할 수 있는데, 튜플당 한 번만 내는 세금이며 그 뒤로는 영원히 평범한 힙이다. undo 진영에서는 최신 버전을 제외한 모든 버전을 읽을 때마다 _재구성_해야 한다. 저장소의 다른 부분에 있는 포인터를 따라가 델타를 적용한다. 뜨거운 테이블을 상대로 한 장기 보고서는 스냅샷과 현재 상태의 차이가 커질수록 점점 느려지고, 그 과정에서 undo 페이지로 버퍼 풀을 압박한다.
유휴 스냅샷 문제는 사라지지 않았다. 실패 방식만 바뀌었다. undo 공간은 유한하다. 장기 실행 질의가 필요로 하는 버전의 undo가 이미 재활용됐다면 Oracle은 PostgreSQL처럼 정리를 멈추지 않는다. 질의를 죽인다. 데이터베이스에서 가장 익숙한 오류 코드 중 하나인 ORA-01555: snapshot too old다. InnoDB는 같은 설계 안에서 반대 선택을 한다. purge가 가장 오래된 읽기 뷰를 기다리고, 이력이 쌓인다(SHOW ENGINE INNODB STATUS에서 history list length가 오르는 것을 보라). undo 테이블스페이스는 부풀어 오른다. MySQL 8의 자동 undo 절단은 로그가 비활성화된 테이블스페이스를 줄이지만, 성장을 유발한 장기 읽기가 바로 로그를 활성 상태로 유지하는 원인이므로 독자가 있는 동안 쓰레기는 그대로다. 이는 pgstattuple로 보이지 않는 곳으로 옮겨진 팽창이다. 같은 근본 긴장이다. 독자를 취소하거나 쓰레기를 보관하라.
InnoDB의 purge 스레드는 기능적으로 다른 이름의 vacuum이다. undo 이력을 훑고 delete-marked 인덱스 레코드를 제거하며, autovacuum처럼 쓰기 폭주에서 뒤처질 수 있다. MySQL 쪽의 전쟁 이야기(history list length 수백만, purge 지연 정체)는 명사만 바꾼 Postgres 팽창 이야기처럼 읽힌다.
그러므로 undo 진영은 MVCC 비용을 없애지 않았다. 미래(백그라운드 정리, 테이블 팽창)에서 작성자의 핵심 경로(롤백, 제자리 갱신 기록 유지)와 오래된 데이터를 읽는 독자로 옮겼다. 거의 항상 커밋하고, 인덱스 많은 행을 갱신하며, 뜨거운 테이블에 대해 장기 읽기를 드물게 실행하는 작업 부하라면 이 선택은 진정으로 더 낫다. 대략 Uber가 가졌던 작업 부하다. 이는 특정 작업 부하 형태에 유리한 실제 절충이지 보편적 업그레이드가 아니다.
SQL Server는 MVCC 자체가 선택이었다는 사실을 상기시킨다. 기본 READ COMMITTED는 전혀 버전 관리되지 않는다. 독자는 공유 잠금을, 작성자는 배타 잠금을 잡으며 서로를 막는다. MVCC가 해결하기 위해 존재하는 동시성 문제는 단순히... 일어나도록 허용되고 잠금 힌트와 짧은 트랜잭션으로 관리된다. 수년간 “내 독자가 막힌다”의 표준 해결책은 커밋되지 않은 행을 읽도록 선택하는 WITH (NOLOCK)였다.
2005년 이후 버전 관리는 옵트인으로 사용할 수 있다(READ_COMMITTED_SNAPSHOT 또는 완전한 SNAPSHOT 격리). 이를 켜면 SQL Server는 갱신 전 행 버전을 버전 저장소로 복사하기 시작한다. 저장소는 tempdb에 있고 각 수정 행에는 버전 포인터가 찍힌다. 포인터는 14바이트로 모든 버전 관리 행에 추가되며, 그 행의 체인을 tempdb를 거슬러 올라가는 데 쓰인다. 이는 잠금 엔진에 덧댄 undo식 설계이며 기억할 만한 운영 특성이 하나 있다. 버전 저장소는 공유되고 인스턴스 전체 범위이며, 모든 사람의 정렬 스필과 임시 테이블이 있는 같은 tempdb에 산다. 잊힌 장기 실행 스냅샷 트랜잭션 하나로 버전 저장소를 다듬을 수 없게 되면 tempdb는 디스크가 다 찰 때까지 커지고, 그 시점에 아무 관계없는 데이터베이스를 포함해 _전체 인스턴스_가 질의에 실패하기 시작한다. 유휴 스냅샷 문제의 재등장이자, 이 글의 어떤 설계보다 넓은 피해 범위다.
SQL Server에서 가장 흥미로운 최근 발전은 Accelerated Database Recovery다. 2019년에 도입됐고 Azure SQL에서는 기본으로 켜져 있으며, 영구적 버전 저장소를 추가했다. 그리고 그 위치를 보라. tempdb가 아니라 사용자 데이터베이스 내부다. 행 버전은 데이터베이스별 PVS로 옮겨가며, 위의 인스턴스 전체 피해 범위를 줄이고 트랜잭션 크기와 무관하게 롤백과 충돌 복구를 즉시 끝내며 undo를 재생하는 대신 이전 버전을 다시 읽는다. 다시 읽어 보라. 대표적인 잠금 및 undo 엔진이 PostgreSQL의 크게 비난받는 설계가 첫날부터 공짜로 가졌던 단 하나의 특성, 상수 시간 중단을 얻으려 상당한 엔지니어링을 쏟았다. 절충은 양방향으로 작동한다.
MongoDB의 WiredTiger 엔진은 네 질문에 아직 보지 못한 억양으로 답한다. 이전 버전은 RAM에 산다. 읽기는 시작 시 취한 스냅샷에 대해 수행된다. 작성자는 페이지 현재 이미지에 매달린 메모리 내 델타로 변경을 추가하고, 독자는 체인을 뒤로 걸어 자신의 스냅샷이 볼 자격이 있는 버전으로 간다. 체크포인트는 주기적으로 깨끗한 페이지 이미지를 디스크로 내보내고 저널은 내구성을 제공한다. 그리고 undo 절에서 나왔으니 주목하라. 롤백은 공짜다. 미커밋 버전은 디스크에 없었으므로 트랜잭션 중단은 메모리를 버리는 일이다. WiredTiger는 Microsoft의 엔지니어링 프로그램 없이 첫날부터 상수 시간 중단을 가졌다.
이 설계는 실제 이점을 준다. 디스크에 데드 튜플이 없고, 크기를 잡아야 할 undo 테이블스페이스도 없으며, 독자는 결코 막히지 않는다. 청구서는 한 사실을 통해 도착한다. 캐시는 유한하고 공유되며, 어떤 스냅샷이 여전히 필요로 하는 버전은 축출할 수 없다.
WiredTigerHS.wt)로 흘러가며 이를 고정한 독자가 살아 있는 정확히 그 기간만큼 커진다. 파일로 된 InnoDB 이력 목록, 즉 찾아볼 줄 알아야 하는 곳으로 옮겨진 팽창이다.SnapshotTooOld로 답한다. 이 글에서 이 오류를 가진 세 번째 엔진이다.정리는 축출과 체크포인트다. RAM에서 계속 실행되고, 대부분 보이지 않다가 스냅샷이 점심시간 내내 열려 있을 때 드러나는 vacuum이다.
특히 CockroachDB와 YugabyteDB 같은 분산 SQL 신생 주자들은 LSM 트리(RocksDB 또는 파생물)에 데이터를 저장하며, MVCC 답도 다시 다르다. 행 버전은 단순히 _키_이며, 논리 키 뒤에 커밋 타임스탬프가 붙는다. 갱신은 절대 제자리에서 아무것도 건드리지 않고 더 새로운 키를 쓸 뿐이다. 읽기는 자신의 스냅샷 타임스탬프 이하에서 가장 최신 버전을 찾는다. 삭제는 툼스톤 키를 쓴다.
한 의미에서 이는 PostgreSQL 철학을 더 밀어붙인 것이다. 버전을 추가하고, 수정하지 않으며, 나중에 정리한다. 정리 이야기는 다르다. 이전 버전은 컴팩션으로 제거된다. 이는 LSM의 저장소 계층을 정상적으로 백그라운드에서 다시 쓰는 과정이며, 쓰레기 수집 창이 이를 지배한다. TTL보다 어린 버전은 시간 여행 읽기와 팔로어 읽기를 위해 보관된다. CockroachDB의 기본 창은 역사적으로 시간 단위였으며(처음에는 25시간, 이후 줄었다). vacuum 명령은 없고 마케팅은 이를 언급할 것이다. 하지만 컴팩션은 vacuum 그 자체다. 저장소 엔진 내부에서 쓰기 증폭으로 같은 지연 정리 세금을 계속 내며, 실패 방식은 위의 모든 것과 운을 맞춘다.
아무것도 해결되지 않았다. 네 질문이 새 답을 얻었고 비용은 컴팩터로 옮겨갔다.
관계형 세계를 떠나도 같은 네 질문이 글자 그대로 다시 나타난다. 모든 Kubernetes 클러스터의 원하는 상태를 보관하는 합의 기반 키-값 저장소 etcd는 이름과 설계 모두에서 MVCC다. 모든 쓰기는 클러스터 전체 revision 카운터를 증가시키고 값은 bbolt 파일에 (key, revision) 아래 저장된다. 갱신은 덮어쓰지 않고 키의 새 revision을 추가한다. 특정 revision에서 읽으면 그 이하의 최신 버전을 얻는다. 삭제는 툼스톤을 쓴다. 이것이 방금 읽은 LSM 절처럼 들린다면 그래야 한다. 그리고 이 글 상단의 네 혐의처럼 들린다면 계속 읽어라.
etcdctl compact) 또는 일정(--auto-compaction-retention)으로 실행한다. 이를 건너뛰면 백엔드는 한계 없이 커진다. 쓰기 속도로 쓰레기가 만들어지고 컴팩션 속도로 수집된다. SQL을 뺀 팽창 러닝머신이다.etcdctl defrag를 실행해야 하며, 각 노드는 다시 쓰는 동안 잠시 멈춘다. 한 번에 쿼럼 멤버 하나씩 하는 VACUUM FULL이다.etcdserver: mvcc: required revision has been compacted로 실패시킨다. 오래된 resourceVersion에서 감시를 재개하는 컨트롤러가 있는 모든 Kubernetes 클러스터에 나타나는 또 다른 ORA-01555다.NOSPACE를 발생시키고, 사람이 컴팩션하고 조각 모음을 하며 경보를 해제할 때까지 Kubernetes 제어 플레인의 쓰기를 포함한 쓰기를 거부한다. 저장소 위생 작업을 장애로 바꾸고 운영자에게 돌봄을 떠넘기는 안전 한계라는 점에서 순환 장애와 가장 가까운 비관계형 친척이다.etcd는 네 질문에 PostgreSQL과 거의 정확히 같은 방식으로 답했다. 버전을 추가하고, 수정하지 않으며, 나중에 정리하고, 독자를 일찍 취소하지 않는다. 같은 운영 일정을 물려받았다. 이 질문들은 관계형 모델의 지배보다 앞서며 이를 넘어 오래갈 것이다. 청구서를 피할 수는 없고, 누가 낼지 고를 수 있을 뿐이다.
분산 키-값 이웃 FoundationDB는 이 글의 누구보다 점심 문제에 잔혹하게 답한다. 트랜잭션에는 단호한 5초 수명이 있다. 끝이다. 점심시간 내내 트랜잭션을 열어 두면 vacuum, purge, 컴팩터를 막는 것이 아니라 트랜잭션이 그냥 죽는다. 유휴 트랜잭션 문제를 명령으로 클라이언트 문제로 만든 여기 유일한 설계다.
undo 설계가 단순하게 더 좋았다면, 명백한 움직임은 PostgreSQL에 undo 기반 저장소 엔진을 주는 것이었을 것이다. 사람들이 시도했다. 2018년에 발표된 zheap은 정확히 그것이었다. 제자리 갱신과 undo 로그로 팽창 문제를 정면으로 겨냥했다. EnterpriseDB에서 수년간의 작업, 코어에 제안된 일반 undo 프레임워크, CYBERTEC에서의 두 번째 생명을 낳았고, 그 뒤 멈췄다. 코드는 사실상 휴면 상태다. 그렇다고 아이디어가 사라진 것은 아니다. EDB의 Álvaro Herrera가 PGConf.DE 2026 기조연설에서 2029년 무렵의 Postgres를 그렸을 때, 희망 목록의 첫 번째 코어 테이블 접근 방식은 “zheap (no need for vacuum)”이었다. 다만 동사 시제에 주목하라. 10년 안에 가능할 수도 있다. 지금까지 zheap이 주는 정직한 교훈은 힙의 단순성이 구조를 떠받친다는 것이다. 버전이 undo에 살면 복구, 복제, 인덱스, 핫 스탠바이를 포함한 모든 하위 시스템이 새 불변 조건을 물려받고, 엔진 절반의 행 저장 방식을 바꾸기 위해 나머지 절반을 재구축하게 된다.
시도는 코어 밖에서 계속된다. OrioleDB(현재 Supabase 산하인 Alexander Korotkov의 엔진)는 undo 기반 버전 관리, 인덱스 조직 테이블, 행 수준 WAL을 결합하며 현대적 재고가 가치 있다는 벤치마크 수치를 게시한다. 여전히 업스트림으로 가는 중인 코어 PostgreSQL 패치가 필요하다. 테이블 접근 방식 API(PostgreSQL 12)는 힙만이 유일한 답이 아니게 하기 위해 정확히 존재하며, 순환을 완전히 끝낼 64비트 트랜잭션 ID는 장기 패치셋이 있고 적어도 하나의 상용 포크에서 이미 제공된다.
한편 코어 PostgreSQL은 설계를 바꾸지 않고 상수를 줄이는 데 15년을 썼다. HOT 갱신(8.3), vacuum이 깨끗한 페이지를 건너뛰게 하는 가시성 맵(8.4/9.6), B-트리 중복 제거(13), 인덱스 페이지 내부의 버전 변동과 싸우는 상향식 인덱스 삭제(14), 다중 패스 인덱스 vacuum을 강제하던 메모리 한계를 제거한 재작성 vacuum TID 저장소(17), 그리고 VACUUM FULL과 CLUSTER를 통합하고 배타 잠금 없이 재구축하는 CONCURRENTLY 모드를 둔 19의 REPACK 명령이 있다. 이 글 상단의 네 혐의는 2010년에는 모두 더 나빴다. 설계는 버텼고 청구서는 줄었다.
다섯 가지 저장소 설계(etcd는 PostgreSQL 열에 대응), 같은 네 질문, 실패 방식이 없는 열은 없다.
| PostgreSQL heap | Oracle / InnoDB undo | SQL Server + RCSI | WiredTiger (MongoDB) | LSM (CockroachDB etc.) | |
|---|---|---|---|---|---|
| 이전 버전 위치 | 테이블 안 | undo 세그먼트/로그 | tempdb 버전 저장소 | 캐시 안, 델타 체인으로(이력 저장소로 스필) | LSM 안, 타임스탬프가 붙은 키 |
| 갱신이 쓰는 것 | 완전한 새 행 + 모든 인덱스 항목(HOT 제외) | 제자리 + undo 델타, 바뀌지 않은 보조 인덱스는 그대로 | 제자리 + tempdb로 버전 복사 | 메모리 내 델타, 체크포인트에서 페이지 재작성, 이동한 문서는 모든 인덱스 갱신 | 새 키 버전 |
| 보조 인덱스 대상 | 물리 ctid, 모든 이동 버전을 모든 인덱스가 따름(HOT 제외) | 논리 PK(InnoDB), rowid(Oracle), 제자리 갱신에서 안정적 | 클러스터링 키 / RID, 행은 14바이트 버전 태그 획득 | RecordId, 이동한 문서는 모든 인덱스 재작성 | 논리 키, 각 버전은 타임스탬프 접미사가 붙은 새 키 |
| 중단 비용 | 거의 0 | 수행한 작업에 비례 | ADR(2019+)에서는 거의 0, 그전에는 undo 재생 | 거의 0(미커밋 델타 폐기) | 거의 0(intent를 느리게 제거) |
| 장기 독자가 일으키는 것 | 팽창, vacuum 기아, 붙들린 xmin | ORA-01555 또는 undo/이력 증가 | tempdb 증가, 인스턴스 전체 위험(ADR은 버전을 사용자 DB로 이동) | 이력 저장소 증가, 캐시 압박, 축출 정체 | GC 창 오류, 툼스톤 부채 |
| 정리 | vacuum(사용자가 튜닝) | purge/undo 재활용(그들이 하지만 여전히 정체) | 버전 저장소 정리 | 축출 + 체크포인트(계속, RAM에서) | 컴팩션(계속) |
| 특징적 장애 | 순환, 팽창 | 이력 목록 폭발, 하루 종일 롤백 | tempdb 가득 참 | 노드 전체 지연 확산; SnapshotTooOld | 툼스톤에 막힌 스캔 |
다중 버전 관리, 이력 보관, 이력 읽기, 이력 폐기의 비용은 보존된다. 각 엔진은 누가, 언제, 어떻게 실패하며 낼지만 고른다. PostgreSQL은 미래에 청구하고 팽창으로 실패한다. undo 진영은 작성자와 이력 독자에게 청구하고 질의를 취소하거나 undo를 부풀리며 실패한다. SQL Server는 tempdb에 청구하고 인스턴스 전체로 실패한다. WiredTiger는 캐시에 청구하고 모두를 한꺼번에 느리게 하며 실패한다. LSM 엔진은 컴팩터에 청구하고 시간 여행과 변동에서 실패한다.
이 표의 이쪽에서 본 PostgreSQL의 특정 선택은 독자를 결코 막지 않고, “snapshot too old”로 질의를 취소하지 않으며(옵트인하지 않는 한, 그리고 그것조차 사랑받지 못해 17에서 제거됐다), 롤백을 기다리게 하지 않고, 검사할 수 없는 곳에 쓰레기를 숨기지 않는 선택이다. 모든 데드 튜플은 pageinspect로 열 수 있는 8KB 페이지에 놓여 있다. 대가는 쓰레기가 당신 것이라는 점이다. 보이고, 측정 가능하며, 유지보수 일정에 올라간다.
그러므로 그렇다. PostgreSQL의 MVCC는 나쁘다. 다른 모든 것도 그렇다. Postgres는 운영상 시끄러운 실패 방식과 수동 튜닝 완화책을 골랐고, 그래서 zheap이 시도됐으며 OrioleDB가 존재한다. 그러나 누군가 데이터베이스가 다중 버전 관리를 해결했다고 말하면 네 질문을 하라. 이전 버전은 어디에 사는가, 체인은 어느 방향을 가리키는가, 인덱스는 무엇을 가리키는가, 누가 정리하는가. 그리고 트랜잭션이 점심시간 내내 열려 있으면 무슨 일이 일어나는지 물어라.
갱신 2027-07-29: 독자와 무엇을 언급할 가치가 있는지 논의한 뒤 Oracle 행 이동 단서를 추가했다.