신규: Helius가 Light Protocol을 인수했습니다
저렴한 쓰기의 대가: RocksDB는 어떻게 읽을까요?
블로그/엔지니어링

저렴한 쓰기의 대가: RocksDB는 어떻게 읽을까요?

Developer Experience EngineerX의 0xIchigoLinkedIn의 0xIchigoGitHub의 0xIchigo
읽는 데 14분

키-값 저장소에서 읽는 일은 간단해 보입니다. 키를 제공하고 값을 가져오면 됩니다. 하지만 이 시리즈의 이전 글에서는 이러한 생각이 왜 잘못되었는지 살펴봤습니다. LSM 트리는 데이터 정리 작업을 미뤄 쓰기 비용을 낮추는 절충안을 택합니다.

그 대가는 읽기가 치러야 합니다.

키의 현재 값은 memtable, 레벨 0의 파일 또는 그 아래 정렬된 어느 레벨에든 있을 수 있습니다. 읽기 경로는 모든 위치를 확인하지 않고 이 값의 최신 버전을 찾아야 합니다.

이 글에서는 검색을 빠르게 유지하는 메커니즘과 그 비용을 결정하는 계산법을 다룹니다.

RocksDB는 키를 어떻게 읽을까요?

RocksDB의 읽기는 최신 데이터에서 가장 오래된 데이터 순으로 진행되며, 키의 첫 번째 버전을 찾는 즉시 중단합니다. 핵심은 이것뿐이며, 나머지는 모두 이 과정을 더 빠르게 만듭니다.

검색 순서는 고정되어 있으며 이전 글에서 설명한 쓰기 경로를 역순으로 따릅니다. 활성 memtable, 불변 memtable, 레벨 0의 SST 파일, 레벨 1부터 아래로 정렬된 SST 파일을 차례로 탐색합니다.

RocksDB는 먼저 활성 memtable을 확인합니다. 최신 쓰기를 보관하고 메모리에 있으며 skiplist를 사용하기 때문입니다. 따라서 조회는 정렬된 메모리 내 검색으로 수행되며 I/O가 거의 들지 않습니다. 키를 찾는 즉시 검색을 중단합니다.

활성 memtable 다음에는 가득 찼지만 아직 디스크에 플러시되지 않은 불변 memtable을 확인합니다. 최신 항목부터 오래된 항목 순으로 확인합니다.

그다음에는 디스크를 확인합니다. 레벨 0 파일은 키 범위가 겹치므로 최신 파일부터 오래된 파일 순으로 각각 확인해야 합니다. 각 파일은 memtable이 받아들인 데이터의 스냅샷이므로 동일한 키가 동시에 두 레벨 0 파일에 존재할 수 있습니다.

각 레벨 0 파일은 키가 숨어 있을 수 있는 별도의 위치입니다. 이 때문에 레벨 0 파일 수가 읽기에 부담을 주며, RocksDB는 그 수를 제한하기 위해 쓰기를 중단시킵니다.

레벨 1부터는 같은 레벨의 SST 파일이 정렬되어 있고 서로 겹치지 않으므로, 레벨마다 최대 하나의 파일만 키를 포함할 수 있습니다. 파일 메타데이터를 이진 검색해 해당 파일을 찾은 다음 파일 내부로 검색을 이어갑니다. 인덱스 블록이 올바른 데이터 블록을 찾고, 데이터 블록은 키를 포함하거나 이 레벨에 키가 없음을 보여줍니다.

시퀀스 번호는 RocksDB 읽기 경로에서 중요합니다. 첫 번째 버전을 찾는 즉시 검색을 중단할 수 있기 때문입니다. 이로써 해당 버전이 최신임을 보장합니다. 예를 들어 읽기에서 tombstone을 찾으면 성공한 것으로 처리하고 키가 삭제되었다고 반환합니다. 검색은 tombstone에서 끝나며 더 오래된 값은 디스크에 남습니다.

RocksDB는 어떤 읽기 연산을 제공할까요?

