mini-lsm通关笔记Week3Day2

项目地址:https://github.com/skyzh/mini-lsm

个人实现地址:https://gitee.com/cnyuyang/mini-lsm

在本章中,您将:

  • 重构你的memtable/WAL以存储一个键(key)的多个版本。
  • 实现新的引擎写入路径,为每个键(key)分配一个时间戳。
  • 让你的合并过程处理一个键(key)的多个版本。
  • 实现新的引擎读取路径,以返回键(key)的最新版本。

要运行测试用例,请执行以下操作:

cargo x copy-test --week 3 --day 2
cargo x scheck

注意:在完成本章后,您还需要通过2.4章之前的所有测试内容。

Task 1-MemTable, Write-Ahead Log, and Read Path

在此任务中,您需要修改:

src/wal.rs
src/mem_table.rs
src/lsm_storage.rs

我们已经将引擎中的大部分键(key)设置为KeySlice,它包含一个键(key)和一个时间戳。但是,我们系统的某些部分仍然没有考虑时间戳。在我们的第一个任务中,您需要修改您的memtable和WAL实现,以将时间戳考虑在内。

您需要首先更改存储在memtable中的SkipMap的类型。

pub struct MemTable {
    // map: Arc<SkipMap<Bytes, Bytes>>,
    map: Arc<SkipMap<KeyBytes, Bytes>>, // Bytes -> KeyBytes
    // ...
}

之后,您可以继续修复所有编译器错误,以完成此任务。

MemTable::get

我们保留get接口,以便测试用例仍然可以查询memtable中某个key的特定版本。在完成此任务后,不应该在读取路径中使用此接口。假设我们存储KeyBytes,即(Bytes, u64)在跳表(即map变量)中,而用户使用KeySlice,即(&[u8], u64)进行查询。我们必须想办法将后者转换为前者的引用,这样我们就可以检索跳表中的数据了。

为实现这个转换,您可以使用不安全代码强制将&[u8]强制转换为静态,并使用Bytes::from_static从静态切片创建字节对象。这是合理的,因为Bytes不会尝试释放切片的内存,因为它被认为是静态的。

剧透:将u8切片转换为Bytes

Bytes::from_static(unsafe { std::mem::transmute(key.key_ref()) })

这不是一个问题,因为我们之前的是Bytes&[u8],其中Bytes实现了Borrow<[u8]>

MemTable::put

函数签名应该更改为fn put(&self, key: KeySlice, value: &[u8]),并且您需要在您的实现中将键切片转换为KeyBytes

MemTable::scan

函数签名应该改为fn scan(&self, lower: Bound<KeySlice>, upper: Bound<KeySlice>) -> MemTableIterator。您需要将KeySlice转换为KeyBytes,并将其用作SkipMap::range参数。

MemTable::flush

现在,在将memtable刷新到SST时,应该使用key时间戳,而不是使用默认的时间戳。

MemTableIterator

它现在应该存储(KeyBytes, Bytes),返回的键类型应该是KeySlice

Wal::recover和Wal::put

预写日志现在应该接受KeySlice而不是键。在序列化和反序列化WAL记录时,您应该将时间戳放入WAL文件中,并对时间戳和之前的所有其他字段执行校验和。

WAL格式如下:

| key_len (exclude ts len) (u16) | key | ts (u64) | value_len (u16) | value | checksum (u32) |

LsmStorageInner::get

之前,我们实现get的方式是先在memtable查找,然后依次扫描SST。在刚刚的任务中我们将memtable的入参更改为使用key-ts,我们需要重新实现get接口。最简单的方法是在我们拥有的所有东西上创建一个合并迭代器—-memtables、immutable memtables、L0 SSTs和其他级别SSTs,与在scan中所做的相同,只是我们对SSTs进行了一个bloom filter过滤。

LsmStorageInner::scan

您需要合并新的memtable API,并且您应该将扫描范围设置为(user_key_begin, TS_RANGE_BEGIN)(user_key_end,TS_RANGE_END)。请注意,在处理排除边界时,需要将迭代器正确定位到下一个键(而不是相同时间戳的当前键)。

先按题目要求,修改MemTable结构体中变量map的类型。再处理各函数的报错。

MemTable::get

修改函数签名为pub fn get(&self, _key: KeySlice) -> Option<Bytes>

