mini-lsm通關筆記Week1Day4

余为民同志發表於2024-08-21

專案地址:https://github.com/skyzh/mini-lsm

個人實現地址:https://gitee.com/cnyuyang/mini-lsm

Task 1-SST Builder

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

src/table/builder.rs

src/table.rs

SST由儲存在磁碟上的資料塊和索引塊組成。通常,資料塊都是懶載入的-直到使用者發出請求,它們才會被載入到記憶體中。索引塊也可以按需載入,但在本教程中,我們簡單假設所有SST索引塊(元資訊塊)都可以放入記憶體(實際上我們沒有索引塊的實現)。通常,SST檔案的大小為256MB。

SST構建器類似於之前實現的BlockBuilder——使用者將在構建器上呼叫add。你應該在SST builder中維護一個BlockBuilder,並在必要時拆分塊。此外,你還需要維護塊後設資料BlockMeta,其中包括每個塊中的第一個/最後一個鍵以及每個塊的偏移量。build函式將對SST進行編碼,使用FileObject::create將所有內容寫入磁碟,並返回一個SsTable物件。

SST的編碼如下:

-------------------------------------------------------------------------------------------
|         Block Section         |          Meta Section         |          Extra          |
-------------------------------------------------------------------------------------------
| data block | ... | data block |            metadata           | meta block offset (u32) |
-------------------------------------------------------------------------------------------

timate_size函式
你還需要實現SsTableBuilder的es,這樣呼叫者就可以知道什麼時候可以開始一個新的SST來寫入資料。函式不需要非常精確。假設資料塊資料量遠遠大於後設資料塊,我們可以簡單地返回資料塊的大小為estimate_size。

除了SST構建器,你還需要完成塊後設資料的編碼/解碼,以便SsTableBuilder::build可以生成有效的SST檔案。

BlockMeta的編碼與解碼

先實現元資訊的編碼與解碼。

如圖所示就是將記憶體中的BlockMeta陣列寫入到磁碟中,同時能將磁碟中的二進位制資訊還原回來。

編碼

先新增BlockMeta的個數,在寫入每個BlockMeta,因為first_keylast_key是可變長的字串型別,所有需要新增長度資訊保證正常解碼。

pub fn encode_block_meta(
    block_meta: &[BlockMeta],
    #[allow(clippy::ptr_arg)] // remove this allow after you finish
    buf: &mut Vec<u8>,
) {
    buf.put_u32(block_meta.len() as u32);
    for meta in block_meta {
        buf.put_u32(meta.offset as u32);
        buf.put_u16(meta.first_key.len() as u16);
        buf.put(meta.first_key.raw_ref());
        buf.put_u16(meta.last_key.len() as u16);
        buf.put(meta.last_key.raw_ref());
    }
}

解碼

解碼就是編碼的逆過程

pub fn decode_block_meta(mut buf: &[u8]) -> Vec<BlockMeta> {
    let num = buf.get_u32();
    let mut block_meta: Vec<BlockMeta> = Vec::with_capacity(num as usize);
    for i in 0..num {
        let offset: usize = buf.get_u32() as usize;
        let first_key_len = buf.get_u16();
        let first_key = KeyBytes::from_bytes(buf.copy_to_bytes(first_key_len as usize));
        let last_key_len = buf.get_u16();
        let last_key = KeyBytes::from_bytes(buf.copy_to_bytes(last_key_len as usize));
        block_meta.push(BlockMeta {
            offset,
            first_key,
            last_key,
        })
    }
    block_meta
}

SsTableBuilder建造者

成員變數&建構函式

  • builder:BlockBuilder
  • first_key:儲存的第一個key,用於加快查詢
  • last_key:儲存的最後一個key,用於加快查詢
  • data:Block編碼後的資料
  • meta:元資訊
  • block_size:每個Block的大小

建構函式:

pub fn new(block_size: usize) -> Self {
    SsTableBuilder {
        builder: BlockBuilder::new(block_size),
        first_key: Vec::new(),
        last_key: Vec::new(),
        data: Vec::new(),
        meta: Vec::new(),
        block_size,
    }
}

finish_block

存在兩種情況可能,可能會新生成一個Block用於儲存資料:

  • Block存不資料
  • SsTableBuilder呼叫build生成SsTable,不再新增資料
fn finish_block(&mut self) {
    let builder = std::mem::replace(&mut self.builder, BlockBuilder::new(self.block_size));
    let encoded_block = builder.build().encode();
    self.meta.push(BlockMeta {
        offset: self.data.len(),
        first_key: KeyBytes::from_bytes(self.first_key.clone().into()),
        last_key: KeyBytes::from_bytes(self.last_key.clone().into()),
    });
    self.data.append(&mut encoded_block.to_vec())
}

