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

最近在做 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+樹的正確性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章