RocksDB는 읽기 경로를 서로 다르게 사용하는 네 가지 읽기 방식을 제공합니다. Get, MultiGet, Iterators, Snapshots입니다.

Get

Get은 이전 섹션에서 살펴본 포인트 조회입니다. 최신 항목부터 오래된 항목 순으로 하나의 키를 찾으며, 가장 먼저 발견한 버전을 사용합니다.

MultiGet

MultiGet은 포인트 조회를 일괄 처리합니다. 읽기 작업이 RocksDB에 키 집합을 제공하면, RocksDB는 키를 정렬하고 키가 접근할 가능성이 있는 파일별로 그룹화합니다.

이를 통해 RocksDB는 하나의 SST를 열어 인덱스와 필터 블록을 한 번만 확인하고 I/O를 일괄 처리할 수 있습니다. 읽기 경로는 Get과 같지만, 비용이 고정되어 배치 전체에 분산됩니다.

Iterators

Iterators은 범위를 스캔합니다. 즉, 특정 키를 찾아간 후 정렬된 순서에 따라 앞이나 뒤로 이동합니다.

실질적으로 iterator는 k-way 병합입니다. 즉, 서로 다른 k개의 정렬된 목록을 하나의 완전히 정렬된 시퀀스로 결합합니다. memtable, 모든 불변 memtable, 모든 L0 파일, 더 깊은 각 레벨의 파일 하나 등 활성 상태인 모든 데이터 소스가 자체 정렬 커서를 제공하며, 힙은 다음으로 가장 작은 키를 가진 커서를 반복해서 내보냅니다.

각 단계에서 최신 버전 우선 규칙이 적용됩니다. 여러 소스가 같은 키를 제공하면 최신 버전이 선택되고 나머지는 폐기됩니다.

iterator가 진행될 때 가려진 값과 tombstone은 건너뜁니다. 따라서 삭제된 키로 가득한 범위는 아무것도 반환하지 않으면서 조용히 통과해야 합니다. 삭제가 많은 키 공간에서는 포인트 조회로는 드러나지 않는 방식으로 스캔이 느려집니다.

Snapshot

Snapshot은 특정 시점에 캡처한 시퀀스 번호입니다. 이를 포함하는 읽기는 해당 번호보다 최신인 버전을 무시하고, 그 번호 이하에서 가장 최신인 버전을 반환합니다. 정수 비교만으로 일관된 특정 시점 읽기를 구현할 수 있습니다.

스냅샷 생성 자체는 사실상 무료입니다. 비용은 나중에 발생하기 때문입니다. 활성 스냅샷이 여전히 볼 수 있는 버전은 아무리 오래되었어도 회수할 수 없습니다.

Get과 MultiGet은 원하는 키를 정확히 알고 있습니다. 따라서 “이 키가 어디에 있는가?”보다 저렴한 질문을 할 수 있습니다. 바로 “어떤 파일에 이 키가 확실히 없는가?”입니다. Iterators은 이 지름길을 전혀 사용할 수 없습니다. 이 저렴한 질문에 답하는 것이 블룸 필터의 유일한 역할입니다.

블룸 필터란 무엇인가요?

블룸 필터는 집합에 관해 한 가지 질문에 답합니다. 이 원소가 집합에 확실히 없는가? 답은 확실히 없음 또는 있을 수 있음이며, 확실히 있다는 답은 하지 않습니다. 키가 확실히 없는 파일은 검색 중 열 필요가 없으므로 I/O를 절약할 수 있습니다.

SST를 만들 때 모든 키를 몇 개의 해시 함수에 통과시키며, 각 해시는 작은 비트 배열인 필터 블록의 비트를 설정합니다.

읽을 때는 찾는 키를 같은 방식으로 해싱하고 해당 비트를 확인합니다. 설정되지 않은 비트가 하나라도 있으면 그 키는 삽입된 적이 없으므로 여기에 확실히 존재하지 않습니다.

모든 비트가 설정되어 있으면 키가 아마도 존재합니다. 다른 키의 해시가 우연히 같은 비트를 설정했을 수 있기 때문에 확정할 수 없습니다.

