← 개인 공부 · Database
STUDY NOTE · 개념 정리

MVCC와 InnoDB 잠금 정리: 일관된 읽기, 갭 락, 넥스트 키 락, 팬텀 리드

Database
InnoDB는 읽기는 MVCC(스냅숏)로, 쓰기는 잠금으로 동시성을 처리한다. 평범한 SELECT는 언두 로그로 과거 버전을 만들어 읽기 때문에 잠금을 기다리지 않는다. 반대로 UPDATE·DELETE·SELECT ... FOR UPDATE는 최신 버전을 읽고(잠금 읽기), REPEATABLE READ에서는 인덱스 레코드뿐 아니라 레코드 사이의 틈(갭)까지 잠가 팬텀을 막는다. 이 글은 트랜잭션·격리 수준 글의 다음 편이다.

1. 두 종류의 읽기

구분일관된 읽기 (Consistent Read)잠금 읽기 (Locking Read)
문장일반 SELECTSELECT ... FOR UPDATE / FOR SHARE, UPDATE, DELETE, INSERT의 중복 검사
읽는 버전스냅숏(Read View) 시점의 버전항상 최신 커밋 버전 (current read)
잠금안 걸고, 안 기다림레코드·갭에 잠금을 걸고, 충돌하면 기다림
구현언두 로그로 과거 버전 재구성인덱스 레코드 잠금

같은 트랜잭션 안에서도 둘이 다른 결과를 낼 수 있다. REPEATABLE READ에서 다른 트랜잭션이 행을 추가하고 커밋하면, 일반 SELECT로는 안 보이는데 SELECT ... FOR UPDATE로는 보인다.

2. MVCC의 구조

모든 행은 숨은 컬럼을 갖는다.

숨은 컬럼의미
DB_TRX_ID이 버전을 마지막으로 바꾼 트랜잭션 ID
DB_ROLL_PTR이전 버전이 있는 언두 로그 위치 (버전 체인)

UPDATE는 행을 제자리에서 고치되 이전 값을 언두 로그에 남기고, 롤 포인터로 이어 둔다. 그래서 한 행은 '최신 버전 → 이전 버전 → 더 이전 버전'의 체인이 된다.

Read View는 '내가 볼 수 있는 커밋이 어디까지인가'를 정하는 스냅숏이다.

값의미
m_idsRead View를 만들 때 아직 진행 중이던 트랜잭션 목록
up_limit_id진행 중이던 것 중 가장 작은 ID (이보다 작으면 이미 커밋 → 보임)
low_limit_id다음에 발급될 ID (이 이상이면 내 뒤에 시작 → 안 보임)
creator_trx_id나 자신 (내가 바꾼 건 보임)

행의 DB_TRX_ID가 '보이지 않는' 트랜잭션이면, 롤 포인터를 따라가며 보이는 버전이 나올 때까지 거슬러 올라간다.

격리 수준Read View를 만드는 시점결과
READ COMMITTEDSELECT 문장마다 새로문장마다 최신 커밋을 봄 → Non-repeatable read 가능
REPEATABLE READ트랜잭션의 첫 SELECT 때 한 번끝날 때까지 같은 스냅숏 → 일반 SELECT는 다시 읽어도 같음

언두 로그는 그 버전을 볼 수 있는 Read View가 남아 있는 동안 지울 수 없다. 오래 열려 있는 트랜잭션이 있으면 언두가 계속 쌓이고(purge 지연), 버전 체인이 길어져 읽기도 느려진다. 긴 트랜잭션을 피해야 하는 이유 중 하나다.

3. 잠금의 종류

잠금잠그는 범위목적
레코드 락 (Record Lock)인덱스 레코드 하나그 행의 수정 막기
갭 락 (Gap Lock)인덱스 레코드 사이의 틈 (레코드 자체는 아님)그 틈에 새 행 INSERT 막기
넥스트 키 락 (Next-Key Lock)레코드 + 그 앞의 갭REPEATABLE READ의 기본 잠금, 팬텀 방지
삽입 의도 락 (Insert Intention)INSERT 직전 갭에 거는 특수 갭 락같은 갭의 다른 위치 INSERT끼리는 서로 안 막음
모드S (공유)X (배타)
S (공유)같이 가능충돌
X (배타)충돌충돌

갭 락끼리는 S든 X든 서로 충돌하지 않는다. 갭 락의 유일한 목적은 '그 틈에 INSERT 못 하게'이기 때문이다.

중요: InnoDB의 잠금은 행이 아니라 인덱스에 걸린다. 조건에 쓸 인덱스가 없으면 풀 스캔하면서 지나간 모든 레코드와 갭을 잠가서, 사실상 테이블 전체가 잠긴다.

