[20250423] LSM Tree
Table of Contents
1 LSM Tree란?
- 디스크 기반 또는 플래시 기반 스토리지 시스템에서 키-값 쌍을 효율적으로 저장하고 검색하기 위해 설계된 데이터 구조 [1]
- 메모리 내 구조와 디스크 기반 구조를 결합하여 읽기 및 쓰기 작업 모두를 최적화하는 데 특히 효과적.
- 높은 삽입 및 삭제율을 보이는 파일에 대한 저비용 인덱싱을 제공하도록 설계된 디스크 기반 데이터 구조
- 전통적인 인플레이스 업데이트와 달리 LSM 트리는 모든 쓰기 작업을 삽입으로 취급하는 아웃오브플레이스 업데이트를 수행
2 기본 아이디어
- LSM 트리의 기본 아이디어는 쓰기 성능을 우선시하여 데이터를 메모리에 먼저 기록한 다음 비동기적으로 순차적인 방식으로 디스크에 병합하는 것
- B-트리가 인플레이스 업데이트를 수행하고 쓰기 작업량이 많은 시나리오에서 임의 I/O 병목 현상으로 어려움을 겪을 수 있는 방식과 대조 [3]
- LSM 트리는 SSD 및 NVDIMM과 같은 최신 스토리지 장치의 특성을 더 잘 활용하여 빠른 순차적 I/O 및 바이트 수준 주소 지정을 활용 [4]
위 설명만 봐도 대충 쓰기 I/O 성능이 좋아지겠지만 문제는 읽기 성능이 좋지 않는 것은 확실하다. 즉, LSM트리는 쓰기 증폭이(디스크에 쓰는 데이터양)이 낮지만 읽기 증폭(쿼리당 디스크 읽는 데이터양)이 높을 것이며 이는 B-Tree와 반대의 경향을 가진다.[5] 그래서 뭐 어떤 애플리케이션이냐에 따라 선택해야할 듯
3 LSM Tree의 구성 요소
LSM-트리 여러 구성요소를 가진다. Membtable, SSTable(Sorted String Table), Levels, WAL(Write-Ahead Logging), Bloom 필터 까지가 기본 구성요소로 본다. 쓰기는 간단하게 append 하는 방식이지만 그렇게 쓰면 읽기가 힘들테니 여러가지 방식으로 읽기를 최적화하려는 것 같다. 이렇게 B-Tree와 다르게 LSM Tree 는 하나의 자료구조가 아니라 아키텍처에 가까운 것 같다.
3.1 Membtable
- Memtable은 LSM트리에서 메모리 내 데이터 구조로, 최근 쓰기 작업(삽입, 업데이트 삭제)를 저장함.
- 보통 SkipList나 균형 이진 탐색 트리로 구현되어 빠른 삽입, 조회를 지원.
- 쓰기 작업이 Memtable에 먼저 저장하고 특정 조건(일반적으로 4MB)에 도달하면 디스크로 플러시.
- 읽기 작업 시 가장 최근 데이터를 제공하며, Bloom 필터로 성능을 최적화할 수 있다. (근데 Bloom 필터는 SSTable 에서 사용되는 것이 합당하다. Memtable 은 빠르잖아. IO 병목이 있는 곳에 Bloom 필터를 써야지.)
대충 위 내용만 봐도 아~ 대충 쓰기 전에 모이는 인덱스 같은 거구만? 이라는 느낌이다. (근데 역시 WAL 가 있으니까 거기에 먼저 쓰고 Memtable에 쓸 것이다. 어딘가에선 WAL 대신 Raft 로그를 쓴다는데 나중에 알아보자) 궁금한 거은 memtable 구현에 기본이 skip list 라는데 이 구조도 궁금하다. LevelDB, RocksDB 등에서는 memtable 구현에 skip list를 사용한다. (둘다 아주 유명한 DB이다.)
성능
- 삽입 : O(log n)
- 조회 : O(log n) 추가로 Bloom 필터를 사용하면 불필요한 검색을 줄여 읽기 속도 향상.
3.2 Level DB 의 Memtable 구현
Level DB가 아주 유명하니까 이거를 참고해보자.
class MemTable { private: int refs_; // 참조 카운터 Arena arena_; // 메모리 할당 풀 SkipList<Key, Value> table_; // Skip List };
- ref_ : 가비지 컬렉션의 참조 카운터와 비슷한 역할인듯 0이 되면 안전하게 삭제된다.
- arena_ : 잘 모르겠지만 아레나 라는 경기장같은 큰 메모리 공간을 만들어서, 여러 행사나 활동을 수용하게 해주는 듯 SkipList 를 만들 때, 여기서 미리 만든 할당받은 메모리를 사용한다.
- table_ : 키-값 쌍 핵심 자료 구조.
쓰기 작업
Skip List의 삽입은 O(log N) 시간 복잡도를 가지며, 메모리 내 작업이므로 매우 빠름.
- WAL(Write-Ahead Log) 기록:
- 데이터가 Memtable에 삽입되기 전에 먼저 WAL에 기록. 이는 시스템 장애 시 데이터 복구를 보장
- Skip List에 삽입:
- WAL 기록 후, 키-값 쌍이 Memtable의 Skip List에 삽입. Skip List는 항상 정렬된 상태를 유지하므로, 삽입 시 적절한 위치에 데이터를 추가.
- 크기 제한 확인:
- Memtable의 크기가 설정된 임계값(기본적으로 4MB)에 도달하면 불변 Memtable로 전환.
읽기 작업
탐색 역시 O(log N) 시간 복잡도를 가지며, 메모리에서 수행되므로 효율적임.
- Skip List 에서 키 검색
- 키가 존재하면 값을 반환하거나, 없으면 디스크에서 SSTable 에서 추가 탐색.
불변 Memtable으로의 전환
아까도 말했듯이, 크기가 커지면 flush 하기 위해 불변 Memtable으로 전환. (스냅샷 같은 느낌)
- 불변 상태로 전환
- 새 Memtable 생성
- 백그라운드 플러시 (당연히 비동기)
디스크로 플러시
- SSTable 파일 생성 (플러시 하는 단위로 파일이 하나 생성되는 느낌?)
- SSTable 구성 요소
- 데이터 블록 : 키-값 쌍을 저장.
- 인덱스 블록: 데이터 블록 내 키 위치를 가리킴.
- Bloom 필터: 키 존재 여부를 빠르게 확인.
- 푸터: 파일 메타데이터 포함.
- SSTable 구성 요소
- Level 0 저장:
- 생성된 SSTable은 Level 0에 저장되며, 이후 압축 과정을 통해 상위 레벨로 이동.
3.3 Skip List
Skip List 는 특이하게 확률적 자료 구조라고 한다. 균형트리랑 유사한 성능을 가지지만 구현이 더 간단하다고 함.(퀵 소트 같은건가?)
위키에 따르면 1990년 William Pugh 가 처음 발표한 자료구조라고 한다. 정렬된 리스트에서 O(log n) 평균 시간 복잡도를 제공하며, 균형 이진 탐색 트리(BST)와 유사한 성능을 보인다. 최약의 경우 O(n) 이지만 대체로 평균 시간은 O(log n) 이다.
3.3.1 구조와 구성 요소
Skip List 는 여러 층의 연결 리스트로 구성된다.
구성 요소 | 설명 |
--- | --- |
0층(기본 리스트) | 모든 데이터가 포함되는 기본 리스트 |
1층 | 0층의 데이터를 일정 확률로 샘플링하여 포함 |
2층 | 1층의 데이터를 일정 확률로 샘플링하여 포함 |
상위층 | 각 층은 하위 층의 일부 요소를 포함하며, 건너뛰기 링크를 통해 빠른 탐색 가능. |
노드 | 각 노드는 키, 값, 그리고 여러 레벨의 포인터를 포함. 높이는 무작위로 결정됨. |
구조적으로, Skip List 는 아래처럼 동작함
- 가장 아래층(0층)에 모든 요소를 포함하는 정렬된 연결 리스트가 있다.
- 위층으로 갈 수록 요소 수가 줄어들며, 각 층은 하위 층의 요소를 건너뛰는 링크를 가진.
- 예를 들어 1부터 9까지 숫자가 있을 때, 0층은 1->2->3->4->5->6->7->8->9 이고, 1층은 2->4->6->8, 2층은 4->8 과 같이 구성될 수 있음. (이 기준은 https://www.geeksforgeeks.org/skip-list/ 에서 참고가능. 뭔가 복잡함 자세히 봐야함.)
- 특이하게 각 노드의 높이는 랜덤하게 결정됨.
3.3.2 Skip List 조회
스킵리스트는 연결 리스트에 층을 추가하여, 일부 데이터를 건너뒤며 탐색할 수 있게 만든 것이다.
- 0층: 모든 데이터가 다 있는 기본 층 (예: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9).
- 1층: 몇 개를 건너뛰고 중요한 데이터만 (예: 2 → 4 → 6 → 8).
- 2층: 1층에서 더 많이 건너뛰어서 더 적은 데이터 (예: 4 → 8).
이게 가장 위층에서부터 아래로 내려오면서 목표를 찾는 과정이다.
숫자 7일 찾고 싶다면:
- 2층에서 시작:
- 4는 7보다 작으므로 다음 노드로 이동
- 8은 7보다 크니까 더 갈 필요 없음
- 그러니 4에서 멈추고 1층에서 이어서 찾아본다
- 1층
- 4에서 시작해서 다음은 6이다. 6은 7보다 작으니 다음 노드로
- 또 8이다. 8은 7보다 크니까 멈춤
- 6에서 0층으로
- 0층
- 6 다음은 7. 검색 끝.
그런데 어떻게 2층에서 1층의 4부터 시작할 수 있을까? 이것이 SKIP LIST 의 특이한 점인데, 아래층으로 향하는 직통 포인터가 있는 것이다. 이렇게 층별로 점프를 해서 대충 위치를 잡고 층을 내려오면서 자세한 위치를 찾는 것이다.
3.3.3 Skip List 삽입
- 삽입 위치 찾기 : 먼저 조회 연산과 유사하게 목표 키의 삽입 위치를 찾아야 함. 이렇게 상위에서 찾아 내려갈 때, 각 층마다 위치를 기록해놓는다. 나중에 업데이트할 목록을 유지해놓는 것
- 무작위 레벨 선택 : 새 노드의 레벨을 무작위로 결정. 일반 적으로 기하 분포를 따른다. P=0.5 확율로 상위 레벨에 포함된다. 그리고 전체 최대 레벨(MaxLevel)를 초과하지 않음
- 최대 레벨 조정 : 현재 최대 레벨보다 커지면 최대 레벨을 증가시키고, 헤더 노드 포인터를 조정
- 노드 삽입 : 선택된 레벨까지 각 층에서 업데이트 노드 포인터를 조정하여 새 노드를 삽입.
3.3.4 Skip List 수정
- 키 검색: 먼저 조회 연산을 통해 목표 키를 찾는다.
- 값 변경: 키가 존재하면 해당 노드의 값을 변경한다.
- 키 없음 처리: 키가 없으면 알아서 처리하셈
3.3.5 Skip List 삭제
- 키 검색: 조회 연산과 유사하게 키를 찾으며, 각 레벨에서 업데이트 노드 목록을 유지.(필요하면 제거해야 하니까)
- 노드 제거: 가 존재하면, 각 레벨에서 업데이트 노드의 포인터를 조정하여 삭제할 노드를 건너뛰도록 설정합니다. 업데이트 노드의 포인터가 삭제 노드의 다음 노드를 가리키도록 한다.
- 최대 레벨 조정: 삭제 후, 현재 최대 레벨에 노드가 남아 있지 않으면 최대 레벨을 감소시킨다.
import random class Node: def __init__(self, key, value, level): self.key = key # 노드의 고유 식별자 (정렬 기준) self.value = value # 노드의 값 self.forward = [None] * (level + 1) # 각 레벨의 다음 노드 포인터 배열 (노드가 3층까지 있다면 0부터 3까지 포인터가 있음) class SkipList: def __init__(self, max_level=16, p=0.5): self.max_level = max_level # 최대 레벨, 기본값 16 (2^16 요소 예상) self.p = p # 상위 레벨로 올라갈 확률 self.level = 0 # 현재 최고 레벨 self.header = Node(None, None, max_level) # 모든 층의 시작점인 더미 노드, 단 하나만 존재. def random_level(self): """새 노드의 레벨을 무작위로 결정""" level = 0 # p=0.5 확율로 상위 레벨로 올라가기. while random.random() < self.p and level < self.max_level: level += 1 return level def _find_predecessors(self, key): """각 레벨에서 목표 키 직전의 노드를 찾아 업데이트 배열을 반환.""" # 업데이트 노드를 기록하기 위함? update = [None] * (self.max_level + 1) # 헤더 노드부터 시작 current = self.header # 현재 레벨부터 0층까지 내려오면서 업데이트 노드를 기록 # forward[i] 는 레벨 i 에서 # 첫 current.forward[i] 는 레벨 [i] 의 처음 노드를 가리킴. # 즉, 처음시작하면 level 3이라고 하면 current.forward[3] 은 레벨 3의 처음 노드를 가리킴. # 그리고 내부 루프가 끝나면 level 2로 가는거임 # 직통 포인터는 뭘까? 만약에 내가 level 3에서 찾은 데이터가 있다면 그 녀석이 다음 current 가 된다 # 여기서 forward만 2로 바꾸면 level 2에 존재하는 노드로 연결됨. 없을 수 있나? 아니 그럼 SkipList 의미가 없음 # 각 상위층은 바로 밑 하위층에 무조건 있어야함. 그러므로 직통포인터가 있는셈이나 다름없음. 직접적으로 포인터가 연결되어 있지는 않지만! for i in range(self.level, -1, -1): # self.level -1, -1 는 self.level 부터 0까지 -1로 줄어든다. # 현재 노드가 목표 키보다 작으면 계속 이동 while current.forward[i] and current.forward[i].key < key: current = current.forward[i] update[i] = current # 업데이트 노드를 기록 return update def search(self, key): """키를 찾아 값을 반환. 없으면 None 반환""" # 키를 찾기 위한 업데이트 노드를 찾음 update = self._find_predecessors(key) current = update[0].forward[0] # 0층에서 키를 찾으면 리턴 if current is not None and current.key == key: return current.value return None def insert(self, key, value): """키-값 쌍을 삽입하거나 기존 키의 값을 업데이트""" update = self._find_predecessors(key) current = update[0].forward[0] if current is not None and current.key == key: current.value = value # 키가 이미 있으면 값 업데이트 else: new_level = self.random_level() if new_level > self.level: for i in range(self.level + 1, new_level + 1): update[i] = self.header self.level = new_level new_node = Node(key, value, new_level) for i in range(new_level + 1): new_node.forward[i] = update[i].forward[i] update[i].forward[i] = new_node def delete(self, key): """주어진 키를 삭제""" update = self._find_predecessors(key) current = update[0].forward[0] if current is not None and current.key == key: for i in range(self.level + 1): if update[i].forward[i] != current: break update[i].forward[i] = current.forward[i] while self.level > 0 and self.header.forward[self.level] is None: self.level -= 1 def __str__(self): """스킵 리스트의 구조를 문자열로 반환""" result = [] for lvl in range(self.level, -1, -1): result.append(f"Level {lvl}:") current = self.header.forward[lvl] while current is not None: result.append(f" {current.key}") current = current.forward[lvl] result.append("\n") return "".join(result)
여기서 중요한 것은 _findpredecessors 이다. 이 메서드는 주어진 key에 대해 각 레벨에서 그 키가 들어갈 위치 직전의 노드를 찾아 리스트로 반환한다. ( update 리스트)
여기서 update[i] 는 레벨 i 에서 key 보다 작은 가장 큰 키를 가진 노드를 가리킨다.
current = update[0].forward[0] 코드가 핵심이다.
- update[0] : 레벨 0 에서 key 보다 작은 가장 큰 키를 가진 노드를 가리킨다.
- forward[0] : 그 노드의 레벨 0에서 다음 노드를 가리킵니다. 즉, 찾고자 하는 key 가 들어갈 위치가 여기 있다.
- https://docs.yugabyte.com/preview/architecture/docdb/lsm-sst/
- https://vivekbansal.substack.com/p/what-is-lsm-tree
- level DB : https://github.com/google/leveldb/blob/main/doc/impl.md
- level DB는 Memtable을 두개로 관리 : https://shangzeyuan.com/blog/system_overview_leveldb/
- rocks DB : https://github.com/facebook/rocksdb/wiki/MemTable
- skip list : https://www.geeksforgeeks.org/skip-list/
- skip list wiki : https://en.wikipedia.org/wiki/Skip_list
- skip list 논문(1990) : https://15721.courses.cs.cmu.edu/spring2018/papers/08-oltpindexes1/pugh-skiplists-cacm1990.pdf
4 레퍼런스
- [1] https://www.scylladb.com/glossary/log-structured-merge-tree/#:~:text=A%20log%2Dstructured%20merge%2Dtree,memory%20and%20disk%2Dbased%20structures.
- [2] https://www.scylladb.com/glossary/log-structured-merge-tree/
- [3] https://www.cs.umb.edu/~poneil/lsmtree.pdf
- [4] https://dev.to/creativcoder/what-is-a-lsm-tree-3d75
- [5] http://smalldatum.blogspot.com/2016/01/summary-of-advantages-of-lsm-vs-b-tree.html