따라서 거짓 양성이 발생할 수 있습니다. 검색이 파일을 열어 확인했지만 아무것도 찾지 못해, 필터가 아끼려던 I/O를 결국 사용하게 됩니다.

RocksDB의 일반적인 설정인 키당 10비트에서 거짓 양성률은 약 1%입니다. 10비트를 사용하면 키가 없는 파일을 건너뛸 확률이 99%입니다. 키가 백만 개인 SST의 필터는 1MB를 조금 넘으며, 인덱스와 함께 메모리에 상주하고 보호 대상 디스크에 접근하지 않고도 확인할 수 있습니다. 시스템 엔지니어링에서 이보다 적은 비용으로 더 큰 효과를 내는 구조는 손에 꼽습니다.

이 시리즈의 이전 글에서 필터 블록 크기가 0으로 나온 이유도 여기에 있습니다. 블룸 필터는 선택적으로 사용하며 filter_policy을 통해 애플리케이션이 원하는 키당 비트 수로 구성합니다. 기본값은 필터를 설정하지 않습니다.

워크로드를 알 수 없는 라이브러리의 기본값으로는 타당합니다. 하지만 프로덕션에서는 용납하기 어렵습니다. 필터 사용 여부에 따라 누락된 키의 비용이 파일당 한 번의 메모리 확인으로 끝날 수도, 파일마다 인덱스 블록을 읽어야 할 수도 있기 때문입니다.

한 가지 제약은 검색 대상의 정확한 키를 알 때만 블룸 필터가 유용하다는 점입니다. 블룸 필터는 key:0301이 이 파일에 있는지는 답할 수 있지만, 해시가 순서를 없애므로 이 파일에 key:0090과 key:0110 사이의 항목이 있는지는 답할 수 없습니다.

다음 키를 미리 알 수 없는 Iterators은 이 구조의 도움을 받지 못합니다. 블룸 필터는 포인트 조회 기술입니다. 범위 작업이 많은 워크로드는 다른 해결책을 찾아야 합니다.

RocksDB 접두사 탐색은 어떤가요?

RocksDB는 스캔 문제를 부분적으로 해결하기 위해 해싱 대상을 바꿉니다. 접두사 추출기는 키에 의미 있는 구조가 있음을 RocksDB에 알려줍니다. 예를 들면 처음 8바이트나 구분자 앞의 모든 내용입니다. RocksDB가 접두사를 인식하면 전체 키 대신 또는 전체 키와 함께 접두사를 대상으로 블룸 필터를 만들 수 있습니다. 파일에 특정 접두사로 시작하는 키가 있는지 답하는 필터는 iterator에 도움이 됩니다.

하지만 이 지름길은 하나의 접두사에만 적용됩니다.

주어진 접두사를 스캔할 때는 포인트 조회와 같은 방식으로 파일과 블록을 건너뛸 수 있습니다. 한 접두사에서 다음 접두사로 넘어가는 스캔은 같은 도움을 받지 못합니다. 접두사 간 반복 순서 보장을 유지하는 방법은 이를 가능하게 하는 세부 구성까지 별도로 깊이 살펴볼 만한 주제입니다.

접두사 탐색이 일반적인 범위 스캔을 저렴하게 만들지는 않습니다.

대신 주어진 접두사에 대해 매우 흔한 특정 형태의 범위 스캔 하나를 저렴하게 만듭니다.

RocksDB 블록 캐시란 무엇인가요?

블록 캐시는 압축 해제된 블록을 보관하는 메모리 내 캐시입니다. SST에서 데이터 블록을 읽으면 압축을 풀어 보관하므로, 다음 읽기에서 같은 블록이 필요할 때 디스크에 접근하지 않고 찾을 수 있습니다. 즉, 자주 쓰이는 키는 자주 쓰이는 블록에 모이고, 그런 블록에서는 더 이상 I/O 비용이 발생하지 않습니다.

따라서 블룸 필터는 검색이 파일에 접근하지 않도록 하고, 블록 캐시는 반드시 접근해야 하는 파일을 저렴하게 다시 방문하도록 합니다.