先使用std::mem::replaceself.builder中的資料替換成空的物件,將存滿資料的物件返回賦值給builder

呼叫builderbuild()函式生成Block物件,再呼叫encode()編碼出二進位制資料encoded_block

將元資訊新增進meta中,將編碼後的二進位制資料新增進data

add操作

pub fn add(&mut self, key: KeySlice, value: &[u8]) {
    if self.first_key.is_empty() {
        self.first_key = Bytes::copy_from_slice(key.raw_ref()).into();
    }

    self.last_key = Bytes::copy_from_slice(key.raw_ref()).into();

    if !self.builder.add(key, value) {
        self.finish_block();
        self.builder.add(key, value);
    }
}

判斷當前Block塊能否新增進當前Block,如果不能則呼叫上述finish_block函式,再進行新增。同時儲存用於每個Block的first_keylast_key

pub fn add(&mut self, key: KeySlice, value: &[u8]) {
    if self.first_key.is_empty() {
        self.first_key = Bytes::copy_from_slice(key.raw_ref()).into();
    }

    if !self.builder.add(key, value) {
        self.finish_block();
        self.builder.add(key, value);
        self.first_key = Bytes::copy_from_slice(key.raw_ref()).into();
    }

    self.last_key = Bytes::copy_from_slice(key.raw_ref()).into();
}

build操作

就是將SsTableBuilder建造者中儲存的資料,寫入磁碟:

pub fn build(
    mut self,
    id: usize,
    block_cache: Option<Arc<BlockCache>>,
    path: impl AsRef<Path>,
) -> Result<SsTable> {
    self.finish_block();
    let mut buf = self.data;
    let meta_offset = buf.len();
    BlockMeta::encode_block_meta(&self.meta, &mut buf);
    buf.put_u32(meta_offset as u32);
    let file = FileObject::create(path.as_ref(), buf)?;
    Ok(SsTable {
        id,
        file,
        first_key: self.meta.first().unwrap().first_key.clone(),
        last_key: self.meta.last().unwrap().last_key.clone(),
        block_meta: self.meta,
        block_meta_offset: meta_offset,
        block_cache,
        bloom: None,
        max_ts: 0,
    })
}

按照文件說明先寫入資料部分,再寫入後設資料部分,最後寫入後設資料的偏移距離。

SsTable讀取

就是編碼的逆過程,即SsTable物件的open方法:

pub fn open(id: usize, block_cache: Option<Arc<BlockCache>>, file: FileObject) -> Result<Self> {
    let len = file.1;
    let meta_block_offset = (&file.read(len - 4, 4)?[..]).get_u32();
    let len = len - meta_block_offset as u64 - 4;
    let meta_block = &file.read(meta_block_offset as u64, len)?[..];
    let block_meta = BlockMeta::decode_block_meta(meta_block);
    Ok(SsTable {
        file,
        block_meta_offset: meta_block_offset as usize,
        id,
        block_cache,
        first_key: block_meta.first().unwrap().first_key.clone(),
        last_key: block_meta.last().unwrap().last_key.clone(),
        bloom: None,
        block_meta: block_meta,
        max_ts: 0,
    })
}

Task 2-SST Iterator

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

src/table/iterator.rs
src/table.rs

與BlockIterator類似,您需要在SST上實現一個迭代器。請注意,您應該按需載入資料。例如,如果您的迭代器位於塊1,則在到達下一個塊之前,不應該在記憶體中儲存任何其他塊內容。

SsTableIterator應該實現StorageIterator特性,以便將來可以與其他迭代器組合使用。

有一點需要注意的是find_to_key函式。基本上,您需要對塊後設資料執行二進位制搜尋,以找到可能包含鍵的塊。有可能key在LSM樹中不存在,所以塊迭代器在一次查詢後立即失效。例如:

--------------------------------------
| block 1 | block 2 |   block meta   |
--------------------------------------
| a, b, c | e, f, g | 1: a/c, 2: e/g |
--------------------------------------

我們建議只使用每個塊的第一個鍵來執行二分查詢,以降低實現的複雜性。如果我們在這個SST中查詢b,則相當簡單——使用二分查詢,我們可以知道塊1包含鍵a<=keys<e。因此,我們載入塊1,並尋找塊迭代器到對應的位置。

但是,如果我們要尋找d,我們將定位到塊1,如果我們僅使用first key作為二分查詢條件,但在塊1中尋找d將到達塊的末尾。因此,我們應該在查詢之後檢查迭代器是否無效,必要時切換到下一個塊。或者您可以利用最後一個關鍵後設資料直接定位到正確的塊,這取決於您。

seek_to_first

