cmu15445 數據庫系統實驗一:buffer pool manager

cmu15445 是一門關於數據庫管理系統(DBMS)設計與實現的經典公開課。該課程以 Database System Concepts 爲教材,提供隨堂講義、筆記和視頻,精心準備了幾個互相勾連的小實驗。該課程十分注重系統設計和編程實現,用主講教授 Andy Pavlo 的話說,這是一門能夠寫在簡歷上、而且能幫你拿到好 offer 的課程。git

這個假期得空,翻出這門課程,即被其翔實的內容、精當的組織所折服。無奈時間有限,只能以實驗爲主線,輔以講義和筆記,簡單跟一跟。若是再有時間,就去掃下教材和視頻。從實驗一開始,每一個實驗 autograder 跑過以後,出一篇筆記,聊以備忘。Andy Pavlo  教授建議不要公開實驗代碼倉庫,所以文章儘可能少貼代碼,多寫思路。github

本篇是實驗一,管理文件系統的頁在內存中的緩存 —— buffer pool manager。web

概覽

實驗的目標系統 BusTub 是一個面向磁盤的 DBMS,但磁盤上的數據不支持字節粒度的訪問。這就須要一個管理頁的中間層,但 Andy Pavlo  教授堅持不使用 mmap 將頁管理權力讓渡給操做系統,所以實驗一 的目標便在於主動管理磁盤中的頁(page)在內存中的緩存,從而,最小化磁盤訪問次數(時間上)、最大化相關數據連續(空間上)。數據庫

該實驗能夠分解爲相對獨立的兩個子任務:編程

  1. 維護替換策略的:LRU replacement policy
  2. 管理緩衝池的:buffer pool manager

兩個組件都要求線程安全。數組

本文首先從基本概念、核心數據流整體分析下實驗內容,而後分別對兩個子任務進行梳理。緩存

做者:青藤木鳥 https://www.qtmuniao.com/2021/02/10/cmu15445-project1-buffer-pool/, 轉載請註明出處安全

實驗分析

剛開始寫實驗代碼的時候,感受細節不少,實現時很容易丟三落四。但隨着實現和思考的深刻,漸漸摸清了全貌,發現只要明確幾個基本概念和核心數據流,便可以提綱挈領。微信

基本概念

buffer pool 的操做的基本單位爲一段邏輯連續的字節數組,在磁盤上表現爲頁(page),有惟一的標識 page_id;在內存中表現爲幀(frame),有惟一的標識 frame_id。爲了記下哪些 frame 存的哪些 page,須要使用一個頁表(page table)數據結構

下邊行文可能會混用 page 和 frame,由於這兩個概念都是 buffer pool 管理數據的基本單位,通常爲 4k,其區別以下:

  1. page id 是這一段單位數據的全局標識,而 frame id 只是在內存池(frame 數組)中索引某個 page 下標
  2. page 在文件系統中是一段邏輯連續的字節數組;在內存中,咱們會給其附加一些元信息: pin_count_is_dirty_

基本概念

而管理幀的內存池大小通常來講是遠小於磁盤的,所以在內存池滿了後,再從磁盤加載新的頁到內存池,須要 某種替換策略(replacer)將一些再也不使用的頁踢出內存池以騰出空間。

核心數據流

先說結論,buffer pool manager 的實現核心,在於對內存池中全部 frame 的狀態的管理。所以,若是咱們能梳理出 frame 的狀態機,即可以把握好核心數據流。

buffer pool 維護了一個 frame 數組,每一個 frame 有三種狀態:

  1. free:初始狀態,沒有存聽任何 page
  2. pinned:存放了 thread 正在使用的 page
  3. unpinned:存放了 page,但 page 已經再也不爲任何 thread 所使用

而待實現函數:

FetchPageImpl(page_id)
NewPageImpl(page_id)
UnpinPageImpl(page_id, is_dirty)
DeletePageImpl(page_id)

即是驅動狀態機中上述狀態發生改變的動做(action),狀態機以下:

frame 狀態機