여기에는 캐시가 두 개라는 미묘한 점이 있습니다.

블록 캐시는 RocksDB가 직접 관리하며 압축 해제된 블록을 보관합니다. 그 아래에는 운영체제의 페이지 캐시가 있으며, RocksDB가 최근 읽은 압축 상태의 원시 파일 페이지를 보관합니다. 같은 데이터가 동시에 두 곳을 차지할 수 있습니다. 블록 캐시에는 압축 해제된 상태로, 페이지 캐시에는 압축된 상태로 존재합니다. 이 때문에 RocksDB의 실제 메모리 사용량은 늘 과소평가되며, 페이지 캐시를 무시한 캐시 크기 논의는 시스템의 절반만 고려하는 셈입니다.

인덱스와 필터 블록은 특별히 처리됩니다. 크기가 작고 파일에 도달하는 거의 모든 읽기에서 참조되며, 캐시 누락 비용이 막대하므로 메모리에 상주시키는 것이 사실상 필수입니다.

블록 캐시 예산 안에 둘지, table reader의 자체 메모리에 별도로 둘지는 구성 선택 사항입니다. 즉, cache_index_and_filter_blocks으로 결정합니다.

tombstone과 병합 체인은 읽기에 어떤 비용을 유발할까요?

tombstone과 병합 체인은 쓰기 경로에서 생성되어 읽기 경로에 영향을 주는 레코드 유형이지만, compaction을 통해서만 정리할 수 있습니다. tombstone은 compaction이 자신과 가려진 레코드를 제거할 때까지 쌓이며, 병합 체인은 compaction이 확정된 값으로 접을 때까지 늘어납니다.

tombstone을 찾은 포인트 조회의 비용은 낮습니다. “삭제됨”도 유효한 결과이므로 검색을 중단하고 답을 반환합니다. 하지만 iterator는 tombstone에서 멈출 수 없습니다. tombstone을 처리하고, 그것이 가린 버전을 건너뛴 뒤, 다음 활성 키를 찾을 때까지 계속 진행해야 합니다. 삭제가 많은 키 공간은 아무것도 반환하지 않으면서 탐색하고 폐기해야 하는 항목으로 가득한 지뢰밭입니다. 이를 스캔하면 적은 결과를 만들기 위해 많은 임의 작업을 수행할 수 있습니다. 포인트 조회에서는 전혀 드러나지 않지만 범위 스캔은 그대로 영향을 받습니다.

병합 피연산자도 같은 비용을 반대 방향으로 유발합니다. 병합된 키를 읽을 때 최신 레코드에서 멈출 수 없습니다. 피연산자는 값이 아니라 변경에 대한 설명이기 때문입니다. 따라서 전체 값, tombstone 또는 트리의 맨 아래에 도달할 때까지 피연산자를 계속 수집한 다음, 병합 연산자를 통해 체인을 접어야 합니다.

이전 글의 예제에서는 세 개짜리 체인을 접었지만, 설계상 체인 길이가 3으로 제한되지는 않습니다. compaction 사이에 만 번 병합된 카운터를 읽으려면 만 개의 피연산자를 수집하고 접어야 하며, 쓰기 경로는 이를 막지 않습니다.

증폭 트라이앵글

증폭 트라이앵글은 모든 LSM 트리를 지배하는 세 가지 절충 관계입니다. 바로 쓰기 증폭, 읽기 증폭, 공간 증폭입니다. 이 중 두 가지를 개선하면 나머지 하나가 악화됩니다.

Athanassoulis 등은 이러한 절충 관계를 RUM 추측(2016)으로 정식화했습니다. 이에 따르면 접근 방식은 읽기 오버헤드, 업데이트 오버헤드, 메모리 또는 공간 오버헤드 중 최대 두 가지만 최소화할 수 있으며, 나머지 하나가 비용을 흡수합니다. B-tree는 한 지점, 즉 읽기가 저렴하고 업데이트가 비싼 위치에 있습니다. LSM 트리는 다른 지점, 즉 업데이트가 저렴하고 읽기가 비싼 위치에 있습니다. 트라이앵글은 그 사이의 영역을 보여줍니다.

