벤치마크가 멈춘 걸 3시간 지켜보다가 6초 만에 고쳤다
Source: Dev.to
3시간. bench_column_index가 멈춰버린 채로 실행된 시간
저는 moteDB v0.2.0을 준비하면서 평소와 같은 성능 테스트 스위트를 돌렸습니다. 12개의 DB 인스턴스를 병렬로 실행하고, 각 인스턴스는 SELECT WHERE col = ? 쿼리를 수행하는 동안 백그라운드 스레드가 인덱스를 구축했습니다. 밀리초 안에 끝나야 할 쿼리들이 몇 분, 몇 시간, 그리고 결국 전혀 진행되지 않았습니다.
문제의 원인은 컬럼 인덱스에 대한 모든 읽기와 쓰기를 보호하던 하나의 RwLock이었습니다. 백그라운드 스레드가 대량 삽입을 위해 쓰기 락을 잡으면, 모든 쿼리가 차단되었습니다. 바로 그거였죠—12개의 스레드가 하나의 락을 두고 싸우고 있었습니다.
아래는 제가 취한 조치와 3시간 걸리던 대기 시간을 6.6초로 단축시킨 방법입니다.
우리를 죽이고 있던 아키텍처
v0.1.7은 매우 단순한 설계였습니다: B‑Tree 하나, RwLock 하나. 깔끔했지만 잘못됐습니다.
SELECT WHERE col = ? → acquire read lock → traverse B‑Tree → return
Background index build → acquire write lock → bulk insert → release
두 경로가 동시에 같은 락을 잡으려 하면, 쓰기 스레드 뒤에 쿼리들이 대기열에 쌓입니다. 인스턴스가 12개라면, 대기열은 소진보다 더 빨리 커집니다. 시스템은 살아 있는 듯 보였지만—스레드는 실행 중이고 메모리는 할당됐지만—진전은 전혀 없었습니다.
다른 모델이 필요했습니다. 제가 선택한 설계는 다음과 같습니다:
두 층 아키텍처 (RocksDB 스타일)
| 컴포넌트 | 목적 |
|---|---|
IndexMemBuffer | parking_lot::RwLock 로 보호되는 메모리 내 BTreeMap (나노초 수준 경쟁). 쓰기는 여기서 먼저 일어납니다. |
GenericBTree | 디스크에 저장되는 B‑Tree. 읽기는 여기서 수행됩니다. 쓰기는 백그라운드 드레인 시에만 발생합니다. |
drain_lock | Mutex 로 구현돼 try_lock을 사용해 버퍼‑→‑B‑Tree 마이그레이션을 직렬화합니다. 따라서 쓰기 스레드가 차단되지 않습니다. |
tombscones | 삭제된 키를 추적하는 HashSet. 드레인된 버퍼가 데이터를 다시 살리는 것을 방지합니다. |
메모리 버퍼가 임계값을 초과하면 원자적으로 불변 스냅샷으로 전환됩니다. 드레인 스레드는 이를 받아 읽기를 차단하지 않고 B‑Tree를 구축합니다. 새로운 쓰기는 새로운 활성 버퍼에 기록됩니다.
TOCTOU 수정: get()이 이제 무덤(stone) 필터와 LRU‑캐시 쓰기 모두에 동일한 락을 유지하도록 하여, 두 연산 사이에 키가 삭제될 수 있는 레이스 윈도우를 없앴습니다.
결과:
bench_column_index runtime: 3+ hours → 6.6 seconds
성능 작업의 세 단계
핵심 락 경쟁 외에도, 이번 릴리즈 사이클에서는 세 가지 성능 단계에 집중했습니다.
Phase 1 – 메모리 레이아웃
Arc를 사용해 매get()시 전체 행을memcpy하는 일을 없앴습니다.- 벡터가 아닌 테이블은 제네릭 래퍼 대신 자체
BTreeMap을 사용해 행당 24 바이트를 절감했습니다. - 100 K 행 기준으로 약 10 MB의 메모리를 절약했으며, 쿼리 로직은 전혀 건드리지 않았습니다.
Phase 2 – 시스템 콜 감소
- DiskANN 삽입 경로를 최적화했습니다.
SQ8Vectors에 대해 영구 파일 핸들을 재사용했습니다.- 이전에는 캐시 미스당 2개의 시스템 콜이 발생했지만, 이번 단계에서 I/O 레이어의 오버헤드를 제거했습니다.
Phase 3 – 공간 인덱스 & FTS
- i‑Octree 가 이제 배치 로딩에 Morton 코드를 사용합니다. 리프 노드는 트리 분할을 최소화하도록 순차적으로 채워집니다.
- LSM
scan_range()를 전체 결과를 메모리에 올리는 방식에서 스트리밍 스캔 방식으로 전환했습니다. - FTS 는 append‑only 샤드 쓰기로 바뀌었으며, 샤드가 5 세그먼트에 도달하면 병합을 지연 트리거합니다.
- 컬럼 프레디케이트 푸시‑다운: 먼저 타임스탬프 컬럼을 디코드해 행을 찾고, 필요할 때만 대상 컬럼을 디코드합니다—이미 필터링된 컬럼을 디코드하는 일을 피합니다.
- 공간 쿼리 행 캐시 + 행당
HashMap할당 제거 → 8 000배 빠른 공간 범위 쿼리 성능을 달성했습니다.
28가지 문제를 찾아낸 감사
이번 릴리즈 전에 적대적 감사를 세 차례 수행했습니다. 발견된 문제는 다음과 같습니다:
| 영역 | 문제 내용 |
|---|---|
| B‑Tree | 리프 인덱스가 범위를 벗어나면서 패닉 발생 |
| Async index pipeline | 중복 삽입으로 텍스트 인덱스 패닉 |
| WAL compression + DiskANN | 세 가지 별도 데드락 |
close() | 체크포인트 전 백그라운드 스레드에 알리지 않음 |
| Column/text index | 비동기 파이프라인이 완성되기 전에 쿼리 → 폴백 없음 |
| SUM precision loss | 부동소수점 누적에서 두 단계 보정 알고리즘으로 전환 |
| BTreeMap scan | 모든 결과를 한 번에 메모리로 올려 메모리 급증 |
| Primary‑key query | 재시작 후 인덱스 누락, 스캔 폴백 없음 |
| glibc arena | db.close() 를 명시적으로 호출할 때 동시 충돌 → malloc 비스레드 안전 문제. close() 호출을 직렬화해 해결 |
glibc arena 버그는 특히 흥미로웠습니다. 다수의 스레드가 동시에 close() 를 호출하면 arena 할당기가 충돌했는데, 이는 malloc 이 현재 코드에서 쓰이는 방식으로는 스레드 안전하지 않기 때문이었습니다. 해결책은 간단히 동시에 close() 를 호출하지 않도록 하는 것이었습니다—돌이켜보면 당연한 이야기였습니다.
엣지 디바이스도 이제 사랑받는다
moteDB는 임베디드·엣지 하드웨어를 목표로 합니다. v0.2.0에서는 전용 최적화를 추가했습니다:
EdgeIndexConfig– DiskANN 에 메모리 사용량을 제한하는 인덱스 설정을 도입해 그래프 메모리 풋프린트를 제한합니다.- FTS bounded shard counter + VersionStore eviction – 장시간 작업 중 메모리 증가를 방지합니다.
- Dead‑code cleanup – 약 2 200줄을 제거해 바이너리 크기를 감소시켰습니다.
- Zero clippy warnings – 모든 경고가 사라졌으며, 컴파일도 깨끗합니다.
대규모 테스트
새 테스트 인프라는 동시성 엣지 케이스를 다룹니다:
wait_for_indexes_ready()–pending_index_batches원자 카운터를 폴링해 인덱스 준비 상태를 결정적으로 확인합니다.- CI adaptive data scaling – CI 환경을 감지해 자동으로 테스트 데이터 양을 축소합니다.
749개의 새로운 테스트 케이스가 4스레드 동시성 하에 실행되며, 약 3분 안에 완료되고 멈춤 현상 없이 끝납니다.
숫자로 보는 성과
| 지표 | 값 |
|---|---|
| 커밋 수 | 35 |
| 수정된 소스 파일 | 89 |
| 추가된 라인 | 28 118 |
| 삭제된 라인 | 14 815 |
| 성능 최적화 | 11 |
| 버그 수정 | 21 |
| 새 기능 | 3 |
릴리즈는 crates.io 에서 확인할 수 있습니다—다음과 같이 추가하세요:
motedb = "0.2.0"
또는 cargo add motedb 를 실행하세요.
엣지 하드웨어에서 moteDB 를 사용하거나, 백그라운드 인덱스 구축 중에도 쿼리가 멈추지 않는 데이터베이스가 필요하다면, v0.2.0 을 한번 써보세요!
백그라운드에서 인덱스를 구축하면서, 이번 업데이트는 업그레이드할 가치가 충분합니다.
벤치마크 스위트가 더 이상 멈추지 않습니다.