쓰기가 진행되는 동안 읽기는 어떻게 될까요? DB는 MVCC(Multi-Version Concurrency Control)로 읽기와 쓰기가 서로를 막지 않게 합니다. Quipu-Log는 B-tree도, 트랜잭션 버전도 없습니다. 하지만 같은 목적 — 읽기가 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다 — 을 훨씬 단순한 방법으로 달성합니다. 인덱스를 복사하고, 파일 길이를 고정합니다.
ReadSnapshot은 인메모리 인덱스를 클론하고 각 세그먼트 파일의 "지금 이 순간 길이"를 기록한다. 그 이후로 쓰기가 아무리 일어나도 스냅샷은 그 순간을 본다 — 잠금 없이.
당신이 아는 것: DB의 MVCC
PostgreSQL이나 MySQL InnoDB는 각 행에 버전을 붙여 관리합니다. UPDATE를 하면 이전 버전을 지우지 않고, 새 버전을 만들고 이전 버전을 "만료" 처리합니다. 읽기 트랜잭션은 자신이 시작할 때의 "스냅샷 ID"를 받아, 그 이후에 커밋된 변경은 보이지 않습니다. 이것이 Snapshot Isolation입니다. 핵심: 읽기 트랜잭션이 쓰기를 막지 않고, 쓰기 트랜잭션도 읽기를 막지 않습니다.
하지만 이를 구현하려면 "어느 버전을 볼 수 있는가"를 추적하는 복잡한 자료구조(undolog, MVCC 체인, vacuum)가 필요합니다.
DB의 MVCC는 같은 행의 여러 버전을 디스크에 두고, 읽기 트랜잭션마다 자신이 볼 수 있는 버전을 골라 읽는다. 복잡하지만 정밀하다. Quipu-Log의 ReadSnapshot은 인메모리 인덱스를 통째로 클론하고, 세그먼트 파일의 현재 길이에 상한선을 박는다. 구조는 다르지만 목적은 같다: 읽기와 쓰기의 비차단.
ReadSnapshot: copy-on-read
AuditStore::snapshot()을 부르면 ReadSnapshot이 만들어집니다.
crates/quipu-core/src/store.rs — AuditStore::snapshot()pub fn snapshot(&mut self) -> Result<ReadSnapshot> {
Ok(ReadSnapshot {
keys: self.cfg.keys.clone(),
registries: self.registries
.iter()
.map(|(k, v)| (k.clone(), v.idx.clone())) // 인덱스 클론
.collect(),
logs: self.logs.slices()?, // 세그먼트 파일 경로 + 현재 길이
relations: self.relations.slices()?,
})
}
두 가지가 일어납니다.
- 레지스트리 인덱스 클론 — 현재 인메모리 인덱스(어떤 엔티티 ID → 어떤 버전 uid)를 복사합니다. 이 클론은 독립적이어서, 이후
append()가 원본 인덱스를 업데이트해도 클론된 스냅샷은 변하지 않습니다. - 세그먼트 슬라이스 기록 — 각 세그먼트 파일의 경로와 지금 이 순간의 파일 길이(bound)를
SegmentSlice로 기록합니다. 이후 append가 파일을 더 길게 만들어도, 스냅샷은bound이전까지만 읽습니다.
SegmentReader의 bound: 파일 길이 고정
SegmentSlice의 bound 필드가 핵심입니다. 스냅샷을 찍은 순간의 파일 길이가 bound로 기록됩니다. 스캔할 때 SegmentReader::open_bounded()는 bound를 넘어서는 읽지 않습니다.
crates/quipu-core/src/storage/segment.rs — SegmentReader::open_bounded()pub fn open_bounded(path: &Path, bound: u64) -> Result<Self> {
let file = File::open(path)?;
let end = file.metadata()?.len().min(bound); // bound 이상은 읽지 않는다
let mut reader = BufReader::with_capacity(256 * 1024, file);
if end >= SEGMENT_HEADER as u64 { read_header(&mut reader, path)?; }
Ok(Self { path, reader, offset: SEGMENT_HEADER.min(end as usize) as u64,
end /* 읽기는 여기까지 */ })
}
왜 이것이 중요할까요? append가 파일 끝에 바이트를 추가하는 중에도, 스냅샷 리더는 bound까지만 봅니다. 반쯤 쓰인 프레임(BufWriter가 아직 flush하지 않은 것)을 볼 일이 없습니다. 읽기와 쓰기가 같은 파일을 공유하지만, 경계가 명확하게 나뉩니다.
스캔은 호출자 스레드에서
파이프라인 모드에서 AuditHandle::snapshot()을 부르면, 스냅샷 객체 생성만 writer 스레드에서 일어납니다(인덱스 클론). 그 뒤 스냅샷은 호출자에게 반환되고, snapshot.query()는 호출자 스레드에서 실행됩니다. writer 스레드는 그 사이에도 emit()된 이벤트를 계속 씁니다. 느린 쿼리가 emit을 막지 않습니다.
README의 Snapshots 섹션 설명:
Queries run on a read snapshot (
handle.snapshot(&role)?): it clones the in-memory registry indexes and scans on the caller's thread, never blocking writes.
이것이 Quipu-Log의 "MVCC"입니다. 버전 체인 없이, 인덱스 클론 + 파일 bound로 읽기-쓰기 격리를 달성합니다.
MVCC와의 비교: 무엇이 같고 무엇이 다른가
| 속성 | DB MVCC | Quipu-Log ReadSnapshot |
|---|---|---|
| 목적 | 읽기와 쓰기 비차단 | 읽기와 쓰기 비차단 |
| 구현 방법 | 행마다 버전 체인, undolog | 인덱스 클론 + 파일 bound |
| 스냅샷 비용 | 낮음(트랜잭션 ID 부여) | 인덱스 클론 (O(엔티티 수)) |
| 스냅샷 이후 변경 | 버전 체인으로 가려진다 | bound 이후 파일은 읽지 않는다 |
| vacuum 필요 | 있음 (오래된 버전 청소) | 없음 (retention이 세그먼트 단위로 삭제) |
도서관에 새 책이 계속 들어옵니다. "지금 보유 목록"을 인쇄해서 나눠줬다고 하면, 그 이후에 새 책이 들어와도 내 인쇄물은 바뀌지 않습니다. 나는 인쇄 시점의 목록으로 책을 찾습니다. Quipu-Log의 스냅샷도 마찬가지입니다 — 인덱스 클론이 그 "인쇄물"입니다.
스냅샷의 한계
인덱스 클론 비용은 등록된 엔티티 수에 비례한다. 엔티티가 수백만 건이면 클론 자체가 수십 밀리초가 될 수 있다. 또 스냅샷은 불변이므로 스캔 중에 append된 레코드는 보이지 않는다 — 이것이 의도된 "스냅샷 격리" 동작이지만, "방금 append한 것이 왜 안 보이지?"로 혼란스러울 수 있다. flush()를 호출하고 새 스냅샷을 얻으면 반드시 보인다.
정리
- DB의 MVCC와 같은 목적(읽기-쓰기 비차단)을, Quipu-Log는 copy-on-read 스냅샷으로 달성한다.
ReadSnapshot은 레지스트리 인덱스 클론 + 세그먼트 파일의 bound 기록으로 만들어진다. 이후 append가 파일을 늘려도 스냅샷은 bound 이전만 본다.- 스캔은 호출자 스레드에서 실행된다. Writer 스레드는 스캔을 기다리지 않는다.
- 버전 체인, undolog, vacuum이 없다. 단순하지만 스냅샷 비용이 인덱스 크기에 선형 비례한다.
① DB MVCC와 Quipu-Log ReadSnapshot이 "같은 목적"을 달성하는 방법의 차이를 한 문단으로 설명해 보세요.
② 스냅샷을 찍은 직후에 append가 일어났습니다. 그 append를 스냅샷으로 읽을 수 있나요? 없다면 왜인가요?
③ 쿼리 스캔이 Writer 스레드가 아닌 호출자 스레드에서 실행되어야 하는 이유를 파이프라인 관점에서 설명해 보세요.