boltdb 是市面上爲數很少的純 go 語言開發的、單機 KV 庫。boltdb 基於 Howard Chu's LMDB 項目 ,實現的比較清爽,去掉單元測試和適配代碼,核心代碼大概四千多行。簡單的 API、簡約的實現,也是做者的意圖所在。因爲做者精力所限,原 boltdb 已經封版,再也不更新。若想改進,提交新的 pr,建議去 etcd 維護的 fork 版本 bbolt。node
爲了方便,本系列導讀文章仍以再也不變更的原 repo 爲基礎。該項目麻雀雖小,五臟俱全,僅僅四千多行代碼,就實現了一個基於 B+ 樹索引、支持一寫多讀事務的單機 KV 引擎。代碼自己簡約樸實、註釋得當,若是你是 go 語言愛好者、若是對 KV 庫感興趣,那 boltdb 絕對是不可錯過的一個 repo。mysql
本系列計劃分紅三篇文章,依次圍繞數據組織、索引設計、事務實現等三個主要方面對 boltdb 源碼進行剖析。因爲三個方面不是徹底正交解耦的,所以敘述時會不可避免的產生交織,讀不懂時,暫時略過便可,待有全貌,再回來梳理。本文是第一篇, boltdb 數據組織。git
概覽
數據庫中經常使用的索引設計有兩種,一個是 B+ 樹,一個是 LSM-tree。B+ 樹比較經典,好比說傳統單機數據庫 mysql 就是 B+ 樹索引,它對快速讀取和範圍查詢(range query)比較友好。LSM-tree 是近年來比較流行的索引結構,Bigtable、LevelDB、RocksDB 都有它的影子;前面文章也有提到,LSM-tree 使用 WAL 和多級數據組織以犧牲部分讀性能,換來強悍的隨機寫性能。所以,這也是一個經典的取捨問題。github
BoltDB 在邏輯上以桶來組織數據,一個桶能夠看作一個命名空間,是一組 KV 對的集合,和對象存儲的桶概念相似。每一個桶對應一棵 B+ 樹,命名空間是能夠嵌套的,所以 BoltDB 的 Bucket 間也是容許嵌套的。在實現上來講,子 bucket 的 root node 的 page id 保存在父 bucket 葉子節點上實現嵌套。web
每一個 db 文件,是一組樹形組織的 B+ 樹。咱們知道對於 B+ 樹來講,分支節點用於查找,葉子節點存數據。sql
-
頂層 B+ 樹,比較特殊,稱爲 root bucket,其全部葉子節點保存的都是子 bucket B+ 樹根的 page id 。 -
其餘 B+ 樹,不妨稱之爲 data bucket,其葉子節點多是正經常使用戶數據,也多是子 bucket B+ 樹根的 page id。
相比普通 B+ 樹,boltdb 的 B+ 樹有幾點特殊之處:數據庫
-
節點的分支個數不是一個固定範圍,而是依據其所存元素大小之和來限制的,這個上限即爲頁大小。 -
其分支節點(branch node)所存分支的 key,是其所指向分支的最小 key。 -
全部葉子節點並無經過鏈表首尾相接起來。 -
沒有保證全部的葉子節點都在同一層。
在代碼組織上,boltdb 索引相關的源文件以下:數組
-
bucket.go:對 bucket 操做的高層封裝。包括kv 的增刪改查、子bucket 的增刪改查以及 B+ 樹拆分和合並。 -
node.go:對 node 所存元素和 node 間關係的相關操做。節點內所存元素的增刪、加載和落盤,訪問孩子兄弟元素、拆分與合併的詳細邏輯。 -
cursor.go:實現了相似迭代器的功能,能夠在 B+ 樹上的葉子節點上進行隨意遊走。
本文將由主要分三部分,由局部到總體來一步步揭示 BoltDB 是如何進行索引設計的。首先會拆解樹的基本單元,其次剖析 bucket 的遍歷實現,最後分析樹的生長和平衡過程。緩存
做者:青藤木鳥 https://www.qtmuniao.com/2020/12/14/bolt-index-design, 轉載請註明出處微信
基本單元——節點(Node)
B+ 樹的基本構成單元是節點(node),對應在上一篇中提到過文件系統中存儲的頁(page),節點包括兩種類型,分支節點(branch node)和葉子節點(leaf node)。但在實現時,他們複用了同一個結構體,並經過一個標誌位 isLeaf
來區分:
// node 表示內存中一個反序列化後的 page
type node struct {
bucket *Bucket // 其所在 bucket 的指針
isLeaf bool // 是否爲葉子節點
// 調整、維持 B+ 樹時使用
unbalanced bool // 是否須要進行合併
spilled bool // 是否須要進行拆分和落盤
key []byte // 所含第一個元素的 key
pgid pgid // 對應的 page 的 id
parent *node // 父節點指針
children nodes // 孩子節點指針(只包含加載到內存中的部分孩子)
inodes inodes // 所存元素的元信息;對於分支節點是 key+pgid 數組,對於葉子節點是 kv 數組
}
node/page 轉換
page 和 node 的對應關係爲:文件系統中一組連續的物理 page,加載到內存成爲一個邏輯 page ,進而轉化爲一個 node。下圖爲一個在文件系統上佔用兩個 pagesize 空間的一段連續數據 ,首先 mmap 到內存空間,轉換爲 page
類型,而後經過 node.read
轉換爲 node
的過程。
其中 node.read
將 page 轉換爲 node 的代碼以下:
// read 函數經過 mmap 讀取 page,並轉換爲 node
func (n *node) read(p *page) {
// 初始化元信息
n.pgid = p.id
n.isLeaf = ((p.flags & leafPageFlag) != 0)
n.inodes = make(inodes, int(p.count))
// 加載所包含元素 inodes
for i := 0; i < int(p.count); i++ {
inode := &n.inodes[i]
if n.isLeaf {
elem := p.leafPageElement(uint16(i))
inode.flags = elem.flags
inode.key = elem.key()
inode.value = elem.value()
} else {
elem := p.branchPageElement(uint16(i))
inode.pgid = elem.pgid
inode.key = elem.key()
}
_assert(len(inode.key) > 0, "read: zero-length inode key")
}
// 用第一個元素的 key 做爲該 node 的 key,以便父節點以此做爲索引進行查找和路由
if len(n.inodes) > 0 {
n.key = n.inodes[0].key
_assert(len(n.key) > 0, "read: zero-length node key")
} else {
n.key = nil
}
}
node 元素 inode
inode
表示 node 所含的內部元素,分支節點和葉子節點也複用同一個結構體。對於分支節點,單個元素爲 key+引用;對於葉子節點,單個元素爲用戶 kv 數據。
注意到這裏對其餘節點的引用類型爲 pgid
,而非 node*
。這是由於 inode
中指向的元素並不必定加載到了內存。boltdb 在訪問 B+ 樹時,會按需將訪問到的 page 轉化爲 node,並將其指針存在父節點的 children
字段中, 具體的加載順序和邏輯在後面小結會詳述。
type inode struct {
// 共有變量
flags uint32
key []byte
// 分支節點使用
pgid pgid // 指向的分支/葉子節點的 page id
// 葉子節點使用
value []byte // 葉子節點所存的數據
}
inode
會在 B+ 樹中進行路由——二分查找時使用。
新增元素
全部的數據新增都發生在葉子節點,若是新增數據後 B+ 樹不平衡,以後會經過 node.spill
來進行拆分調整。主要代碼以下:
// put inserts a key/value.
func (n *node) put(oldKey, newKey, value []byte, pgid pgid, flags uint32) {
// 找到插入點
index := sort.Search(len(n.inodes), func(i int) bool { return bytes.Compare(n.inodes[i].key, oldKey) != -1 })
// 若是 key 是新增而非替換,則需爲待插入節點騰空兒
exact := (len(n.inodes) > 0 && index < len(n.inodes) && bytes.Equal(n.inodes[index].key, oldKey))
if !exact {
n.inodes = append(n.inodes, inode{})
copy(n.inodes[index+1:], n.inodes[index:])
}
// 給要替換/插入的元素賦值
inode := &n.inodes[index]
inode.key = newKey
// ...
}
該函數的主幹邏輯比較簡單,就是二分查找到待插入位置,若是是覆蓋寫則直接覆蓋;不然就要新增一個元素,並總體右移,騰出插入位置。可是該函數簽名頗有意思,同時有 oldKey
和 newKey
,開始時感受很奇怪。其調用代碼有兩處:
-
在葉子節點插入用戶數據時, oldKey
等於newKey
,此時這兩個參數是有冗餘的。 -
在 spill
階段調整 B+ 樹時,oldKey
可能不等於newKey
,此時是有用的,但從代碼上來看,用處仍然很隱晦。
在和朋友討論後,大體得出以下結論:爲了不在葉子節點最左側插入一個很小的值時,引發祖先節點的 node.key
的鏈式更新,而將更新延遲到了最後 B+ 樹調整階段(spill
函數)進行統一處理 。此時,須要利用 node.put
函數將最左邊的 node.key
更新爲 node.inodes[0].key
:
// 該代碼片斷在 node.spill 函數中
if node.parent != nil { // 若是不是根節點
var key = node.key // split 後,最左邊 node
if key == nil { // split 後,非最左邊 node
key = node.inodes[0].key
}
node.parent.put(key, node.inodes[0].key, nil, node.pgid, 0)
node.key = node.inodes[0].key
}
節點遍歷
因爲 Golang 不支持 Python 中相似 yield
機制,boltdb 使用棧保存遍歷上下文實現了一個樹節點順序遍歷的迭代器:cursor
。在邏輯上能夠理解爲對某 B+ 樹葉子節點所存元素遍歷的迭代器。以前提到,boltdb 的 B+ 樹沒有使用鏈表將全部葉子節點串起來,所以須要一些額外邏輯來進行遍歷中各類細節的處理。
這麼實現增長了遍歷的複雜度,可是減小了維持 B+ 樹平衡性質的難度,也是一種取捨。
cursor
和某個 bucket 綁定,實現瞭如下功能,須要注意,當遍歷到的元素爲嵌套 bucket 時,value = nil
。
type Cursor
func (c *Cursor) Bucket() *Bucket // 返回綁定的 bucket
func (c *Cursor) Delete() error // 刪除 cursor 指向的 key
func (c *Cursor) First() (key []byte, value []byte) // 定位到並返回該 bucket 第一個 KV
func (c *Cursor) Last() (key []byte, value []byte) // 定位到並返回該 bucket 最後一個 KV
func (c *Cursor) Next() (key []byte, value []byte) // 定位到並返回該 bucket 下一個 KV
func (c *Cursor) Prev() (key []byte, value []byte) // 定位到並返回該 bucket 前一個 KV
func (c *Cursor) Seek(seek []byte) (key []byte, value []byte) // 定位到並返回該 bucket 內指定的 KV
因爲 boltdb 中 B+ 樹左右葉子節點並無經過鏈表串起來,所以遍歷時須要記下遍歷路徑以進行回溯。其結構體以下:
type Cursor struct {
bucket *Bucket // 使用該句柄來進行 node 的加載
stack []elemRef // 保留路徑,方便回溯
}
elemRef
結構體以下,page 和 node 是一一對應的,若是 page 加載到了內存中(經過 page 轉換而來),則優先使用 node,不然使用 page;index 表示路徑通過該節點時在 inodes
中的位置。
type elemRef struct {
page *page
node *node
index int
}
輔助函數
這幾個 API 在實現的時候是有一些通用邏輯能夠進行復用的,所以能夠做爲構件拆解出來。其中移動 cursor 在實現上,就是調整 cursor.stack
數組所表示的路徑。
// 尾遞歸,查詢 key 所在的 node,而且在 cursor 中記下路徑
func (c *Cursor) search(key []byte, pgid pgid)
// 藉助 search,查詢 key 對應的 value
// 若是 key 不存在,則返回待插入位置的 kv 對:
// 1. ref.index < ref.node.count() 時,則返回第一個比給定 key 大的 kv
// 2. ref.index == ref.node.count() 時,則返回 nil
// 上層 Seek 須要處理第二種狀況。
func (c *Cursor) seek(seek []byte) (key []byte, value []byte, flags uint32)
// 移動 cursor 到以棧頂元素爲根的子樹中最左邊的葉子節點
func (c *Cursor) first()
// 移動 cursor 到以棧頂元素爲根的子樹中最右邊的葉子節點
func (c *Cursor) last()
// 移動 cursor 到下一個葉子元素
// 1. 若是當前葉子節點後面還有元素,則直接返回
// 2. 不然須要藉助保存的路徑找到下一個非空葉子節點
// 3. 若是當前已經指向最後一個葉子節點的最後一個元素,則返回 nil
func (c *Cursor) next() (key []byte, value []byte, flags uint32)
組合遍歷
有了以上幾個基本構件,咱們來梳一下主要 API 函數的實現:
// 1. 將根節點放入 stack 中
// 2. 調用 c.first() 定位到根的第一個葉子節點
// 3. 若是該節點爲空,則調用 c.next() 找到下一個非空葉子節點
// 4. 返回其該葉子節點第一個元素
func (c *Cursor) First() (key []byte, value []byte)
// 1. 將根節點放入 stack 中
// 2. 調用 c.last() 定位到根的最後一個葉子節點
// 3. 返回其最後一個元素,不存在則返回空
func (c *Cursor) Last() (key []byte, value []byte)
// 1. 直接調用 c.next 便可
func (c *Cursor) Next() (key []byte, value []byte)
// 1. 遍歷 stack,回溯到第一個有前驅元素的分支節點
// 2. 將節點的 ref.index--
// 3. 調用 c.last(),定位到該子樹的最後一個葉子節點
// 4. 返回其最後一個元素
func (c *Cursor) Prev() (key []byte, value []byte)
// 1. 調用 c.seek(),找到第一個 >= key 的節點中元素。
// 2. 若是 key 正好落在兩個葉子節點中間,調用 c.next() 找到下一個非空節點
func (c *Cursor) Seek(seek []byte) (key []byte, value []byte) // 定位到並返回該 bucket 內指定的 KV
這些 API 的實現敘述起來稍顯繁瑣,但只要抓住其主要思想,而且把握一些邊界和細節,便能很容易看懂。主要思想比較簡單:cursor 最終目的是在全部葉子節點的元素進行遍歷,可是葉子節點並無經過鏈表串起來,所以須要藉助一個 stack 數組記下遍歷上下文——路徑,來實現對前驅後繼的快速(由於前驅後繼與當前葉子節點大機率共享前綴路徑)訪問。
另外須要注意的一些邊界和細節以下:
-
每次移動,須要先找節點,再找節點中元素。 -
若是節點已經轉換爲 node,則優先訪問 node;不然,訪問 mmap 出來的 page。 -
分支節點所記元素的 key 爲其指向的節點的 key,也即其節點所包含元素的最小 key。 -
使用 Golang 的 sort.Search
獲取第一個小於給定 key 的元素下標須要作一些額外處理。 -
幾個邊界判斷,node 中是否有元素、index 是否大於元素值、該元素是否爲子 bucket。 -
若是 key 不存在時,seek/search 定位到的是 key 應當插入的點。
樹的生長
咱們分幾個時間節點來展開說明下 boltdb 中 B+ 樹的生命週期:
-
數據庫初始化時 -
事務開啓後 -
事務提交時
最後在理解了這幾個階段的狀態的基礎上,整個串一下其生長過程。
初始化時
數據庫初始化時,B+ 樹只包含一個空的葉子節點,該葉子節點即爲 root bucket 的 root node。
func (db *DB) init() error {
// ...
// 在 pageid = 3 的地方寫入一個空的葉子節點.
p = db.pageInBuffer(buf[:], pgid(3))
p.id = pgid(3)
p.flags = leafPageFlag
p.count = 0
// ...
}
以後 B+ 樹的生長都由寫事務中對 B+ 樹節點的增刪、調整來完成。按 boltdb 的設計,寫事務只能串行進行。boltdb 使用 COW 的方式對節點進行修改,以保證不影響併發的讀事務。即,將要修改的 page 讀到內存,修改並調整後,申請新的 page 將變更後的 node 落盤。
這種方式能夠方便的實現讀寫併發和事務,在本系列文章下一篇中將詳細分析其緣由。但無疑,其代價比較高昂,即便一個 key 的修改/刪除,都會引發對應葉子節點所在 B+ 樹路徑上全部節點的修改和落盤。所以若是修改較頻繁,最好在單個事務中作 Batch。
Bucket 較小時
在 bucket 包含的數據還不多時,不會給 bucket 開闢新的 page,而是將其內嵌(inline)在父 bucket 的葉子節點中。是否能內嵌的判斷邏輯在 bucket.inlineable
中:
-
只包含一個葉子節點 -
數據尺寸不大於 1/4 個頁 -
不包含子 bucket
事務開啓後
在每次事務初始化時,會在內存中拷貝一份 root bucket 的句柄,以此做爲以後動態加載修改路徑上 node 的入口。
func (tx *Tx) init(db *DB) {
//...
// 拷貝並初始化 root bucket
tx.root = newBucket(tx)
tx.root.bucket = &bucket{}
*tx.root.bucket = tx.meta.root
// ...
}
在讀寫事務中,用戶調用 bucket.Put
新增或者修改數據時,涉及兩個階段:
-
查找定位:利用 cursor 定位到指定 key 所在 page -
加載插入:加載路徑上全部節點,並在葉子節點插入 kv
func (b *Bucket) Put(key []byte, value []byte) error {
// ...
// 1. 將 cursor 定位到 key 所在位置
c := b.Cursor()
k, _, flags := c.seek(key)
// ...
// 2. 加載路徑上節點爲 node,而後插入 key value
key = cloneBytes(key)
c.node().put(key, key, value, 0, 0)
return nil
}
查找定位。該實現邏輯在上一節所探討的 cursor 的 cursor.seek
中,其主要邏輯爲從根節點出發,不斷二分查找,訪問對應 node/page,直到定位到待插入 kv 的葉子節點。這裏面有個關鍵的節點訪問函數 bucket.pageNode
,該函數不會加載 page 爲 node,而只是複用已經緩存的 node ,或者直接訪問 mmap 到內存空間中的相關 page。
加載插入。在該階段,首先經過 cursor.node()
將 cursor 棧中保存的全部節點索引加載爲 node
,並緩存起來,避免重複加載以進行復用;而後經過 node.put
經過二分查找將 key value 數據插入葉子 node
。
在只讀事務中,只會有查找定位的過程,所以只會經過 mmap 對 page
訪問,而不會有 page
到 node
的轉換過程。
對於 bucket.Delete
操做,和上述兩個階段相似,只不過加載插入變成了加載刪除。能夠看出,這個階段全部的修改都發生在內存中,文件系統中保存的以前 page 組成的 B+ 樹結構並未遭到破壞,所以讀事務能夠併發進行。
事務提交前
在寫事務開啓後,用戶可能會進行一系列的新增/刪除,大量的相關節點被轉化爲 node 加載到內存中,改動後的 B+ 樹由文件系統中的 page 和內存中的 node 共同構成,且因爲數據變更,可能會形成某些節點元素數量過少、而另一些節點元素數量過多。
所以在事務提交前,會先按必定策略調整 B+ 樹,使其維持較好的查詢性質,而後將全部改動的 node
序列化爲 page
增量的寫入文件系統中,構成一棵新的、持久化的、平衡的 B+ 樹。
這個階段涉及到兩個核心邏輯:bucket.rebalance
和 bucket.spill
, 他們分別對應節點的 merge 和 split,是維持 boltdb B+ 樹查找性質的關鍵函數,下面來分別梳理下。
rebalance。該函數旨在將太小(key數太少或者整體尺寸過小)的節點合併到鄰居節點上。調整的主要邏輯在 node.rebalance
中, bucket.rebalance
主要是個外包裝:
func (b *Bucket) rebalance() {
// 對全部緩存的 node 進行調整
for _, n := range b.nodes {
n.rebalance()
}
// 對全部子 bucket 進行調整
for _, child := range b.buckets {
child.rebalance()
}
}
node.rebalance
主要邏輯以下:
-
判斷 n.unbalanced
標記,避免對某個節點進行重複調整。使用標記的緣由有二,一是按需調整,二是避免重複調整。 -
判斷該節點是須要 merge,判斷標準爲: n.size() > pageSize / 4 && len(n.inodes) > n.minKeys()
,不須要則結束。 -
若是該節點是根節點,且只有一個孩子節點,則將其和其惟一的孩子合併。 -
若是該節點沒有孩子節點,則刪除該節點。 -
不然,將該節點合併到左鄰。若是該節點是第一個節點,沒左鄰,則將右鄰合併過來。 -
因爲這次調整可能會致使節點的刪除,所以向上遞歸看是否須要進一步調整。
須要明確的是,只有 node.del()
的調用纔會致使 n.unbalanced
被標記。只有兩個地方會調用 node.del()
:
-
用戶調用 bucket.Delete
函數刪除數據。 -
子節點調用 node.rebalance
進行調整時,刪除被合併的節點。
而 2 又是由 1 引發的,所以能夠說,只有用戶在某次寫事務中刪除數據時,纔會引發 node.rebanlance
主邏輯的實際執行。
spill,該函數功能有二,將過大(尺寸大於一個 page)節點拆分、將節點寫入髒頁(dirty page)。與 rebalance 同樣,主要邏輯在 node.spill
中。不一樣的是,bucket.spill
中也有至關一部分邏輯,主要是處理 inline bucket 的問題。前面提到,若是一個 bucket 內容過少,就會直接內嵌在父 bucket 的葉子節點中。不然,則先調用子 bucket 的 spill 函數,而後將子 bucket 的根節點 pageid 放在父 bucket 葉子節點中。能夠看出, spill 調整是一個自下而上的過程,相似於樹的後序遍歷。
// spill 將尺寸大於一個節點的 page 拆分,並將調整後的節點寫入髒頁
func (b *Bucket) spill() error {
// 自下而上,先遞歸調整子 bucket
for name, child := range b.buckets {
// 若是子 bucket 能夠內嵌,則將其全部數據序列化後內嵌到父 bucket 相應葉子節點
var value []byte
if child.inlineable() {
child.free()
value = child.write()
// 不然,先調整子 bucket,而後將其根節點 page id 做爲值寫入父 bucket 相應葉子節點
} else {
if err := child.spill(); err != nil {
return err
}
value = make([]byte, unsafe.Sizeof(bucket{}))
var bucket = (*bucket)(unsafe.Pointer(&value[0]))
*bucket = *child.bucket
}
// 若是該子 bucket 沒有緩存任何 node(說明沒有數據變更),則直接跳過
if child.rootNode == nil {
continue
}
// 更新 child 父 bucket(即本 bucket)的對該子 bucket 的引用
var c = b.Cursor()
k, _, flags := c.seek([]byte(name))
if !bytes.Equal([]byte(name), k) {
panic(fmt.Sprintf("misplaced bucket header: %x -> %x", []byte(name), k))
}
if flags&bucketLeafFlag == 0 {
panic(fmt.Sprintf("unexpected bucket header flag: %x", flags))
}
c.node().put([]byte(name), []byte(name), value, 0, bucketLeafFlag)
}
// 若是該 bucket 沒有緩存任何 node(說明沒有數據變更),則終止調整
if b.rootNode == nil {
return nil
}
// 調整本 bucket
if err := b.rootNode.spill(); err != nil {
return err
}
b.rootNode = b.rootNode.root()
// 因爲調整會增量寫,形成本 bucket 根節點引用變動,所以須要更新 b.root
if b.rootNode.pgid >= b.tx.meta.pgid {
panic(fmt.Sprintf("pgid (%d) above high water mark (%d)", b.rootNode.pgid, b.tx.meta.pgid))
}
b.root = b.rootNode.pgid
return nil
}
node.spill
的主要邏輯以下:
-
判斷 n.spilled
標記,默認爲 false,代表全部節點都須要調整。若是調整過,則跳過。 -
因爲是自下而上調整,所以須要遞歸調用以先調整子節點,再調節本節點。 -
調整本節點時,將節點按照 pagesize 進行拆分。 -
爲全部新節點申請新的合適尺寸的 pages,而後將 node 寫入 page(此時尚未寫回文件系統)。 -
若是 spilt 生成了新的根節點,則須要向上遞歸調用調整其餘分支。爲了不重複調整,會將本節點 children
置空。
能夠看出,boltdb 維持 B+ 樹查找性質,並不是像教科書 B+ 樹同樣,將全部分支節點的分支樹維護在一個固定範圍,而是直接按節點元素是否可以保存到一個 page 中來作的。這樣作能夠減小 page 內部碎片,實現也相對簡單。
這兩個函數都經過 put/delete 後標記來實現按需調整,但不一樣的是,rebalance 先 merge 自己,再 merge 子 bucket;而 spill 先 split 子 bucket,再 split 自己。另外,調整時對他們調用順序是有要求的,須要先調用 balance 進行無腦 merge,而後在調用 spill,按 pagesize 進行拆分後,寫入髒頁。
總結一下, 在 db 初始化時,只有一個頁保存 root bucket 的根節點。以後的 B+ 樹在 bucket.Create
的時候進行建立。初始時內嵌在父 bucket 的葉子節點中,讀事務不會對 B+ 樹結構形成任何改變,寫事務中全部變更,會先寫到內存中,在事務提交時,會進行平衡調整,而後增量的寫入文件系統。隨着寫入數據的增多,B+ 樹會不斷進行拆分,變深,不在內嵌於父 bucket 中。
小結
boltdb 使用類 B+ 樹組織數據庫索引,全部數據存在葉子節點,分支節點只用於路由查找。boltdb 支持 bucket 間的嵌套,在實現上表現爲 B+ 樹的嵌套,經過 page id 來維持父子 bucket 間的引用。
boltdb 中的 B+ 樹爲了實現簡單,沒有使用鏈表將全部葉子節點串在一塊兒。爲了支持對數據的順序遍歷,額外實現了一個 curosr 遍歷邏輯,經過保存遍歷棧來提升遍歷效率、快速跳轉。
boltdb 對 B+ 樹的生長以事務爲週期,並且生長只發生在寫事務中。在寫事務開始後,會複製 root bucket 的根節點,而後將改動涉及到的節點按需加載到內存,並在內存中進行修改。在寫事務結束前,在對 B+ 樹調整後,將全部改動涉及到的 node 申請新的 page,寫回文件系統,完成 B+ 樹一次生長。釋放的樹節點,在沒有讀事務佔用後,會進入 freelist 供以後使用。
註解
節點:B+ 樹中的節點,在文件系統或者 mmap 後表現爲 page,在內存中轉換後成爲 node。
路徑:樹中的路徑指樹的根節點到當前節點的順序通過的全部節點。
參考
-
boltdb repo:https://github.com/boltdb/bolt
本文分享自微信公衆號 - 分佈式點滴(distributed-system)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。