對應到實現時數據結構上:

  1. 保存 page 數據的 frame 數組爲 pages_
  2. 全部 free  frame 的索引(frame_id)保存在 free_list_
  3. 全部 unpinned  frame 的索引保存在 replacer_
  4. 全部 pinned  frame 索引和 unpinned frame 的索引保存在 page_table_ 中,並經過 page 中 pin_count_ 字段來區分兩個狀態。

上圖中,NewPage1 和 NewPage2 表示在 NewPage 函數中,每次獲取空閒 frame 時,會先去空閒列表(freelist_)中取一個 free frame,若是取不到,纔會去 replacer_ 中驅逐一個 unpinned 的 frame 後使用。這體現了 buffer pool manager 實現的一個目標:最小化磁盤訪問,緣由後面分析。

實驗組件

把握了本實驗的基本概念和核心數據流後,再來分析兩個子任務。

TASK #1 - LRU REPLACEMENT POLICY

之前在 LeetCode 上寫過相關實現,所以很天然的帶入以前經驗,但隨後發現這兩個接口有一些不一樣。

LeetCode 上提供的是 kv store 接口,在 get/set 的時候完成新老順序的維護,並在內存池滿後自動替換最老的 KV。

但本實驗提供的是 replacer 接口,維護一個 unpinned 的 frame_id 列表 ,在調用  Unpin 時將 frame_id 加入列表並維護新老順序、在調用 Pin 時將 frame_id 從列表中摘除、在調用 Victim 的時候將最老的 frame_id 返回。

固然,本質上仍是同樣,所以本實驗我也是採用 unordered_map 和 doubly linked list 的數據結構,實現細節再也不贅述。須要注意的是,若是 Unpin 時發現 frame_id 已經在 replacer 中,則直接返回,並不改變列表的新老順序。由於邏輯上來講,同一個 frame_id,並不能被 Unpin 屢次,所以咱們只須要考慮 frame_id 第一次 Unpin。

放到更大的語境中,本質上,replacer 就是一個維護了回收順序的回收站,即咱們將全部 pin_count_ = 0 的 page 不直接從內存中刪除,而是放入回收站中。根據數據訪問的時間局部性原理,剛剛被訪問的 page 極可能再次被訪問,所以當咱們不得不從回收站中真刪(Victim)一個 frame 時,須要刪最老的 frame。當以後咱們想訪問一個剛加入回收站的數據時, 只須要將 page 從這個回收站中撈出來,從而省去一次磁盤訪問,這也就達到了最小化磁盤訪問的目標。

TASK #2 - BUFFER POOL MANAGER

在實驗分析部分已經把核心邏輯說的差很少了,這裏簡單羅列一下我實現中遇到的問題。

page_table_ 的範圍。在最初實現時,畫出 frame 的狀態機以後,感受 page_table_ 中只放 pinned frame id 很完美:可使 frame id 按狀態互斥的分佈在 free_list_replacer_page_table_ 中。但後來發現,若是不將 unpinned frame id 保存在 page_table_ 中,就不能很好地複用 pin_count_ = 0 的 page 了,replacer 也就沒有了意義。

dirty page 的刷盤時機。有兩種策略,一種是每次 Unpin 的時候都刷,這樣會刷比較頻繁,但能保證異常掉電重啓後內容不丟;一種是在 replacer victimized 的時候 lazily 的刷,這樣能保證刷的次數最少。這是性能和可靠性取捨,僅考慮本實驗,二者確定都能過。

NewPage 不要讀盤。這個就是我寫的 bug 了,畢竟 NewPage 的時候,磁盤上根本沒有對應 page 的內容,所以會報以下錯誤:

2021-02-18 16:53:47 [autograder/bustub/src/storage/disk/disk_manager.cpp:121:ReadPage] DEBUG - Read less than a page
2021-02-18 16:53:47 [autograder/bustub/src/storage/disk/disk_manager.cpp:108:ReadPage] DEBUG - I/O error reading past end of file

複用 frame 時清空元信息。在複用一個從 replacer 中驅逐的 frame 時尤爲要注意,使用前必定要將 pin_count_\is_dirty_ 這些字段清空。固然,在 DeletePage 的時候,也須要注意將 page_id_ 置爲 INVALID_PAGE_ID 、清空上述字段。不然,再次使用時, 若是 pin_count_Unpin 後,數值不爲 0,會致使 DeletePage 時刪不掉該 page。