按题目要求构建出用于跳表查询的keyBytes类型变量,进行查询:

pub fn get(&self, _key: KeySlice) -> Option<Bytes> {
    let key: KeyBytes = KeyBytes::from_bytes_with_ts(Bytes::from_static(unsafe { std::mem::transmute(_key.key_ref()) }), _key.ts());
    match self.map.get(&key) {
        Some(entry) => Some(entry.value().clone()),
        None => None,
    }
}

调用点for_testing_get_slice,修改:

pub fn for_testing_get_slice(&self, key: &[u8]) -> Option<Bytes> {
    self.get(KeySlice::from_slice(key, TS_DEFAULT))
}

只需要修改此处保证测试用例正常运行,其他调用点后续不使用该函数,改为scan查询。

MemTable::put

修改往map中插入数据的键的构造:

self.map.insert(
    KeyBytes::from_bytes_with_ts(Bytes::copy_from_slice(_key.key_ref()), _key.ts()),
    Bytes::copy_from_slice(_value),
);

先构造出Bytes,再加上时间戳构造出KeyBytes,就是跳表所需的数据类型。

调用点for_testing_put_slice,修改:

pub fn for_testing_put_slice(&self, key: &[u8], value: &[u8]) -> Result<()> {
    self.put(KeySlice::from_slice(key, TS_DEFAULT), value)
}

只需要修改此处保证测试用例正常运行。

MemTable::scan

修改函数签名后,新增一个辅助函数map_key_bound帮助实现Bound<KeySlice>Bound<KeyBytes>的转化:

// Create a bound of `Bytes` from a bound of `KeySlice`.
pub(crate) fn map_key_bound(bound: Bound<KeySlice>) -> Bound<KeyBytes> {
    match bound {
        Bound::Included(x) => Bound::Included(KeyBytes::from_bytes_with_ts(
            Bytes::copy_from_slice(x.key_ref()),
            x.ts(),
        )),
        Bound::Excluded(x) => Bound::Excluded(KeyBytes::from_bytes_with_ts(
            Bytes::copy_from_slice(x.key_ref()),
            x.ts(),
        )),
        Bound::Unbounded => Bound::Unbounded,
    }
}


pub fn scan(&self, _lower: Bound<KeySlice>, _upper: Bound<KeySlice>) -> MemTableIterator {
    let (lower, upper) = (map_key_bound(_lower), map_key_bound(_upper)); // 修改
    let mut iterator = MemTableIteratorBuilder {
        map: self.map.clone(),
        iter_builder: |map| map.range((lower, upper)),
        item: (KeyBytes::new(), Bytes::new()), // 修改
    }
    .build();
    iterator.next().unwrap();
    iterator
}

MemTable::flush

Week3Day1的任务中为了解决编译问题,这里临时性的将时间戳设置为TS_DEFAULT,需要将SsTableBuilder::add函数的入参修改为:

_builder.add(entry.key().as_key_slice(), &entry.value()[..]);

MemTableIterator

为配合MemTable中函数的修改,MemTableIterator结构体也需要同步修改:

  1. map类型同步修改为Arc<SkipMap<KeyBytes, Bytes>>

  2. SkipMapRangeIter类型修改为:``

type SkipMapRangeIter<'a> = crossbeam_skiplist::map::Range<
    'a,
    KeyBytes,
    (Bound<KeyBytes>, Bound<KeyBytes>),
    KeyBytes,
    Bytes,
>;
  1. item类型同步修改为(KeyBytes, Bytes)

  2. key()方法实现修改为:

fn key(&self) -> KeySlice {
    self.borrow_item().0.as_key_slice()
}

Wal::recover和Wal::put

put函数:

  1. 函数签名修改
  2. key_len 计算需要加上时间戳的长度(8)
  3. 修改写入key的方式,原来直接写入_key变量,现在需要通过_key.key_ref()获取
  4. 向磁盘和校验和中同时写入时间戳
