CMU15-455 Lab2 - task4 Concurrency Index -併發B+樹索引演算法的實現

南風sa發表於2021-03-16

最近在做 CMU-15-445 Database System,lab2 是需要完成一個支援併發操作的B+樹,最後一部分的 Task4 是完成併發的索引這裡對這部分加鎖的思路和完成做一個總結,關於 B+ 樹本身的操作(插入、刪除)之後再整理。

一些基礎知識

索引的併發控制

併發控制協議是DBMS用來確保對共享物件進行併發操作的“正確”結果的方法。

協議的正確性標準可能會有所不同:

  • 邏輯正確性:這意味著執行緒能夠讀取應允許其讀取的值。
  • 物理正確性:這意味著資料結構中沒有指標,這將導致執行緒讀取無效的記憶體位置。

Lock 和 Latch

Lock

Lock 是一種較高階別的邏輯原語,可保護資料庫的內容(例如,元組,表,資料庫)免受其他事務的侵害。事務將在整個持續時間內保持鎖定狀態。資料庫系統可以將查詢執行時所持有的鎖暴露給使用者。鎖需要能夠回滾更改。

Latch

latch 是低階保護原語,用於保護來自其他執行緒的DBMS內部資料結構(例如,資料結構,記憶體區域)的關鍵部分。 latch 僅在執行操作期間保持。 latch 不需要能夠回滾更改。 latch 有兩種模式:

  • READ:允許多個執行緒同時讀取同一專案。一個執行緒可以獲取讀 latch ,即使另一個執行緒也已獲取它。
  • WRITE:僅允許一個執行緒訪問該專案。如果另一個執行緒以任何模式保持該 latch ,則該執行緒將無法獲取寫 latch 。持有寫 latch 的執行緒還可以防止其他執行緒獲取讀 latch

這部分 提供的 RWLatch 的實現真的寫得好,放到末尾來參考

這裡對他們的不同做一個比較:

Latch 的實現

用於實現 latch 的基礎原語是通過現代CPU提供的原子比較和交換(CAS)指令實現的。這樣,執行緒可以檢查記憶體位置的內容以檢視其是否具有特定值。如果是這樣,則CPU將舊值交換為新值。否則,記憶體位置的值將保持不變。

