여러 트랜잭션이 같은 행을 동시에 읽고 쓰는데도, SELECT가 한 번도 락에 막히지 않는다. 이게 신기해서 처음 InnoDB를 공부할 때 "락을 안 걸고 어떻게 일관된 읽기가 되지?"라는 의문이 가장 오래 남았다. 답은 MVCC(Multi-Version Concurrency Control) 였다.
MySQL Reference Manual §15.7(InnoDB Locking and Transaction Model)에 따르면 InnoDB는 동일 행의 여러 버전을 undo log로 함께 유지하고, 트랜잭션 시작 시점의 "스냅샷"을 통해 읽기를 처리한다. 락을 걸지 않으니 쓰기와 읽기가 서로를 막지 않는다.
이 글에서는 다음 네 가지를 정리한다.
DB_TRX_ID, DB_ROLL_PTR)이 어떤 의미인가MVCC는 같은 행을 여러 "버전"으로 두고, 각 트랜잭션에게 자기 시점에 맞는 버전을 보여주는 동시성 제어 방식이다.
전통적인 락 기반 동시성 제어는 "쓰는 동안 못 읽고, 읽는 동안 못 쓴다"가 기본이다. MVCC는 이걸 깨기 위한 트릭이다. 핵심 아이디어는 단순하다.
이 구조 덕분에 일반 SELECT(consistent read)는 락을 잡지 않고, 쓰는 트랜잭션과 충돌 없이 진행된다. MySQL Reference Manual §15.7.2.3 (Consistent Nonlocking Reads)이 같은 내용을 설명한다.
InnoDB는 사용자에게 안 보이는 시스템 컬럼을 모든 행에 끼워 넣는다. MySQL Reference Manual §15.6.1(Clustered and Secondary Indexes)·§15.7.2.2 부근에서 언급된다.
| 컬럼 | 크기 | 역할 |
|---|---|---|
DB_TRX_ID | 6 byte | 이 버전을 마지막으로 쓴 트랜잭션의 ID |
DB_ROLL_PTR | 7 byte | 이전 버전을 가리키는 undo record 포인터 |
DB_ROW_ID | 6 byte | 사용자가 PK를 안 줬을 때 InnoDB가 만드는 내부 ID |
DB_ROLL_PTR을 따라가면 undo log에 저장된 이전 버전이 나온다. 그 버전에도 또 DB_ROLL_PTR이 있어서, 결국 행 하나는 시간 축을 따라 연결된 버전 체인을 이룬다.
[현재 행 v3] DB_TRX_ID=110, DB_ROLL_PTR ──┐
▼
[undo log: v2] DB_TRX_ID=104, DB_ROLL_PTR ──┐
▼
[undo log: v1] DB_TRX_ID= 97, DB_ROLL_PTR=NULL
트랜잭션이 읽기를 시작할 때 InnoDB는 ReadView라는 스냅샷 객체를 만든다. 핵심 필드는 다음과 같다(소스 코드와 일치하는 이름 그대로 부르는 경우가 많다).
| 필드 | 의미 |
|---|---|
m_creator_trx_id | 이 ReadView를 만든 트랜잭션 자신의 ID |
m_ids | ReadView 생성 시점에 활성 상태(아직 커밋·롤백 안 된)인 트랜잭션 ID 집합 |
m_up_limit_id | m_ids 중 가장 작은 ID. 이보다 작은 trx_id는 이미 커밋된 변경 |
m_low_limit_id | ReadView 생성 시점에 아직 할당되지 않은 다음 trx_id. 이 값 이상이면 나중에 시작된 트랜잭션 |
가시성(visibility) 규칙은 직관적이다.
DB_TRX_ID == m_creator_trx_id → 내가 만든 변경이니까 보인다.DB_TRX_ID < m_up_limit_id → 내가 시작하기 전에 이미 커밋된 변경이니까 보인다.DB_TRX_ID >= m_low_limit_id → 내가 시작한 뒤에 등장한 트랜잭션이니까 안 보인다.m_ids에 포함되어 있는가?"안 보인다"는 결론이 나오면 DB_ROLL_PTR을 따라 이전 버전으로 한 단계 더 거슬러 올라가서 같은 판정을 반복한다. 첫 번째로 가시 판정이 통과한 버전이 그 트랜잭션이 보게 될 값이다.
자주 헷갈리는 지점이 두 개 있다.
(1) ReadView가 만들어지는 시점이 격리 수준마다 다르다.
MySQL Reference Manual §15.7.2.1(Transaction Isolation Levels)에서 이 차이를 명시한다.
(2) consistent read와 locking read는 다른 길을 탄다.
SELECT ... (그냥 SELECT): consistent read. 스냅샷에서 읽고, 락 안 잡음.SELECT ... FOR UPDATE, SELECT ... FOR SHARE: locking read. 현재 버전을 잠그고 읽음. MVCC가 아니라 락 경로.UPDATE, DELETE: 마찬가지로 현재 버전(latest committed)을 본다. 자기 스냅샷이 아니라 실제로 존재하는 최신 행을 잡아야 데이터를 갱신할 수 있기 때문이다.이 부분이 한 번 꼬이면 "내 SELECT 결과랑 UPDATE 결과가 다르다"는 현상으로 나타난다. (소위 write skew의 변종.)
REPEATABLE READ에서 두 트랜잭션이 같은 행을 다루는 흐름을 보면 가시성 규칙이 한눈에 들어온다.
-- 초기 상태: products(id=1, stock=10), DB_TRX_ID=97 라고 가정
-- 트랜잭션 A (trx_id=110)
START TRANSACTION; -- 아직 ReadView 안 만들어짐
SELECT stock FROM products WHERE id=1;
-- 여기서 첫 read → ReadView 생성
-- m_up_limit_id=110 직전 활성 트랜잭션 기준
-- 결과: 10
-- (동시에) 트랜잭션 B (trx_id=111)
START TRANSACTION;
UPDATE products SET stock=8 WHERE id=1; -- 새 버전 생성 (DB_TRX_ID=111)
COMMIT;
-- 다시 트랜잭션 A
SELECT stock FROM products WHERE id=1;
-- 현재 버전의 DB_TRX_ID=111
-- A의 ReadView 기준 111 >= low_limit_id → 안 보임
-- DB_ROLL_PTR 따라 undo log의 v1(stock=10)으로 이동
-- 결과: 10 ← 반복 가능한 읽기
UPDATE products SET stock = stock - 1 WHERE id=1;
-- 이건 consistent read가 아니라 latest read
-- 현재 stock=8 위에서 갱신 → stock=7
COMMIT;
마지막 UPDATE가 묘하다. SELECT로는 분명 10을 봤는데, UPDATE가 만든 결과는 7이다. 스냅샷에서는 10 - 1 = 9가 자연스러워 보이지만, UPDATE는 스냅샷이 아니라 실제 최신 버전(8) 을 잡아 갱신하기 때문이다.
consistent read는 "과거의 스냅샷에서 읽는다"는 것, locking write는 "현재 최신 버전에 손댄다"는 것 — 이 두 개가 한 트랜잭션 안에 공존한다.
엣지 케이스 하나 더. REPEATABLE READ에서 같은 행을 두 번 읽었는데 두 번째 결과가 첫 번째와 다르게 나오는 경우가 있다. 트랜잭션 A가 직접 그 행을 UPDATE했을 때다. A 자신의 변경은 자기 스냅샷에 보여야 하기 때문이다(가시성 규칙 1). MVCC가 깨진 게 아니라 자기 변경은 즉시 반영이라는 규칙이 잘 작동하고 있는 것.
SELECT와 UPDATE가 같은 트랜잭션 안에서도 다른 버전을 본다는 점은 한 번은 직접 부딪혀 보는 게 좋다.다음에 더 파고들 만한 주제:
storage/innobase/include/read0read.h의 ReadView 정의 (필드 이름 검증용)