pub fn put(&self, _key: KeySlice, _value: &[u8]) -> Result<()> { // 函数签名修改
    let mut file = self.file.lock();
    let mut buf: Vec<u8> = Vec::new();
    let key_len = _key.key_len() + 8; // 计算需要加上时间戳的长度
    let mut hasher = crc32fast::Hasher::new();
    hasher.write_u16(key_len as u16);
    buf.put_u16(key_len as u16);
    hasher.write(_key.key_ref()); // 通过_key.key_ref()获取
    buf.put_slice(_key.key_ref()); // 通过_key.key_ref()获取
    hasher.write_u64(_key.ts()); // 写入时间戳
    buf.put_u64(_key.ts()); // 写入时间戳
    ...
}

recover函数:

  1. 函数签名修改
  2. 读取键时,最后8位为时间戳
  3. 构建KeyBytes插入跳表
 pub fn recover(path: impl AsRef<Path>, skiplist: &SkipMap<KeyBytes, Bytes>) -> Result<Self> { // 函数签名修改
    ...
    let key = Bytes::copy_from_slice(&rbuf[..(key_len - 8)]); // 读取键时,最后8位为时间戳
    hasher.write(&key);
    rbuf.advance(key_len - 8);
    let ts = rbuf.get_u64(); // 读取时间戳
    hasher.write_u64(ts); // 加入校验和计算
    ....
    skiplist.insert(KeyBytes::from_bytes_with_ts(key, ts), value); // 构建KeyBytes插入跳表
    ...
 }

LsmStorageInner::get

就是将原来get的实现改为scan,如从memtable查询,原来的实现为:

if let Some(value) = snapshot.memtable.get(_key) {
    if value.is_empty() {
        return Ok(Some(value));
    }
}

修改方案:

let memtable = snapshot.memtable.scan(
    Bound::Included(KeySlice::from_slice(key, TS_RANGE_BEGIN)),
    Bound::Included(KeySlice::from_slice(key, TS_RANGE_END)),
);
if memtable.is_valid() && memtable.key().key_ref() == key {
    if memtable.value().is_empty() {
        return Ok(None);
    }
}

这里要注意的时,查询的时间戳开始的数值TS_RANGE_BEGINstd::u64::MAX原因是:优先要获取到时间戳较大的值,时间戳大的为最新值。

同时也要注意到key.rs中排序方式的变化:self.0.partial_cmp(&other.0)修改为(self.0.as_ref(), Reverse(self.1)).cmp(&(other.0.as_ref(), Reverse(other.1)))

代表在mvcc改造前,排序仅仅看键的内容以及读取顺序。现在除了看键的内核,还需要对时间戳进行倒排。因为时间戳是唯一的,不能重复,所以理论上没有重复的带时间戳的键。

LsmStorageInner::scan

类型上面将scan函数的使用进行改造,这里需要实现一个功能函数map_key_bound_plus_ts,帮助实现Bound<&[u8]>Bound<KeyBytes>的转换:

// mem_table.rs
pub(crate) fn map_key_bound_plus_ts(bound: Bound<&[u8]>, ts: u64) -> Bound<KeySlice> {
    match bound {
        Bound::Included(x) => Bound::Included(KeySlice::from_slice(x, ts)),
        Bound::Excluded(x) => Bound::Excluded(KeySlice::from_slice(x, ts)),
        Bound::Unbounded => Bound::Unbounded,
    }
}

// lsm_storage.rs
pub fn scan(
    &self,
    _lower: Bound<&[u8]>,
    _upper: Bound<&[u8]>,
) -> Result<FusedIterator<LsmIterator>> {
    ...
    memtable_iters.push(Box::new(snapshot.memtable.scan(
        map_key_bound_plus_ts(_lower, TS_RANGE_BEGIN),
        map_key_bound_plus_ts(_upper, TS_RANGE_END),
    )));
    for memtable in snapshot.imm_memtables.iter() {
        memtable_iters.push(Box::new(memtable.scan(
            map_key_bound_plus_ts(_lower, TS_RANGE_BEGIN),
            map_key_bound_plus_ts(_upper, TS_RANGE_END),
        )));
    }
    ...
}

Task 2-Write Path

在此任务中,您需要修改:

src/lsm_storage.rs

我们在LsmStorageInner中有一个mvcc变量,它包含了我们在多版本并发控制中需要使用的所有数据结构。当您打开目录并初始化存储引擎时,您将需要创建该结构。

