最近在做 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+樹,基本的思想如下:
- 獲取 parent 的 latch
- 獲取 child 的 lacth
- 如果 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);
}
}
統一釋放與延遲刪除頁面
將所有加上鎖的頁面加入到 transaction
的pageset
中,最終根據讀寫事務的不同來統一釋放鎖以及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 的結束階段即可。
其他特殊情況
處理併發確實是一個比較難的事情,需要考慮到各種情況,這裡再說明幾個容易出錯的可能:
- 第一個事務,安全的刪除某個葉子節點 A,同時第二個事務不安全的刪除第一個事務葉子節點相鄰節點 B,此時B 可能會找兄弟節點 A 借元素或者是合併,所以此時 B 需要獲得 A 的寫鎖才能夠繼續進行
關於測試用例的一點吐槽
不得不吐槽一下 lab2b 的幾個 Task 的測試用例是真的拉跨,絕大多數 bug 都檢測不出來,只能自己構建大資料的測試用例,建議自己構造 1,000,000 長度的測試用例來測試B+樹在大資料的壓力下能夠正確執行頁面替換演算法,能否正確的執行悲觀鎖演算法,是否會出現死鎖,只有經過這樣的測試才能驗證B+樹的正確性。