Boltdb 源碼導讀(一):Boltdb 數據組織




boltdb 是市面上爲數很少的純 go 語言開發的、單機 KV 庫。boltdb 基於  Howard Chu'sLMDB 項目 ,實現的比較清爽,去掉單元測試和適配代碼,核心代碼大概四千多行。簡單的 API、簡約的實現,也是做者的意圖所在。因爲做者精力所限,原 boltdb 已經封版,再也不更新。若想改進,提交新的 pr,建議去 etcd 維護的 fork 版本 bbolt。css

爲了方便,本系列導讀文章仍以再也不變更的原 repo 爲基礎。該項目麻雀雖小,五臟俱全,僅僅四千多行代碼,就實現了一個基於 B+ 樹索引、支持一寫多讀事務的單機 KV 引擎。代碼自己簡約樸實、註釋得當,若是你是 go 語言愛好者、若是對 KV 庫感興趣,那 boltdb 絕對是不可錯過的一個 repo。
node

本系列計劃分紅三篇文章,依次圍繞數據組織索引設計事務實現等三個主要方面對 boltdb 源碼進行剖析。因爲三個方面不是徹底正交解耦的,所以敘述時會不可避免的產生交織,讀不懂時,暫時略過便可,待有全貌,再回來梳理。本文是第一篇, boltdb 數據組織。
git


引子

一個存儲引擎最底層的構成,就是處理數據在各類物理介質(好比在磁盤上、在內存裏)上的組織。而這些數據組織也體現了該存儲引擎在設計上的取捨哲學。github

在文件系統上,boltdb 採用(page)的組織方式,將一切數據都對齊到頁;在內存中,boltdb 按 B+ 樹組織數據,其基本單元是節點(node),一個內存中的樹節點對應文件系統上一個或者多個連續的頁。boltdb 就在數據組織上就只有這兩種核心抽象,可謂設計簡潔。固然,這種簡潔必然是有代價的,後面文章會進行詳細分析。golang

本文首先對節點和頁的關係進行整體說明,而後逐一分析四種頁的格式及其載入內存後的表示,最後按照 db 的生命週期串一下 db 文件的增加過程以及載入內存的策略。數據庫

概述

本文主要涉及到 page.go 和 freelist.go 兩個源文件,主要分析了 boltdb 各類 page 在磁盤上的格式和其加載到內存中後的表示。swift

頂層組織

boltdb 的數據組織,自上而下來講:數組

  1. 每一個 db 對應一個文件。緩存

  2. 在邏輯上:微信

    • 一個 db 包含多個桶(bucket),至關於多個命名空間(namespace)

    • 每一個桶對應一棵 B+ 樹,每一個桶的樹根在頂層也組織成一棵樹,但不必定是 B+ 樹

  3. 在物理上:

    • 一個 db 文件是按頁爲單位進行順序存儲

    • 一個頁大小和操做系統的頁大小保持一致(一般是 4KB)

頁和節點

頁分爲四種類型:

  • 元信息頁:全局有且僅有兩個 meta 頁,保存在文件;它們是 boltdb 實現事務的關鍵

  • 空閒列表頁:有一種特殊的頁,存放空閒頁(freelist) id 列表;他們在文件中表現爲一段一段的連續的頁

  • 兩種數據頁:剩下的頁都是數據頁,有兩種類型,分別對應 B+ 樹中的中間節點和葉子節點

頁和節點的關係在於:

  1. 頁是 db 文件存儲的基本單位,節點是 B+ 樹的基本構成節點

  2. 一個數據節點對應一到多個連續的數據頁

  3. 連續的數據頁序列化加載到內存中就成爲一個數據節點

總結一下:在文件系統上線性組織的數據頁,經過頁內指針,在邏輯上組織成了一棵二維的 B+ 樹,該樹的樹根保存在元信息頁中,而文件中全部其餘沒有用到的頁的 id 列表,保存在空閒列表頁中。

頁格式和內存表示

boltdb 中的頁分四種類型:元信息頁、空閒列表頁、中間節點頁和葉子節點頁。boltdb 使用常量枚舉標記:

const ( branchPageFlag = 0x01 leafPageFlag = 0x02 metaPageFlag = 0x04 freelistPageFlag = 0x10)

每一個頁都由定長 header 和數據部分組成:

其中 ptr 指向的是頁的數據部分,爲了不載入內存和寫入文件系統時的序列化和反序列化操做,boltdb 使用了大量的 go unsafe 包中的指針操做。

