Split View: Database MVCC & Isolation Levels 완전 가이드 2025: Read Phenomena, Snapshot Isolation, SSI, PostgreSQL/InnoDB 내부 구조
Database MVCC & Isolation Levels 완전 가이드 2025: Read Phenomena, Snapshot Isolation, SSI, PostgreSQL/InnoDB 내부 구조
들어가며: 왜 격리 수준이 헷갈리는가?
공식 문서의 함정
SQL 표준은 네 개의 격리 수준을 정의한다: Read Uncommitted, Read Committed, Repeatable Read, Serializable. 그리고 세 가지 이상 현상(anomaly)으로 구분한다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | 허용 | 허용 | 허용 |
| Read Committed | 방지 | 허용 | 허용 |
| Repeatable Read | 방지 | 방지 | 허용 |
| Serializable | 방지 | 방지 | 방지 |
이 표를 외우고 나면 안다고 착각한다. 그런데 실제 데이터베이스는 이 표대로 동작하지 않는다:
- PostgreSQL의 Repeatable Read는 Phantom Read를 방지한다 (표에 따르면 허용해야 함).
- MySQL InnoDB의 Repeatable Read는 Snapshot Isolation이며, Next-Key Lock으로 Phantom도 막는다.
- Oracle의 Serializable은 실제론 Snapshot Isolation이다.
- Write Skew라는 이상 현상은 SQL 표준에 없지만 Snapshot Isolation의 핵심 문제다.
즉, "Repeatable Read"라는 이름이 같아도 DB마다 의미가 다르다. 이 글에서는 각 DB가 실제로 어떻게 작동하는지 들여다본다.
1. 트랜잭션과 ACID 복습
ACID의 진짜 의미
- Atomicity: 모두 성공하거나 모두 실패.
- Consistency: 트랜잭션 후에도 데이터베이스 불변식 유지.
- Isolation: 동시 실행되는 트랜잭션들이 서로 영향을 주지 않는 것처럼 보임.
- Durability: 커밋된 데이터는 영속.
이 중에서 Isolation이 가장 어렵고 미묘하다. 완벽한 Isolation(= Serializable)은 트랜잭션을 직렬로 실행하는 것과 같은 결과를 보장하지만, 그러면 성능이 끔찍하다. 그래서 다양한 약한 격리 수준이 제공된다.
격리 수준의 본질
격리 수준은 "어떤 이상 현상(anomaly)을 허용할 것인가" 의 선택이다. 약한 격리 수준은 빠르지만 프로그래머가 버그를 피하기 위해 추가 노력을 해야 한다. 강한 격리 수준은 안전하지만 성능이 떨어진다.
Serializable ←────── 안전, 느림
↑
Snapshot Isolation
↑
Repeatable Read
↑
Read Committed
↑
Read Uncommitted ←── 위험, 빠름
2. Read Phenomena 완전 정복
Dirty Read: 커밋 안 된 데이터 읽기
예시:
-- 초기 상태: account = 100
-- T1
BEGIN;
UPDATE account SET balance = 200; -- 아직 커밋 안 함
-- 갑자기 ROLLBACK
-- T2 (T1의 UPDATE 이후, ROLLBACK 이전)
BEGIN;
SELECT balance FROM account; -- 200 반환! (dirty read)
T1이 결국 롤백되므로 T2가 읽은 200은 존재한 적 없는 값이다. 이는 매우 위험하다.
실전: 거의 모든 최신 DB가 Read Uncommitted를 실제로 지원하지 않는다. 설정해도 Read Committed처럼 동작하는 경우가 많다 (예: PostgreSQL).
Non-repeatable Read: 같은 행이 달라짐
-- T1
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100
-- T2
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
-- T1 계속
SELECT balance FROM account WHERE id = 1; -- 200 (다른 값!)
COMMIT;
한 트랜잭션 내에서 같은 SELECT가 다른 결과를 반환한다. 복잡한 리포트 쿼리에서 일관성이 깨진다.
Phantom Read: 같은 WHERE 결과가 달라짐
-- T1
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 5; -- 10
-- T2
BEGIN;
INSERT INTO orders (user_id, ...) VALUES (5, ...);
COMMIT;
-- T1 계속
SELECT COUNT(*) FROM orders WHERE user_id = 5; -- 11 (유령 행!)
COMMIT;
존재하지 않던 행이 갑자기 나타났다. 이를 Phantom이라 부른다. Non-repeatable read와 유사하지만, 대상이 개별 행이 아니라 범위 쿼리 결과라는 점이 다르다.
Lost Update: 쓴 내용이 사라짐
-- 초기: counter = 0
-- T1과 T2가 동시에:
BEGIN;
SELECT counter FROM stats; -- 둘 다 0
-- 애플리케이션에서 +1 계산
UPDATE stats SET counter = 1;
COMMIT;
-- 결과: counter = 1 (하나의 업데이트가 사라짐!)
두 트랜잭션이 같은 값을 읽고 각자 업데이트하면, 나중에 쓴 것이 먼저 쓴 것을 덮어쓴다.
해결책:
SELECT ... FOR UPDATE(비관적 락)UPDATE stats SET counter = counter + 1(원자적 연산)- Optimistic locking (
WHERE version = ?)
Write Skew: Snapshot의 치명적 약점
-- 병원 스케줄링: 최소 1명의 의사가 당직이어야 함
-- 초기 상태: 의사 Alice와 Bob 둘 다 당직 중
-- T1 (Alice가 오프 신청)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2
-- 2명이니 1명 빠져도 괜찮다고 판단
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- T2 (Bob이 오프 신청, 동시에 진행)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2 (Alice 변경 전 스냅샷)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- 결과: 둘 다 오프! 당직 의사 0명!
두 트랜잭션이 각자 다른 행을 수정했기 때문에 쓰기 충돌이 없다. 하지만 결과적으로 불변식(최소 1명 당직)이 깨진다. 이를 Write Skew라 한다.
Write Skew는 Snapshot Isolation에서 발생할 수 있으며, 진짜 Serializable에서만 완전히 방지된다.
Read Skew: 일관성 없는 집계
-- 계좌 A, B 각각 100원
-- T1 (읽기)
BEGIN;
SELECT balance FROM accounts WHERE id = 'A'; -- 100
-- T2 (송금 A → B)
BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 'A';
UPDATE accounts SET balance = 150 WHERE id = 'B';
COMMIT;
-- T1 계속
SELECT balance FROM accounts WHERE id = 'B'; -- 150
COMMIT;
T1은 A=100, B=150을 읽어서 총합 250원이라는 잘못된 값을 얻는다. 실제로는 A=50, B=150이거나 A=100, B=100이어야 한다.
Snapshot Isolation은 이를 완벽히 해결한다: T1이 시작된 시점의 스냅샷을 보기 때문에 A=100, B=100을 읽는다.
3. MVCC: Multi-Version Concurrency Control
MVCC의 기본 아이디어
전통적 DB는 락으로 동시성을 관리했다. 읽기도 락을 걸어서 쓰기를 막고, 쓰기도 락을 걸어서 읽기를 막았다. 이는 "Readers block writers, writers block readers" 라는 유명한 문제를 낳았다.
MVCC는 이 문제를 해결한다:
"각 행의 여러 버전을 유지하고, 각 트랜잭션은 자신에게 맞는 버전을 읽는다."
결과: 읽기는 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다.
MVCC의 핵심 구조
각 행에 메타데이터 추가:
- xmin: 이 버전을 생성한 트랜잭션 ID.
- xmax: 이 버전을 삭제한 트랜잭션 ID (없으면 0 또는 무한대).
가시성 규칙:
- 트랜잭션 T가 행 버전 V를 볼 수 있다 ↔
xmin <= TAND (xmax == 0ORxmax > T).
즉, T 시작 시점에 이미 존재했고 아직 살아있는 버전만 T에게 보인다.
4. PostgreSQL의 MVCC 심층 분석
행의 실제 저장 구조
PostgreSQL에서 모든 행(튜플)은 다음 헤더를 가진다:
typedef struct HeapTupleHeaderData {
TransactionId t_xmin; // 생성한 트랜잭션
TransactionId t_xmax; // 삭제/업데이트한 트랜잭션
CommandId t_cmin; // 같은 트랜잭션 내 커맨드 ID
CommandId t_cmax;
ItemPointerData t_ctid; // 이 튜플의 위치(또는 다음 버전 포인터)
// ...
};
UPDATE의 실제 동작
PostgreSQL에서 UPDATE는 제자리 갱신이 아니다:
- 기존 행에 xmax를 설정 (삭제 표시).
- 새로운 행을 삽입하고 xmin = 현재 트랜잭션.
- 기존 행의
ctid가 새 행을 가리키도록 연결.
결과: 페이지에 여러 버전의 같은 논리적 행이 공존한다.
Before UPDATE:
[xmin=100, xmax=0, id=1, balance=100]
After UPDATE by tx 150:
[xmin=100, xmax=150, id=1, balance=100] ← 구 버전
[xmin=150, xmax=0, id=1, balance=200] ← 신 버전
스냅샷 (Snapshot)
PostgreSQL 트랜잭션은 시작 시 스냅샷을 얻는다. 스냅샷은:
xmin: 활성 중인 가장 오래된 트랜잭션 ID.xmax: 아직 할당되지 않은 가장 큰 트랜잭션 ID.xip[]: 현재 진행 중인 트랜잭션 ID 목록.
튜플 T가 보이는지 판단:
def is_visible(tuple_xmin, tuple_xmax, snapshot):
# 아직 커밋되지 않은 생성자
if tuple_xmin in snapshot.xip:
return False
if tuple_xmin >= snapshot.xmax:
return False
# 이미 삭제된 경우
if tuple_xmax != 0 and tuple_xmax not in snapshot.xip:
if tuple_xmax < snapshot.xmax:
return False # 삭제 커밋됨
return True
VACUUM: 쓰레기 수집
MVCC의 부작용은 구 버전 행들이 계속 쌓인다는 것이다. 이를 정리하는 것이 VACUUM이다:
- VACUUM: 모든 트랜잭션이 더 이상 볼 수 없는 dead tuple을 회수.
- VACUUM FULL: 테이블을 재구성해서 공간도 회수 (AccessExclusive lock 필요).
- Autovacuum: 자동 트리거.
VACUUM을 게을리하면 테이블이 부풀어 오르고(bloat), 성능이 급격히 저하된다.
PostgreSQL 격리 수준의 진실
PostgreSQL은 Read Uncommitted를 지원하지 않는다. 설정해도 Read Committed처럼 동작한다. 실제 구현:
| 설정 | 실제 동작 |
|---|---|
| Read Uncommitted | = Read Committed |
| Read Committed (기본) | 각 쿼리마다 새 스냅샷 |
| Repeatable Read | 트랜잭션 시작 시 스냅샷 고정 (= Snapshot Isolation) |
| Serializable | SSI (Serializable Snapshot Isolation) |
주의: PostgreSQL의 Repeatable Read는 표준보다 강하다. Phantom Read도 방지한다. 하지만 Write Skew는 여전히 발생 가능하다. 이를 완전히 막으려면 Serializable로 올려야 한다.
5. MySQL InnoDB의 MVCC
Undo Log 기반 MVCC
PostgreSQL이 새 버전을 제자리에 추가하는 방식이라면, InnoDB는 in-place update + undo log 방식이다:
- 실제 데이터 페이지에는 최신 버전만 저장.
- 이전 버전은 undo log에 저장.
- 오래된 트랜잭션이 구 버전을 읽어야 할 때, undo log를 따라가 재구성.
데이터 페이지:
[id=1, balance=200, DB_TRX_ID=150, DB_ROLL_PTR=→undo]
│
▼
Undo Log:
[id=1, balance=100, DB_TRX_ID=100]
장단점 비교
| 항목 | PostgreSQL | InnoDB |
|---|---|---|
| UPDATE 방식 | 새 행 삽입 + 구 행 표시 | 제자리 갱신 + undo log |
| VACUUM 필요 | 예 (bloat 관리) | 아니오 (undo log 자동 정리) |
| 인덱스 영향 | 모든 인덱스 업데이트 (HOT 제외) | 인덱스 값 안 바뀌면 클러스터 인덱스만 |
| 구 버전 조회 | 즉시 (같은 페이지) | undo log 따라가야 함 |
| 긴 트랜잭션 영향 | bloat 유발 | undo log 폭증 |
InnoDB Repeatable Read = Snapshot Isolation
InnoDB의 기본 격리 수준은 Repeatable Read이며, 트랜잭션 시작 시 consistent read view를 생성한다. 이는 Snapshot Isolation과 사실상 동일하다.
Next-Key Lock: Phantom 방지
InnoDB는 Next-Key Lock으로 Repeatable Read에서도 phantom을 방지한다:
-- id 컬럼 인덱스: 5, 10, 20
-- T1
BEGIN;
SELECT * FROM t WHERE id > 8 AND id < 15 FOR UPDATE;
-- 이 쿼리가 (5, 10], (10, 20) 범위에 락을 건다
-- T2
INSERT INTO t VALUES (12); -- 대기! (범위 락 때문)
Next-Key Lock은 인덱스 엔트리 + 그 앞의 갭에 락을 건다. 이를 통해 Repeatable Read + phantom 방지를 달성한다.
Gap Lock, Record Lock, Next-Key Lock
- Record Lock: 특정 인덱스 엔트리에 락.
- Gap Lock: 인덱스 엔트리들 사이의 "간격"에 락.
- Next-Key Lock: Record Lock + 그 앞의 Gap Lock.
이 셋의 조합으로 다양한 격리 수준을 구현한다.
6. Snapshot Isolation과 그 한계
Snapshot Isolation의 매력
Snapshot Isolation(SI)은 많은 DB의 "Repeatable Read" 또는 심지어 "Serializable"로 판매된다. 왜?
- 읽기가 빠르다: 스냅샷만 있으면 락 불필요.
- Read Skew 방지: 일관된 시점의 데이터를 본다.
- 구현 단순: MVCC 위에 바로 얹을 수 있다.
First-committer-wins (FCW) 규칙
SI에서 동일한 행을 여러 트랜잭션이 수정하려 하면, 먼저 커밋한 쪽이 이긴다. 다른 트랜잭션은 "쓰기 충돌" 에러로 반드시 롤백되어야 한다.
-- T1, T2 둘 다 같은 행 수정 시도
-- T1: UPDATE row SET ... WHERE id = 1
-- T2: UPDATE row SET ... WHERE id = 1
-- T1 먼저 커밋 → 성공
-- T2 커밋 시도 → ERROR: could not serialize access
애플리케이션은 이 에러를 잡아서 트랜잭션을 재시도해야 한다.
Write Skew: SI의 치명적 결함
앞서 본 의사 당직 예시가 바로 Write Skew다. 두 트랜잭션이 서로 다른 행을 수정하면 FCW도 적용되지 않는다. 결과적으로 불변식이 깨진다.
Write Skew의 공통 패턴:
- 트랜잭션이 데이터를 읽어서 조건을 확인.
- 조건에 따라 다른 행을 수정.
- 다른 트랜잭션이 같은 조건을 확인하고 같은 로직으로 수정.
- 두 트랜잭션 모두 성공하지만 전체 조건이 깨짐.
Phantom과 Write Skew의 관계
Phantom은 Write Skew의 특수한 경우다:
- 일반 Write Skew: 이미 존재하는 행을 읽음.
- Phantom Write Skew: 조건에 맞는 행이 "없음"을 읽음.
-- 예: 사용자명 고유성 보장
-- T1: 사용자명 "alice"가 없음을 확인 → 추가
-- T2: 사용자명 "alice"가 없음을 확인 → 추가
-- 결과: 같은 이름 두 번 삽입 (SI에서 가능!)
이를 방지하려면 실제 Serializable이 필요하거나, 유니크 제약조건에 의존해야 한다.
7. Serializable Snapshot Isolation (SSI)
문제 제기
Snapshot Isolation은 빠르지만 Write Skew를 허용한다. 진짜 Serializable은 안전하지만 락 때문에 느리다. 더 좋은 방법은 없을까?
2008년 Michael Cahill, Uwe Röhm, Alan Fekete의 논문 "Serializable Isolation for Snapshot Databases" 는 답을 제시했다: SSI (Serializable Snapshot Isolation).
SSI의 핵심 아이디어
"SI처럼 동작하되, 직렬화 불가능한 패턴을 감지해서 롤백한다."
트랜잭션 간 읽기/쓰기 의존성 그래프를 추적하고, 순환(cycle)이 감지되면 한쪽을 취소한다.
Dangerous Structure
Cahill 등은 "rw-dependency의 이중 사이클" 이 serializability를 깨는 유일한 패턴임을 증명했다:
T1 --rw--> T2 --rw--> T3
두 개의 연속된 rw 의존성(T1이 T2가 쓴 것을 읽지 않았는데, T2가 T1이 읽은 것을 썼다)이 있으면 위험하다. SSI는 이를 감지한다.
PostgreSQL의 SSI 구현
PostgreSQL 9.1부터 SERIALIZABLE = SSI로 구현되었다:
- 트랜잭션 시작 시 SI 스냅샷 획득.
- 읽기/쓰기 연산마다 predicate lock을 추적.
- 충돌 패턴 감지 시 한쪽을
ERROR: could not serialize access로 롤백. - 롤백된 트랜잭션은 애플리케이션이 재시도.
장점: 성능은 SI에 가깝고, 안전성은 Serializable. 단점: 롤백 처리 로직 필요, 직관과 다른 시점에 에러 발생.
사용 예시
-- PostgreSQL
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 의사 당직 예시
SELECT COUNT(*) FROM doctors WHERE on_call = true;
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- 동시 실행되는 다른 트랜잭션이 Bob을 수정하면
-- 커밋 시 둘 중 하나는 롤백된다:
-- ERROR: could not serialize access due to read/write dependencies
COMMIT;
애플리케이션 패턴:
def do_doctor_off(doctor_name):
while True:
try:
with db.transaction(isolation="serializable"):
count = db.execute("SELECT COUNT(*) FROM doctors WHERE on_call = true")
if count > 1:
db.execute("UPDATE doctors SET on_call = false WHERE name = %s", doctor_name)
else:
raise ValueError("Cannot go off, you're the only one on call")
return
except SerializationError:
continue # 재시도
8. 격리 수준별 실전 사용 가이드
Read Committed (기본값으로 충분한가?)
대부분의 웹 애플리케이션에서 기본값이다. 개별 쿼리가 일관성 있으면 OK인 경우:
- 단순 CRUD
- 사용자 프로필 조회/수정
- 블로그 포스팅
주의: Non-repeatable read가 허용되므로, 한 트랜잭션에서 같은 행을 두 번 읽는 코드는 조심해야 한다.
Repeatable Read / Snapshot Isolation
보고서 생성, 복잡한 조회에 적합:
- 대시보드 집계
- 긴 트랜잭션 동안 일관된 뷰가 필요할 때
- Read-heavy 분석 쿼리
주의: Write Skew 가능성을 인지하고, 중요한 불변식이 관련된다면 명시적 락이나 Serializable 고려.
Serializable (SSI)
정합성이 절대적으로 중요한 경우:
- 금융 거래
- 의사 당직, 비행기 예약 같은 리소스 할당
- 유니크 제약의 복잡한 조건
트레이드오프:
- 성능 저하 (롤백 오버헤드)
- 재시도 로직 필수
- 높은 동시성 환경에서 경쟁 격화
어떻게 선택할까?
- 기본적으로 Read Committed로 시작.
- 불일치 버그가 보이기 시작하면 해당 트랜잭션만 Serializable로 올림.
- 전체를 Serializable로 하지 말고, 핵심 경로만 선택적 적용.
9. Optimistic vs Pessimistic Locking
격리 수준 외에도 동시성 제어 전략이 있다.
Pessimistic Locking (비관적 락)
"충돌이 자주 일어날 것이다. 미리 막자."
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 락 획득
-- 계산...
UPDATE account SET balance = ... WHERE id = 1;
COMMIT; -- 락 해제
장점: 충돌 없음, 단순한 로직. 단점: 락 경쟁, 데드락, 긴 락 유지 시 성능 저하.
Optimistic Locking (낙관적 락)
"충돌은 드물다. 감지해서 재시도하자."
-- 읽기
SELECT balance, version FROM account WHERE id = 1; -- balance=100, version=5
-- 애플리케이션에서 계산
new_balance = 100 - 30
-- 쓰기 (version 체크)
UPDATE account
SET balance = 70, version = version + 1
WHERE id = 1 AND version = 5;
-- affected rows == 0이면 다른 트랜잭션이 먼저 수정 → 재시도
장점: 락 오버헤드 없음, 고동시성에 유리. 단점: 애플리케이션 재시도 로직 필요, 충돌 많으면 오히려 느림.
선택 가이드
| 상황 | 추천 |
|---|---|
| 읽기 많음, 쓰기 적음 | Optimistic |
| 쓰기 많음, 충돌 자주 | Pessimistic |
| 사용자 대기 시간 중요 | Optimistic |
| 재시도 로직 만들기 어려움 | Pessimistic |
| 분산 시스템 | Optimistic (락 전파 비용) |
10. 분산 트랜잭션과 격리
단일 DB 격리의 한계
지금까지 이야기는 단일 DB 내의 격리였다. 여러 DB나 서비스 간 트랜잭션은 어떻게 할까?
Two-Phase Commit (2PC)
전통적인 분산 트랜잭션 프로토콜:
- Prepare Phase: Coordinator가 모든 참여자에게 "준비됐니?" 질문.
- Commit Phase: 모두 준비됐으면 "커밋해" 명령, 아니면 "롤백" 명령.
문제점:
- Coordinator 장애 시 블로킹.
- 성능 저하 (2번의 네트워크 왕복).
- 참여자 수만큼 비가용성 증가.
Saga 패턴
2PC의 대안. 보상 트랜잭션(compensating transaction) 으로 실패를 복구:
Order Service: create_order(ID)
Payment Service: charge(ID)
Inventory: reserve(ID)
실패 시 역순으로:
Inventory: unreserve(ID)
Payment Service: refund(ID)
Order Service: cancel_order(ID)
장점: 블로킹 없음, 서비스 독립성. 단점: 보상 로직 구현 부담, 격리 수준 낮음 (중간 상태가 외부에 보일 수 있음).
Percolator / Spanner
Google의 분산 DB들은 진짜 Serializable을 위해:
- Percolator (Bigtable 위): 2PC + MVCC로 범용 분산 트랜잭션.
- Spanner: TrueTime API로 전역 시계 동기화 + 2PC + Paxos.
TiDB는 Percolator 모델을 오픈소스로 구현했다.
11. 실전 트러블슈팅
문제 1: "deadlock detected"
원인: 두 트랜잭션이 서로의 락을 기다림.
해결:
- 항상 같은 순서로 락을 획득 (예: id 오름차순).
- 락 유지 시간 단축 (짧은 트랜잭션).
SELECT ... FOR UPDATE SKIP LOCKED로 건너뛰기.- 재시도 로직 구현.
문제 2: "could not serialize access"
원인: SSI 롤백.
해결:
- 애플리케이션에서 재시도.
- 지수 백오프.
- 경쟁이 심한 경로는 명시적 락으로 전환 고려.
문제 3: 긴 트랜잭션으로 인한 bloat (PostgreSQL)
증상: 디스크 사용량 급증, 쿼리 느려짐.
원인: 오래 열려있는 트랜잭션이 VACUUM을 막음.
해결:
-- 오래 실행 중인 트랜잭션 확인
SELECT pid, state, query, xact_start, now() - xact_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start;
-- 수동 VACUUM
VACUUM (ANALYZE, VERBOSE) table_name;
예방:
idle_in_transaction_session_timeout설정.- 트랜잭션을 짧게 유지.
- 애플리케이션이 트랜잭션을 열어두고 유휴 상태로 두지 않기.
문제 4: Lost Update in Web Applications
증상: 사용자가 동시에 편집하면 한 명의 변경사항이 사라짐.
해결:
version컬럼 + optimistic locking.- 또는 CRDT 기반 협업 편집.
- 또는 사용자 피드백 ("누군가 먼저 수정했습니다. 병합하시겠습니까?").
퀴즈로 복습하기
Q1. PostgreSQL의 Repeatable Read와 SQL 표준의 Repeatable Read의 차이는?
A. SQL 표준에서 Repeatable Read는 Phantom Read를 허용한다. 그러나 PostgreSQL의 Repeatable Read는 Snapshot Isolation으로 구현되어 있어 Phantom도 방지한다. 단, Write Skew는 여전히 발생 가능하다. 이를 완전히 막으려면 Serializable로 올려야 한다.
Q2. Write Skew가 Lost Update와 다른 점은?
A. Lost Update는 같은 행을 두 트랜잭션이 수정해서 하나의 업데이트가 사라지는 것이다. First-committer-wins 규칙이나 원자적 UPDATE로 막을 수 있다. Write Skew는 서로 다른 행을 수정해서 쓰기 충돌은 없지만, 읽은 데이터에 기반한 결정이 전체적으로 불변식을 깨는 경우다. Snapshot Isolation으로는 막을 수 없으며 Serializable이 필요하다.
Q3. PostgreSQL과 InnoDB의 MVCC 구현상 가장 큰 차이는?
A. PostgreSQL은 새 버전을 테이블 자체에 추가한다(copy-on-write). 그래서 dead tuple이 쌓이고 VACUUM으로 정리해야 한다. InnoDB는 제자리 갱신 + undo log를 사용한다. 최신 버전은 데이터 페이지에, 과거 버전은 undo log에 저장된다. InnoDB는 VACUUM이 필요 없지만, 긴 트랜잭션이 undo log 폭증을 유발할 수 있다.
Q4. SSI (Serializable Snapshot Isolation)이 어떻게 성능을 유지하면서 Serializable을 달성하는가?
A. SSI는 기본적으로 Snapshot Isolation처럼 동작한다 (락 없음, 빠른 읽기). 대신 트랜잭션들의 읽기/쓰기 의존성을 추적한다. "직렬화 불가능한 이중 rw 사이클"이 감지되면 한 트랜잭션을 롤백시킨다. 이는 낙관적 접근이다: 대부분의 경우 충돌이 없으면 SI의 성능을 유지하고, 충돌 시에만 페널티가 발생한다.
Q5. SELECT ... FOR UPDATE가 MVCC 환경에서 왜 여전히 유용한가?
A. MVCC는 읽기 일관성을 제공하지만 쓰기 직전의 상태 확인은 보장하지 않는다. FOR UPDATE는 현재 커밋된 최신 버전에 행 락을 걸고 읽어온다. 이후 UPDATE가 이 버전에 기반해 이뤄지므로 Lost Update를 방지한다. Snapshot Isolation에서 Write Skew를 수동으로 막거나, Lost Update를 명시적으로 방지할 때 필요하다.
마치며: Isolation Level의 함정과 해법
핵심 교훈
-
"Repeatable Read"라는 이름은 믿지 마라. DB마다 다르게 구현되어 있다. 문서와 실제 동작을 확인하자.
-
Snapshot Isolation이 만능이 아니다. Write Skew는 미묘하지만 치명적이다. 불변식에 민감한 로직은 Serializable을 고려하자.
-
완벽한 Serializable은 비싸다. 성능이 중요하면 전체를 SI로 하고 위험 경로만 Serializable로 승격하는 전략이 실용적이다.
-
MVCC는 공짜가 아니다. PostgreSQL은 VACUUM, InnoDB는 undo log. 장기 트랜잭션은 양쪽 모두에 독이다.
-
격리 수준은 방어선의 한 겹. 유니크 제약, 외래 키, 애플리케이션 검증을 함께 활용하자.
실무 체크리스트
- 우리 팀 DB의 기본 격리 수준이 무엇인가?
- Write Skew가 발생할 수 있는 로직이 있는가?
- 긴 트랜잭션이 VACUUM/undo log에 부담을 주고 있는가?
- 재시도 로직이 필요한 격리 수준을 쓰고 있는가?
- 데드락 발생 시 감지 및 재시도가 준비되어 있는가?
분산 시스템의 어려움 중 많은 부분이 "데이터 일관성"이다. Isolation Level은 그 한복판에 있다. 이름 외우기보단 실제 동작을 이해하는 것이 버그를 막는다.
참고 자료
- A Critique of ANSI SQL Isolation Levels (Berenson et al., 1995) - 격리 수준의 허점
- Serializable Isolation for Snapshot Databases (Cahill et al., 2008) - SSI 원 논문
- PostgreSQL Concurrency Control - 공식 문서
- MySQL InnoDB Locking and Transaction Model
- Designing Data-Intensive Applications, Ch.7 - Martin Kleppmann의 명저
- Jepsen: PostgreSQL Analysis - 검증 리포트
- Hermitage: Isolation Level Test Suite - 각 DB의 실제 동작 비교
Database MVCC & Isolation Levels Complete Guide 2025: Read Phenomena, Snapshot Isolation, SSI, PostgreSQL/InnoDB Internals
Introduction: Why Are Isolation Levels So Confusing?
The Trap of Official Documentation
The SQL standard defines four isolation levels: Read Uncommitted, Read Committed, Repeatable Read, and Serializable. It also distinguishes them by three kinds of anomalies.
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Allowed | Allowed | Allowed |
| Read Committed | Prevented | Allowed | Allowed |
| Repeatable Read | Prevented | Prevented | Allowed |
| Serializable | Prevented | Prevented | Prevented |
Memorize the table and you think you understand it. But actual databases don't behave according to this table:
- PostgreSQL's Repeatable Read prevents Phantom Read (the table says it should allow it).
- MySQL InnoDB's Repeatable Read is Snapshot Isolation, and Next-Key Locks also block phantoms.
- Oracle's Serializable is actually Snapshot Isolation.
- Write Skew is an anomaly not in the SQL standard, but it's the central problem of Snapshot Isolation.
In other words, "Repeatable Read" means different things in different DBs even though the name is the same. In this article, we look at how each DB actually works.
1. Transactions and ACID Revisited
What ACID Really Means
- Atomicity: All succeed or all fail.
- Consistency: Database invariants are preserved after the transaction.
- Isolation: Concurrently running transactions appear not to affect each other.
- Durability: Committed data is persistent.
Of these, Isolation is the hardest and most subtle. Perfect Isolation (= Serializable) guarantees the same result as running transactions serially, but performance would be terrible. That's why various weaker isolation levels are offered.
The Essence of Isolation Levels
An isolation level is a choice of "which anomalies will we allow." Weaker isolation levels are fast but require extra programmer effort to avoid bugs. Stronger isolation levels are safe but slower.
Serializable <------- Safe, Slow
^
Snapshot Isolation
^
Repeatable Read
^
Read Committed
^
Read Uncommitted <---- Dangerous, Fast
2. Read Phenomena Fully Conquered
Dirty Read: Reading Uncommitted Data
Example:
-- Initial state: account = 100
-- T1
BEGIN;
UPDATE account SET balance = 200; -- Not yet committed
-- Suddenly ROLLBACK
-- T2 (after T1's UPDATE, before ROLLBACK)
BEGIN;
SELECT balance FROM account; -- Returns 200! (dirty read)
Since T1 ends up rolling back, the 200 T2 read is a value that never existed. This is extremely dangerous.
In practice: Almost every modern DB does not actually support Read Uncommitted. Even if set, it often behaves like Read Committed (e.g., PostgreSQL).
Non-repeatable Read: The Same Row Changes
-- T1
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100
-- T2
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
-- T1 continues
SELECT balance FROM account WHERE id = 1; -- 200 (different value!)
COMMIT;
The same SELECT within a transaction returns different results. Consistency breaks down in complex report queries.
Phantom Read: The Same WHERE Result Changes
-- T1
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 5; -- 10
-- T2
BEGIN;
INSERT INTO orders (user_id, ...) VALUES (5, ...);
COMMIT;
-- T1 continues
SELECT COUNT(*) FROM orders WHERE user_id = 5; -- 11 (phantom row!)
COMMIT;
A row that didn't exist suddenly appears. This is called a Phantom. It's similar to non-repeatable read, but the target is the result of a range query, not an individual row.
Lost Update: Writes Disappear
-- Initial: counter = 0
-- T1 and T2 simultaneously:
BEGIN;
SELECT counter FROM stats; -- Both see 0
-- Application computes +1
UPDATE stats SET counter = 1;
COMMIT;
-- Result: counter = 1 (one update is lost!)
When two transactions read the same value and each updates it, the later write overwrites the earlier one.
Solutions:
SELECT ... FOR UPDATE(pessimistic lock)UPDATE stats SET counter = counter + 1(atomic operation)- Optimistic locking (
WHERE version = ?)
Write Skew: Snapshot's Fatal Weakness
-- Hospital scheduling: at least 1 doctor must be on call
-- Initial state: both Alice and Bob are on call
-- T1 (Alice requests off)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2
-- 2 doctors, so 1 going off is fine
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- T2 (Bob requests off, concurrently)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2 (snapshot before Alice's change)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- Result: both off! Zero doctors on call!
Since the two transactions modified different rows, there's no write conflict. But the result is that the invariant (at least 1 on call) is broken. This is called Write Skew.
Write Skew can occur under Snapshot Isolation and is fully prevented only under true Serializable.
Read Skew: Inconsistent Aggregation
-- Accounts A, B each with 100
-- T1 (read)
BEGIN;
SELECT balance FROM accounts WHERE id = 'A'; -- 100
-- T2 (transfer A -> B)
BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 'A';
UPDATE accounts SET balance = 150 WHERE id = 'B';
COMMIT;
-- T1 continues
SELECT balance FROM accounts WHERE id = 'B'; -- 150
COMMIT;
T1 reads A=100, B=150 and gets a total of 250, which is wrong. Actually it should be A=50, B=150 or A=100, B=100.
Snapshot Isolation solves this perfectly: T1 sees a snapshot from its start time and reads A=100, B=100.
3. MVCC: Multi-Version Concurrency Control
The Basic Idea of MVCC
Traditional DBs managed concurrency with locks. Reads took locks that blocked writes, writes took locks that blocked reads. This led to the famous "Readers block writers, writers block readers" problem.
MVCC solves this:
"Keep multiple versions of each row, and each transaction reads the version appropriate for itself."
Result: Reads don't block writes, and writes don't block reads.
The Core Structure of MVCC
Metadata added to each row:
- xmin: The transaction ID that created this version.
- xmax: The transaction ID that deleted this version (0 or infinity if none).
Visibility rule:
- Transaction T can see row version V
<->xmin <= TAND (xmax == 0ORxmax > T).
That is, only versions that already existed at T's start and are still alive are visible to T.
4. Deep Dive into PostgreSQL's MVCC
The Actual Storage Structure of Rows
In PostgreSQL, every row (tuple) has the following header:
typedef struct HeapTupleHeaderData {
TransactionId t_xmin; // Creating transaction
TransactionId t_xmax; // Deleting/updating transaction
CommandId t_cmin; // Command ID within same transaction
CommandId t_cmax;
ItemPointerData t_ctid; // Location of this tuple (or pointer to next version)
// ...
};
How UPDATE Actually Works
In PostgreSQL, UPDATE is not in-place:
- Set xmax on the old row (mark deleted).
- Insert a new row with xmin = current transaction.
- Link the old row's
ctidto point to the new row.
Result: multiple versions of the same logical row coexist on the page.
Before UPDATE:
[xmin=100, xmax=0, id=1, balance=100]
After UPDATE by tx 150:
[xmin=100, xmax=150, id=1, balance=100] <- old version
[xmin=150, xmax=0, id=1, balance=200] <- new version
Snapshot
A PostgreSQL transaction obtains a snapshot at start. The snapshot contains:
xmin: oldest active transaction ID.xmax: largest not-yet-assigned transaction ID.xip[]: list of currently in-progress transaction IDs.
Determining whether tuple T is visible:
def is_visible(tuple_xmin, tuple_xmax, snapshot):
# Creator not yet committed
if tuple_xmin in snapshot.xip:
return False
if tuple_xmin >= snapshot.xmax:
return False
# Already deleted
if tuple_xmax != 0 and tuple_xmax not in snapshot.xip:
if tuple_xmax < snapshot.xmax:
return False # delete committed
return True
VACUUM: Garbage Collection
A side effect of MVCC is that old row versions keep piling up. Cleaning them is the job of VACUUM:
- VACUUM: reclaims dead tuples no transaction can see anymore.
- VACUUM FULL: reorganizes the table to reclaim space too (requires AccessExclusive lock).
- Autovacuum: triggered automatically.
Neglecting VACUUM causes table bloat and sharply degrades performance.
The Truth About PostgreSQL Isolation Levels
PostgreSQL does not support Read Uncommitted. Even when set, it behaves like Read Committed. Actual implementation:
| Setting | Actual Behavior |
|---|---|
| Read Uncommitted | = Read Committed |
| Read Committed (default) | New snapshot per query |
| Repeatable Read | Snapshot fixed at transaction start (= Snapshot Isolation) |
| Serializable | SSI (Serializable Snapshot Isolation) |
Note: PostgreSQL's Repeatable Read is stronger than the standard. It also prevents Phantom Read. But Write Skew can still occur. To fully prevent it, raise to Serializable.
5. MySQL InnoDB's MVCC
Undo-Log-Based MVCC
Where PostgreSQL appends new versions in place, InnoDB uses in-place update + undo log:
- Only the latest version is stored in the actual data page.
- Previous versions are stored in the undo log.
- When an older transaction needs to read an old version, it walks the undo log to reconstruct it.
Data page:
[id=1, balance=200, DB_TRX_ID=150, DB_ROLL_PTR=-> undo]
|
v
Undo Log:
[id=1, balance=100, DB_TRX_ID=100]
Pros and Cons Compared
| Aspect | PostgreSQL | InnoDB |
|---|---|---|
| UPDATE method | New row insert + mark old row | In-place update + undo log |
| VACUUM needed | Yes (bloat management) | No (undo log auto-cleaned) |
| Index impact | All indexes updated (except HOT) | Only clustered index if index value unchanged |
| Old-version lookup | Instant (same page) | Must walk undo log |
| Long-transaction impact | Causes bloat | Undo log explosion |
InnoDB Repeatable Read = Snapshot Isolation
InnoDB's default isolation level is Repeatable Read, and it creates a consistent read view at transaction start. This is effectively identical to Snapshot Isolation.
Next-Key Lock: Phantom Prevention
InnoDB prevents phantoms in Repeatable Read with Next-Key Locks:
-- id column index: 5, 10, 20
-- T1
BEGIN;
SELECT * FROM t WHERE id > 8 AND id < 15 FOR UPDATE;
-- This query locks the range (5, 10], (10, 20)
-- T2
INSERT INTO t VALUES (12); -- Waits! (due to range lock)
A Next-Key Lock locks an index entry plus the gap before it. This achieves Repeatable Read + phantom prevention.
Gap Lock, Record Lock, Next-Key Lock
- Record Lock: lock on a specific index entry.
- Gap Lock: lock on the "gap" between index entries.
- Next-Key Lock: Record Lock + the Gap Lock before it.
Combinations of these implement the various isolation levels.
6. Snapshot Isolation and Its Limits
The Appeal of Snapshot Isolation
Snapshot Isolation (SI) is sold as "Repeatable Read" or even "Serializable" in many DBs. Why?
- Reads are fast: no locks needed, just a snapshot.
- Prevents Read Skew: sees consistent point-in-time data.
- Simple to implement: layers right on top of MVCC.
The First-Committer-Wins (FCW) Rule
Under SI, when multiple transactions try to modify the same row, the one that commits first wins. The other transactions must be rolled back with a "write conflict" error.
-- Both T1 and T2 try to modify the same row
-- T1: UPDATE row SET ... WHERE id = 1
-- T2: UPDATE row SET ... WHERE id = 1
-- T1 commits first -> success
-- T2 attempts commit -> ERROR: could not serialize access
The application must catch this error and retry the transaction.
Write Skew: SI's Fatal Flaw
The doctor on-call example earlier is Write Skew. When two transactions modify different rows, FCW doesn't apply. The result is a broken invariant.
The common Write Skew pattern:
- Transaction reads data to check a condition.
- Based on the condition, modifies a different row.
- Another transaction checks the same condition with the same logic and modifies.
- Both transactions succeed, but the overall condition is violated.
The Relationship Between Phantom and Write Skew
Phantom is a special case of Write Skew:
- General Write Skew: reads existing rows.
- Phantom Write Skew: reads that "no rows match" the condition.
-- Example: ensuring username uniqueness
-- T1: checks that username "alice" does not exist -> inserts
-- T2: checks that username "alice" does not exist -> inserts
-- Result: same name inserted twice (possible under SI!)
Preventing this requires actual Serializable or reliance on unique constraints.
7. Serializable Snapshot Isolation (SSI)
The Problem
Snapshot Isolation is fast but allows Write Skew. True Serializable is safe but slow due to locks. Is there a better way?
The 2008 paper by Michael Cahill, Uwe Rohm, and Alan Fekete, "Serializable Isolation for Snapshot Databases", provided the answer: SSI (Serializable Snapshot Isolation).
SSI's Key Idea
"Behave like SI, but detect non-serializable patterns and roll them back."
Track read/write dependency graphs between transactions, and if a cycle is detected, abort one side.
Dangerous Structure
Cahill et al. proved that "a double cycle of rw-dependencies" is the only pattern that breaks serializability:
T1 --rw--> T2 --rw--> T3
Two consecutive rw dependencies (T1 didn't read what T2 wrote, but T2 wrote what T1 read) are dangerous. SSI detects these.
PostgreSQL's SSI Implementation
Since PostgreSQL 9.1, SERIALIZABLE is implemented as SSI:
- Acquire SI snapshot at transaction start.
- Track predicate locks on every read/write operation.
- On detecting a conflict pattern, roll back one side with
ERROR: could not serialize access. - The application retries the rolled-back transaction.
Pros: performance close to SI, safety of Serializable. Cons: requires rollback-handling logic; errors occur at counterintuitive moments.
Usage Example
-- PostgreSQL
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- Doctor on-call example
SELECT COUNT(*) FROM doctors WHERE on_call = true;
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- If a concurrent transaction modifies Bob,
-- one will be rolled back at commit:
-- ERROR: could not serialize access due to read/write dependencies
COMMIT;
Application pattern:
def do_doctor_off(doctor_name):
while True:
try:
with db.transaction(isolation="serializable"):
count = db.execute("SELECT COUNT(*) FROM doctors WHERE on_call = true")
if count > 1:
db.execute("UPDATE doctors SET on_call = false WHERE name = %s", doctor_name)
else:
raise ValueError("Cannot go off, you're the only one on call")
return
except SerializationError:
continue # retry
8. A Practical Guide to Each Isolation Level
Read Committed (Is the Default Enough?)
The default for most web applications. Fine when individual queries being consistent is enough:
- Simple CRUD
- User profile read/update
- Blog posts
Caution: Non-repeatable read is allowed, so code that reads the same row twice in a transaction needs care.
Repeatable Read / Snapshot Isolation
Good for report generation and complex queries:
- Dashboard aggregations
- When a consistent view is needed across a long transaction
- Read-heavy analytical queries
Caution: Be aware of the Write Skew possibility; consider explicit locks or Serializable when important invariants are involved.
Serializable (SSI)
When absolute correctness is essential:
- Financial transactions
- Resource allocation like doctor on-call or flight booking
- Complex conditions beyond unique constraints
Trade-offs:
- Performance cost (rollback overhead)
- Retry logic required
- Contention escalates in high-concurrency environments
How to Choose?
- Start with Read Committed by default.
- As inconsistency bugs surface, raise only that transaction to Serializable.
- Don't make everything Serializable. Apply it selectively to critical paths.
9. Optimistic vs Pessimistic Locking
Beyond isolation levels, there are concurrency control strategies.
Pessimistic Locking
"Conflicts will happen often. Prevent them up front."
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- Acquire lock
-- compute...
UPDATE account SET balance = ... WHERE id = 1;
COMMIT; -- Release lock
Pros: no conflicts, simple logic. Cons: lock contention, deadlocks, performance degradation when locks are held long.
Optimistic Locking
"Conflicts are rare. Detect and retry."
-- Read
SELECT balance, version FROM account WHERE id = 1; -- balance=100, version=5
-- Application computes
new_balance = 100 - 30
-- Write (version check)
UPDATE account
SET balance = 70, version = version + 1
WHERE id = 1 AND version = 5;
-- If affected rows == 0, another transaction modified it first -> retry
Pros: no lock overhead, good for high concurrency. Cons: application-side retry logic required; slower when conflicts are frequent.
Selection Guide
| Situation | Recommended |
|---|---|
| Read-heavy, few writes | Optimistic |
| Write-heavy, frequent conflicts | Pessimistic |
| User-facing latency matters | Optimistic |
| Retry logic hard to build | Pessimistic |
| Distributed systems | Optimistic (lock propagation cost) |
10. Distributed Transactions and Isolation
The Limits of Single-DB Isolation
Everything so far has been isolation within a single DB. What about transactions across multiple DBs or services?
Two-Phase Commit (2PC)
The traditional distributed transaction protocol:
- Prepare Phase: Coordinator asks all participants "ready?"
- Commit Phase: If all are ready, send "commit"; otherwise "rollback".
Problems:
- Blocking on coordinator failure.
- Performance hit (two network round-trips).
- Unavailability grows with the number of participants.
Saga Pattern
An alternative to 2PC. Recovers from failures with compensating transactions:
Order Service: create_order(ID)
Payment Service: charge(ID)
Inventory: reserve(ID)
On failure, in reverse order:
Inventory: unreserve(ID)
Payment Service: refund(ID)
Order Service: cancel_order(ID)
Pros: no blocking, service independence. Cons: compensating logic is a burden, weak isolation (intermediate states may be externally visible).
Percolator / Spanner
Google's distributed DBs aim for true Serializable:
- Percolator (on top of Bigtable): general-purpose distributed transactions via 2PC + MVCC.
- Spanner: TrueTime API for global clock sync + 2PC + Paxos.
TiDB is an open-source implementation of the Percolator model.
11. Real-World Troubleshooting
Problem 1: "deadlock detected"
Cause: Two transactions waiting on each other's locks.
Solution:
- Always acquire locks in the same order (e.g., ascending id).
- Shorten lock hold times (shorter transactions).
- Use
SELECT ... FOR UPDATE SKIP LOCKEDto skip. - Implement retry logic.
Problem 2: "could not serialize access"
Cause: SSI rollback.
Solution:
- Retry in the application.
- Exponential backoff.
- Consider switching hot paths to explicit locks.
Problem 3: Bloat from Long Transactions (PostgreSQL)
Symptoms: disk usage spikes, queries slow down.
Cause: A long-open transaction blocks VACUUM.
Solution:
-- Find long-running transactions
SELECT pid, state, query, xact_start, now() - xact_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start;
-- Manual VACUUM
VACUUM (ANALYZE, VERBOSE) table_name;
Prevention:
- Set
idle_in_transaction_session_timeout. - Keep transactions short.
- Don't leave transactions open and idle in the application.
Problem 4: Lost Update in Web Applications
Symptom: When users edit concurrently, one user's changes disappear.
Solution:
versioncolumn + optimistic locking.- Or CRDT-based collaborative editing.
- Or user feedback ("Someone else edited first. Merge?").
Quiz Review
Q1. What's the difference between PostgreSQL's Repeatable Read and the SQL standard's Repeatable Read?
A. In the SQL standard, Repeatable Read allows Phantom Read. However, PostgreSQL's Repeatable Read is implemented as Snapshot Isolation and prevents phantoms too. But Write Skew can still occur. To fully prevent it, raise to Serializable.
Q2. How is Write Skew different from Lost Update?
A. Lost Update is when two transactions modify the same row and one update disappears. It can be prevented by the first-committer-wins rule or atomic UPDATE. Write Skew is when transactions modify different rows so there's no write conflict, but decisions based on read data break the overall invariant. Snapshot Isolation cannot prevent it; Serializable is required.
Q3. What's the biggest implementation difference between PostgreSQL's and InnoDB's MVCC?
A. PostgreSQL appends new versions into the table itself (copy-on-write). Dead tuples accumulate and must be cleaned by VACUUM. InnoDB uses in-place update + undo log. The latest version is in the data page and past versions are in the undo log. InnoDB doesn't need VACUUM, but long transactions can cause undo log explosion.
Q4. How does SSI (Serializable Snapshot Isolation) achieve Serializable while maintaining performance?
A. SSI behaves like Snapshot Isolation by default (no locks, fast reads). It tracks read/write dependencies between transactions. When a "non-serializable double rw cycle" is detected, it rolls back one transaction. This is an optimistic approach: without conflicts it keeps SI's performance, and penalties only occur during conflicts.
Q5. Why is SELECT ... FOR UPDATE still useful in an MVCC environment?
A. MVCC provides read consistency but doesn't guarantee verification of state just before write. FOR UPDATE takes a row lock on the current latest committed version and reads it. A subsequent UPDATE is based on this version, preventing Lost Update. This is needed when you want to manually block Write Skew under Snapshot Isolation or explicitly prevent Lost Update.
Conclusion: Isolation-Level Pitfalls and Remedies
Key Lessons
-
Don't trust the name "Repeatable Read". Each DB implements it differently. Check the docs and actual behavior.
-
Snapshot Isolation is not a cure-all. Write Skew is subtle but fatal. Consider Serializable for invariant-sensitive logic.
-
Perfect Serializable is expensive. When performance matters, a practical strategy is to keep the whole system on SI and promote only risky paths to Serializable.
-
MVCC is not free. PostgreSQL has VACUUM, InnoDB has the undo log. Long transactions are poison for both.
-
Isolation level is one layer of defense. Use unique constraints, foreign keys, and application-side validation together.
Real-World Checklist
- What's the default isolation level of our team's DB?
- Is there any logic where Write Skew could occur?
- Are long transactions straining VACUUM/undo log?
- Are we using an isolation level that needs retry logic?
- Is deadlock detection and retry ready?
Much of the difficulty of distributed systems is "data consistency." Isolation levels sit right in the middle of that. Understanding actual behavior rather than memorizing names is what prevents bugs.
References
- A Critique of ANSI SQL Isolation Levels (Berenson et al., 1995) - Flaws in isolation levels
- Serializable Isolation for Snapshot Databases (Cahill et al., 2008) - The original SSI paper
- PostgreSQL Concurrency Control - Official docs
- MySQL InnoDB Locking and Transaction Model
- Designing Data-Intensive Applications, Ch.7 - Martin Kleppmann's classic
- Jepsen: PostgreSQL Analysis - Verification report
- Hermitage: Isolation Level Test Suite - Comparison of actual DB behavior