鎖的粒度。最粗暴的就是每一個函數範圍粒度加鎖便可,後期若是須要優化,再將鎖的粒度變細。

實驗代碼

FetchPageImpl 爲例強調下一些實現的細節,注意到,實驗已經經過註釋給出了實現框架。

我使用中文註釋注出了一些我認爲須要注意的點。

Page *BufferPoolManager::FetchPageImpl(page_id_t page_id) {
  // a. 使用自動獲取和釋放鎖
  std::scoped_lock<std::mutex> lock(latch_);
  
  // 1.     Search the page table for the requested page (P).
  // 1.1    If P exists, pin it and return it immediately.
  auto target = page_table_.find(page_id); // b. 判斷存在與訪問數據只用一次查找
  if (target != page_table_.end()) {
    frame_id_t frame_id = target->second;
    // c. 經過指針運算獲取 frame_id 處存放的 Page 結構體
    Page *p = pages_ + frame_id; 
    p->pin_count_++;
    replacer_->Pin(frame_id); // d. 將對應 page 從「回收站」中撈出
    return p;
  }

  // 1.2    If P does not exist, find a replacement page (R) from either the free list or the replacer.
  //        Note that pages are always found from the free list first.
  frame_id_t frame_id = -1
  Page *p = nullptr;
  if (!free_list_.empty()) {
    frame_id = free_list_.back(); // e. 在結尾處操做效率高一點
    free_list_.pop_back();
    assert(frame_id >= 0 && frame_id < static_cast<int>(pool_size_));
    p = pages_ + frame_id;
    
    // f. 從 freelist 中獲取的 dirty page 已經在 delete 時寫回了
  } else {
    bool victimized = replacer_->Victim(&frame_id);
    if (!victimized) {
      return nullptr;
    }
    assert(frame_id >= 0 && frame_id < static_cast<int>(pool_size_));
    p = pages_ + frame_id;

    // 2.     If R is dirty, write it back to the disk.
    if (p->IsDirty()) {
      disk_manager_->WritePage(p->GetPageId(), p->GetData());
      p->is_dirty_ = false;
    }
    p->pin_count_ = 0// g. 將元信息 pin_count_ 清空
  }

  // 3.     Delete R from the page table and insert P.
  page_table_.erase(p->GetPageId()); // h. 時刻注意區分 p->GetPageId() 與 page_id 是否相等,別混用
  page_table_[page_id] = frame_id;

  // 4.     Update P's metadata, read in the page content from disk, and then return a pointer to P.
  p->page_id_ = page_id;
  p->ResetMemory();
  disk_manager_->ReadPage(page_id, p->GetData());
  p->pin_count_++;
  return p;
}

實驗相關 autograder 能夠在 FAQ 中找到註冊地址和邀請碼,提交代碼的時候最好不要提交 github 倉庫地址,會有不少格式問題。能夠每次按照實驗頁面的指示,將相關文件按目錄結構達成 zip 包提交便可。

提交事項

仔細閱讀實驗描述,提交前須要注意的事項:

  1. 在 build 目錄運行 make format ,自動格式化。
  2. 在 build 目錄運行 make check-lint,檢查一些語法問題。
  3. 本身針對每一個函數在本地設計一些測試,寫到相關文件(本實驗 buffer_pool_manager_test.cpp )中,而且打開測試開關,在 build 文件夾下,編譯 make buffer_pool_manager_test,運行 ./test/buffer_pool_manager_test

貼一個 project1 autograder 的實驗結果:

autograder 結果

小結

這是 cmu15445 第一個實驗,實現了在磁盤和內存間按需搬運頁(page)的 buffer pool manager。本實驗的關鍵之處在於把握基本概念,梳理出核心數據流,在此基礎上注意一些實現的細節便可。







歡迎關注分佈式點滴


歡迎關注個人視頻號

本文分享自微信公衆號 - 分佈式點滴(distributed-system)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索