MVCC와 2PL
동시 트랜잭션을 락으로 푸는 방식과 옛 버전으로 푸는 방식
트랜잭션끼리 서로 안 보이게 하는 두 가지 내부 기법. 2PL은 락을 COMMIT까지 쥐고 있어 정합성을 만들지만 읽기가 쓰기에 막혀 동시성이 떨어진다. MVCC는 옛 버전을 남겨두고 읽기 트랜잭션이 스냅샷을 읽게 해서 락 없이 격리를 만든다. Postgres는 옛 버전을 테이블 안에 그대로 두고 VACUUM으로 청소하고, InnoDB는 옛 버전을 undo log에 빼두고 purge로 치운다.
개념
트랜잭션과-격리-수준은 "어떤 이상 현상을 막을지"를 정의했다면, 이 노트는 DB가 그걸 어떻게 구현하는지의 이야기다. 접근법은 크게 둘이다.
- 2PL (Two-Phase Locking) — 락으로 푼다. 트랜잭션 도중엔 락을 절대 풀지 않고 COMMIT 시점에 한꺼번에 해제
- MVCC (Multi-Version Concurrency Control) — 옛 버전을 남겨둔다. 읽기 트랜잭션은 자기 시작 시점의 스냅샷을 읽으므로 락이 필요 없음
현대 DB(Postgres, MySQL InnoDB, Oracle)는 둘을 섞어서 쓴다. 읽기는 MVCC, 쓰기는 여전히 2PL. 이름은 같은 MVCC지만 Postgres와 InnoDB의 구현은 철학이 반대라 장애 양상도 다르다.
왜 2PL만으론 부족한가
락의 타이밍을 어설프게 놓으면 중간 상태가 노출된다. 계좌이체 예시.
T1 (A→B 1000원 이체) T2 (총액 조회)
─────────────────────────────────────────────
A 락 → A = 9000 (UPDATE)
A 락 해제 ←────── 여기서 풀어버림
A 읽기 → 9000
B 읽기 → 0
총액 = 9000 ← 1000원이 증발한 것처럼 보임
B 락 → B = 1000
COMMIT
T1이 A 락을 일찍 푸는 바람에 "A는 빠졌지만 B엔 아직 안 들어온 순간"을 T2가 봤다. 원래 10000이어야 할 총액이 9000.
2PL 규칙
이걸 막는 규칙이 단순하다.
Phase 1 (Growing): 락 획득만. 해제 금지
Phase 2 (Shrinking): 락 해제만. 획득 금지
그래프로 그리면 보유 락 수가 산 모양(올라갔다 내려옴). 한 번 해제 시작하면 다시 못 잡는다. 실무 DB는 전부 Strict 2PL — 해제를 아예 COMMIT/ROLLBACK 시점으로 미룬다. 트랜잭션이 끝날 때까지 쥔 모든 락이 동시에 풀리므로 중간 상태 노출이 불가능하다.
2PL의 대가
락을 오래 쥐고 있으니 부작용이 확실하다.
- 대기 줄 — 핫 로우(hot row)에 트랜잭션들이 줄 서서 blocking
- 데드락 — 여러 락을 교차 획득하면 서로 영원히 기다림. DB가 감지해서 한쪽 abort
- 읽기-쓰기 충돌 — 전통 2PL은 읽기도 S락이 필요. 쓰기가 X락 쥐고 있으면 모든 읽기가 막힘. 분석 쿼리 하나가 OLTP 전체를 얼릴 수 있음
읽기가 쓰기를 막고, 쓰기가 읽기를 막는 구조 자체가 OLTP에선 치명적. 여기서 MVCC가 등장한다.
MVCC — 락 없는 읽기
핵심 아이디어 한 줄.
"읽기는 락을 안 잡는다. 대신 자기 트랜잭션이 시작된 시점의 옛 버전을 읽으면 된다."
같은 이체 시나리오를 MVCC로 보면:
T1 T2 (MVCC 읽기)
─────────────────────────────────────────────
A = 9000 (새 버전 만듦, 옛 10000은 남김)
A 읽기 → 10000 (내 시작 전 버전)
B 읽기 → 0 (내 시작 전 버전)
총액 = 10000 ✓
B = 1000 (새 버전)
COMMIT
T2는 T1의 변경을 못 본다. T1이 끝나든 말든 상관없이, T1 시작 전 세계의 스냅샷을 그대로 읽는다. "readers don't block writers, writers don't block readers"가 이래서 성립.
MVCC가 해결 못 하는 것
쓰기끼리 충돌은 여전히 락이 필요하다. 같은 행에 두 UPDATE가 동시에 오면 누구 버전이 최종인지 정해야 하므로. MVCC가 들어와도 쓰기는 2PL 방식의 X락을 쓴다. SELECT ... FOR UPDATE도 X락. 즉 MVCC는 락을 없앤 게 아니라 읽기 경로에서만 락을 뺀 것.
Postgres vs InnoDB — 같은 MVCC, 반대 철학
옛 버전을 어디에 저장하느냐에서 길이 갈린다.
InnoDB — undo log에 빼두기
clustered index: id=1, balance=9000 (최신만)
↓ 포인터
undo log: "이전엔 10000, 그 이전엔 10500, ..."
테이블엔 최신만, 옛 버전은 전부 별도 공간(undo log)에 체인으로. 읽기가 옛 시점 스냅샷을 원하면 최신값에서 undo 체인을 역산해 복원한다.
장점은 테이블 자체가 작고 깔끔하다는 것. 대부분의 쿼리는 최신값이니 일반 경로가 빠르다. 또 A(원자성) 보장용 undo 정보와 MVCC가 같은 구조를 공유하므로 일석이조.
단점은 undo log가 공유 자원이라는 점. long-running transaction이 있으면 undo를 못 지운다(그 트랜잭션이 옛 버전을 볼 수 있어야 하니까). 쌓이면 "undo log 폭발" — InnoDB 대표 장애. 옛 버전 읽기는 체인 역산이라 거슬러 올라갈수록 느려진다.
Postgres — 테이블 안에 그대로
accounts 테이블 (같은 id=1 행이 물리적으로 여러 개):
balance=10000 xmin=T10, xmax=T50 ← T10이 만듦, T50이 지움
balance=9000 xmin=T50, xmax=T80
balance=8500 xmin=T80, xmax=null ← 현재 살아있는 행
UPDATE는 새 행 삽입 + 옛 행에 xmax 마킹이지 제자리 수정이 아니다. 각 행에 xmin(만든 트랜잭션 ID), xmax(지운 트랜잭션 ID)가 붙어있어, 읽는 트랜잭션 T는 xmin < T < xmax인 행을 고르면 된다.
장점은 옛 버전 읽기도 최신 읽기와 같은 비용이라는 점. 체인 역산 없음. undo log 같은 중앙 병목도 없다. ROLLBACK도 "새로 만든 행에 xmin 무효 표시"면 되니 사실상 공짜.
단점은 테이블이 부풀어오른다(bloat). 죽은 행이 쌓이고, UPDATE마다 인덱스에도 새 엔트리 추가가 필요해 쓰기 증폭이 있다. 누가 안 치우면 테이블이 끝없이 커진다.
핵심 차이
| InnoDB | Postgres | |
|---|---|---|
| 옛 버전 위치 | undo log (별도 공간) | 테이블 안에 직접 |
| UPDATE 비용 | 저렴 (undo에 기록) | 비쌈 (새 행 + 인덱스) |
| 옛 버전 읽기 | 체인 역산 (느림) | 행 선택 (빠름) |
| ROLLBACK | undo 적용 | 사실상 공짜 |
| 청소 메커니즘 | undo log purge | VACUUM |
| Long tx 장애 양상 | undo log 폭발 | 테이블 bloat |
VACUUM과 XID wraparound
Postgres는 죽은 행을 VACUUM이 회수한다. 기본적으로 켜져 있는 autovacuum 데몬이 백그라운드에서 돈다. 실제 OS에 디스크 반납이 아니라 "이 슬롯 재사용 가능" 표시에 가깝다 (공간이 새 INSERT에 재활용됨).
Long transaction이 VACUUM을 막는다
T1: BEGIN (1시간째, 스냅샷 T=1000 보존 중)
그 동안 다른 트랜잭션들이 UPDATE → 죽은 행 쌓임
autovacuum: "이 죽은 행 치울까?"
→ "T1이 아직 T=1000을 보고 있음. 지금 죽은 이 행이
T1한텐 살아있을 수 있어. 못 치움"
그래서 Postgres 튜닝 제1 원칙: 트랜잭션 짧게 유지. 분석용 장시간 쿼리는 운영 DB에서 안 돌리는 게 답. InnoDB도 구조는 똑같다 — long tx가 있으면 undo log purge가 막히고 undo가 계속 커진다.
XID wraparound
Postgres 트랜잭션 ID는 32비트. 40억 개 쓰면 한 바퀴 돈다. VACUUM은 죽은 행 회수뿐 아니라 "충분히 옛 트랜잭션이 만든 살아있는 행"에 "freeze" 표시를 붙여 XID 의존성을 끊는 작업도 한다. 이게 밀리면 DB가 읽기 전용 강제 셧다운. Postgres 대형 장애의 대표 사례.
언제 문제가 되나
MVCC 구현 차이를 아는 게 실무에서 드러나는 순간은 대체로 비슷하다.
- 장시간 트랜잭션 — 분석 쿼리, 백그라운드 배치, 앱 버그로 COMMIT 안 된 세션. Postgres면 bloat, InnoDB면 undo log 폭발
- 대량 UPDATE — Postgres는 UPDATE가 쓰기 증폭이 크다. 대량 UPDATE 후엔 VACUUM/ANALYZE 수동 트리거 고려
- 핫 로우에 경합 — MVCC가 읽기 경합은 풀지만 쓰기 경합은 못 푼다. 좋아요 카운터 같은 건 여전히 낙관적 락이나 큐잉 필요
- Repeatable Read의 동작 차이 — Postgres RR은 스냅샷 격리라 phantom까지 막지만, MySQL RR은 phantom을 next-key lock으로 막는 쪽. 같은 이름 다른 동작
더 보기
- 트랜잭션과-격리-수준 — 이 노트가 구현하는 "격리성"의 정의와 이상 현상 4가지
- WAL과-버퍼-풀 — MVCC의 undo 정보가 COMMIT 안 된 트랜잭션 복구에도 쓰이는 지점
- DB-스토리지-레이아웃 — 버전 여러 개가 저장되는 "페이지"의 정체
- 동시성 — OS 레이어의 뮤텍스·세마포어. DB의 행 락은 이 위에 얹힌 추상화