이 글의 모든 내용은 트라이앵글의 한 꼭짓점이 스스로를 방어하는 방식이었습니다.

쓰기 증폭은 애플리케이션이 논리적으로 쓴 바이트와 디스크에 물리적으로 기록된 바이트의 비율입니다. 하나의 Put은 WAL에 한 번, L0로 플러시될 때 한 번, compaction으로 한 레벨 아래로 이동할 때마다 기록됩니다. 레벨이 12개인 leveled LSM에서는 논리적 쓰기 한 번이 수명 주기 동안 흔히 10~30번의 물리적 쓰기가 될 수 있습니다. 이전 글에서는 이 모든 쓰기가 순차적이라는 점을 장점으로 봤지만, 이 글에서는 이를 비용으로 봅니다.

읽기 증폭은 쿼리가 답을 찾기 위해 확인해야 하는 위치의 수를 뜻합니다. 포인트 조회의 최악의 경우는 모든 memtable과 L0 파일, 그리고 더 깊은 각 레벨의 파일 하나를 확인하는 것입니다. 이 글의 앞부분에서 살펴본 검색 과정입니다. 실제 비용은 블룸 필터와 블록 캐시를 통과하고 남은 작업에 따라 달라집니다. 범위 스캔에서는 모든 소스를 한꺼번에 열어 두는 k-way 병합이 이에 해당합니다. 읽기 증폭은 이 글의 핵심 주제입니다. 필터, 캐시, 접두사 추출기는 그 비용을 줄이는 메커니즘입니다.

공간 증폭은 저장된 논리적 데이터와 사용한 디스크 공간의 비율입니다. compaction을 기다리는 오래된 버전, tombstone과 그것이 가린 레코드, 접히지 않은 병합 체인, 활성 스냅샷이 고정한 모든 버전은 애플리케이션이 더 이상 읽을 수 없거나 원하지 않는 데이터에 디스크 공간을 사용합니다. 잘 조정된 leveled compaction은 이 비율을 약 1.1배로 유지하지만, 다른 구성은 훨씬 더 많은 공간을 대가로 지불합니다.

트라이앵글에 따르면 두 꼭짓점을 개선하면 나머지 하나가 악화됩니다. 즉,

  • 적극적으로 compact: 읽기가 빨라지고 공간 사용량이 줄지만, 모든 키를 계속 다시 쓰므로 쓰기 증폭이 증가합니다
  • 느슨하게 compact: 쓰기는 원활하지만 읽기가 확인할 파일이 쌓이고 죽은 데이터가 디스크에 오래 남습니다
  • 필터에 메모리 투자: 캐시와 읽기 성능은 개선되지만, 메모리는 트라이앵글의 모든 꼭짓점이 경쟁하는 조용한 네 번째 자원입니다

“빠름”이라고 표시된 설정은 없습니다. 주어진 워크로드에 맞거나 맞지 않는 트라이앵글상의 위치만 있을 뿐입니다.

읽기 경로 살펴보기

다음 예제에서는 통계를 활성화한 상태로 이전 글의 예제 프로젝트를 재사용합니다.

src/main.rs의 내용을 다음으로 교체하세요.

코드
use rocksdb::{BlockBasedOptions, Options, DB};

