从零构建:用 Rust 实现一个简易键值存储引擎
一、存储引擎的入门难题:从理论到可运行代码的鸿沟
数据库内核是后端开发中最"黑盒"的领域之一。MySQL 的 InnoDB、RocksDB 的 LSM-Tree,源码动辄数十万行,初学者很难从中学到核心原理。但一个最小化的键值存储引擎只需要三个组件:日志写入器(WAL)、内存索引(MemTable)、磁盘持久化(SSTable)。用 Rust 从零实现一个简易 KV Store,不仅能理解存储引擎的核心机制,还能实践 Rust 的文件 I/O、序列化和并发安全。
graph TB A[写入请求] --> B[写入 WAL 日志<br/>顺序写入, 保证持久性] B --> C[写入 MemTable<br/>内存中的排序跳表] C --> D{MemTable 是否满?} D -->|否| E[返回成功] D -->|是| F[冻结 MemTable → Immutable MemTable] F --> G[后台刷写为 SSTable<br/>有序磁盘文件] G --> H[创建新 MemTable<br/>继续接收写入] H --> E I[读取请求] --> J{MemTable 中查找} J -->|命中| K[返回值] J -->|未命中| L{Immutable MemTable} L -->|命中| K L -->|未命中| M[SSTable 查找<br/>从新到旧二分搜索] M -->|命中| K M -->|未命中| N[返回 Key 不存在]二、LSM-Tree 存储引擎的核心机制
2.1 WAL 的写入与恢复
WAL(Write-Ahead Log)保证崩溃恢复:每次写入操作先追加到 WAL 文件,再更新内存索引。重启时重放 WAL 中的所有操作,重建 MemTable。WAL 是顺序追加写入,性能极高。
2.2 MemTable 的有序性保证
MemTable 使用跳表(SkipList)或 B-Tree 实现,保证键有序。有序性是后续刷写 SSTable 的前提——SSTable 要求键有序存储,这样查找时可以用二分搜索。
graph LR subgraph MemTable 跳表 A[Head] --> B[key: a] B --> C[key: c] C --> D[key: f] D --> E[key: h] A -->|skip| D B -->|skip| E end subgraph SSTable 磁盘文件 F[Data Block: a,c,f,h] --> G[Index Block: a→0, c→16, f→32, h→48] G --> H[Footer: index_offset] end2.3 SSTable 的结构与查找
SSTable(Sorted String Table)是磁盘上的有序键值文件,由数据块、索引块和页脚组成。索引块记录每个数据块的起始键和偏移量,查找时先在索引块二分定位数据块,再在数据块内二分查找。
三、生产级代码实现与最佳实践
3.1 WAL 实现
use std::fs::{File, OpenOptions}; use std::io::{Write, BufWriter, BufRead, BufReader}; use std::path::Path; use serde::{Serialize, Deserialize}; /// WAL 记录 #[derive(Serialize, Deserialize, Debug)] pub enum WalEntry { Set { key: String, value: String }, Delete { key: String }, } /// WAL 写入器 pub struct WalWriter { writer: BufWriter<File>, } impl WalWriter { pub fn open(path: &Path) -> Result<Self, std::io::Error> { let file = OpenOptions::new() .create(true) .append(true) .open(path)?; Ok(Self { writer: BufWriter::new(file), }) } /// 追加一条记录 pub fn append(&mut self, entry: &WalEntry) -> Result<(), std::io::Error> { let json = serde_json::to_string(entry) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; self.writer.write_all(json.as_bytes())?; self.writer.write_all(b"\n")?; self.writer.flush()?; // 每次写入后立即刷盘,保证持久性 Ok(()) } } /// WAL 恢复器 pub struct WalRecovery; impl WalRecovery { /// 重放 WAL 重建内存状态 pub fn replay(path: &Path) -> Result<Vec<WalEntry>, std::io::Error> { let file = File::open(path)?; let reader = BufReader::new(file); let mut entries = Vec::new(); for line in reader.lines() { let line = line?; if line.trim().is_empty() { continue; } match serde_json::from_str::<WalEntry>(&line) { Ok(entry) => entries.push(entry), Err(_) => continue, // 跳过损坏的行(可能是崩溃时未写完) } } Ok(entries) } }3.2 MemTable 实现(基于 BTreeMap)
use std::collections::BTreeMap; /// 内存表:键值有序存储 pub struct MemTable { data: BTreeMap<String, Option<String>>, // None 表示已删除 approximate_size: usize, } impl MemTable { pub fn new() -> Self { Self { data: BTreeMap::new(), approximate_size: 0, } } /// 插入键值对 pub fn set(&mut self, key: String, value: String) { self.approximate_size += key.len() + value.len(); self.data.insert(key, Some(value)); } /// 删除键(插入墓碑标记) pub fn delete(&mut self, key: String) { self.data.insert(key, None); } /// 查找键 pub fn get(&self, key: &str) -> Option<Option<&String>> { self.data.get(key).map(|v| v.as_ref()) } /// 获取近似大小(用于判断是否需要刷盘) pub fn approximate_size(&self) -> usize { self.approximate_size } /// 获取有序迭代器(用于刷写 SSTable) pub fn iter(&self) -> impl Iterator<Item = (&String, &Option<String>)> { self.data.iter() } /// 清空 MemTable pub fn clear(&mut self) { self.data.clear(); self.approximate_size = 0; } }3.3 SSTable 实现与查找
use std::fs::{File, OpenOptions}; use std::io::{Write, Read, Seek, SeekFrom, BufWriter}; use std::path::{Path, PathBuf}; /// SSTable 索引条目 #[derive(Serialize, Deserialize)] pub struct IndexEntry { pub key: String, pub offset: u64, pub length: u64, } /// SSTable 文件 pub struct SSTable { path: PathBuf, index: Vec<IndexEntry>, } impl SSTable { /// 从 MemTable 刷写新的 SSTable pub fn flush_from_memtable( memtable: &MemTable, dir: &Path, sequence: u64, ) -> Result<Self, Box<dyn std::error::Error>> { let filename = format!("sstable_{:06}.dat", sequence); let path = dir.join(&filename); let file = File::create(&path)?; let mut writer = BufWriter::new(file); let mut index = Vec::new(); let mut offset: u64 = 0; for (key, value) in memtable.iter() { let entry_start = offset; // 写入数据行: [key_len:u16][key][value_len:u16][value_or_tombstone] let key_bytes = key.as_bytes(); writer.write_all(&(key_bytes.len() as u16).to_le_bytes())?; writer.write_all(key_bytes)?; match value { Some(val) => { let val_bytes = val.as_bytes(); writer.write_all(&(val_bytes.len() as u16).to_le_bytes())?; writer.write_all(val_bytes)?; offset += 2 + key_bytes.len() as u64 + 2 + val_bytes.len() as u64; } None => { // 墓碑标记: value_len = 0xFFFF writer.write_all(&0xFFFFu16.to_le_bytes())?; offset += 2 + key_bytes.len() as u64 + 2; } } index.push(IndexEntry { key: key.clone(), offset: entry_start, length: offset - entry_start, }); } writer.flush()?; Ok(SSTable { path, index }) } /// 在 SSTable 中查找键 pub fn get(&self, key: &str) -> Result<Option<Option<String>>, Box<dyn std::error::Error>> { // 在索引中二分查找 let pos = self.index.binary_search_by(|entry| entry.key.as_str().cmp(key)); match pos { Ok(idx) => { let entry = &self.index[idx]; let mut file = File::open(&self.path)?; file.seek(SeekFrom::Start(entry.offset))?; let mut buf = vec![0u8; entry.length as usize]; file.read_exact(&mut buf)?; // 解析数据行 let key_len = u16::from_le_bytes([buf[0], buf[1]]) as usize; let _stored_key = &buf[2..2 + key_len]; let val_len_offset = 2 + key_len; let val_len = u16::from_le_bytes([ buf[val_len_offset], buf[val_len_offset + 1], ]); if val_len == 0xFFFF { // 墓碑标记:键已删除 Ok(Some(None)) } else { let val_start = val_len_offset + 2; let val_end = val_start + val_len as usize; let value = String::from_utf8(buf[val_start..val_end].to_vec())?; Ok(Some(Some(value))) } } Err(_) => Ok(None), // 键不在此 SSTable 中 } } }3.4 KV Store 主结构
/// 简易键值存储引擎 pub struct KvStore { dir: PathBuf, memtable: MemTable, sstables: Vec<SSTable>, wal: WalWriter, flush_threshold: usize, // MemTable 刷盘阈值(字节) } impl KvStore { pub fn open(dir: &Path) -> Result<Self, Box<dyn std::error::Error>> { std::fs::create_dir_all(dir)?; // 1. 从 WAL 恢复 MemTable let wal_path = dir.join("wal.log"); let mut memtable = MemTable::new(); if wal_path.exists() { let entries = WalRecovery::replay(&wal_path)?; for entry in entries { match entry { WalEntry::Set { key, value } => memtable.set(key, value), WalEntry::Delete { key } => memtable.delete(key), } } } // 2. 加载已有 SSTable let mut sstables = Vec::new(); // 实际实现需要扫描目录、加载索引 let wal = WalWriter::open(&wal_path)?; Ok(Self { dir: dir.to_path_buf(), memtable, sstables, wal, flush_threshold: 4 * 1024 * 1024, // 4MB }) } /// 设置键值对 pub fn set(&mut self, key: String, value: String) -> Result<(), Box<dyn std::error::Error>> { // 1. 写入 WAL self.wal.append(&WalEntry::Set { key: key.clone(), value: value.clone(), })?; // 2. 写入 MemTable self.memtable.set(key, value); // 3. 检查是否需要刷盘 if self.memtable.approximate_size() >= self.flush_threshold { self.flush_memtable()?; } Ok(()) } /// 查找键 pub fn get(&self, key: &str) -> Result<Option<String>, Box<dyn std::error::Error>> { // 1. 在 MemTable 中查找 if let Some(value) = self.memtable.get(key) { return Ok(value.cloned()); } // 2. 在 SSTable 中查找(从新到旧) for sstable in self.sstables.iter().rev() { if let Some(result) = sstable.get(key)? { return Ok(result); } } Ok(None) } /// 刷写 MemTable 到 SSTable fn flush_memtable(&mut self) -> Result<(), Box<dyn std::error::Error>> { let sequence = self.sstables.len() as u64; let sstable = SSTable::flush_from_memtable( &self.memtable, &self.dir, sequence )?; self.sstables.push(sstable); self.memtable.clear(); // 清空 WAL(新 MemTable 为空,旧 WAL 不再需要) let wal_path = self.dir.join("wal.log"); std::fs::write(&wal_path, "")?; Ok(()) } }四、简易 KV Store 的架构权衡
4.1 写入性能 vs 读取性能
| 设计选择 | 写入性能 | 读取性能 | 空间放大 |
|---|---|---|---|
| MemTable 大(64MB) | 高(减少刷盘次数) | 低(SSTable 少,但单文件大) | 低 |
| MemTable 小(4MB) | 低(频繁刷盘) | 高(SSTable 多,但单文件小) | 高 |
| 布隆过滤器 | 无影响 | 显著提升(跳过不含键的 SSTable) | 微增 |
4.2 Compaction 策略缺失的影响
本实现没有 Compaction(合并 SSTable),随着写入增多,SSTable 文件数量线性增长,读取需要遍历所有文件。生产级引擎(RocksDB)使用 Level Compaction 或 Size-Tiered Compaction 定期合并 SSTable,消除重复和墓碑记录。
4.3 适用边界与禁用场景
适用场景:
- 学习存储引擎核心原理
- 写多读少、数据量小的嵌入式场景
- 不需要事务和复杂查询的简单缓存
禁用场景:
- 生产环境(缺少 Compaction、并发控制、事务)
- 大数据量(无 Compaction 导致读取性能退化)
- 需要范围查询(本实现只支持点查)
五、总结
从零实现一个 KV Store 是理解存储引擎最直接的方式。核心只有三个组件:WAL 保证持久性、MemTable 提供快速写入、SSTable 实现磁盘持久化。LSM-Tree 的设计哲学是"写入优化":所有写入都是顺序追加,避免随机写。代价是读取需要多级查找——MemTable → Immutable MemTable → SSTable(从新到旧)。这个简易实现缺少 Compaction、布隆过滤器和并发控制,但已经包含了存储引擎的核心骨架。理解了这个骨架,再读 RocksDB 的源码就有了地图。