有幾種方法可以在DBMS中實現 latch 。每種方法在工程複雜性和執行時效能方面都有不同的權衡。這些測試和設定步驟是自動執行的(即,沒有其他執行緒可以更新測試和設定步驟之間的值。

Blocking OS Mutex

latch(鎖存器) 的一種可能的實現方式是OS內建的互斥鎖基礎結構。 Linux提供了mutex(fast user-space mutex ),它由(1) user space 中的自旋 latch 和(2)OS級別的 mutex 組成。

如果DBMS可以獲取 user space latch ,則設定 latch 。即使它包含兩個內部 latch ,它也顯示為DBMS的單個 latch 。如果DBMS無法獲取 user space latch ,則它將進入核心並嘗試獲取更昂貴的互斥鎖。如果DBMS無法獲取第二個互斥鎖,則執行緒會通知OS鎖已被阻塞,然後對其進行排程。

作業系統互斥鎖通常是DBMS內部的一個不好的選擇,因為它是由OS管理的,並且開銷很大。

Test-and-Set Spin Latch (TAS)

自旋 latch 是OS互斥鎖的更有效替代方法,因為它是由DBMS控制的。自旋 latch 本質上是執行緒在記憶體中嘗試更新的位置(例如,將布林值設定為true)。執行緒執行CAS以嘗試更新記憶體位置。如果無法獲取 latch ,則DBMS可以控制會發生什麼。它可以選擇重試(例如,使用while迴圈)或允許作業系統取消排程。因此,與OS互斥鎖相比,此方法為DBMS提供了更多的控制權,而OS互斥鎖無法獲取 latch 而使OS得到了控制權。

Reader-Writer Latches

互斥鎖和自旋 latch 不區分讀/寫(即,它們不支援不同的模式)。 DBMS需要一種允許併發讀取的方法,因此,如果應用程式進行大量讀取,它將具有更好的效能,因為讀取器可以共享資源,而不必等待。

讀寫器 latch 允許將 latch 保持在讀或寫模式下。它跟蹤在每種模式下有多少個執行緒保持 latch 並正在等待獲取 latch 。讀取器-寫入器 latch 使用前兩個 latch 實現中的一種作為原語,並具有其他邏輯來處理讀取器-寫入器佇列,該佇列是每種模式下對 latch 的佇列請求。不同的DBMS對於如何處理佇列可以具有不同的策略。

B+樹加鎖演算法

為了能儘可能安全和更多的人使用B+樹,需要使用一定的鎖演算法來講 B+ 樹的某部分鎖住來進行操作。這裡將使用 Lock crabbing / coupling 協議來允許多個執行緒同時訪問/修改B+樹,基本的思想如下:

  1. 獲取 parent 的 latch
  2. 獲取 child 的 lacth
  3. 如果 child 是 “安全的”,那麼就可以釋放 parent 的鎖。“安全”指的是某個節點在子節點更新的時候不會 分裂 split 或者 合併 merge(在插入的時候不滿,在刪除的時候大於最小的大小)

當然,這裡需要區分讀鎖和寫鎖,read latch 和 write latch

鎖是在針對於每個 Page 頁面上的,我們將對每個 內部頁面或者根頁面進行鎖

推薦看 cmu-15-445 lecture 9 的例子,講解的非常清楚,這裡舉幾個例子:

例子

查詢

... 一步步向下加鎖,如果獲得了子頁面,就將父親的讀鎖直接釋放

刪除和插入

刪除和插入其實都是寫鎖,區別不大,只是在具體的判斷某個節點是否安全的地方進行不同的判斷即可。這裡舉一個不安全插入的例子:

優化

我們上面聊到的其實是悲觀鎖的一種實現,也就是說如果處於不安全狀態,我們就一定加鎖(注意,不安全狀態不一定),所以可能效率可能會稍微打一點折扣,這裡介紹一下樂觀鎖的思路:

假定預設是查詢多,大多數操作不會進行分裂 split 或者 合併 merge,不會修改到父親頁面,一直樂觀地在樹中採用讀鎖來遍歷,直到真的發現會修改父親頁面之後,再次以悲觀鎖的方式執行一次寫操作即可。

Leaf Scan

剛才提到的的執行緒以“自上而下”的方式獲取 latch 。這意味著執行緒只能從其當前節點下方的節點獲取 latch 。如果所需的 latch 不可用,則執行緒必須等待直到可用。鑑於此,永遠不會出現死鎖。

但是葉節點掃描很容易發生死鎖,因為現在我們有執行緒試圖同時在兩個不同方向(即從左到右和從右到左)獲取鎖。索引 latch 不支援死鎖檢測或避免死鎖。

所以解決這個問題的唯一方法是通過編碼規則。葉子節點同級 latch 獲取協議必須支援“無等待”模式。也就是說,B +樹程式碼必須處理失敗的 latch 獲取。這意味著,如果執行緒嘗試獲取葉節點上的 latch ,但該 latch 不可用,則它將立即中止其操作(釋放其持有的所有 latch ),然後重新開始操作。

我的想法是:可以來採用單方向的鎖,或者是兩個方向的葉子鎖,假定一個方向的優先順序問題,優先順序高的可以搶佔優先順序較低方向的鎖。

具體實現思路

transaction

每個執行緒針對資料庫的操作都會新建一個事務,每個事務內都會執行不同的操作,在每次執行事務的時候對當前事務進行一個標記,用一個列舉型別表明本次事務是增刪改查的哪一種:

/**
 * 操作的類別
 */
enum class OpType { READ = 0, INSERT, DELETE, UPDATE };

自頂向下遞迴查詢子頁面

這是整個悲觀鎖演算法的最基礎的地方,也就是上方的例子中的內容,當我們遞迴去查詢 Leaf Page 的時候就可以對“不安全的”頁面加上鎖,此後,就不需要再次加鎖了,同時將所有鎖住的 Page 利用 transaction 來儲存,這樣在最終修改結束之後統一釋放不安全頁面的讀鎖或者寫鎖即可。

這裡對兩個核心的函式進行說明:

遞迴查詢子頁面

遞迴查詢子頁面過程中需要加鎖,將這個邏輯抽離出去,根據不同事務的操作來決定是否釋放鎖

/*
 * Find leaf page containing particular key, if leftMost flag == true, find
 * the left most leaf page
 */
INDEX_TEMPLATE_ARGUMENTS
Page *BPLUSTREE_TYPE::FindLeafPage(const KeyType &key, bool leftMost, Transaction *transaction) {
  {
    std::lock_guard<std::mutex> lock(root_latch_);
    if (IsEmpty()) {
      return nullptr;
    }
  }

  // 從 buffer pool 中取出對應的頁面
  Page *page = buffer_pool_manager_->FetchPage(root_page_id_);
  BPlusTreePage *node = reinterpret_cast<BPlusTreePage *>(page->GetData());

  if (transaction->GetOpType() == OpType::READ) {
    page->RLatch();
  } else {
    page->WLatch();
  }
  transaction->AddIntoPageSet(page);

  while (!node->IsLeafPage()) {  // 如果不是葉節點
    InternalPage *internal_node = reinterpret_cast<InternalPage *>(node);
    page_id_t child_page_id;
    if (leftMost) {
      child_page_id = internal_node->ValueAt(0);
    } else {
      child_page_id = internal_node->Lookup(key, comparator_);
    }

    Page *child_page = buffer_pool_manager_->FetchPage(child_page_id);
    BPlusTreePage *new_node = reinterpret_cast<BPlusTreePage *>(child_page->GetData());
    HandleRWSafeThings(page, child_page, new_node, transaction);  // 處理這裡的讀寫鎖,父親和孩子的都在這裡加鎖
    page = child_page;
    node = new_node;
  }

  return page;  // 最後找到的葉子頁面的包裝型別
}

處理向下查詢頁面鎖

/**
 * 根據孩子頁面是否安全判斷是否能夠釋放鎖
 *  1. 讀鎖,給孩子加上鎖之後給父親解鎖
 *  2. 寫鎖,孩子先加寫鎖,然後判斷孩子是否安全,安全的話釋放所有父親的寫鎖,然後將孩子加入到 PageSet 中
 */
INDEX_TEMPLATE_ARGUMENTS
void BPLUSTREE_TYPE::HandleRWSafeThings(Page *page, Page *child_page, BPlusTreePage *child_node,
                                        Transaction *transaction) {
  auto qu = transaction->GetPageSet();
  if (transaction->GetOpType() == OpType::READ) {
    child_page->RUnlatch();  // 給孩子加鎖
    ReleaseAllPages(transaction);
    transaction->AddIntoPageSet(child_page);  // 將孩子加入到 PageSet 中

  } else if (transaction->GetOpType() == OpType::INSERT) {
    child_page->WLatch();              // 給孩子加上讀鎖
    if (child_node->IsInsertSafe()) {  // 孩子插入安全,父親的寫鎖都可以釋放掉了
      ReleaseAllPages(transaction);
    }
    transaction->AddIntoPageSet(child_page);

  } else if (transaction->GetOpType() == OpType::DELETE) {
    child_page->WLatch();
    if (child_node->IsDeleteSafe()) {
      ReleaseAllPages(transaction);
    }
    transaction->AddIntoPageSet(child_page);
  }
}

統一釋放與延遲刪除頁面

將所有加上鎖的頁面加入到 transactionpageset 中,最終根據讀寫事務的不同來統一釋放鎖以及Unpin操作。

/**
 * 事務結束,釋放掉所有的鎖
 */
INDEX_TEMPLATE_ARGUMENTS
void BPLUSTREE_TYPE::ReleaseAllPages(Transaction *transaction) {
  auto qu = transaction->GetPageSet();
  if (transaction->GetOpType() == OpType::READ) {
    for (Page *page : *qu) {
      page->RUnlatch();
      buffer_pool_manager_->UnpinPage(page->GetPageId(), false);
    }
  } else {
    for (Page *page : *qu) {
      page->WUnlatch();
      buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
    }
  }
  (*qu).clear();

  // 延遲刪除到事務結束
  auto delete_set = transaction->GetDeletedPageSet();
  for (page_id_t page_id : *delete_set) {
    buffer_pool_manager_->DeletePage(page_id);
  }
  delete_set->clear();
}

針對 root_page_id

由於在好幾個地方會修改 root_page_id,所以在訪問和修改 root_page_id 的時候,需要額外使用一個同步變數來是這些程式互斥。

std::mutex root_latch;

Unpin 操作的統一

多執行緒訪問頁面有一個很重要的點,那就是需要在不需要使用頁面的時候 Unpin 掉,此時 buffer pool 才能正確的執行替換演算法。在出現多執行緒之後,將所以需要加鎖頁面的釋放 Unpin 操作統一到 transaction 的結束階段即可。

其他特殊情況

處理併發確實是一個比較難的事情,需要考慮到各種情況,這裡再說明幾個容易出錯的可能:

  1. 第一個事務,安全的刪除某個葉子節點 A,同時第二個事務不安全的刪除第一個事務葉子節點相鄰節點 B,此時B 可能會找兄弟節點 A 借元素或者是合併,所以此時 B 需要獲得 A 的寫鎖才能夠繼續進行

關於測試用例的一點吐槽

不得不吐槽一下 lab2b 的幾個 Task 的測試用例是真的拉跨,絕大多數 bug 都檢測不出來,只能自己構建大資料的測試用例,建議自己構造 1,000,000 長度的測試用例來測試B+樹在大資料的壓力下能夠正確執行頁面替換演算法,能否正確的執行悲觀鎖演算法,是否會出現死鎖,只有經過這樣的測試才能驗證B+樹的正確性。

相關文章