Rust Database Kernel: Build LSM-Tree Storage Engine from Scratch with 6 Core Modules
Four Database Kernel Pain Points — How LSM-Tree Breaks Through
B+Tree write amplification is severe — every random write flushes an entire page; LSM-Tree concepts are complex — MemTable, SSTable, Compaction layers are deeply nested and confusing; Compaction strategy selection is hard — Leveled, Tiered, FIFO each have tradeoffs; WAL implementation is difficult — sequential writes guarantee durability but crash recovery logic is intricate. In 2026, the Rust + LSM-Tree + tokio stack delivers the best practice for database storage engines: lock-free concurrent skip-list MemTable, ordered SSTable flush, WAL crash recovery, tiered Compaction space reclamation — 10x write performance over B+Tree with controllable read amplification.
This article walks you through 6 core modules, covering the complete journey from MemTable → SSTable → WAL → Compaction → Bloom Filter → Block Cache.
Key Takeaways
- Master concurrent MemTable read/write using crossbeam skip list
- Understand SSTable disk format design and Block index mechanisms
- Implement WAL write-ahead log for crash recovery consistency
- Apply Leveled Compaction strategy to reclaim space and reduce read amplification
- Build Bloom Filter and Block Cache to accelerate point query paths
Table of Contents
- Core Concepts at a Glance
- Problem Analysis: 5 Key Challenges
- Module 1: MemTable In-Memory Table
- Module 2: SSTable Disk Persistence
- Module 3: WAL Write-Ahead Log
- Module 4: Compaction Merge Strategy
- Module 5: Bloom Filter
- Module 6: Block Cache
- Pitfall Guide: 5 Common Traps
- Error Troubleshooting: 10 Common Errors
- Advanced Optimization Tips
- Comparative Analysis
- Summary & Outlook
- Recommended Online Tools
Core Concepts at a Glance
| Concept | Description |
|---|---|
| LSM-Tree | Log-Structured Merge-Tree, append-only ordered structure with far superior write performance over B+Tree |
| MemTable | In-memory ordered key-value table, typically implemented with skip list; freezes and flushes when full |
| SSTable | Sorted String Table, immutable ordered file on disk, organized by Blocks |
| Compaction | Merges multiple SSTables to eliminate duplicates and tombstones, reducing read amplification |
| WAL | Write-Ahead Log, ensures no data loss after crash |
| Bloom Filter | Probabilistic data structure for quickly determining if a Key may exist in an SSTable |
| Block Cache | Caches SSTable Data Blocks to reduce disk IO |
| Sorted Run | A group of non-overlapping ordered SSTables after Compaction |
| Tombstone | Deletion marker, only truly removed during Compaction |
Architecture Overview
┌──────────────────────────────────────────────────────┐
│ LSM-Tree Storage Engine │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────┐ │
│ │ Write │ │ Read │ │ Compaction │ │
│ │ Path │ │ Path │ │ Scheduler │ │
│ └─────┬──────┘ └─────┬──────┘ └────────┬─────────┘ │
│ │ │ │ │
│ ┌─────▼──────────────▼─────────────────▼─────────┐ │
│ │ Active MemTable (SkipList) │ WAL (Append) │ │
│ └──────────────┬──────────────────────────────────┘ │
│ │ freeze & flush │
│ ┌──────────────▼──────────────────────────────────┐ │
│ │ L0: [SST-0] [SST-1] [SST-2] (overlap ok) │ │
│ │ L1: [SST-3] [SST-4] (Sorted Run) │ │
│ │ L2: [SST-5] [SST-6] [SST-7] (Sorted Run) │ │
│ │ L3: [SST-8] ... [SST-15] (Sorted Run) │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │Bloom Filter │ │ Block Cache │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────┘
Problem Analysis: 5 Key Challenges
| Challenge | Pain Point | LSM Solution |
|---|---|---|
| Write & Read Amplification | B+Tree random write amplification 10-30x, LSM read amplification requires multi-level lookup | MemTable batch flush + Compaction reduces write amplification; Bloom Filter + Cache reduces read amplification |
| Compaction Strategy Selection | Leveled has high write amplification, Tiered has high space amplification, FIFO unsuitable for persistence | Hybrid strategy: Tiered for L0 fast merge, Leveled for L1+ space control |
| Concurrency Control | MemTable read-write contention, Compaction conflicts with reads | crossbeam lock-free skip list + read-write separation (Active/Immutable MemTable) |
| Crash Recovery | Unflushed data loss, partial WAL writes | WAL atomic writes + checksum verification + replay on restart |
| Memory Management | Uncontrolled MemTable growth, Block Cache eviction policy | Size threshold triggers freeze + LRU Cache limits memory ceiling |
Module 1: MemTable In-Memory Table
MemTable is the first stop for LSM-Tree writes — all write operations enter MemTable first, and when full, it freezes into an Immutable MemTable awaiting flush. crossbeam-skiplist provides a lock-free concurrent skip list, the most suitable data structure for MemTable in the Rust ecosystem.
Skip-List Based MemTable
use crossbeam_skiplist::SkipMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
pub struct MemTable {
table: Arc<SkipMap<Vec<u8>, Vec<u8>>>,
approximate_size: AtomicUsize,
capacity: usize,
}
impl MemTable {
pub fn new(capacity: usize) -> Self {
Self {
table: Arc::new(SkipMap::new()),
approximate_size: AtomicUsize::new(0),
capacity,
}
}
pub fn put(&self, key: Vec<u8>, value: Vec<u8>) {
let entry_size = key.len() + value.len();
self.table.insert(key, value);
self.approximate_size.fetch_add(entry_size, Ordering::Relaxed);
}
pub fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
self.table.get(key).map(|entry| entry.value().clone())
}
pub fn delete(&self, key: Vec<u8>) {
self.table.insert(key, Vec::new());
}
pub fn is_full(&self) -> bool {
self.approximate_size.load(Ordering::Relaxed) >= self.capacity
}
pub fn iter(&self) -> impl Iterator<Item = (Vec<u8>, Vec<u8>)> + '_ {
self.table.iter().map(|entry| {
(entry.key().clone(), entry.value().clone())
})
}
pub fn len(&self) -> usize {
self.table.len()
}
}
MemTable Freeze & Switch
use tokio::sync::RwLock;
pub struct MemTableManager {
active: Arc<RwLock<MemTable>>,
immutable: Arc<RwLock<Option<MemTable>>>,
capacity: usize,
}
impl MemTableManager {
pub fn new(capacity: usize) -> Self {
Self {
active: Arc::new(RwLock::new(MemTable::new(capacity))),
immutable: Arc::new(RwLock::new(None)),
capacity,
}
}
pub async fn put(&self, key: Vec<u8>, value: Vec<u8>) -> Result<(), String> {
let active = self.active.read().await;
if active.is_full() {
drop(active);
self.freeze_and_switch().await?;
let active = self.active.read().await;
active.put(key, value);
} else {
active.put(key, value);
}
Ok(())
}
pub async fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
if let Some(val) = self.active.read().await.get(key) {
return Some(val);
}
self.immutable.read().await.as_ref().and_then(|t| t.get(key))
}
async fn freeze_and_switch(&self) -> Result<(), String> {
let mut active = self.active.write().await;
let mut immutable = self.immutable.write().await;
if immutable.is_some() {
return Err("Immutable MemTable still flushing".into());
}
let frozen = std::mem::replace(&mut *active, MemTable::new(self.capacity));
*immutable = Some(frozen);
Ok(())
}
pub async fn take_immutable(&self) -> Option<MemTable> {
self.immutable.write().await.take()
}
}
Module 2: SSTable Disk Persistence
SSTable is the disk storage unit of LSM-Tree — immutable, ordered, organized by Blocks. Each SSTable consists of Data Blocks, Meta Block, Index Block, and Footer. The Index Block records each Data Block's starting Key and offset.
SSTable Format Design
┌─────────────────────────────────────┐
│ Data Block 0 [key1, val1, ...] │
│ Data Block 1 [keyN, valN, ...] │
│ ... │
│ Meta Block (Bloom Filter) │
│ Index Block [block0_offset, ...] │
│ Footer [meta_offset, index_offset] │
└─────────────────────────────────────┘
SSTable Builder
use std::io::{BufWriter, Write};
use std::fs::File;
use crc32fast::Hasher;
const BLOCK_SIZE: usize = 4 * 1024;
pub struct SsTableBuilder {
data_blocks: Vec<Vec<u8>>,
current_block: Vec<u8>,
index_entries: Vec<IndexEntry>,
first_key_in_block: Option<Vec<u8>>,
block_count: usize,
}
#[derive(Clone)]
pub struct IndexEntry {
pub first_key: Vec<u8>,
pub offset: u64,
pub length: u32,
}
impl SsTableBuilder {
pub fn new() -> Self {
Self {
data_blocks: vec![],
current_block: vec![],
index_entries: vec![],
first_key_in_block: None,
block_count: 0,
}
}
pub fn add(&mut self, key: &[u8], value: &[u8]) {
if self.first_key_in_block.is_none() {
self.first_key_in_block = Some(key.to_vec());
}
let entry_len = 4 + key.len() + 4 + value.len();
if self.current_block.len() + entry_len > BLOCK_SIZE && !self.current_block.is_empty() {
self.finish_block();
}
if self.first_key_in_block.is_none() {
self.first_key_in_block = Some(key.to_vec());
}
self.current_block.extend_from_slice(&(key.len() as u32).to_le_bytes());
self.current_block.extend_from_slice(key);
self.current_block.extend_from_slice(&(value.len() as u32).to_le_bytes());
self.current_block.extend_from_slice(value);
}
fn finish_block(&mut self) {
if self.current_block.is_empty() {
return;
}
let offset = self.data_blocks.len() as u64 * BLOCK_SIZE as u64;
let length = self.current_block.len() as u32;
self.index_entries.push(IndexEntry {
first_key: self.first_key_in_block.take().unwrap_or_default(),
offset,
length,
});
let mut block = std::mem::take(&mut self.current_block);
block.resize(BLOCK_SIZE, 0);
self.data_blocks.push(block);
self.block_count += 1;
}
pub fn build(mut self, path: &std::path::Path) -> std::io::Result<()> {
self.finish_block();
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
for block in &self.data_blocks {
writer.write_all(block)?;
}
let index_offset = self.data_blocks.len() as u64 * BLOCK_SIZE as u64;
for entry in &self.index_entries {
writer.write_all(&(entry.first_key.len() as u32).to_le_bytes())?;
writer.write_all(&entry.first_key)?;
writer.write_all(&entry.offset.to_le_bytes())?;
writer.write_all(&entry.length.to_le_bytes())?;
}
writer.write_all(&index_offset.to_le_bytes())?;
writer.flush()?;
Ok(())
}
}
Module 3: WAL Write-Ahead Log
WAL (Write-Ahead Log) is the crash recovery guarantee for LSM-Tree — every write operation is first appended to WAL, then written to MemTable. After a crash, replaying WAL recovers unflushed data.
WAL Implementation
use std::io::{BufWriter, Write, BufReader, Read};
use std::fs::{File, OpenOptions};
use std::path::Path;
use crc32fast::Hasher;
const WAL_RECORD_HEADER_SIZE: usize = 4 + 4 + 4;
pub enum WalRecord {
Put { key: Vec<u8>, value: Vec<u8> },
Delete { key: Vec<u8> },
}
pub struct WalWriter {
writer: BufWriter<File>,
}
impl WalWriter {
pub fn create(path: &Path) -> std::io::Result<Self> {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self { writer: BufWriter::new(file) })
}
pub fn append(&mut self, record: &WalRecord) -> std::io::Result<()> {
let mut payload = Vec::new();
match record {
WalRecord::Put { key, value } => {
payload.push(0u8);
payload.extend_from_slice(&(key.len() as u32).to_le_bytes());
payload.extend_from_slice(key);
payload.extend_from_slice(&(value.len() as u32).to_le_bytes());
payload.extend_from_slice(value);
}
WalRecord::Delete { key } => {
payload.push(1u8);
payload.extend_from_slice(&(key.len() as u32).to_le_bytes());
payload.extend_from_slice(key);
}
}
let mut hasher = Hasher::new();
hasher.update(&payload);
let checksum = hasher.finalize();
self.writer.write_all(&checksum.to_le_bytes())?;
self.writer.write_all(&(payload.len() as u32).to_le_bytes())?;
self.writer.write_all(&payload)?;
self.writer.flush()?;
Ok(())
}
}
pub struct WalReplayer;
impl WalReplayer {
pub fn replay(path: &Path) -> std::io::Result<Vec<WalRecord>> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut records = Vec::new();
loop {
let mut header = [0u8; WAL_RECORD_HEADER_SIZE];
match reader.read_exact(&mut header) {
Ok(()) => {}
Err(_) => break,
}
let checksum = u32::from_le_bytes(header[0..4].try_into().unwrap());
let len = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize;
let mut payload = vec![0u8; len];
reader.read_exact(&mut payload)?;
let mut hasher = Hasher::new();
hasher.update(&payload);
if hasher.finalize() != checksum {
tracing::warn!("WAL checksum mismatch, stopping replay");
break;
}
let record = match payload[0] {
0 => {
let key_len = u32::from_le_bytes(payload[1..5].try_into().unwrap()) as usize;
let key = payload[5..5 + key_len].to_vec();
let val_len = u32::from_le_bytes(
payload[5 + key_len..9 + key_len].try_into().unwrap()
) as usize;
let value = payload[9 + key_len..9 + key_len + val_len].to_vec();
WalRecord::Put { key, value }
}
1 => {
let key_len = u32::from_le_bytes(payload[1..5].try_into().unwrap()) as usize;
let key = payload[5..5 + key_len].to_vec();
WalRecord::Delete { key }
}
_ => break,
};
records.push(record);
}
Ok(records)
}
}
Module 4: Compaction Merge Strategy
Compaction is the core of LSM-Tree space reclamation — merging multiple SSTables eliminates duplicate Keys and Tombstones, reducing read amplification. Leveled Compaction ensures only one Sorted Run per level, keeping space amplification controllable but write amplification higher.
Leveled Compaction Implementation
use std::collections::BTreeMap;
use std::path::PathBuf;
pub struct CompactionScheduler {
base_dir: PathBuf,
level_size_multiplier: usize,
max_level: usize,
}
impl CompactionScheduler {
pub fn new(base_dir: PathBuf) -> Self {
Self {
base_dir,
level_size_multiplier: 10,
max_level: 6,
}
}
pub fn should_compact(&self, level: usize, sst_count: usize) -> bool {
let max_count = self.level_capacity(level);
sst_count > max_count
}
fn level_capacity(&self, level: usize) -> usize {
if level == 0 { 4 } else { self.level_size_multiplier.pow(level as u32) }
}
pub fn pick_compaction(
&self,
level: usize,
sstables: &[SsTableMeta],
) -> Option<CompactionTask> {
if sstables.is_empty() {
return None;
}
let input_ssts: Vec<SsTableMeta> = if level == 0 {
sstables.to_vec()
} else {
vec![sstables[0].clone()]
};
let smallest_key = input_ssts.iter()
.map(|s| s.smallest_key.clone())
.min()?;
let largest_key = input_ssts.iter()
.map(|s| s.largest_key.clone())
.max()?;
Some(CompactionTask {
level,
input_ssts,
smallest_key,
largest_key,
target_level: level + 1,
})
}
pub fn execute_compaction(
&self,
task: &CompactionTask,
) -> std::io::Result<Vec<SsTableMeta>> {
let mut merged: BTreeMap<Vec<u8>, Vec<u8>> = BTreeMap::new();
for sst in &task.input_ssts {
let reader = SsTableReader::open(&sst.path)?;
for (key, value) in reader.iter() {
if value.is_empty() {
merged.remove(&key);
} else {
merged.insert(key, value);
}
}
}
let mut new_ssts = Vec::new();
let mut builder = SsTableBuilder::new();
let mut count = 0;
for (key, value) in merged {
builder.add(&key, &value);
count += 1;
if count >= 1024 * 4 {
let sst_id = format!("L{}-{:06}", task.target_level, new_ssts.len());
let path = self.base_dir.join(&sst_id);
builder.build(&path)?;
new_ssts.push(SsTableMeta {
id: sst_id,
path,
smallest_key: Vec::new(),
largest_key: Vec::new(),
level: task.target_level,
});
builder = SsTableBuilder::new();
count = 0;
}
}
if count > 0 {
let sst_id = format!("L{}-{:06}", task.target_level, new_ssts.len());
let path = self.base_dir.join(&sst_id);
builder.build(&path)?;
new_ssts.push(SsTableMeta {
id: sst_id,
path,
smallest_key: Vec::new(),
largest_key: Vec::new(),
level: task.target_level,
});
}
Ok(new_ssts)
}
}
#[derive(Clone)]
pub struct SsTableMeta {
pub id: String,
pub path: PathBuf,
pub smallest_key: Vec<u8>,
pub largest_key: Vec<u8>,
pub level: usize,
}
pub struct CompactionTask {
pub level: usize,
pub input_ssts: Vec<SsTableMeta>,
pub smallest_key: Vec<u8>,
pub largest_key: Vec<u8>,
pub target_level: usize,
}
pub struct SsTableReader;
impl SsTableReader {
pub fn open(_path: &PathBuf) -> std::io::Result<Self> { Ok(Self) }
pub fn iter(&self) -> Vec<(Vec<u8>, Vec<u8>)> { vec![] }
}
Module 5: Bloom Filter
Bloom Filter is a critical optimization for the LSM-Tree read path — quickly determining whether a Key may exist before opening an SSTable, avoiding unnecessary disk IO. False positive rate is controllable, and space overhead is minimal.
Bloom Filter Implementation
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
pub struct BloomFilter {
bitmap: Vec<u64>,
num_bits: usize,
num_hashes: usize,
}
impl BloomFilter {
pub fn new(expected_items: usize, fp_rate: f64) -> Self {
let num_bits = Self::optimal_num_bits(expected_items, fp_rate);
let num_hashes = Self::optimal_num_hashes(num_bits, expected_items);
let bitmap_len = (num_bits + 63) / 64;
Self {
bitmap: vec![0u64; bitmap_len],
num_bits,
num_hashes,
}
}
pub fn insert(&mut self, key: &[u8]) {
let hashes = self.hash_key(key);
for i in 0..self.num_hashes {
let bit_pos = hashes[i] % self.num_bits;
let word_idx = bit_pos / 64;
let bit_idx = bit_pos % 64;
self.bitmap[word_idx] |= 1u64 << bit_idx;
}
}
pub fn may_contain(&self, key: &[u8]) -> bool {
let hashes = self.hash_key(key);
for i in 0..self.num_hashes {
let bit_pos = hashes[i] % self.num_bits;
let word_idx = bit_pos / 64;
let bit_idx = bit_pos % 64;
if self.bitmap[word_idx] & (1u64 << bit_idx) == 0 {
return false;
}
}
true
}
fn hash_key(&self, key: &[u8]) -> Vec<usize> {
let mut hasher1 = DefaultHasher::new();
key.hash(&mut hasher1);
let h1 = hasher1.finish() as usize;
let mut hasher2 = DefaultHasher::new();
key.hash(&mut hasher2);
let h2 = hasher2.finish() as usize;
(0..self.num_hashes)
.map(|i| h1.wrapping_add(i.wrapping_mul(h2)))
.collect()
}
fn optimal_num_bits(n: usize, p: f64) -> usize {
let ln2 = std::f64::consts::LN_2;
((-(n as f64) * p.ln()) / (ln2 * ln2)).ceil() as usize
}
fn optimal_num_hashes(m: usize, n: usize) -> usize {
((m as f64 / n as f64) * std::f64::consts::LN_2).ceil() as usize
}
}
Module 6: Block Cache
Block Cache caches hot SSTable Data Blocks, reducing disk IO. LRU eviction automatically removes cold data when memory is constrained.
LRU Block Cache Implementation
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct BlockCache {
cache: Arc<RwLock<LruCache<Vec<u8>, Vec<u8>>>>,
}
struct LruCache<K, V> {
entries: HashMap<K, V>,
order: Vec<K>,
capacity: usize,
}
impl<K: Clone + Eq + std::hash::Hash, V> LruCache<K, V> {
fn new(capacity: usize) -> Self {
Self {
entries: HashMap::new(),
order: Vec::new(),
capacity,
}
}
fn get(&mut self, key: &K) -> Option<&V> {
if self.entries.contains_key(key) {
self.order.retain(|k| k != key);
self.order.push(key.clone());
self.entries.get(key)
} else {
None
}
}
fn put(&mut self, key: K, value: V) {
if self.entries.contains_key(&key) {
self.order.retain(|k| k != &key);
self.order.push(key.clone());
self.entries.insert(key, value);
return;
}
if self.entries.len() >= self.capacity {
if let Some(evicted) = self.order.first().cloned() {
self.order.remove(0);
self.entries.remove(&evicted);
}
}
self.order.push(key.clone());
self.entries.insert(key, value);
}
}
impl BlockCache {
pub fn new(capacity: usize) -> Self {
Self {
cache: Arc::new(RwLock::new(LruCache::new(capacity))),
}
}
pub async fn get(&self, sst_id: &str, block_offset: u64) -> Option<Vec<u8>> {
let key = format!("{}:{}", sst_id, block_offset).into_bytes();
self.cache.write().await.get(&key).cloned()
}
pub async fn put(&self, sst_id: &str, block_offset: u64, data: Vec<u8>) {
let key = format!("{}:{}", sst_id, block_offset).into_bytes();
self.cache.write().await.put(key, data);
}
}
Pitfall Guide: 5 Common Traps
Trap 1: Unbounded MemTable Growth Causes OOM
// ❌ Wrong: No capacity limit
let memtable = MemTable::new(usize::MAX);
// ✅ Correct: Set reasonable capacity, freeze and flush when full
let memtable = MemTable::new(64 * 1024 * 1024); // 64MB
if memtable.is_full() {
manager.freeze_and_switch().await?;
flush_to_sstable(immutable).await?;
}
Trap 2: WAL Without fsync Loses Data on Crash
// ❌ Wrong: Only flush without fsync
writer.flush()?;
// ✅ Correct: fsync ensures data hits disk
writer.flush()?;
file.sync_all()?;
Trap 3: Deleting SSTable During Compaction Causes Read Failure
// ❌ Wrong: Delete old SSTables immediately after Compaction
for sst in &old_sstables { std::fs::remove_file(&sst.path)?; }
// ✅ Correct: Delete only after reference count reaches zero (or delayed deletion)
for sst in &old_sstables {
if sst.ref_count.load(Ordering::SeqCst) == 0 {
std::fs::remove_file(&sst.path)?;
} else {
pending_deletions.push(sst.clone());
}
}
Trap 4: Bloom Filter False Positive Rate Set Too High
// ❌ Wrong: 0.1 false positive rate, 10% unnecessary IO
let filter = BloomFilter::new(100000, 0.1);
// ✅ Correct: 0.01 false positive rate, recommended for production
let filter = BloomFilter::new(100000, 0.01);
Trap 5: Unreasonable SSTable Block Size
// ❌ Wrong: Block too small, index bloat
const BLOCK_SIZE: usize = 256;
// ✅ Correct: 4KB-64KB, balancing compression ratio and index size
const BLOCK_SIZE: usize = 4 * 1024;
Error Troubleshooting: 10 Common Errors
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | Cannot allocate memory |
MemTable has no capacity limit causing OOM | Set MemTable capacity limit; freeze and flush when full |
| 2 | WAL checksum mismatch |
Partial WAL write or disk failure | Truncate corrupted records, replay valid portion |
| 3 | SSTable not found: L0-000001 |
Compaction deleted SSTable being read | Implement reference counting or delayed deletion |
| 4 | Compaction stuck: level overflow |
L0 file count exceeds threshold without timely merge | Adjust Compaction trigger threshold or add merge threads |
| 5 | Bloom filter false positive rate too high |
Expected item count doesn't match actual | Recalculate Bloom Filter parameters, increase bitmap size |
| 6 | Block cache hit rate below 10% |
Insufficient cache capacity or scattered access pattern | Increase Cache capacity or adjust LRU strategy |
| 7 | crossbeam-skiplist: key already exists |
Duplicate Key insertion | Use insert to overwrite instead of insert_if_absent |
| 8 | IO error: Too many open files |
SSTable file handles not released | Implement file handle pool or limit concurrent opens |
| 9 | tokio: task panicked during Compaction |
Memory overflow during Compaction merge | Batch merge, limit per-Compaction memory usage |
| 10 | data corruption after crash recovery |
WAL and MemTable state inconsistent | Ensure WAL writes before MemTable, use two-phase commit |
Advanced Optimization Tips
1. Prefix Compression
Adjacent Keys within an SSTable Block share prefixes, reducing storage space. For example, user:1001 and user:1002 share the user: prefix, storing only the diff.
2. Partitioned Compaction
Partition large SSTables by Key Range; Compaction only merges overlapping partitions, avoiding full rewrites. RocksDB's Partitioned Index Filter uses this strategy.
3. Dynamic Level Adjustment
Dynamically adjust per-level size multipliers based on actual data volume at startup, avoiding wasting Compaction resources on empty levels. LevelDB's dynamic_level_bytes option implements this optimization.
4. Write Stall Flow Control
Actively limit write rate when L0 file count is too high, preventing read latency spikes. Set level0_slowdown_writes_trigger and level0_stop_writes_trigger two-level thresholds.
5. Parallel Compaction
Multiple threads execute Compaction tasks at different levels in parallel, leveraging multi-core for faster space reclamation. tokio's spawn_blocking is suitable for wrapping CPU-intensive merge operations.
Comparative Analysis
| Dimension | LSM-Tree | B+Tree | LSM-B+Tree Hybrid |
|---|---|---|---|
| Write Performance | ⭐ Very High (sequential writes) | ⭐ Low (random writes + page splits) | ⭐ High (writes go through LSM) |
| Read Performance | ⭐ Medium (multi-level lookup) | ⭐ High (single tree lookup) | ⭐ High (hot data via Cache) |
| Write Amplification | ⭐ Medium (Compaction overhead) | ⭐ High (page rewrite + logging) | ⭐ Medium |
| Space Amplification | ⭐ Medium (multi-version + Tombstone) | ⭐ Low (in-place update) | ⭐ Low |
| Crash Recovery | ⭐ Fast (WAL replay) | ⭐ Slow (Redo/Undo Log) | ⭐ Fast |
| Range Query | ⭐ Good (SSTable ordered) | ⭐ Good (leaf chain) | ⭐ Good |
| Concurrency Control | ⭐ Simple (append-only) | ⭐ Complex (fine-grained locking) | ⭐ Medium |
| Use Case | Write-intensive | Read-intensive | Mixed workloads |
Selection Recommendations
- LSM-Tree: Write-intensive, time-series data, log storage (recommended first choice)
- B+Tree: Read-intensive, frequent transactions, point queries dominant
- Hybrid: Mixed read-write, latency-sensitive OLTP scenarios
Summary & Outlook
This article built a complete LSM-Tree storage engine from 6 core modules: MemTable in-memory table → SSTable disk persistence → WAL write-ahead log → Compaction merge strategy → Bloom Filter → Block Cache. Rust's ownership system guarantees memory safety, crossbeam's lock-free skip list provides high-concurrency MemTable, and tokio's async runtime enables Compaction and read-write parallelism.
Future directions: Column Family support for multi-data isolation, distributed Compaction across nodes, ZSTD compression for storage cost reduction, persistent memory (PMEM) to eliminate WAL bottleneck. The essence of LSM-Tree storage engines is not "append writes replacing in-place updates" but "using the determinism of ordered merges to replace the unpredictability of random writes."
Recommended Online Tools
- JSON Formatter: /en/json/format — Debug SSTable metadata and Compaction config
- Hash Calculator: /en/encode/hash — Compute Bloom Filter hashes and Key checksums
- Base64 Codec: /en/encode/base64 — Encode WAL records and binary data
- Code Formatter: /en/dev/code-formatter — Format Rust storage engine code
Related Reading
- Rust Tokio Channel Patterns — Compaction task queue implementation
- Rust Axum Web Framework — Build storage engine HTTP interface
- Rust Ownership & Memory Guide — Understanding Rust memory safety model
External References
Summary: The 6 core modules of the Rust database kernel storage engine form the complete data path from memory to disk: MemTable lock-free concurrent writes → SSTable ordered flush persistence → WAL crash recovery guarantee → Compaction space reclamation and read optimization → Bloom Filter fast filtering → Block Cache hot data acceleration. Rust's ownership system makes memory safety zero-cost, crossbeam and tokio make concurrency and async naturally fuse. Remember, the essence of LSM-Tree storage engines is not "using append writes to replace in-place updates" but "using the determinism of ordered merges to replace the unpredictability of random writes."
Try these browser-local tools — no sign-up required →