How MVCC and Transactions Work in RocksDB
Author: Artem Krylysov | Source: artem.krylysov.com | Published: 2026-07-23
한 줄 요약
LSM-tree가 MVCC의 절반(제자리 수정을 하지 않아 자연히 여러 버전이 생김)을 공짜로 주지만, 나머지 절반인 스냅샷·원자적 배치·충돌 탐지는 모두 시퀀스 번호 비교라는 같은 프리미티브로 귀결되며 어려운 것은 MVCC가 아니라 동시성이다.
핵심 주장/내용
- MVCC의 두 요건: ① 쓰기가 키를 제자리에서 수정·삭제하지 않고 새 버전을 만든다 ② 읽기가 읽기 시작 시점의 일관된 뷰를 본다 — 쓰기가 독립적으로 새 버전을 계속 만드는 동안 읽기는 옛 버전에 접근. LSM-tree는 memtable → 불변 SST 흐름으로 자연히 여러 버전을 갖고 compaction이 옛 버전을 지우는 GC이므로 절반은 이미 된 셈이지만, 읽기에 스냅샷을 제공하는 방법과 GC가 어떤 테이블이 in-flight 쿼리에 접근 중인지 아는 방법이 더 필요하다
- 시퀀스 번호: RocksDB는 삽입된 키마다 단조 증가하는 번호를 부여한다. memtable과 SST에 실제로 들어가는 것은 (user_key, sequence_number, value_type) 삼중항 + 값. value type은 insert냐 delete냐
- 두 개의 시퀀스를 추적 — last allocated(쓰기 시 마지막 부여된 번호)와 published(읽기에 보이는 번호, 쓰기 완료 후 전진)
- 읽기 쿼리가 시작하면 published 시퀀스를 캡처하고 그것으로 memtable과 SST를 조회. seq 4로 시작한 리더는 seq 5의
dog,b를 건너뛴다
- 내부 키 정렬: user key 오름차순, 그 다음 시퀀스 번호 내림차순 — 같은 user key면 큰 시퀀스가 먼저 온다. 그래서 published 4를 캡처한 쿼리는
dog 4로 skip list seek을 하고, 내부 키 순서에서dog 4가dog 5보다 크고dog 1보다 작으므로dog 1에 착지해 멈출 수 있다 - memtable = concurrent skip list: 레이어 0이 모든 엔트리(연결 리스트), 각 상위 레이어가 이전 레이어의 부분집합 → 평균 O(log n) 탐색. 락 없이 포인터에 대한 atomic 연산으로 구현되어 읽기와 쓰기가 진짜로 동시에 일어난다. non-GC 언어의 concurrent 알고리즘 GC라는 어려운 문제를 arena allocator로 우아하게 해결 — 개별 엔트리는 절대 해제하지 않고, 삭제는 DELETE value type의 삽입이며, memtable 전체 메모리는 그것이 더 이상 쓰이지 않게 된 뒤 해제
- 원자적 업데이트: write batch를 적용할 때 배치 키 개수만큼 시퀀스를 한 번에 증가시켜 원자적으로 할당하고, 세 키가 모두 memtable에 삽입된 뒤에야 그 번호를 published. 어떤 리더도 부분 배치를 볼 수 없다
- 스냅샷:
db.snapshot()호출 시 기록된 published 시퀀스 번호일 뿐이다. 스냅샷을 통한 읽기는 더 높은 시퀀스의 키를 건너뛴다. RocksDB는 활성 스냅샷 목록에도 등록하고 compaction이 그 목록을 확인해 살아있는 스냅샷이 아직 볼 수 있는 키 버전은 지우지 않는다 - SuperVersion — 테이블 버저닝: 읽기에 필요한 모든 것의 참조 카운팅된 뷰(active memtable, immutable memtables, SST 레벨). 읽기가 시작하면 참조를 획득하고 끝나야 해제. 참조가 0이 되면 자식 필드를 해제하며 그들의 카운트를 감소시키고, 자식 자신의 카운트가 0에 도달할 때만 메모리 해제·파일 삭제 스케줄이 수행된다 → 구조체도 자식도 읽기 중간에 해제될 수 없다
- lock vs latch: lock은 논리적(키·키 범위·row 위) — 걸려 있으면 해제될 때까지 누구도 수정 못 함. latch는 물리적(동시 접근 중 자료구조를 data race에서 보호하는 뮤텍스 등). 이 정의로 보면 RocksDB 코어는 논리적 키 락을 잡지 않고 읽기·쓰기 경로가 흔한 hot case에서 latch를 잡지 않도록 설계되었다. 다만 flush나 compaction 후 SuperVersion을 교체할 때 짧게 잡는 coarse-grained per-DB 뮤텍스처럼 덜 빈번한 경로는 latch를 요구
- 트랜잭션: 쓰기가 트랜잭션 버퍼에 staging되고 커밋 후에야 DB에 적용. 커밋 안 된 트랜잭션에서 읽으면 버퍼와 트리 나머지를 병합해 자기 쓰기를 읽을 수 있다
- Pessimistic — put/delete 시 키를 락. lock manager가 per-DB 맵에 각 갱신 키를 트랜잭션 ID와 연결. 다른 트랜잭션이 잠긴 키를 갱신하려 하면 커밋/롤백까지 블록되고 lock timeout 안에 안 되면 에러. 데드락 가능(
deadlock_detect를 켜면 블록 전에 wait cycle을 확인하고 즉시 에러, 기본은 꺼져 있어 lock timeout으로 해소) - Optimistic — 쓰기에서 충돌 검사를 하지 않아 put/delete가 블록되거나 실패하지 않고 커밋 시점에 검사. 공유 맵에 개별 잠긴 키를 추적하는 대신 키를 100만 개의 사전 할당된 뮤텍스에 해싱(메모리를 내주고 경합을 줄이는 트레이드오프). 연산마다 호출 시점의 published 시퀀스를 기록해두고, 커밋에 관여하는 모든 키의 뮤텍스를 잡은 상태에서 memtable을 확인해 기록한 것보다 큰 시퀀스가 있으면 커밋 실패
- optimistic은 memtable만 검증한다(pessimistic은 트리 전체). 트랜잭션 중간에 memtable이 flush되면 검증할 대상이 없어져 커밋이 실패하고 재시도해야 한다
set_snapshot(true)는 읽기를 스냅샷에 고정하는 게 아니라 충돌 탐지에만 영향을 준다(저자도 혼란스러운 API라고 지적). 이걸 켜면 T1이 키 락을 획득한 뒤 스냅샷 설정 이후 그 키가 갱신됐는지(스냅샷 시퀀스보다 높은 버전이 있는지) 확인해 있으면 쓰기가 에러
- Pessimistic — put/delete 시 키를 락. lock manager가 per-DB 맵에 각 갱신 키를 트랜잭션 ID와 연결. 다른 트랜잭션이 잠긴 키를 갱신하려 하면 커밋/롤백까지 블록되고 lock timeout 안에 안 되면 에러. 데드락 가능(
- 격리 수준 매핑 (RocksDB에는 SQL 같은 고수준 API가 없고 저수준 API로 직접 만들어야 한다):
- Read uncommitted — RocksDB에서 불가능. 트랜잭션 쓰기가 버퍼에 있다가 커밋 시 원자적으로 적용되므로 dirty read가 안 생긴다
- Read committed — 평범한
db.get이나 트랜잭션이 읽기에 스냅샷을 안 쓸 때. 트랜잭션 중간에 연속된 두 get이 다른 값을 볼 수 있다 - Snapshot isolation — 읽기와 쓰기 충돌 탐지 양쪽에 스냅샷을 켜야 한다(
set_snapshot은 충돌 탐지만 커버하므로 읽기는 트랜잭션의snapshot()을 통해야 함). non-repeatable read는 피하지만 write skew 이상은 여전히 가능- write skew 예: Alice가 checking·savings 각 200을 인출하며 각자의 스냅샷에서
100+100-200 >= 0체크를 통과. 서로 다른 키를 갱신했으므로 충돌 검증도 양쪽 다 통과하고 결국 두 계좌 모두 -200
- write skew 예: Alice가 checking·savings 각 200을 인출하며 각자의 스냅샷에서
- Serializable — point read만 관여하는 트랜잭션이면 snapshot isolation +
get_for_update로 근접 가능.get_for_update는 put/delete와 같은 방식으로 충돌 검사를 유발하는 특수 get이라 읽기가 충돌 탐지에 참여한다- 하지만 range scan을 하는 트랜잭션에는 phantom read 이상이 가능하다.
get_for_update는 스캔이 반환한 키를 잠그지만 아직 존재하지 않던 키는 잠글 것이 없다. T1이 총합 50을 열면 실제로는 $150 - RocksDB에 range lock manager(2020년 기여)가 있지만 RocksDB 위키 어디에도 문서화되어 있지 않고 C·Java API로 노출되지 않아 C++에서만 사용 가능
- 하지만 range scan을 하는 트랜잭션에는 phantom read 이상이 가능하다.
- Optimistic vs Pessimistic 성능 — 사용자 관점에서 유일한 차이는 어디서 abort하느냐다. Pessimistic은 모든 쓰기 연산에서 락을 획득하고 충돌 시 (lock timeout 초과 후) 일찍 abort. Optimistic은 키별 락이 없고 커밋 동안만 coarse-grained 락을 잡는다. 충돌이 드물고 전체 트랜잭션 재시도가 싼 워크로드에서 optimistic이 대체로 더 높은 처리량
- “mostly read” 워크로드 (모든 트랜잭션이 공유 키를
get_for_update로 읽고 고유 키를 쓰며 1024번에 한 번쯤 공유 키를 교체) — 2 스레드부터 optimistic 처리량은 평평하게 유지되고 pessimistic은 공유 키 락에 줄을 선다 - “contended key” 워크로드 (모든 트랜잭션이 같은 키를
get_for_update로 읽고 CPU 작업을 한 뒤 되씀) — pessimistic은 CPU를 소비하지 않고 락에서 대기해 작업이 커밋당 한 번만 실행되는 반면, optimistic은 작업을 다 마친 뒤 커밋에서야 충돌을 발견해 재시도에서 비싼 작업을 다시 한다
- “mostly read” 워크로드 (모든 트랜잭션이 공유 키를
주요 수치 / 사실
- Optimistic 트랜잭션의 사전 할당 뮤텍스 100만 개
- RocksDB 네이티브 트랜잭션을 쓰는 대형 프로젝트는 MyRocks(RocksDB 기반 MySQL 스토리지 엔진)와 ArangoDB 정도. Facebook의 MyRocks 리포는 2026년 3월 1일 GitHub에서 아카이브됐지만 엔진은 MariaDB·Percona Server의 일부로 여전히 사용 가능
- TiDB/TiKV와 YugabyteDB는 RocksDB 위에서 돌지만 네이티브 트랜잭션을 쓰지 않고, CockroachDB의 RocksDB 영감 KV 스토어 Pebble은 트랜잭션을 아예 지원하지 않는다. 이유: RocksDB 네이티브 트랜잭션은 node-local이라 락이 단일 노드의 KV 스토어 인스턴스별로 유지되는데, 분산 DB는 여러 노드에 걸쳐 트랜잭션을 조율해야 하므로 자체 분산 트랜잭션 레이어를 위에 구현한다
- MVCC를 쓰는 DB: PostgreSQL, CockroachDB, FoundationDB, MongoDB WiredTiger, LMDB
- 결론: 어려운 부분은 MVCC가 아니라 동시성이다. 스냅샷·원자적 배치·양쪽 트랜잭션 방식의 충돌 탐지가 전부 같은 프리미티브 — 시퀀스 번호 비교 — 로 귀결된다
관련 위키
- Database Concurrency Control
- Storage-Workload Architecture Taxonomy
- Disaggregated OLTP Architecture
- Idempotency
- Partition Sizing and Management
Source: 원문 보기