本文主要介紹B+樹的Copy-On-Write,包括由來、設計思路和核心源碼實現(以Xapian源碼爲例)。中文的互聯網世界裏,對B樹、B+樹的科普介紹很豐富,但對它們在工業界的實際使用卻幾乎沒有相關介紹文章,本文既是總結分享,也是資料索引。html
在閱讀本文以前須要先對B+樹有概念上的認識,能夠閱讀wiki,也能夠看看這兩篇簡單易懂的中文漫畫解讀,B-tree,B+tree。git
在介紹CoW(Copy-on-Write)以前,首先思考這樣一個問題:使用B+樹的數據庫在提供讀、寫服務時,若是葉子節點發生了節點分裂,而此時又有讀行爲,怎麼保證讀寫的線程安全?譬如:準備讀取葉子節點leaf時,leaf分裂爲leaf和leaf-new兩個block,這時候仍是讀取leaf節點,不就可能致使數據丟失了嗎,怎麼解決的?github
其中的一種解決方法即是CoW B+樹(其它解決方法還有:B-link樹、加鎖後原地更新數據等等),這種方法,也有些文章(見文末參考文獻)起了一個抽象層次更高的名字,叫作:shadowing。實現思路:在對數據進行操做(增、刪、改)以前,先把全部可能操做到的層級(全部祖先節點)數據塊都拷貝一份出來,後面的修改就在這份拷貝後的數據塊上作修改,修改完以後再把這些數據塊寫入到磁盤文件新的位置上,這時候磁盤中就有兩份數據,一份是修改以前的,一份是修改以後的,從修改以前的根節點開始遍歷,能夠讀到全部修改以前的舊版數據,從修改以後的新根節點開始遍歷,能夠讀到全部修改以後的新版數據。 從不一樣的根節點進去能夠讀取到不一樣版本的數據,這個CoW既保證了讀寫安全,也帶有很優雅的數據備份功能(數據快照)。數據庫
舉個實際的例子:在一個有7個節點(block)的B+樹中,根節點爲A,其葉子節點C有修改操做。把C以及它的祖先節點都拷貝一份:C'、B'、A',而後再在這些新拷貝的節點上修改數據,最後將修改後的數據寫入到磁盤文件新的位置上。在這個過程當中,若是有業務在讀取這顆B+樹,仍然能夠讀取到C、B、A的完整舊數據。等到C'、B'、A'節點的數據刷寫到磁盤完畢,再修改這顆B+樹的根節點爲A',這時業務就能讀取到這顆B+樹的新數據,此時舊數據A、B、C也仍然存在,能夠選擇保留做爲備份,也能夠選擇回收磁盤空間。(參考了文末的文章《B-trees, Shadowing, and Clones》)api
B+樹的CoW示例,每個框表明磁盤中的一個數據塊(block) 安全
原理部分介紹完了,很簡單吧?併發
咱們來看看Xapian是怎麼實現CoW B+樹的,由於這是一個大系統裏的一部分代碼,看不懂也不要緊,感興趣的朋友也能夠直接閱讀完整源碼。app
首先是,在修改前的節點拷貝。源碼:高併發
void GlassTable::alter() { LOGCALL_VOID(DB, "GlassTable::alter", NO_ARGS); Assert(writable); if (flags & Xapian::DB_DANGEROUS) { C[0].rewrite = true; return; } int j = 0; while (true) { if (C[j].rewrite) return; /* all new, so return */ C[j].rewrite = true; glass_revision_number_t rev = REVISION(C[j].get_p()); if (rev == revision_number + 1) { return; } Assert(rev < revision_number + 1); uint4 n = C[j].get_n(); free_list.mark_block_unused(this, block_size, n); /// 將當前須要被拷貝的block設置爲空閒,這個空閒標記要等到新block被刷到磁盤(commit操做)以後才生效 SET_REVISION(C[j].get_modifiable_p(block_size), revision_number + 1); /// j層級的遊標申請新的內存block,並設置版本號+1 n = free_list.get_block(this, block_size); /// 從空閒塊中取一個塊號做爲新block的塊序號(block_size*n也便是這個塊在磁盤文件的偏移) C[j].set_n(n); /// 將塊序號設置到遊標中 if (j == level) return; /// 若是根節點也已經拷貝完畢,則返回 j++; /// j+1,準備拷貝父節點 BItem_wr(C[j].get_modifiable_p(block_size), C[j].c).set_block_given_by(n); /// 修改當前(j-1)層級的父節點指向新的block,注意:這裏修改的也是拷貝後的節點數據 } }
而後,在新block中修改數據,這塊包括了增、刪、改,代碼比較多,不貼。post
最後,將全部的修改提交(commit),這裏有順序要求:一、將遊標中全部未持久化的數據寫入磁盤;二、在內存中讓新根節點生效;三、新根節點以及其它meta信息寫入版本文件(也就是記錄B+樹元信息的文件:iamglass文件)。
部分源碼:
void GlassDatabase::apply() { LOGCALL_VOID(DB, "GlassDatabase::apply", NO_ARGS); if (!postlist_table.is_modified() && !position_table.is_modified() && !termlist_table.is_modified() && !value_manager.is_modified() && !synonym_table.is_modified() && !spelling_table.is_modified() && !docdata_table.is_modified()) { return; } glass_revision_number_t new_revision = get_next_revision_number(); int flags = postlist_table.get_flags(); try { set_revision_number(flags, new_revision); /// 這裏作了數據寫入磁盤、生效新節點數據的操做 } catch (const Xapian::Error &e) { modifications_failed(new_revision, e.get_description()); throw; } catch (...) { modifications_failed(new_revision, "Unknown error"); throw; } /// 下面這一票代碼,是爲了記錄修改到changeset文件,changeset文件用於主從節點的增量數據同步 GlassChanges * p; p = changes.start(new_revision, new_revision + 1, flags); version_file.set_changes(p); postlist_table.set_changes(p); position_table.set_changes(p); termlist_table.set_changes(p); synonym_table.set_changes(p); spelling_table.set_changes(p); docdata_table.set_changes(p); }
以上是CoW B+tree的主要內容。
有朋友可能會有疑問,我就添加幾個字節的數據,採用CoW設計,須要拷貝多個數據塊,是否太浪費了?確實浪費,因此採用CoW的系統通常都會攢一堆數據以後,再寫入到B+樹索引中,在儘可能保證時效性前提下減小拷貝新數據塊、減小寫磁盤;另外,對讀多寫少的業務場景來講,寫入時的性能浪費幾乎能夠忽略,而帶來的收益倒是讀取的高併發,是很是值得的trade-off。
參考《A Short History of the BTree》,CoW B+tree是第三代技術。第四代技術圍繞着CoW的更新效率、空間浪費、隨機IO這幾方面的缺點作了優化,做者稱之爲「‘stratified B-tree」,相關文章《Stratified B-trees and versioning dictionaries》,後續有時間再作學習。
學習資料:
一、《Xapian源碼1.4.10》
二、《how the append-only btree works》介紹如何實現append-only B-tree,很是詳細易懂
三、《A Short History of the BTree》B-tree歷史介紹
四、《B-tree, Shadowing, and Clones》 很詳細的B+樹 Shadowing、COW介紹文章