type pgid uint64type page struct { id pgid flags uint16 // 頁類型,值爲四種類型之一 count uint16 // 對應的節點包含元素個數,好比說包含的 kv 對 overflow uint32 // 對應節點溢出頁的個數,即便用 overflow+1 個頁來保存對應節點 ptr uintptr // 指向數據對應的 byte 數組,當 overlay>0 時會跨越多個連續頁;不過多個物理也在內存中也只會用一個 page 結構體來表示}

元信息頁(metaPage)

boltdb 中有且僅有兩個元信息頁,保存在 db 文件的開頭(pageid = 0 和 1)。可是在元信息頁中,ptr 指向的內容並不是元素列表,而是整個 db 的元信息的各個字段。

元信息頁加載到內存後數據結構以下:

type meta struct { magic uint32 version uint32 pageSize uint32 // 該 db 頁大小,經過 syscall.Getpagesize() 獲取,一般爲 4k flags uint32 //  root bucket // 各個子 bucket 根所組成的樹 freelist pgid // 空閒列表所存儲的起始頁 id pgid pgid // 當前用到的最大 page id,也即用到 page 的數量 txid txid // 事務版本號,用以實現事務相關 checksum uint64 // 校驗和,用於校驗 meta 頁是否寫完整}

空閒列表頁(freelistPage)

空閒列表頁是 db 文件中一組連續的頁(一個或者多個),用於保存在 db 使用過程當中因爲修改操做而釋放的頁的 id 列表。

在內存中表示時分爲兩部分,一部分是能夠分配的空閒頁列表 ids,另外一部分是按事務 id 分別記錄了在對應事務期間新增的空閒頁列表。

// 表示當前已經釋放的 page 列表// 和寫事務剛釋放的 pagetype freelist struct { ids []pgid // all free and available free page ids. pending map[txid][]pgid // mapping of soon-to-be free page ids by tx. cache map[pgid]bool // fast lookup of all free and pending page ids.}


其中 pending 部分須要單獨記錄主要是爲了作 MVCC 的事務:

  1. 寫事務回滾時,對應事務待釋放的空閒頁列表要從 pending 項中刪除。

  2. 某個寫事務(好比 txid=7)已經提交,但可能仍有一些讀事務(如 txid <=7)仍然在使用其剛釋放的頁,所以不能當即用做分配。

這部份內容會在 boltdb 事務中詳細說明,這裏只需有個印象便可。

空閒列表轉化爲 page

freelist 經過 write 函數,在事務提交時將本身寫入給定的頁,進行持久化。在寫入時,會將 pending 和 ids 合併後寫入,這是由於:

  1. write 函數是在寫事務提交時調用,寫事務是串行的,所以 pending 中對應的寫事務都已經提交。

  2. 寫入文件是爲了應對崩潰後重啓,而重啓時沒有任何讀操做,天然不用擔憂有讀事務還在用剛釋放的頁。

func (f *freelist) write(p *page) error { // 設置頁類型 p.flags |= freelistPageFlag
// page.count 是 uint16 類型,其能表示的範圍爲 [0, 64k-1] 。若是空閒頁 id 列表長度超出了此範圍,就須要另想辦法。 // 這裏用了個 trick,將 page.count 置爲 64k 即 0xFFF,而後在數據部分的第一個元素存實際數量(以 pgid 爲類型,即 uint64)。 lenids := f.count() if lenids == 0 { p.count = uint16(lenids) } else if lenids < 0xFFFF { p.count = uint16(lenids) // copyall 會將 pending 和 ids 合併並排序 f.copyall(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[:]) } else { p.count = 0xFFFF ((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[0] = pgid(lenids) f.copyall(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[1:]) }
return nil}


注意本步驟只是將 freelist 轉化爲內存中的頁結構,須要額外的操做,好比 tx.write() 纔會將對應的頁真正持久化到文件。

空閒列表從 page 中加載

在數據庫重啓時,會首先從前兩個元信息頁恢復出一個合法的元信息。而後根據元信息中的 freelist 字段,找到存儲 freelist 頁的起始地址,進而將其恢復到內存中。

func (f *freelist) read(p *page) { // count == 0xFFFF 代表實際 count 存儲在 ptr 所指向的內容的第一個元素 idx, count := 0, int(p.count) if count == 0xFFFF { idx = 1 count = int(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[0]) }
// 將空閒列表從 page 拷貝內存中 freelist 結構體中 if count == 0 { f.ids = nil } else { ids := ((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[idx:count] f.ids = make([]pgid, len(ids)) copy(f.ids, ids)
// 保證 ids 是有序的 sort.Sort(pgids(f.ids)) }
// 從新構建 freelist.cache 這個 map. f.reindex()}

空閒列表分配

做者原版的空閒列表分配異常簡單,分配單位是頁,分配策略是首次適應:即從排好序的空閒頁列表 ids 中,找到第一段等於指定長度的連續空閒頁,而後返回起始頁 id。

// 若是能夠找到連續 n 個空閒頁,則返回起始頁 id// 不然返回 0func (f *freelist) allocate(n int) pgid { if len(f.ids) == 0 { return 0 }
// 遍歷尋找連續空閒頁,並判斷是否等於 n var initial, previd pgid for i, id := range f.ids { if id <= 1 { panic(fmt.Sprintf("invalid page allocation: %d", id)) }
// 若是不連續,則重置 initial if previd == 0 || id-previd != 1 { initial = id }
if (id-initial)+1 == pgid(n) { // 當正好分配到 ids 中前 n 個 page 時,僅簡單往前調整 f.ids 切片便可。 // 儘管一時會形成空間浪費,可是在對 f.ids append/free 操做時,會按需 // 從新空間分配,從新分配會致使這些浪費空間被回收掉 if (i + 1) == n { f.ids = f.ids[i+1:] } else { copy(f.ids[i-n+1:], f.ids[i+1:]) f.ids = f.ids[:len(f.ids)-n] }
// 從 cache 中刪除對應 page id for i := pgid(0); i < pgid(n); i++ { delete(f.cache, initial+i) }
return initial }
previd = id } return 0}


這個 GC 策略至關簡單直接,是線性的時間複雜度。阿里彷佛作過一個 patch,將全部空閒 page 按其連續長度 group by 了一下。

葉子節點頁(leafPage)

這種頁對應某個 Bucket 的 B+ 樹中葉子節點,或者頂層 Bucket 樹中的葉子節點。

對於前者來講,頁中存儲的基本元素爲某個 bucket 中一條用戶數據。對於後者來講,頁中的一個元素爲該 db 中的某個 subbucket 。

// page ptr 指向的字節數組中的單個元素type leafPageElement struct {  flags uint32 // 普通 kv (flags=0)仍是 subbucket(flags=bucketLeafFlag) pos uint16 // kv header 與對應 kv 的距離 ksize uint32 // key 的字節數 vsize uint32 // val 字節數}


其詳細結構以下:

能夠看出,leaf page 在組織數據時,將元素頭leafPageElement)和元素自己key value)分開存儲。這樣的好處在於 leafPageElement 是定長的,能夠按下標訪問對應元素。在二分查找指定 key 時,只需按需加載相應頁到內存(訪問 page 時是經過 mmap 進行的,所以只有訪問時纔會真正將數據從文件系統中加載到內存)便可。