fn main() {
	let with_bloom = std::env::args().nth(1).as_deref() == Some("bloom");
	let path = if with_bloom { "/tmp/read-bloom" } else { "/tmp/read-nobloom" };

	let mut opts = Options::default();
	opts.create_if_missing(true);
	opts.enable_statistics();

	if with_bloom {
		let mut bbt = BlockBasedOptions::default();

		bbt.set_bloom_filter(10.0, false);
		opts.set_block_based_table_factory(&bbt);
	}

	let db = DB::open(&opts, path).unwrap();

	// Three L0 SSTs with fully overlapping ranges: file b holds keys where
	// key % 4 == b, all spanning 0..600. Keys where key % 4 == 3 exist nowhere
	for batch in 0..3u64 {
		let mut k = batch;

		while k < 600 {
			let key = format!("key:{:04}", k);
			db.put(key.as_bytes(), b"value").unwrap();
			k += 4;
		}

		db.flush().unwrap();
	}

	// key:0404 lives only in the oldest file, meaning the search must get past
	// the two newer files to reach it
	let hit = db.get(b"key:0404").unwrap();
	println!("get(key:0404) = {:?}", hit.map(|b| String::from_utf8(b).unwrap()));

	// key:0303 is inside every file's range and inside no file
	let miss = db.get(b"key:0303").unwrap();
	println!("get(key:0303) = {:?}", miss);

	if let Some(stats) = db.property_value("rocksdb.options-statistics").unwrap() {
		for line in stats.lines() {
			if line.contains("bloom.filter.useful")
				|| line.contains("bloom.filter.full.positive")
				|| line.contains("block.cache.data") {
				println!("{}", line);
			}
		}
	}
}

위에는 키 범위가 완전히 겹치는 플러시된 L0 파일 세 개가 있습니다. 두 키 모두 어느 파일에든 숨어 있을 수 있으므로 범위 메타데이터만으로 제외할 수 있는 파일은 없습니다.

적중 항목인 key:0404은 가장 오래된 파일에만 있습니다. 최신 파일부터 검색하므로 해당 항목이 없는 파일 두 개를 처리한 뒤에야 키가 있는 파일에 도달합니다.

누락 항목인 key:0303은 모든 파일의 범위 안에 있지만 어느 파일에도 없습니다. 블룸 필터만이 이 읽기 비용을 줄일 수 있습니다.

그대로 실행한 다음 bloom 인수를 사용해 다시 실행하세요.

필터가 없을 때의 출력은 다음과 같습니다.

코드
get(key:0404) = Some("value")
get(key:0303) = None
rocksdb.block.cache.data.miss COUNT : 3
rocksdb.block.cache.data.hit COUNT : 3
rocksdb.bloom.filter.useful COUNT : 0

두 번의 조회에 답하기 위해 데이터 블록에 여섯 번 접근했습니다. 적중 조회는 세 파일에서 각각 데이터 블록을 읽었고, 그중 두 개는 도움이 되지 않아 디스크 읽기 세 번의 비용이 발생했습니다. 누락 조회도 같은 세 파일을 확인했습니다. 이때 데이터 블록 접근 세 번이 캐시 적중이었던 이유는 첫 번째 조회가 방금 캐시를 예열했기 때문입니다. 블록 캐시는 제 역할을 했고, 효과를 낸 것은 그것뿐이었습니다.

필터가 있을 때의 출력은 다음과 같습니다.

코드
get(key:0404) = Some("value")
get(key:0303) = None
rocksdb.block.cache.data.miss COUNT : 1
rocksdb.block.cache.data.hit COUNT : 0
rocksdb.bloom.filter.useful COUNT : 5
rocksdb.bloom.filter.full.positive COUNT : 1

데이터 블록 접근은 총 한 번입니다. useful이 5인 이유를 직접 계산해 확인할 수 있습니다.

  • 적중 조회는 최신 파일의 필터와 두 번째 파일의 필터를 확인했고, 둘 다 없다고 답했습니다. 가장 오래된 파일의 필터는 유일한 full.positive을 반환했으며, 이는 참 양성으로 확인되었습니다. 따라서 두 번 건너뜁니다
  • 누락 조회는 세 필터를 모두 확인해 세 번 모두 없다는 답을 받았습니다. 따라서 세 번 건너뛰고 연 파일은 없습니다

적중과 누락을 더하면 파일 확인은 다섯 번입니다. 필터가 메모리에서 답한 다섯 번의 파일 확인은 필터가 없을 때 수행해야 했던 다섯 번의 데이터 블록 읽기에 해당합니다. 키당 10비트로 블록 접근 여섯 번이 한 번으로 줄었습니다.