create_and_seek_to_first與之類似,就是讀取0號block塊,呼叫BlockIterator::create_and_seek_to_first方法生成BlockIterator:

pub fn seek_to_first(&mut self) -> Result<()> {
    self.blk_iter = BlockIterator::create_and_seek_to_first(self.table.read_block(0)?);
    self.blk_idx = 0;
    Ok(())
}

seek_to_key

create_and_seek_to_key與之類似,有以下兩種情況:

  1. 找到第一個不滿足key < first_key的,本block中存在大於key

  1. 找到第一個不滿足key < first_key的,本block中不存在大於key,需要跳轉下一個block

pub fn seek_to_key(&mut self, key: KeySlice) -> Result<()> {
    let mut index = self.table.find_block_idx(key);
    self.blk_iter = BlockIterator::create_and_seek_to_key(self.table.read_block(index)?, key);
    if !self.blk_iter.is_valid() && index != self.table.num_of_blocks() - 1 {
        index += 1;
        self.blk_iter = BlockIterator::create_and_seek_to_first(self.table.read_block(index)?);
    }
    self.blk_idx = index;
    Ok(())
}

find_block_idx

在剛剛的函式實現中,需要為SsTable物件實現find_block_idx方法。找到最後一個first_key <= keyblock

pub fn find_block_idx(&self, key: KeySlice) -> usize {
    self.block_meta
        .partition_point(|meta| meta.first_key.as_key_slice() <= key)
        .saturating_sub(1)
}
  • partition_point:常用於迭代器上,它用來找出迭代器中滿足某個條件的元素與不滿足該條件的元素之間的分界點。此函式返回的是一個索引值,指示了第一個不滿足給定謂詞(predicate)的元素的位置。
  • saturating_sub:用於執行飽和減法。飽和減法是指當減法操作的結果超出型別的表示範圍時,結果會被“飽和”到該型別的邊界值。

Task 3-Block Cache

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

src/table/iterator.rs
src/table.rs

你可以在SsTable上實現一個新的read_block_cached函式。

我們使用moka-rs作為我們的塊快取實現。塊以(sst_id,block_id)作為快取鍵進行快取。您可以使用try_get_with從快取中獲取塊(如果命中快取)或者填充快取(如果未命中快取)。如果有多個請求讀取相同的塊並且快取未命中,則try_get_with將僅向磁碟發出單個讀取請求,並將結果廣播給所有請求。

在這一點上,你可以修改任務二SST Iterator使用read_block_cached而不是read_block來利用塊快取。

moka-rs

moka 是一個流行的 Rust 第三方庫,主要用於提供高效能的快取解決方案。它被廣泛應用於需要快速訪問資料的應用場景,比如 Web 伺服器、資料庫系統和其他對效能有高要求的服務。

主要特點

  • 高效能:moka 使用高效的記憶體分配策略和併發控制機制來實現低延遲和高吞吐量。
  • 執行緒安全:它提供了執行緒安全的快取實現,可以輕鬆地在多執行緒環境中使用。
  • 多種容量限制:moka 支援基於條目數量或總位元組數的快取容量限制。
  • LRU 快取淘汰策略:預設情況下,moka 使用最近最少使用 (Least Recently Used, LRU) 演算法來管理快取中的條目。

使用示例

下面是一個簡單的使用示例,建立一個執行緒安全的快取,並新增一些鍵值對:

use moka::sync::Cache;

fn main() {
    // 建立一個快取例項,設定最大容量為 100 個條目
    let cache: Cache<String, String> = Cache::new(100);

    // 插入一些鍵值對
    cache.insert("key1".to_string(), "value1".to_string());
    cache.insert("key2".to_string(), "value2".to_string());

    // 獲取快取中的值
    if let Some(value) = cache.get("key1") {
        println!("Value of key1: {}", value);
    } else {
        println!("Key1 not found");
    }

    // 清除快取
    cache.clear();
}

try_get_with

try_get_with 方法是一種在快取中查詢鍵對應的值的方法,如果鍵不在快取中,則會嘗試使用一個閉包來計算該值,並將其插入快取。這個方法對於避免重複計算和提高效能特別有用,因為它可以確保每個鍵只被計算一次

read_block_cached

先使用if let獲取block_cache的值,再使用try_get_with獲取/插入新資料。

pub fn read_block_cached(&self, block_idx: usize) -> Result<Arc<Block>> {
    if let Some(ref block_cache) = self.block_cache {
        let blk = block_cache
            .try_get_with((self.id, block_idx), || self.read_block(block_idx))
            .map_err(|e| anyhow!("{}", e))?;
        Ok(blk)
    } else {
        return self.read_block(block_idx);
    }
}

相關文章