inodes := p.leafPageElements()index := sort.Search(int(p.count), func(i int) bool { return bytes.Compare(inodes[i].key(), key) != -1})


若是元素頭和對應元素緊鄰存儲,則需將 leafPageElement 數組對應的全部頁順序讀取,所有加載到內存,才能進行二分。

另一個小優化是 pos 存儲的是元素頭的起始地址到元素的起始地址的相對偏移量,而非以 ptr 指針爲起始地址的絕對偏移量。這樣能夠用盡可能少的位數(pos 是 uint16) 表示儘可能長的距離。

func (n *branchPageElement) key() []byte { buf := (*[maxAllocSize]byte)(unsafe.Pointer(n)) // buf 是元素頭起始地址 return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[n.pos]))[:n.ksize]}


中間節點頁(branchPage)

中間節點頁和葉子節點頁的結構大致相同。不一樣之處在於,頁中保存的數據的 value 是 page id,即該中間節點在哪些 key 上的分支分別指向的 page 。

branchPageElement 中的 key 存的是其指向的頁中的起始 key。

轉換流程

boltdb 使用 mmap 將 db 文件映射到內存空間。在構建樹而且訪問過程當中,按需將對應的頁加載到內存裏,而且利用操做系統的頁緩存策略進行替換。

文件增加

當咱們打開一個 db 時,若是發現該 db 文件爲空,會在內存中初始化四個頁(4*4k=16K),分別是兩個元信息頁、一個空的空閒列表頁和一個空的葉子節點頁,而後將其寫入 db 文件,而後走正常打開流程。

func (db *DB) init() error { // 設置頁大小與操做系統一致 db.pageSize = os.Getpagesize()
buf := make([]byte, db.pageSize*4) // 在 buffer 中建立兩個元信息頁. for i := 0; i < 2; i++ { p := db.pageInBuffer(buf[:], pgid(i)) p.id = pgid(i) p.flags = metaPageFlag
// 初始化元信息頁. m := p.meta() m.magic = magic m.version = version m.pageSize = uint32(db.pageSize) m.freelist = 2 m.root = bucket{root: 3} m.pgid = 4 m.txid = txid(i) m.checksum = m.sum64() }
// 在 pgid=2 的頁寫入一個空的空閒列表. p := db.pageInBuffer(buf[:], pgid(2)) p.id = pgid(2) p.flags = freelistPageFlag p.count = 0
// 在 pgid=3 的頁寫入一個空的葉子元素. p = db.pageInBuffer(buf[:], pgid(3)) p.id = pgid(3) p.flags = leafPageFlag p.count = 0
// 將 buffer 中的這四個頁寫入數據文件並刷盤 if _, err := db.ops.writeAt(buf, 0); err != nil { return err } if err := fdatasync(db); err != nil { return err }
return nil}


隨着數據的不斷寫入,須要申請新的頁。boltdb 首先會去 freelist 中找有無可重複利用的頁,若是沒有,就只能進行 re-mmap(先 mumap 在 mmap),擴大 db 文件。每次擴大會進行倍增(所以從 16K * 2 = 32K 開始),到達 1G 後,再次擴大會每次新增 1G。

func (db *DB) mmapSize(size int) (int, error) { // 從 32KB 開始,直到 1GB. for i := uint(15); i <= 30; i++ { if size <= 1<<i { return 1 << i, nil } }
// Verify the requested size is not above the maximum allowed. if size > maxMapSize { return 0, fmt.Errorf("mmap too large") }
// 對齊到 maxMmapStep = 1G sz := int64(size) if remainder := sz % int64(maxMmapStep); remainder > 0 { sz += int64(maxMmapStep) - remainder }
// 對齊到 db.pageSize pageSize := int64(db.pageSize) if (sz % pageSize) != 0 { sz = ((sz / pageSize) + 1) * pageSize }
// 不能超過 maxMapSize if sz > maxMapSize { sz = maxMapSize }
return int(sz), nil}


在 32 位 機器上文件最大不能超過 maxMapSize = 2G;在 64 位機器上,文件上限爲 256T。

讀寫流程

在打開一個已經存在的 db 時,會首先將 db 文件映射到內存空間,而後解析元信息頁,最後加載空閒列表。

在 db 進行讀取時,會按需將訪問路徑上的 page 加載到內存,並轉換爲 node,進行緩存。

在 db 進行修改時,使用 COW 原則,全部修改不在原地,而是在改動前先複製一份。若是葉子節點 node 須要修改,則 root bucket 到該 node 路徑上所涉及的全部節點都須要修改。這些節點都須要新申請空間,而後持久化,這些和事務的實現息息相關,以後會在本系列事務文章中作詳細說明。

小結

boltdb 在數據組織方面只使用了兩個概念:頁(page) 和節點 (node)。每一個數據庫對應一個文件,每一個文件中包含一系列線性組織的頁。頁的大小固定,依其性質不一樣,分爲四種類型:元信息頁、空閒列表頁、葉子節點頁、中間節點頁。打開數據庫時,會漸次進行如下操做:

  1. 利用 mmap 將數據庫文件映射到內存空間。

  2. 解析元信息頁,獲取空閒列表頁 id 和 bucket 樹頁 id。

  3. 依據空閒列表頁 id ,將全部空閒頁列表載入內存。

  4. 依據 bucket 樹起始地址,解析 bucket 樹根節點。

  5. 根據讀寫需求,從樹根開始遍歷,按需將訪問路徑上的數據頁(中間節點頁和葉子節點頁)載入內存成爲節點(node)。

能夠看出,節點分兩種類型:中間節點(branch node)和葉子節點(leaf node)。

另外須要注意的是,多個子 Bucket 樹和 Bucket 對應的 B+ 樹複用了 bucket 這個數據結構,致使這一塊稍微有點很差理解。在下一篇 boltdb 的索引設計中,將詳細剖析 boltdb 是如何組織多個 bucket 以及單個 bucket 內的 B+ 樹索引的。

參考

  1. github,boltdb repo

  2. 我叫尤加利,boltdb 源碼分析




不妨一讀

漫談 LevelDB 數據結構(二):布隆過濾利器


golang Context 源碼剖析



掃描二維碼

獲取更多文章

分佈式點滴



本文分享自微信公衆號 - 分佈式點滴(distributed-system)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索