이는 작은 규모의 증폭 트라이앵글입니다. 필터가 없는 실행은 읽기 증폭 비용을 전부 치렀고, 필터를 사용한 실행은 메모리로 그 비용을 지불했습니다. 파일당 수백 바이트의 필터를 사용한 것입니다. 어느 쪽이든 쓰기 경로는 비용을 지불하지 않았습니다. RocksDB의 모든 튜닝 결정은 이 절충을 더 큰 규모로 적용한 것입니다.

Solana는 읽기 경로에 어떤 부담을 줄까요?

이전 글에서는 검증인을 쓰기 경로의 스트레스 테스트로 설명했습니다. 샤드가 최대 속도로 끊임없이 들어옵니다. 읽기 측면은 이 절충의 나머지 절반입니다. 누구도 다시 읽을 수 없는 ledger는 데이터 센터를 데우는 비싼 방법일 뿐입니다.

검색 순서, 필터, 키 설계에 관해 이 글에서 설명한 모든 내용은 Solana 데이터가 실제로 쿼리되는 방식에 그대로 나타납니다.

검증인 내부에서 Blockstore 읽기는 두 가지 형태로 이루어집니다. 복구 요청을 처리하거나 블록을 재실행 또는 스트리밍합니다.

복구 요청 처리는 다른 검증인이 놓친 샤드를 요청하는 작업입니다. (slot, index)을 기준으로 포인트 조회를 수행합니다. 블록 분량의 ledger를 재실행하거나 스트리밍하는 작업은 주어진 슬롯에 대한 범위 스캔입니다.

샤드 키는 슬롯 번호로 시작하므로 한 슬롯의 모든 샤드가 디스크에 연속으로 배치됩니다. 따라서 “슬롯 n을 가져와라”는 요청은 흩어진 포인트 읽기가 아니라 짧고 순차적인 iterator 탐색이 됩니다.

쓰기 경로의 키 설계가 곧 읽기 경로 최적화입니다. 슬롯 우선 레이아웃은 플러시 시 거의 겹치지 않는 L0 파일을 만들었고, 블록 읽기도 한 번의 연속 탐색으로 바꿨습니다.

RPC 계층은 더 어려운 질문을 합니다. getTransaction은 서명을 기준으로 트랜잭션 상태 데이터를 포인트 조회합니다. 바로 블룸 필터가 필요한 사례입니다. 특정 SST에 없는 서명은 인덱스를 읽는 대신 한 번의 메모리 확인으로 제외해야 합니다.

getSignaturesForAddress은 앞서 살펴본 접두사 형태의 스캔입니다. 주소-서명 인덱스는 주소로 시작하므로 한 계정의 전체 기록이 하나의 연속된 키 범위를 차지합니다. 따라서 한 주소의 모든 활동은 이 구조가 효율적으로 처리하는 범위 스캔 형태가 됩니다.

하지만 애플리케이션이 실제로 묻는 질문은 두 방식 위에 걸쳐 있습니다. “이 주소의 모든 트랜잭션을 가져와라”라는 기본 연산은 없습니다. 이런 질문은 서명을 찾는 접두사 스캔을 수행한 후, 각 서명에 대해 트랜잭션을 포인트 조회하는 방식으로 처리됩니다.

결국 하나의 범위 쿼리가 수백 또는 수천 개의 Get으로 확장됩니다. 이것이 바로 MultiGet의 일괄 처리가 존재하는 이유입니다. 서명을 정렬하고, 접근하는 파일별로 그룹화한 다음, 각 파일의 인덱스와 필터를 한 번만 확인합니다.

외부에서는 하나의 API 호출처럼 보이는 쿼리지만, 읽기 경로에서는 이 글에서 설명한 모든 메커니즘이 순차적으로 작동합니다. iterator, 접두사 정렬, 블룸 필터, 배치 비용 분산, 블록 캐시가 모두 사용됩니다.

저희는 이 API 호출을 구축했습니다.

