컴퓨터공학 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: 언두 로그에 쌓인 미정리 기록의 길이다. 이 값이 계속 늘면 어딘가에 오래 열린 트랜잭션이 있다는 신호다.

확인 문제

  1. MVCC 가 없애는 충돌과 없애지 못하는 충돌은 각각 무엇인가?
  2. PostgreSQL 에서 UPDATE 한 번은 힙에 어떤 변화를 남기는가?
  3. 오래 열린 트랜잭션 하나가 테이블 부풂을 일으키는 이유는?
  4. 힙 방식(PostgreSQL)과 언두 방식(InnoDB)의 비용 차이를 한 문장씩 설명하라.

풀이

  1. 읽기–쓰기 충돌은 없앤다. 같은 행에 대한 쓰기–쓰기 충돌은 여전히 대기 또는 실패로 조정해야 한다.
  2. 옛 버전의 xmax 에 자기 트랜잭션 ID 를 적고, xmin 이 자기 ID 인 새 버전을 추가한다.
  3. 그 트랜잭션의 스냅숏이 볼 수도 있는 버전은 지울 수 없어서, 그 이후 생긴 죽은 버전들이 vacuum 으로 회수되지 못하고 쌓인다.
  4. 힙 방식은 옛 버전이 테이블에 남아 청소(VACUUM)가 필요하다. 언두 방식은 테이블은 깔끔하지만 오래된 스냅숏이 옛 버전을 재구성하려면 긴 언두 체인을 따라가야 한다.

더 읽을거리 (References)