在您的write_batch实现中,您将需要获取写入批处理中所有键的提交时间戳。您可以通过在逻辑开始时使用self.mvcc().latest_commit_ts() + 1,在逻辑结束时使用self.mvcc().update_commit_ts(ts)来递增下一个提交时间戳来获取时间戳。为了确保所有的写入都具有不同的时间戳,并且新的key放在旧的key之上,你需要在函数的开头持有一个写锁self.mvcc().write_lock.lock(),这样在同一时间只能有一个线程写入存储引擎。

按题目的要求先在LsmStorageInner::open函数中初始化mvcc变量:

let storage = Self {
    ...
    mvcc: Some(LsmMvccInner::new(0)),
    ...
};

实现一个辅助函数:

pub(crate) fn mvcc(&self) -> &LsmMvccInner {
    self.mvcc.as_ref().unwrap()
}

改造write_batch函数,先获取时间戳,使用后更新:

pub fn write_batch<T: AsRef<[u8]>>(&self, _batch: &[WriteBatchRecord<T>]) -> Result<()> {
    let _lck = self.mvcc().write_lock.lock();
    let ts = self.mvcc().latest_commit_ts() + 1;

    ...
    self.mvcc().update_commit_ts(ts);
    Ok(())
}

Task 3-MVCC Compaction

在此任务中,您需要修改:

src/compact.rs

我们在前几章中做的是,只保留key的最新版本,当我们合并key到底层时,如果key被移除,则移除key。使用MVCC,我们现在有了与键相关联的时间戳,我们不能使用相同的逻辑来进行合并。

在本章中,您可以简单地移除掉删除键的逻辑。你可以暂时忽略compact_to_bottom_level ,在合并过程中,你应该保留key的所有版本。

此外,您还需要以一种方式实现合并算法,即将具有不同时间戳的相同密钥放在同一个SST文件中,即使它超过了SST大小限制。这确保了如果某个键在某个级别的SST中找到,它将不会在该级别的其他SST文件中,因此简化了系统许多部分的实现。

这里仅仅将ForceFullCompaction合并策略中移除键的逻辑删除:

// if !value.is_empty() { // 注释掉
builder.add(key, value);
// } // 注释掉

以一种方式实现合并算法,即将具有不同时间戳的相同密钥放在同一个SST文件中,即使它超过了SST大小限制。这确保了如果某个键在某个级别的SST中找到,它将不会在该级别的其他SST文件中,因此简化了系统许多部分的实现该部分未实现,修改了week3day2中的测试点,先不实现,看看后续是否有影响。

Task 4-LSM Iterator

在此任务中,您需要修改:

src/lsm_iterator.rs

在上一章中,我们实现了LSM迭代器,将具有不同时间戳的相同key视为不同key。现在,我们需要重构LSM迭代器,以便在从子迭代器检索多个版本的键时仅返回键的最新版本。

你需要在迭代器中记录prev_key。如果我们已经将某个key的最新版本返回给用户,我们可以跳过所有旧版本,继续下一个key。

此时,除了持久性测试(2.5和2.6)之外,您应该通过前面章节中的所有测试。

  1. LsmIterator结构体中新增记录变量prev_key: Vec<u8>

  2. LsmIterator::new函数改造

pub(crate) fn new(iter: LsmIteratorInner, upper: Bound<Bytes>) -> Result<Self> {
    let mut lsm = Self {
        inner: iter,
        upper,
        prev_key: Vec::new(), // 【新增】
    };
    if lsm.is_valid() && lsm.value().is_empty() {
        lsm.prev_key = lsm.key().to_vec(); // 【新增】
        lsm.next();
    }
    if lsm.is_valid() { // 【新增】
        lsm.prev_key = lsm.key().to_vec(); // 【新增】
    } // 【新增】
    Ok(lsm)
}
  1. LsmIterator::next函数改造:
fn next(&mut self) -> Result<()> {
    self.inner.next();
    if self.inner.is_valid() {
        if self.inner.value().is_empty() {
            self.prev_key = self.key().to_vec();
            return self.next();
        }
        if self.prev_key == self.key().to_vec() { // 【新增】
            return self.next(); // 【新增】
        } // 【新增】
        self.prev_key = self.key().to_vec(); // 【新增】
    }
    Ok(())
}

这一部分要小心处理,不然此前用例会通不过。

posted @   余为民同志  阅读(17)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
点击右上角即可分享
微信分享提示