4. 넥스트 키 락이 팬텀을 막는 방식

age 인덱스에 10, 20, 30이 있을 때 넥스트 키 범위는 (-∞, 10], (10, 20], (20, 30], (30, +∞)이다.

-- 트랜잭션 A (REPEATABLE READ)
SELECT * FROM member WHERE age BETWEEN 15 AND 25 FOR UPDATE;
-- 20 레코드 + (10, 20) 갭 + (20, 30) 갭이 잠김

-- 트랜잭션 B
INSERT INTO member(age) VALUES (18); -- 대기 (10~20 갭)
INSERT INTO member(age) VALUES (27); -- 대기 (20~30 갭)
INSERT INTO member(age) VALUES (35); -- 바로 성공

A가 같은 조건으로 다시 잠금 읽기를 해도 새 행이 끼어들 수 없으니 팬텀이 생기지 않는다. 표준 SQL에서 REPEATABLE READ는 팬텀을 허용하지만, InnoDB는 이 방식으로 대부분 막는다.

검색 조건에 따라 잠금이 줄어든다.

상황걸리는 잠금
유니크 인덱스로 '존재하는' 한 행을 동등 조건 검색레코드 락만 (갭 없음)
유니크 인덱스로 '없는' 값을 검색그 값이 들어갈 갭에 갭 락
일반(비유니크) 인덱스 검색넥스트 키 락 + 마지막 레코드 다음 갭
인덱스 없는 조건스캔한 모든 레코드와 갭
READ COMMITTED갭 락을 거의 쓰지 않음 (외래 키·중복 검사 제외) → 팬텀 가능, 대신 동시성 높음

5. MVCC와 잠금 읽기를 섞을 때의 함정: Lost Update

-- 두 트랜잭션이 동시에 (REPEATABLE READ)
SELECT stock FROM item WHERE id = 1; -- 둘 다 10을 읽음 (스냅숏)
UPDATE item SET stock = 9 WHERE id = 1; -- 앱에서 10 - 1을 계산해서 씀
-- 결과: 두 건이 팔렸는데 재고는 9

일반 SELECT는 잠금을 걸지 않으니 둘 다 같은 값을 읽는다. 해결은 셋 중 하나다.

  1. 원자적 갱신: UPDATE item SET stock = stock - 1 WHERE id = 1 AND stock > 0 (UPDATE는 최신 값을 읽고 잠금)
  2. 비관적 잠금: 처음부터 SELECT ... FOR UPDATE로 읽기
  3. 낙관적 잠금: version 컬럼을 두고 UPDATE ... WHERE id = 1 AND version = 7, 바뀐 행이 0개면 재시도

6. 데드락

갭 락끼리는 충돌하지 않지만, 그 뒤의 INSERT는 상대의 갭 락에 막힌다. 그래서 '없으면 넣기'를 잠금 읽기로 하면 데드락이 자주 난다.

-- A, B가 동시에, id = 100은 없음
SELECT * FROM t WHERE id = 100 FOR UPDATE; -- 둘 다 같은 갭에 갭 락 (서로 안 막음)
INSERT INTO t(id) VALUES (100); -- A는 B의 갭 락을, B는 A의 갭 락을 기다림 → 데드락
  1. InnoDB는 대기 그래프에서 순환을 찾아 한쪽(되돌리기 싼 쪽)을 롤백한다. 애플리케이션은 재시도할 수 있어야 한다.
  2. SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK에서 두 트랜잭션이 잡고 기다린 잠금을 볼 수 있다.
  3. 줄이는 방법: 같은 순서로 자원 잠그기, 트랜잭션 짧게, 조건에 맞는 인덱스 두기, '없으면 넣기'는 INSERT ... ON DUPLICATE KEY UPDATE나 유니크 제약 + 중복 에러 처리로.

7. 정리

질문답
일반 SELECT는 왜 잠금을 안 기다리나언두 로그로 만든 스냅숏 버전을 읽어서 (MVCC)
RC와 RR의 차이를 MVCC로 말하면Read View를 문장마다 만드나, 트랜잭션에 한 번 만드나
RR에서 팬텀은 어떻게 막나일반 읽기는 스냅숏으로, 잠금 읽기는 넥스트 키 락으로 갭까지 잠가서
갭 락끼리 충돌하나안 함. INSERT(삽입 의도 락)만 막음
인덱스가 없으면스캔한 모든 레코드·갭이 잠겨 테이블 잠금처럼 됨
긴 트랜잭션이 왜 나쁜가잠금을 오래 잡고, 언두 purge를 막아 버전 체인이 길어짐
Gunmo Lee