mini-lsm通关笔记Week1Day1
Summary
在本章中,您将:
- 基于Skiplist实现memtables。
- 实现冻结memtable逻辑。
- 实现memtable的LSM读路径
get
函数。
要将测试用例复制到starter目录下中并运行它们,
cargo x copy-test --week 1 --day 1
cargo x scheck
有以下报错:
no such command: nextest
需要先安装nextest
:
cargo install cargo-nextest
Task1-SkipList Memtable
首先,让我们实现LSM存储引擎的内存结构——memtable。我们选择crossbeam的skiplist实现作为memtable的数据结构,因为它支持无锁的并发读写。我们不会深入讨论skiplist是如何工作的,简而言之,它是一个有序的键值映射,很容易允许并发读写。
crossbeam-skiplist提供了与Rust标准库的BTreeMap类似的接口:insert、get和iter。唯一的区别是修改接口(即插入)只需要对Skiplist的不可变引用,而不是可变引用。因此,在您的实现中,您不应该在实现memtable结构时使用任何互斥锁。
您还会注意到MemTable结构没有删除接口。在mini-lsm实现中,删除被表示为对应空值的键。
在此任务中,您需要实现MemTable::get和MemTable::put以启用对memtable的修改。
我们使用bytes类型来存储memtable中的数据,byte::Byte类似于Arc<[u8]>。当你克隆Bytes,或者获取Bytes的切片时,底层数据不会被复制,因此克隆数据的内存代价是很少的。相反,它只是创建一个对存储区域的新引用,当没有对该区域的引用时,存储区域的内存将被释放。
在mini-lsm-starter
文件夹下找到mem_table.rs
,这个是我们要实现的文件。week1_day1.rs
是单元测试文件,可以看到任务1有两个相关的单测
- test_task1_memtable_get
- test_task1_memtable_overwrite
用到了MemTable的create
、for_testing_put_slice
、for_testing_get_slice
三个方法。
create
需要实现MemTable
的构造函数,其中id
是基础数据类型直接使用构造函数的入参_id
,wal
是Option
类型先赋值为None
的变体。map
、approximate_size
都是被Arc
包裹的智能指针。
MemTable {
id: _id,
map: Arc::new(SkipMap::new()),
wal: None,
approximate_size: Arc::new(AtomicUsize::new(0)),
}
for_testing_put_slice
实际调用的是put
方法。需要在该方法中对map
进行insert
操作:
self.map
.insert(Bytes::copy_from_slice(_key), Bytes::copy_from_slice(_value));
Ok(())
返回值Result<T>
等价于Result<T, anyhow::Error>
。需要注意的是,map
的键值都是Bytes
类型,且需要获取所有权,需要使用Bytes
的构造函数将字符串切片转换为Bytes
类型。
for_testing_get_slice
实际调用的是get
方法。需要在该方法中对map
进行get
操作:
match self.map.get(_key) {
Some(entry) => Some(entry.value().clone()),
None => None,
}
map.get
返回的是一个Entry<Bytes,Bytes>
对象,返回的只需要其值的部分。entry.value()
返回的是值的引用,因为rust
不能返回值的引用避免悬垂引用,所以还需要加上.clone()
。
第一个任务只是对SkipMap
的一个封装。
Task2-SkipList Memtable
在此任务中,您需要修改:
src/lsm_storage.rs
现在,我们将把我们的第一个数据结构memtable添加到LsmStorageState。在LsmStorageState::create中,你会发现当创建一个LSM结构体时,我们会初始化一个id为0的memtable。这是处于初始状态的可变memtable。在任何时间点,引擎都只有一个可变memtable。memtable通常有大小限制(即256MB),当达到大小限制时,它将被冻结为不可变的memtable。
查看lsm_storage.rs,你会发现有两个结构体表示一个存储引擎:MiniLSM和LsmStorageInner。MiniLSM是LsmStorageInner的一个薄包装器。您将在LsmStorageInner中实现大部分功能,直到第2周压缩。
LsmStorageState存储LSM存储引擎的当前结构。目前,我们将只使用memtable字段,它存储了当前可变的memtable。在此任务中,您需要实现LsmStorageInner::get、LsmStorageInner::put和LsmStorageInner::delete。它们都应该直接将请求调度到当前的memtable。
您的删除实现应该为该键简单地放置一个空切片,我们将其称为删除墓碑。您的get实现应该相应地处理这种情况。
要访问memtable,您需要使用状态锁。由于我们的memtable实现put操作只需要一个不可变的引用,因此您只需要在state上获取读锁即可修改memtable。这允许从多个线程并发访问memtable。
get
先获取读锁再获取数据,如果为空则返回None
。
match self.state.read().memtable.get(_key) {
None => { Ok(None) }
Some(value) => {
if value.is_empty() {
return Ok(None);
}
Ok(Some(value))
}
}
put
先获取读锁,再调用memtable
的put
方法
self.state.read().memtable.put(_key, _value)
注意这里只需要获取读锁就行,如任务书所描述的:我们的memtable实现put操作只需要一个不可变的引用,因此您只需要在state上获取读锁即可修改memtable。
delete
同上,就是将value
改为""
self.state.read().memtable.put(_key, b"")
Task3-Write Path - Freezing a Memtable
在此任务中,您需要修改:
src/lsm_storage.rs
src/mem_table.rs
memtable的大小不能连续增长,当它达到大小限制时,我们需要冻结它们(并稍后刷新到磁盘)。您可以在LsmStorageOptions中找到memtable大小限制,它等于SST大小限制(而不是num_memtables_limit)。这不是一个硬限制,你应该尽最大努力冻结memtable。
在此任务中,当put/delete键时,您需要计算大约的memtable大小。这可以通过简单地在调用put时将键和值的总字节数相加来计算。如果一个key被放置了两次,尽管skiplist只包含最新的值,但你可以将它计算在近似的memtable大小中两次。一旦一个memtable达到了限制,你应该调用force_freeze_memtable来冻结这个memtable并创建一个新的memtable。
因为可能有多个线程将数据送入存储引擎,force_freeze_memtable可能会从多个线程并发调用。在这种情况下,您需要考虑如何避免竞争条件。
有多个地方可能需要修改LSM状态:冻结可变的memtable、刷新memtable到SST以及GC/compaction。在所有这些修改过程中,可能存在I/O操作。构造锁定策略的直观方法是:
fn freeze_memtable(&self) { let state = self.state.write(); state.immutable_memtable.push(/* something */); state.memtable = MemTable::create(); }
...这样你可以修改任何东西,在你获取LSM state的写锁后。
这目前工作正常。但是,请考虑您想要为已创建的每个memtable创建一个write-ahead日志文件的情况。
fn freeze_memtable(&self) { let state = self.state.write(); state.immutable_memtable.push(/* something */); state.memtable = MemTable::create_with_wal()?; // <- could take several milliseconds }
现在,当我们冻结memtable时,在几毫秒内没有其他线程可以访问LSM状态,这会产生延迟的尖峰。
为了解决这个问题,我们可以把I/O操作放到锁区域之外。
fn freeze_memtable(&self) { let memtable = MemTable::create_with_wal()?; // <- could take several milliseconds { let state = self.state.write(); state.immutable_memtable.push(/* something */); state.memtable = memtable; } }
然后,我们在状态写锁区域内没有昂贵的操作。现在,考虑memtable即将达到容量限制的情况,两个线程成功地将两个键放入memtable中,它们都在放入两个键后发现memtable达到容量限制。他们都会对memtable进行大小检查,并决定冻结它。在这种情况下,我们可能会创建一个空的memtable,然后立即冻结。
为了解决这个问题,所有的状态修改都应该通过状态锁来同步。
fn put(&self, key: &[u8], value: &[u8]) { // put things into the memtable, checks capacity, and drop the read lock on LSM state if memtable_reaches_capacity_on_put { let state_lock = self.state_lock.lock(); if /* check again current memtable reaches capacity */ { self.freeze_memtable(&state_lock)?; } } }
在以后的章节中,你会经常注意到这种模式。例如,对于L0刷新,
fn force_flush_next_imm_memtable(&self) { let state_lock = self.state_lock.lock(); // get the oldest memtable and drop the read lock on LSM state // write the contents to the disk // get the write lock on LSM state and update the state }
这确保了只有一个线程能够修改LSM状态,同时仍然允许并发访问LSM存储。
在本任务中,您需要修改put和delete以遵守memtable的软容量限制。当达到上限时,调用force_freeze_memtable冻结memtable。请注意,我们没有针对此并发场景的测试用例,您需要自己考虑所有可能的竞争条件。此外,请记住检查锁定区域,以确保临界区是所需的最小值。
你可以简单地将下一个memtable id赋值为self.next_sst_id()。注意,imm_memtables存储的memtable是按照从最新到最老的顺序存储的。也就是说,imm_memtables.first()应该是最后一个被冻结的memtable。
首先我们要理解各对象直接的关系
approximate_size计算
就是在对MenTable
进行put
操作时,需要计算"大约"的大小:
let add_size = _key.len() + _value.len();
self.approximate_size.fetch_add(add_size, Ordering::Relaxed);
self.map
.insert(Bytes::copy_from_slice(_key), Bytes::copy_from_slice(_value));
Ok(())
因为approximate_size
提供了原子操作fetch_add
,所以只需要调用即可。
force_freeze_memtable
先放出代码,再对实现进行介绍:
let new_men_table: Arc<MemTable> = Arc::new(MemTable::create(self.next_sst_id()));
{
let mut guard = self.state.write();
let mut snapshot = guard.as_ref().clone();
let old_men_table = std::mem::replace(&mut snapshot.memtable, new_men_table.clone());
snapshot.imm_memtables.insert(0,old_men_table);
snapshot.memtable = new_men_table;
*guard = Arc::new(snapshot)
}
Ok(())
- 先创建
MemTable
以及其Arc
指针,是因为如任务书所表达的,这个操作可能很慢,如果在获取写锁后再进行,会有性能影响。这样能是写锁的代价尽可能的小。
- 获取
state
写锁guard
,以及将state
复制snapshot
,因为state中保存的都是指针,所以这个拷贝也是很快的
- 使用
new_men_table
替换memtable
,原内容返回为old_men_table
- 将
old_men_table
插入imm_memtables
- 修改
guard
指针指向snapshot
Task4-Read Path - Get
在此任务中,您需要修改:
src/lsm_storage.rs
现在你有了多个memtable,你可以修改你的读取路径获取函数来获取一个键的最新版本。确保从最新的memtable到最老的memtable进行遍历。
这个任务就是需要将get
函数进行改造,因为此前的get
操作只会从memtable
里面读取数据。但是现在的数据来源还有可能是被冻结的memtable
。如果当前使用的memtable
没有所需的数据,需要遍历imm_memtables
中的数据:
if let Some(value) = self.state.read().memtable.get(_key) {
if value.is_empty() {
return Ok(None);
}
return Ok(Some(value));
}
for x in &self.state.read().imm_memtables {
if let Some(value) = x.get(_key) {
if value.is_empty() {
return Ok(None);
}
return Ok(Some(value));
}
}
Ok(None)