과거에는 개발자가 클라이언트 측에서 getSignaturesForAddress 호출과 n번의 getTransaction 호출을 반복해야 했으며, n+1 패턴에 늘 필요한 재시도 로직도 구현해야 했습니다.

getTransactionsForAddress은 이 반복을 하나의 메서드로 통합하며, 이 글에서 다룬 읽기 경로 메커니즘에 맞는 다양한 기능을 제공합니다.

오름차순과 내림차순 정렬은 iterator의 두 진행 방향입니다. 커서 기반 페이지네이션은 slot:position 형태의 토큰을 반환합니다. 이는 키 위치이므로 페이지네이션된 쿼리를 재개한다는 것은 이전 스캔이 멈춘 위치를 다시 탐색하는 것입니다. 슬롯 및 시간 필터는 작업을 완료한 뒤 결과를 버리는 대신 작업 범위 자체를 제한합니다.

이 메서드를 제공하는 아카이브 시스템은 수백 테라바이트에 달하는 Solana의 전체 트랜잭션 기록을 RocksDB에 보관합니다. 저희는 이 시스템을 ClickHouse에서 RocksDB로 마이그레이션했습니다. 이 시리즈가 존재하는 이유도 바로 이것입니다. 이 글들에서 설명한 스토리지 엔진을 column family별로 조정해 계정의 전체 수명 기록을 연속 스캔으로 만들고, 서명 조회를 한 번의 메모리 확인으로 처리했습니다. 그래서 저희는 Solana 전체에서 가장 뛰어난 성능의 아카이브 시스템을 제공합니다.

결론

읽기 경로는 LSM 트리가 미뤄 둔 비용을 상환하는 일정이며, 이 글은 그 비용을 항목별 청구서처럼 보여줍니다. 조회는 memtable, L0, 정렬된 모든 레벨을 거쳐 최신 항목부터 오래된 항목 순으로 진행되며, 첫 번째 버전을 찾는 즉시 중단합니다. 다른 모든 기능과 도구는 이 검색에 필요한 접근 횟수를 최소화하기 위해 존재합니다. 블룸 필터는 키가 파일에 확실히 없는지 메모리에서 답해 파일을 열지 않도록 합니다. 블록 캐시는 반드시 열어야 하는 파일을 저렴하게 다시 열도록 합니다. 접두사 설계는 비용을 감당할 수 있는 유일한 범위 스캔 형태를 애플리케이션이 실제로 필요한 형태로 만듭니다.

증폭 트라이앵글은 이 모든 비용을 기록하는 원장입니다. 읽기 비용은 메모리로 줄이고, 쓰기 비용은 앞서 순차적인 할부로 지불합니다. 공간 비용은 검색이 건너뛰는 모든 tombstone과 가려진 버전에서 조용히 누적됩니다.

이 글의 모든 구조는 누적된 파일, 죽은 버전, 접히지 않은 병합 체인의 비용을 관리할 뿐입니다. 여기서 다룬 어떤 것도 그 비용을 실제로 제거하지는 않습니다. 이를 제거하는 작업은 백그라운드에서 계속 실행되며, 얼마나 적극적으로 실행할지 결정하는 것은 RocksDB 튜닝에서 가장 중요한 선택입니다. 이 시리즈의 다음 글에서는 compaction을 다룹니다. compaction의 역할, RocksDB가 제공하는 세 가지 방식, Solana의 Blockstore가 방식을 바꿨다가 다시 되돌린 것으로 유명한 이유를 살펴봅니다.

오후 시간을 들여 데이터베이스 읽기 경로 예제를 읽는 일이 즐겁다면 저희와 함께 만들어 보세요. 여기서 설명한 읽기 경로는 저희가 Solana의 전체 기록을 대상으로 운영하는 경로입니다. 블룸 필터를 확인하는 것과 파일을 여는 것의 차이는 실제 하드웨어와 실제 비용으로 측정됩니다. 엔지니어링 팀의 다양한 포지션에서 채용 중입니다. helius.dev/careers에서 모든 채용 공고를 확인하세요.

Helius 구독하기

최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요

확대 이미지