[CS300 #171] MVCC — 읽기와 쓰기가 서로 기다리지 않는 법
컴퓨터공학 300 주제 시리즈의 171번째 글이다. 전체 지도는 여기.
한 줄 요약
MVCC(Multi-Version Concurrency Control)는 행을 덮어쓰지 않고 새 버전을 추가해, 각 트랜잭션이 자기 스냅숏에 맞는 버전을 읽게 함으로써 읽기가 쓰기를, 쓰기가 읽기를 막지 않게 하는 동시성 제어 방식이다.
왜 필요한가
락만으로 격리를 구현하면 읽는 쪽은 공유 락, 쓰는 쪽은 배타 락을 잡는다. 누군가 행을 고치는 동안에는 그 행을 읽을 수 없고, 누군가 긴 리포트 쿼리로 테이블을 읽는 동안에는 쓸 수 없다. 읽기가 대부분인 웹 서비스에서 이건 큰 손해다.
MVCC 는 발상을 바꾼다. 행을 고칠 때 원래 값을 지우지 않고 새 버전을 하나 더 만든다. 이미 시작한 읽기 트랜잭션은 옛 버전을 계속 보고, 새로 시작한 트랜잭션은 새 버전을 본다. 그러면 읽기와 쓰기가 서로를 기다릴 이유가 사라진다. PostgreSQL 공식 문서도 MVCC 의 핵심 이점을 “읽기를 위한 락이 쓰기를 위한 락과 충돌하지 않는다”로 요약한다. PostgreSQL, MySQL InnoDB, Oracle, SQL Server(스냅숏 격리 사용 시)가 모두 MVCC 를 쓴다.
핵심 개념
버전과 가시성
각 행 버전에는 “누가 만들었고 누가 지웠는가”가 붙는다. PostgreSQL 은 이를 숨은 열 두 개로 둔다.
| 열 | 뜻 |
|---|---|
xmin |
이 버전을 만든 트랜잭션 ID |
xmax |
이 버전을 지운(또는 새 버전으로 대체한) 트랜잭션 ID, 없으면 0 |
UPDATE 는 “옛 버전의 xmax 에 내 ID 를 적고, 새 버전을 xmin = 내 ID 로 추가”하는 것이다. DELETE 는 xmax 만 적는다.
트랜잭션은 시작할 때(Read Committed 에서는 문장마다) 스냅숏을 잡는다. 스냅숏은 “그 시점에 어떤 트랜잭션이 커밋되었는가”의 기록이다. 버전이 보이는 조건은 다음과 같다.
보인다 ⇔ xmin 이 내 스냅숏에서 커밋됨 (또는 나 자신)
AND ( xmax 가 없음 OR xmax 가 내 스냅숏에서 아직 커밋 안 됨 )
그림으로 보기
시간 →
T1(reader) BEGIN ──────── READ x ──────────── READ x ──── COMMIT
T2(writer) BEGIN ── UPDATE x=v2 ─ COMMIT
힙의 버전들:
[x=v1 xmin=T0 xmax=T2] ← T1 의 스냅숏에는 T2 미커밋 → 여전히 보임
[x=v2 xmin=T2 xmax=- ] ← T1 의 스냅숏에는 T2 미커밋 → 안 보임
T1 은 두 번 모두 v1 을 읽는다. T2 는 T1 을 기다리지 않았다.
쓰기끼리는 여전히 충돌한다
MVCC 가 없애는 것은 읽기–쓰기 충돌이다. 두 트랜잭션이 같은 행을 쓰는 것은 여전히 조정해야 한다. PostgreSQL 에서 T2 가 고치는 중인 행을 T3 도 고치려 하면, T3 은 T2 가 끝날 때까지 기다린다. T2 가 커밋하면 Read Committed 의 T3 은 최신 버전을 다시 읽어 조건을 재평가한 뒤 진행하고, Repeatable Read 의 T3 은 “동시 갱신 때문에 직렬화할 수 없다”는 오류로 실패한다.
구현 방식 두 갈래
| 방식 | 옛 버전의 위치 | 대표 | 비용 |
|---|---|---|---|
| 힙에 새 버전 추가 | 테이블 파일 안에 옛 버전이 그대로 남음 | PostgreSQL | 죽은 버전 청소(VACUUM) 필요, 테이블 부풂 |
| 제자리 갱신 + 언두 로그 | 최신 버전은 제자리, 옛 버전은 언두 로그에서 재구성 | MySQL InnoDB, Oracle | 오래된 스냅숏이 긴 언두 체인을 따라가야 함 |
청소: VACUUM 과 퍼지
어떤 트랜잭션의 스냅숏에서도 더는 보이지 않는 옛 버전을 죽은 튜플이라 한다. 지우지 않으면 쌓인다. PostgreSQL 의 VACUUM 은 죽은 튜플이 차지한 공간을 재사용 가능하게 표시하고, 자동 vacuum 데몬이 이를 주기적으로 실행한다. InnoDB 는 퍼지(purge) 스레드가 필요 없어진 언두 기록을 지운다.
여기서 중요한 사실: 가장 오래된 활성 스냅숏보다 새로운 죽은 버전은 지울 수 없다. 그 스냅숏이 아직 그 버전을 봐야 할 수도 있기 때문이다. 그래서 몇 시간째 열려 있는 트랜잭션 하나가 데이터베이스 전체의 청소를 막는다.
PostgreSQL 에는 또 하나의 이유로 vacuum 이 필수다. 트랜잭션 ID 는 32비트라 결국 한 바퀴 돈다. 오래된 행을 “충분히 오래되어 모두에게 보임”으로 동결(freeze)해 두지 않으면, ID 가 돌아왔을 때 과거 행이 미래의 것으로 보이는 문제가 생긴다. 공식 문서의 “Routine Vacuuming” 장이 이를 자세히 다룬다.
직접 해 보기
PostgreSQL 식 xmin/xmax 가시성 규칙을 50줄로 흉내 낸 장난감 저장소다. 쓰기 충돌 검사와 롤백은 생략했다.
import itertools
class MVCC:
"""PostgreSQL 식 xmin/xmax 를 흉내 낸 아주 작은 다중 버전 저장소."""
def __init__(self):
self.versions = [] # [key, value, xmin, xmax]
self.next_xid = itertools.count(1)
self.active = set() # 아직 커밋 안 된 트랜잭션
self.committed = set()
def begin(self):
xid = next(self.next_xid)
self.active.add(xid)
# 스냅숏 = 시작 시점에 '이미 커밋된' 트랜잭션 집합
return {"xid": xid, "snap": frozenset(self.committed)}
def _visible(self, tx, xid):
return xid == tx["xid"] or xid in tx["snap"]
def read(self, tx, key):
for k, v, xmin, xmax in self.versions:
if k == key and self._visible(tx, xmin) and \
not (xmax and self._visible(tx, xmax)):
return v
return None
def write(self, tx, key, value):
for ver in self.versions: # 보이는 현재 버전에 '삭제 표시'
k, v, xmin, xmax = ver
if k == key and self._visible(tx, xmin) and xmax is None:
ver[3] = tx["xid"]
self.versions.append([key, value, tx["xid"], None]) # 새 버전 추가
def commit(self, tx):
self.active.discard(tx["xid"]); self.committed.add(tx["xid"])
db = MVCC()
t0 = db.begin(); db.write(t0, "x", "v1"); db.commit(t0)
reader = db.begin() # 이 시점 스냅숏: x = v1
writer = db.begin()
db.write(writer, "x", "v2")
print("writer 가 보는 x:", db.read(writer, "x"))
print("reader 가 보는 x:", db.read(reader, "x"))
db.commit(writer)
print("writer 커밋 후 reader:", db.read(reader, "x"))
late = db.begin()
print("커밋 후 시작한 트랜잭션:", db.read(late, "x"))
print("저장된 버전들:", db.versions)
결과:
writer 가 보는 x: v2
reader 가 보는 x: v1
writer 커밋 후 reader: v1
커밋 후 시작한 트랜잭션: v2
저장된 버전들: [['x', 'v1', 1, 3], ['x', 'v2', 3, None]]
writer 는 자기가 쓴 v2 를 보고, reader 는 writer 가 커밋한 뒤에도 자기 스냅숏의 v1 을 본다. 누구도 기다리지 않았다. 마지막 줄의 버전 목록이 핵심이다. v1 은 xmax=3 표시만 붙은 채 남아 있다. reader 가 끝나고 나면 아무도 v1 을 볼 수 없게 되고, 그때 vacuum 이 치울 대상이 된다.
PostgreSQL 이 있다면 SELECT xmin, xmax, * FROM 테이블 로 실제 숨은 열을 볼 수 있다.
현업에서는
- “idle in transaction” 세션 경보: 트랜잭션을 열어 놓고 아무것도 안 하는 커넥션은 MVCC 청소를 막는다. PostgreSQL 의
idle_in_transaction_session_timeout으로 이런 세션을 끊고,pg_stat_activity에서xact_start가 오래된 세션을 모니터링한다. - 테이블 부풂(bloat): 갱신이 잦은 테이블은 죽은 튜플이 쌓여 실제 데이터보다 훨씬 커질 수 있다. 자동 vacuum 의 임계값을 테이블별로 낮추는 튜닝이 흔하다.
- 긴 분석 쿼리를 운영 DB 에서 돌리지 말 것: 한 시간짜리 리포트 쿼리는 한 시간 동안 스냅숏을 붙잡는다. 읽기 전용 레플리카나 분석용 저장소로 보낸다. 레플리카에서도
hot_standby_feedback설정에 따라 프라이머리의 청소를 막을 수 있다. - InnoDB 의 history list length: 언두 로그에 쌓인 미정리 기록의 길이다. 이 값이 계속 늘면 어딘가에 오래 열린 트랜잭션이 있다는 신호다.
확인 문제
- MVCC 가 없애는 충돌과 없애지 못하는 충돌은 각각 무엇인가?
- PostgreSQL 에서
UPDATE한 번은 힙에 어떤 변화를 남기는가? - 오래 열린 트랜잭션 하나가 테이블 부풂을 일으키는 이유는?
- 힙 방식(PostgreSQL)과 언두 방식(InnoDB)의 비용 차이를 한 문장씩 설명하라.
풀이
- 읽기–쓰기 충돌은 없앤다. 같은 행에 대한 쓰기–쓰기 충돌은 여전히 대기 또는 실패로 조정해야 한다.
- 옛 버전의
xmax에 자기 트랜잭션 ID 를 적고,xmin이 자기 ID 인 새 버전을 추가한다. - 그 트랜잭션의 스냅숏이 볼 수도 있는 버전은 지울 수 없어서, 그 이후 생긴 죽은 버전들이 vacuum 으로 회수되지 못하고 쌓인다.
- 힙 방식은 옛 버전이 테이블에 남아 청소(VACUUM)가 필요하다. 언두 방식은 테이블은 깔끔하지만 오래된 스냅숏이 옛 버전을 재구성하려면 긴 언두 체인을 따라가야 한다.
더 읽을거리 (References)
- PostgreSQL 공식 문서, Concurrency Control — Introduction
- PostgreSQL 공식 문서, Routine Vacuuming
- PostgreSQL 공식 문서, Transaction Isolation
- Philip A. Bernstein, Nathan Goodman, “Multiversion Concurrency Control — Theory and Algorithms”, ACM Transactions on Database Systems, 8(4), 1983.