MVCC
고친 사람 github-actions[bot]
MVCC 는 데이터를 고칠 때 원래 값을 덮어쓰지 않고 새 판을 하나 더 남겨 둡니다. 읽는 쪽은 자기에게 보여야 할 판을 골라 읽고, 쓰는 쪽은 그 뒤에 새 판을 붙입니다. 그래서 읽는 쪽과 쓰는 쪽이 서로를 기다리지 않습니다.
쉽고 빠른 이해
무슨 일을 하나 — 값을 고칠 때 덮어쓰지 않고 새 판을 하나 더 남깁니다. 잔액 1000 을 1200 으로 고치면 1000 짜리 판과 1200 짜리 판이 같이 남습니다.
왜 이렇게 하나 — 값을 한 벌만 두면 누가 고치는 동안 읽으려는 쪽이 끝나기를 기다려야 합니다. 판이 여러 벌이면 읽는 쪽은 옛 판을 읽고 지나가면 됩니다.
어떻게 도나
- 트랜잭션마다 번호를 매깁니다
- 판마다 어느 트랜잭션이 썼는지 적어 둡니다
- 읽을 때 자기에게 보여야 할 판을 골라 읽습니다
대가 — 옛 판이 쌓여 공간을 먹고, 아무도 안 보는 판을 치우는 일이 따로 생깁니다. 같은 값을 동시에 고치려는 트랜잭션끼리는 여전히 하나가 기다립니다. 그래서 읽기가 많고 그중 오래 걸리는 읽기가 섞인 곳에서 값을 합니다.
상세
이 절은 판을 여러 벌 두면 무엇이 달라지는지를 잔액 한 줄로 보입니다.
MVCC 는 Multiversion Concurrency Control 을 줄인 이름입니다. 우리말로는 다중 버전 동시성 제어라고 부릅니다. 이름의 「동시성 제어」는 여러 작업이 한꺼번에 같은 데이터를 읽고 쓸 때 서로 엉키지 않게 하는 일을 말합니다. 「다중 버전」은 그 일을 하는 방법이 판을 여러 벌 두는 것이라는 뜻입니다.
데이터베이스에서 잔액 1000 인 행을 1200 으로 고친다고 해 봅시다. MVCC 는 1000 을 지우지 않고 1200 짜리 판을 하나 더 만들어 붙입니다. 같은 행에 값이 둘이 되고, 누가 어느 판을 볼지는 그다음에 정합니다. 여기서 말하는 「판」은 리비전이나 버전과 같은 말입니다.
판을 고르는 단위는 트랜잭션입니다. 트랜잭션은 여러 읽기와 쓰기를 하나로 묶어 전부 되거나 전부 안 되게 하는 단위입니다. 한 트랜잭션이 도는 동안 남이 값을 바꿔도 앞뒤가 맞는 값을 보게 해 주는 것이 동시성 제어의 몫입니다. MVCC 는 그 몫을 판을 나눠 가지는 것으로 풉니다.
락만 쓸 때의 곤란
값을 한 벌만 두면 남이 고치는 도중에 읽을 방법이 없습니다. 고치는 중간 값을 읽으면 앞뒤가 안 맞는 결과가 나오기 때문입니다. 그래서 락을 걸어 한쪽이 끝날 때까지 다른 쪽을 세웁니다. 락은 잠금이라고도 부릅니다.
이 방법은 읽기가 몰리면 금방 아픕니다. 하루치 매출을 훑는 보고 화면 하나가 몇 분씩 읽고 있으면, 그동안 그 데이터를 고치려는 트랜잭션이 전부 줄을 섭니다. 반대로 긴 쓰기 하나가 읽는 쪽 전부를 세우기도 합니다.
아래는 같은 상황에서 둘이 어떻게 갈리는지입니다.
| 락만 쓸 때 | MVCC 를 쓸 때 | |
|---|---|---|
| 읽는 쪽 | 쓰는 쪽이 끝나기를 기다린다 | 자기 판을 읽고 지나간다 |
| 쓰는 쪽 | 읽는 쪽이 끝나기를 기다린다 | 새 판을 붙인다 |
| 같은 행을 둘이 쓸 때 | 기다린다 | 기다린다 |
| 저장 공간 | 값 한 벌 | 옛 판이 쌓인다 |
표의 셋째 줄이 이 방식의 경계입니다. 읽기와 쓰기 사이는 떼어 놓지만 쓰기끼리는 그대로 남습니다.
판을 고르는 규칙
읽는 쪽이 아무 판이나 읽으면 안 됩니다. 아직 끝나지 않은 트랜잭션이 만든 판을 읽으면 되돌려질 값을 읽게 됩니다. 방금 붙은 판을 읽으면 트랜잭션 중간에 값이 바뀌어 보입니다. 그래서 「누구에게 어느 판이 보이나」를 정하는 규칙이 필요합니다.
규칙은 번호로 굴러갑니다. 트랜잭션마다 시작 순서대로 커지는 번호를 하나씩 줍니다. 판마다 어느 번호가 썼는지도 적어 둡니다.
읽는 트랜잭션은 시작할 때 스냅샷을 하나 뜹니다. 스냅샷은 그 순간 어느 트랜잭션이 이미 끝났는지를 적은 명단입니다. 읽을 때는 그 명단에 든 번호가 쓴 판 중 가장 나중 것을 고릅니다.
명단에 없는 번호가 쓴 판은 건너뜁니다. 아직 커밋하지 않은 트랜잭션이 붙인 판이 그렇습니다. 나보다 늦게 시작한 트랜잭션이 붙인 판도 그렇습니다. 커밋은 트랜잭션이 자기 결과를 확정해 남들에게 보이게 하는 마지막 단계를 말합니다.
아래는 한 행에 판이 셋 쌓인 상태에서 읽는 트랜잭션 셋이 각자 어디로 가는지입니다. 읽는 쪽 옆에 적힌 것이 그 트랜잭션의 명단입니다.
flowchart TD
subgraph 행 하나에 쌓인 판
V1["잔액 1000 · 번호 10 이 씀"]
V2["잔액 1200 · 번호 20 이 씀"]
V3["잔액 900 · 번호 30 이 씀"]
end
R15["읽는 트랜잭션 15 · 명단 {10}"] --> V1
R25["읽는 트랜잭션 25 · 명단 {10,20}"] --> V2
R35["읽는 트랜잭션 35 · 명단 {10,20}"] --> V2
번호 35 는 가장 나중 판인 900 을 안 읽고 1200 을 읽습니다. 번호 30 이 아직 안 끝나서 35 의 명단에 없기 때문입니다. 번호 15 가 시작할 때는 20 도 아직 안 끝났습니다. 그래서 15 의 명단에는 10 만 있고 15 는 1000 을 읽습니다.
읽는 쪽이 기다리지 않는다는 것을 두 트랜잭션으로 보면 이렇습니다. A 와 B 는 각각 다른 접속입니다.
sequenceDiagram
participant A
participant 행
participant B
Note over A: 여기서 스냅샷을 뜬다
A->>행: 잔액을 읽는다
행-->>A: 1000
Note over B,행: A 를 안 기다린다
B->>행: 잔액을 1200 으로 고친다
Note over B: 커밋한다
A->>행: 잔액을 다시 읽는다
행-->>A: 1000
B 의 쓰기가 A 의 읽기를 밀어내지 않았습니다. A 는 두 번 다 같은 값을 봤습니다.
다만 스냅샷을 언제 뜨는지는 방식마다 다릅니다. 트랜잭션을 시작할 때 한 번 뜨는 방식이 있습니다. 문장 하나마다 새로 뜨는 방식도 있습니다. 문장마다 새로 뜨는 방식이면 A 의 두 번째 읽기는 1200 입니다.
격리 수준은 트랜잭션끼리 서로의 중간 결과를 얼마나 가려 줄지 정하는 설정입니다. 둘 중 어느 쪽을 쓸지도 이 설정이 정합니다.
쓰기끼리 부딪히는 대목
MVCC 가 떼어 놓는 것은 읽기와 쓰기 사이뿐입니다. 같은 행을 두 트랜잭션이 함께 고치려 하면 판을 나눠 줄 수가 없습니다. 둘 다 새 판을 붙이면 한쪽이 쓴 값이 소리 없이 사라지기 때문입니다.
그래서 쓰기 쪽에는 락이 같이 옵니다. 먼저 손댄 트랜잭션이 그 행을 쥡니다. 뒤에 온 트랜잭션은 앞이 끝날 때까지 기다리거나 충돌이라는 답을 받고 물러납니다. 그 락이 행 잠금입니다.
서로 상대가 쥔 행을 기다리면 데드락이 납니다. 둘 다 앞이 풀리기를 기다려 아무도 나아가지 못하는 상태입니다.
판을 나누는 것만으로 안 풀리는 문제도 남습니다. 기다리던 트랜잭션도 앞이 끝나면 자기 쓰기를 마저 합니다. 문제는 그때 쓰는 값이 기다리기 전에 읽어 둔 낡은 판으로 계산한 값이라는 데 있습니다.
잔액 1000 을 둘이 각자 읽어 두고 하나는 100 을, 하나는 200 을 더한다고 해 봅시다. 나중에 쓴 쪽의 값이 먼저 쓴 쪽의 값을 덮습니다. 먼저 더한 100 은 흔적 없이 사라집니다. 이것이 갱신 손실입니다.
sequenceDiagram
participant T1
participant 행
participant T2
T1->>행: 잔액을 읽는다
행-->>T1: 1000
T2->>행: 잔액을 읽는다
행-->>T2: 1000
T1->>행: 1100 을 쓴다
T2->>행: 1200 을 쓴다
Note over 행: T1 이 더한 100 이 사라졌다
쓰기 편향은 둘이 서로 다른 행을 고치는데도 규칙이 깨지는 꼴입니다. 각자 읽은 판에서는 조건이 맞았지만 두 결과를 합쳐 놓으면 안 맞습니다. 막으려면 읽을 때부터 락을 걸거나 커밋 직전에 서로 겹쳤는지 따로 검사해야 합니다.
쌓인 옛 판의 뒤처리
옛 판은 저절로 없어지지 않습니다. 고칠 때마다 판이 하나씩 붙으니 같은 행을 자주 고치면 그 행 뒤로 옛 판이 길게 늘어섭니다. 읽는 쪽은 자기에게 보여야 할 판을 찾을 때까지 그 줄을 훑습니다. 줄이 길어지면 읽기가 점점 느려집니다.
아무도 안 볼 판을 찾아 버리는 청소 작업이 따로 붙습니다. 어느 판을 버려도 되는지는 살아 있는 트랜잭션 중 가장 오래된 번호가 정합니다. 그 번호보다 먼저 밀려난 판은 이제 아무도 안 봅니다. 아무도 안 가리키는 메모리를 찾아 버리는 가비지 컬렉션과 판단 방식이 같습니다.
아래는 번호 10 · 20 · 30 이 차례로 고친 행입니다. 살아 있는 가장 오래된 번호가 25 라면 그 25 는 20 이 쓴 판을 읽습니다. 그래서 20 의 판까지는 남기고, 그보다 먼저 밀려난 10 의 판만 버립니다.
flowchart TD
OLD["살아 있는 가장 오래된 번호 25"]
subgraph 버려도 되는 구역
V10["번호 10 이 쓴 판 · 20 이 밀어냈다"]
end
subgraph 아직 못 버리는 구역
V20["번호 20 이 쓴 판 · 25 가 읽는다"]
V30["번호 30 이 쓴 판 · 지금 값"]
end
V10 --> V20 --> V30
OLD -->|여기까지 남긴다| V20
여기서 오래 열린 트랜잭션 하나가 값비싸집니다. 몇 시간짜리 조회가 하나 떠 있으면 그 번호가 가장 오래된 번호로 남습니다. 경계선이 위로 못 올라가서 그림의 아래 구역이 통째로 남습니다. 청소가 밀리는 동안 저장 공간이 늘고 읽을 줄도 같이 길어집니다.
MVCC 를 고르는 기준
읽기가 많고 그중에 오래 걸리는 읽기가 섞여 있으면 이 방식이 잘 맞습니다. 통계 조회나 보고 화면이 도는 동안에도 쓰기가 계속 들어가야 하는 서비스가 그렇습니다.
반대로 값이 적은 경우도 또렷합니다. 같은 행을 여러 트랜잭션이 쉴 새 없이 고쳐 대면 부딪히는 것은 결국 쓰기끼리라 판을 나눠도 줄이 그대로 섭니다. 저장 공간이 빠듯한 곳에서는 쌓이는 옛 판과 청소 작업이 부담으로 돌아옵니다.
재고 수량처럼 읽은 값이 지금 이 순간의 값이어야 하는 읽기도 있습니다. 이런 읽기에는 스냅샷이 오히려 방해입니다. 그래서 읽을 때 그 행에 락을 직접 걸어 옛 판이 아니라 최신 판을 읽고, 그동안 쓰는 쪽은 기다립니다.
부딪힘을 미리 막지 않고 나중에 확인한다는 점에서 낙관적 잠금과 결이 같습니다. 먼저 잠그고 시작하는 비관적 잠금이 반대편에 섭니다.
관련 항목
MVCC 가 대신하려는 잠금 방식
락 · 비관적 잠금 · 낙관적 잠금 · 2단계 잠금 · 행 잠금 · SELECT FOR UPDATE · 읽기-쓰기 락
MVCC 가 만들어 내는 읽기 시점
스냅샷 · 스냅샷 격리 · 직렬가능 스냅샷 격리 · 격리 수준 · 리비전 · 타임스탬프 · 트랜잭션 ID
옛 판을 걷어 내는 뒤처리 작업
가비지 컬렉션 · VACUUM · 컴팩션 · 툼스톤 · 공간 증폭
MVCC 를 써도 남는 동시성 문제
갱신 손실 · 쓰기 편향 · 팬텀 읽기 · 데드락 · 더티 리드 · 반복 불가능한 읽기
MVCC 를 채택한 데이터베이스
PostgreSQL · MySQL · InnoDB · Oracle Database · CockroachDB · etcd
MVCC 가 딛고 서는 트랜잭션 개념
다른 이름: multiversion concurrency control · Multiversion Concurrency Control · Multi-Version Concurrency Control · 다중 버전 동시성 제어 · 다중버전 동시성 제어 · 다중 버전 병행 제어