etcd 存儲引擎之存儲設計

0 前言

本期我們繼續延續 etcd 存儲引擎系列的話題. 在該系列中,我們以 boltdb 作爲 b + 樹 工程化落地的學習案例,該項目開源地址爲:https://github.com/etcd-io/bbolt,go 語言純度接近 100%. (本系列涉及走讀的 boltdb 源碼版本爲 v1.3.8

下面是本專題的分享節奏,本文是其中的第二篇——存儲設計篇:

1 整體架構

1.1 宏觀模型

藉助上面這張存儲架構圖,我們總結一下 boltdb 的宏觀存儲模型設計:

(這些概念內容已在前文——etcd 存儲引擎之主幹框架 中完成一輪鋪墊. 大家如果看完覺得恍惚,可以在完成本文後續章節學習後再回顧一遍,溫故而知新. )

1.2 研究路線

下面介紹一下本文的學習路線:

2 page

2.1 概念

基於局部性原理,也爲了契合操作系統存儲交換模型,boltdb 內的數據也是也 page 爲單位組織的.

每個 page 分爲 header 和 body 兩部分:

每當 boltdb 實例在初始化時,需要率先完成 4 個 page 的初始化和持久化:

2.2 header

前面提到 page 由 header 和 body 兩部分組成. 下面我們首先來看一下各類 page 通用的 header 結構的詳細定義.

page header 核心屬性包括:

對應源碼如下,儘管命名爲 page,但大家需要知道其含義其實爲 page header:

// page header
type page struct {
    id       pgid    // page 的全局唯一 id. 藉由 page id 和 page size 可以推算出其在 boltdb 中的位置
    flags    uint16  // page 類型,包含:meta、freelist、branch、leaf 四類
    count    uint16  // page 中的數據條數. 針對 freelist,標識空閒頁數量;針對 branch 和 leaf,代表 key 的數量
    overflow uint32  // 溢出頁個數,倘若存在過大的數據,則需要使用到溢出頁. 溢出頁地址連續,共享相同的 page header
    ptr      uintptr // page body 的起始位置
}


type pgid uint64 // 頁 id


const (
    // 四種 page header flag
    branchPageFlag   = 0x01  // b+ 樹枝幹節點
    leafPageFlag     = 0x02  // b+ 樹葉子節點
    metaPageFlag     = 0x04  // meta 
    freelistPageFlag = 0x10  // freelist
)

2.3 meta

2.3.1 類定義

下面介紹的是 meta page 的 body.

meta 中主要記錄是 boltdb 的元數據,其核心屬性包括:

具體代碼展示如下:

// meta 類型 page 的 page body
type meta struct {
    magic    uint32 // 魔數,與 bolt db 版本相關
    version  uint32 // bolt db 版本
    pageSize uint32 // 採用的頁大小,單位 byte
    flags    uint32 // 保留字段,暫未啓用
    root     bucket // 數據庫的始祖 bucket
    freelist pgid   //  freelist page 的 id
    pgid     pgid   // 下一個分配的 page id,mmap 擴容時使用
    txid     txid   // 下一個事務 id,遞增
    checksum uint64 // meta 頁校驗和
}

2.3.2 副本機制

在 boldtb 的 page buffer 中,index = 0 和 1 的兩個 page 一定是 2 個 meta page. 之所以要創建雙份,則和 boldtb 採用的 copy-on-write 策略相關:

至此,一次讀寫事務完成了,對應的 meta page 也實現了更新迭代.

值得一提的是:在 step3 中,倘若 meta page 在溢寫落盤過程中發生錯誤,則代表事務執行失敗,此時磁盤上的 meta page 內容可能也發生損壞,但是由於有雙份 meta page 兜底,因此 boltdb 還能基於另一份完好的 meta page 正常提供服務,數據也基於此會正常回溯到事務提交前的版本,這個點和 step1 中取 meta page 的策略形成了呼應.

(這個點大家第一次看大概率會覺得晦澀,沒關係,可以結合本文 3.3 小節的內容進行呼應)

下面是 db 類定義的代碼示例,可見其中確實持有雙份 meta page 的引用:

type DB struct {
    // ...
    meta0    *meta // 兩個輪換使用的 meta page 之一
    meta1    *meta // 兩個輪換使用的 meta page 之一
    // ...
}

下面是 boltdb 中 transaction 類的定義,其中持有的 meta 屬性正是在 step1 中拷貝生成的 meta 副本:

// 事務
type Tx struct {
    // ...
    db             *DB            // 事務從屬的 db 實例
    meta           *meta          // db meta page
    root           Bucket         // db 根 bucket
    pages          map[pgid]*page // 事務中反序列化過的 page
    // ...
}

2.3.3 序列化

接下來我們串一下 meta page 的正反序列化過程.

讀寫事務提交時,會需要將內存中 meta 副本轉成 page 實例,並最終進行溢寫落盤.

func (tx *Tx) Commit() error {
    // ...髒頁溢寫落盤
    // ...


    // 事務持有的 meta page 副本溢寫落盤
    if err := tx.writeMeta(); err != nil {
        tx.rollback()
        return err
    }
    // ...


    return nil
}
// writeMeta writes the meta to the disk.
func (tx *Tx) writeMeta() error {
    // 序列化 meta 副本
    tx.meta.write(p)


    // ... meta 落盤持久化


    return nil
}

meta 序列化方法爲 meta.write,核心步驟包括:

// meta 內容寫入到 page
func (m *meta) write(p *page) {
    // 前置校驗 ...


    // meta page 的 id 只能是 0 或者 1. 根據 tx id 的單雙數可以得知
    p.id = pgid(m.txid % 2)
    // 標識 page 類型爲 meta
    p.flags |= metaPageFlag


    // 校驗和
    m.checksum = m.sum64()
    // 通過深拷貝方式,令 page ptr 指向 meta 副本的起始位置
    m.copy(p.meta())
}

2.3.4 反序列化

在 boltdb 每次執行 mmap 映射時(初始化或者擴容),都需要更新 db 實例持有的兩個 meta 引用,此時需要對 meta 進行反序列化:

// mmap
func (db *DB) mmap(minsz int) (err error) {
    // 1 加鎖 ...


    // 2 mmap 系統調用 ...


    // 3 更新 db 實例持有的 meta 引用實例
    db.meta0 = db.page(0).meta()
    db.meta1 = db.page(1).meta()


    // ...


    return nil
}

可以看到,獲取 meta 實例的方式是直接從 page buffer 取出 0 號和 1 號 page,並通過其 ptr 引用直接獲取到 meta body:

// 獲取 meta 類型的 page body
func (p *page) meta() *meta {
    return (*meta)(unsafe.Pointer(&p.ptr))
}

2.3.4 校驗

每當啓動事務時,需要從 2 個 meta page 中獲取合法且 tx id 較大的一個作爲拷貝的對象. 此時會涉及到對 meta page 的校驗:

// 初始化事務
func (tx *Tx) init(db *DB) {
    // ...
    // 1 創建 meta 副本
    tx.meta = &meta{}
    // 2 獲取合法且實時性更高的 meta 進行深拷貝
    db.meta().copy(tx.meta)


    // 3 設置 bucket


    // 4 讀寫事務需要遞增 meta 副本中的 tx id
    if tx.writable {
        tx.pages = make(map[pgid]*page)
        tx.meta.txid += txid(1)
    }
}

在 db.meta 方法中,會在優先獲取 tx id 更大的 meta 的同時,對 meta 合法性校驗,保證返回的 meta 內容是合法的:

// 從 db 中獲取合法且實時性更高的 meta
func (db *DB) meta() *meta {
    // 2 個輪換使用的 meta 實例
    metaA := db.meta0
    metaB := db.meta1
    // 優先取 tx id 較大的那個
    if db.meta1.txid > db.meta0.txid {
        metaA = db.meta1
        metaB = db.meta0
    }


    // 校驗 meta 合法性,保證返回的 meta 是合法的
    if err := metaA.validate(); err == nil {
        return metaA
    } else if err := metaB.validate(); err == nil {
        return metaB
    }


    // 兩個 meta 都有問題,panic
    panic("bolt.DB.meta(): invalid meta pages")
}

校驗 meta page 是否合法,包括對 magic、version 和 checksum 的校驗:(主要是 checksum)

// 校驗 meta page
func (m *meta) validate() error {
    if m.magic != magic {
        return ErrInvalid
    } else if m.version != version {
        return ErrVersionMismatch
    } else if m.checksum != 0 && m.checksum != m.sum64() {
        return ErrChecksum
    }
    return nil
}


// The data file format version.
const version = 2


// Represents a marker value to indicate that a file is a Bolt DB.
const magic uint32 = 0xED0CDAED

2.4 freelist

2.4.1 “只借不還”

在介紹 freelist 之前,需要先聊到 boltdb 中一個很重要的機制:boltdb 採取 mmap 操作時,只會擴容,不會縮容. 即便在讀寫事務中存在 page 的釋放,也只會將空閒的 page 緩存到 freelist 中統一管理,而不會將其歸還給操作系統,這樣設計的原因在於:

基於以上,freelist 應運而生,它用於存儲空閒 page 信息,定位類似於 go 語言中 heap,採取以空間換時間的策略,緩存並管理空閒的 page 以供後續複用,降低與操作系統的交互頻率.

2.4.2 類定義

freelist 的類定義如上所示,核心屬性包括:

對應代碼如下:

// 空閒頁 
type freelist struct {
    ids     []pgid          // 已經可用的釋放頁
    pending map[txid][]pgid // 等待被釋放的空閒頁. txid 爲仍在使用這些頁的最大 txid. 每當有事務被分配時,可以找到所有已經小於當前最小 txid 的 tx 使用的 pending page 進行批量釋放
    cache   map[pgid]bool   // 判斷某個頁是否是空閒的或者待釋放的
    allocate       func(txid txid, n int) pgid // 分配 page 的入口函數,有着不同的算法實現. n 爲要求的連續 page 數量.
}

如何理解上述提到的待釋放 pending 列表呢?

這部分內容和 boltdb 事務隔離性掛鉤,爲了保證事務的【可重複讀】語義,在一些讀寫事務中,哪怕已經歸還了某些 page,但只要這些 page 還在被一些版本更早的事務所 “閱讀”,那麼其內容就不能被覆蓋,需要保證閱讀視角的一致性.

(這裏算是做個簡單劇透,有關這部分內容,會在本系列第四篇——etcd 存儲引擎之事務實現 中進一步展開介紹)

2.4.3 free page count

freelist 中的 ids 屬於 page body 部分,結合 page header 中的 count 字段,能夠推到得出 freelist 中的空閒 page 數量:

// page header
type page struct {
    // ...
    count    uint16  // page 中的數據條數. 針對 freelist,爲標識空閒頁數量;
    // ...
}

由於 count 字段爲 uint16,因此能表示的數值範圍有限,最大值即爲 0xFFFF,但在實踐場景中,free page 數可能大於該值,因此 freelist 在讀取 count 值時採取了一種特殊的協議:

上述策略對應的實現示意圖如下:

2.4.4 free 空閒頁

在讀寫事務提交時,倘若有些 page 已經棄置不用,則通過 free 方法將其追加到 freelist 的 pending 列表:

// free 空閒頁
func (f *freelist) free(txid txid, p *page) {
    // meta page 不允許被 free
    if p.id <= 1 {
        panic(fmt.Sprintf("cannot free page 0 or 1: %d", p.id))
    }


    // 追加到 pending 中,key 爲釋放這些 page 的 tx id
    txp := f.pending[txid]
    if txp == nil {
        txp = &txPending{}
        f.pending[txid] = txp
    }
    
    // ...
    for id := p.id; id <= p.id+pgid(p.overflow); id++ {
        // ...
        // 分別追加到 pending 和 cache 中
        txp.ids = append(txp.ids, id)
        f.cache[id] = struct{}{}
    }
}

freelist 中的 pending 是一個 map,key 是事務 id,value 是該事務 free 的空閒頁列表. 之所以要通過 tx id 作爲鍵標識,是因爲 tx id 是全局遞增的,只要某個時刻,所有小於該 tx id 的事務都結束了,就代表這些 old page 已經不再存在 “讀者”,也就可以真正釋放這部分 page,進而得以保全【可重複讀】的語義.

2.4.5 release 空閒頁

當所有小於 txid 的事務都結束時,該 txid 下的空閒頁都能夠真正得到 release,只需要將其從 pending 中移除,並追加到 freelist 的 ids 中即可:

// release 所有 <= txid 的事務的 pending 列表
func (f *freelist) release(txid txid) {
    m := make(pgids, 0)
    for tid, txp := range f.pending {
        if tid <= txid {
            // pending 中的頁追加到 ids 中
            m = append(m, txp.ids...)
            // 從 pending 中移除
            delete(f.pending, tid)
        }
    }
    // ...
}

2.4.6 allocate page

讀寫事務提交時,會基於 copy-on-write 機制,針對所有涉及更改的節點分配新的 page 副本,此時會需要從 db 中分配可用的 page:

// 提交事務
func (tx *Tx) Commit() error {
    // 1 rebalance b+ tree...
    // ...


    opgid := tx.meta.pgid
    // 2 spill b+ tree
    if err := tx.root.spill(); err != nil {
        tx.rollback()
        return err
    }
    
    // 3 free 不用的 page 到 freelist ...
    
    // 4 髒數據 page 溢寫落盤
     
    // 5 meta page 副本溢寫落盤


    // ...
}
func (n *node) spill() error {
    // ...
    // 針對所有涉及變更的 node,free 老的 page,申請分配新的 page
    for _, node := range nodes {
        // free 老的 page
        if node.pgid > 0 {
            tx.db.freelist.free(tx.meta.txid, tx.page(node.pgid))
            node.pgid = 0
        }


        // 分配新的 page
        p, err := tx.allocate((node.size() + tx.db.pageSize - 1) / tx.db.pageSize)
        if err != nil {
            return err
        }


        // ...
    }


    // ...
    return nil
}
func (tx *Tx) allocate(count int) (*page, error) {
    p, err := tx.db.allocate(tx.meta.txid, count)
    // ...
}

此時會使用到 db 中的對象池 pagePool,嘗試複用單個 page 對應的字節數組,並且嘗試從 freelist 中獲取可用的 page,如果沒有找到目標,則通過 mmap 進行擴容:

func (db *DB) allocate(txid txid, count int) (*page, error) {
    // 1 倘若需要獲取的 page 數量爲 1,通過 pagePool 複用字節數組
    var buf []byte
    if count == 1 {
        buf = db.pagePool.Get().([]byte)
    } else {
        buf = make([]byte, count*db.pageSize)
    }
    p := (*page)(unsafe.Pointer(&buf[0]))
    p.overflow = uint32(count - 1)


    // 2 嘗試從 freelist 獲取可用的 page
    if p.id = db.freelist.allocate(txid, count); p.id != 0 {
        return p, nil
    }


    // 3 freelist 資源不足,則通過 mmap 擴容...


    // ...
    return p, nil
}

至此,我們走到 freelist 分配可用 page 的入口方法,該方法是 freelist 類中的一個成員屬性——allocate

type freelist struct {
    freelistType   FreelistType                // freelist 類型,默認使用 array 形式
    ids            []pgid                      // 所有已經釋放可用的 page
    // ...
    cache          map[pgid]struct{}           // 所有空閒 page
    // ...
    allocate       func(txid txid, n int) pgid // 分配可用 page 的入口函數
    // ...
}

默認情況下,allocate 方法的實現是 freelist.arrayAllocate 方法,其實現思路就是平鋪直敘地對可用頁列表 ids 從左往右進行遍歷,直到首次找到滿足要求的 n 個連續可用 page,則返回首個 page 對應 id

// 爲某個事務分配連續的 n 個 可用 page
func (f *freelist) arrayAllocate(txid txid, n int) pgid {
    if len(f.ids) == 0 {
        return 0
    }


    // 按照正序在 freelist 的 ids 列表中遍歷,直到找到連續的 n 個可用 page 時,返回首個 page id
    // 倘若找不到目標,則返回 0
    var initial, previd pgid
    for i, id := range f.ids {
        // ...


        // 新的可用 page 和上一個不連續,則放棄上一個,從新的 page 開始嘗試
        if previd == 0 || id-previd != 1 {
            initial = id
        }


        // 找到連續可用的 n 個 page 了
        if (id-initial)+1 == pgid(n) {
            // 剛好是末尾,則直接從 ids 截斷最後被取走的部分
            if (i + 1) == n {
                f.ids = f.ids[i+1:]
            // 否則從 ids 中部截去被取走的部分
            } else {
                copy(f.ids[i-n+1:], f.ids[i+1:])
                f.ids = f.ids[:len(f.ids)-n]
            }


            // 被取用的 page 從 freelist 的 cache 中移除
            for i := pgid(0); i < pgid(n); i++ {
                delete(f.cache, initial+i)
            }
            // 返回首個 page id
            return initial
        }


        previd = id
    }
    return 0
}

2.4.7 序列化

在讀寫事務提交時,會將最新版本的 freelist 副本序列化成 page 的形式,並通過 meta 副本持有新版本 freelist 的引用:

// 提交讀寫事務
func (tx *Tx) Commit() error {
    // ...
    // 1 調整 b+ 樹
    
    // 2 序列化 freelist,並讓 meta 副本指向它
    if !tx.db.NoFreelistSync {
        err := tx.commitFreelist()
        if err != nil {
            return err
        }
    } 
    
    // 3 髒數據頁落盤
    
    // 4 meta page 落盤,freelist 隨之被落盤更新
    if err := tx.writeMeta(); err != nil {
        tx.rollback()
        return err
    }
    
    // ...


    return nil
}
func (tx *Tx) commitFreelist() error {
    // ...
    
    // freelist 序列化到 page 中
    if err := tx.db.freelist.write(p); err != nil {
        tx.rollback()
        return err
    }
    
    // meta 副本指向新的 freelist page
    tx.meta.freelist = p.id


    return nil
}

freelist.write 是將 freelist 內容序列化成 page 的核心方法:

此處值得一提的是,不管是已釋放 ids 還是待釋放的 pending 中的空閒 page,都會被序列化到 page body 中. 這是因爲 boltdb 正常運行過程中,會統一使用到內存中的 freelist 實例,此時能夠通過 pending 區分出哪些 page 是待釋放的. 倘若未來需要用到 freelist page 反序列化成內存中的 page 實例,則必然是面臨 boltdb 重啓的場景,此時由於不存在運行的事務,因此不存在【可重複讀】的訴求,所有 pending page 都能被視作可用 page 進行復用.

// 將 freelist 的內容序列化的 page 中
func (f *freelist) write(p *page) error {
    // 通過 flag 標識該 page 類型 freelist
    p.flags |= freelistPageFlag


    // 獲取總的空閒頁數量
    lenids := f.count()
    // 如果爲 0
    if lenids == 0 {
        p.count = uint16(lenids)
        // 如果爲小於 0xFFFF 的值
    } else if lenids < 0xFFFF {
        p.count = uint16(lenids)
        // 將所有空閒頁有序的 copy 到 page ptr 的位置中
        f.copyall(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[:])
    } else {
        // 特殊協議
        p.count = 0xFFFF
        // 0 號位置記錄真實的空閒頁數量
        ((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[0] = pgid(lenids)
        // 將所有空閒頁有序的 copy 到 page ptr 的 [1:] 位置中
        f.copyall(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[1:])
    }


    return nil
}

2.4.8 反序列化

在 db 啓動時,倘若 db 文件已存在,則需要讀取其中 freelist 部分內容,將其反序列化成 freelist 實例:

func Open(path string, mode os.FileMode, options *Options) (*DB, error) {
    // ...
    // 加載 freelist
    db.loadFreelist()
    // ...
}
func (db *DB) loadFreelist() {
    db.freelistLoad.Do(func() {
        // 構造 freelist 實例
        db.freelist = newFreelist(db.FreelistType)
        // 反序列化 freelist
        db.freelist.read(db.page(db.meta().freelist))
        // ...
    })
}

freelist.read 是反序列化入口方法:

// 從 page 中讀取 freelist 的內容,將其反序列化到內存中的 freelist 實例
func (f *freelist) read(p *page) {
    // 讀取 page 的 count 值
    idx, count := 0, int(p.count)
    // 如果 count 值爲 65535,則觸發特殊協議,第 0 頁記錄的纔是真實的 free page 數量
    if count == 0xFFFF {
        idx = 1 // 真正的數據往後偏移一個 uint64 的位置
        count = int(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[0])
    }


    // count = 0,代表沒有空閒頁
    if count == 0 {
        f.ids = nil
    } else {
        // 從真正的起始位置開始讀取空閒頁列表
        ids := ((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[idx:count]
        // 將內容深拷貝的 freelist 實例的 ids 中
        f.ids = make([]pgid, len(ids))
        copy(f.ids, ids)


        // 對空閒頁進行排序
        sort.Sort(pgids(f.ids))
    }


    // ...
}

2.5 branch&leaf

2.5.1 branch

branch page 用於存儲數據索引, 對應的是 b+ 樹中的枝幹節點. 由於 boltdb 中,key 數據是不定長的,因此在 branch page 中採取了 shadow paging 技術,通過在 page body 中放入一系列定長的 branchPageElement,在其中分別標註好對應 key 數據的地址和長度,以及該 key 所映射的子節點的 page id.

branchPageElement 的示意圖如下:

對應 branchPageElement 的實現代碼如下:

// b+樹中的枝幹節點中的一個元素
type branchPageElement struct {
    pos   uint32 // 對應 key 的起始位置
    ksize uint32 // 對應 key 的大小
    pgid  pgid   // key 對應子節點的 page id
}

倘若某個 page 是 branch 類型,則通過 page header 獲取指定 index 的 branchPageElement 的方法如下:

// 獲取 index 對應的 branch page element
func (p *page) branchPageElement(index uint16) *branchPageElement {
    return &((*[0x7FFFFFF]branchPageElement)(unsafe.Pointer(&p.ptr)))[index]
}

獲取到 branchPageElement 後,可以結合 ksize 和 pos 信息,通過地址偏移的方式獲取其對應的 key 值

func (b *branchPageElement) key() []byte {
    buf := (*[maxAllocSize]byte)(unsafe.Pointer(b))
    return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[b.pos]))[:b.ksize]
}

2.5.2 leaf

leaf page 用於存儲 kv 數據, 對應的是 b+ 樹中的葉子節點. 和 branch page 情況相同,由於 key 和 value 不定長,因此此處同樣採取了 shadow paging 技術,通過在 page body 中放入一系列定長的 leafPageElement,在其中標註好對應 key 數據的地址、長度以及 value 數據的長度.

elementPageElement 的示意圖如下:

leafPageElement 對應實現代碼如下:

// b+樹中的葉節點中的一個元素
type leafPageElement struct {
    flags uint32 // 標識 kv 對標識內容是普通數據還是 bucket
    pos   uint32 // key 的起始位置
    ksize uint32 // key 的大小
    vsize uint32 // value 的大小
}

倘若某個 page 類型爲 leaf,則通過 page header 獲取指定 index 的 leafPageElement 的方法如下:

// 獲取 index 對應的 leaf page element
func (p *page) leafPageElement(index uint16) *leafPageElement {
    n := &((*[0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr)))[index]
    return n
}

獲取到 leafPageElement 後,結合 pos、ksize、vsize 等信息,通過地址偏移的方式可以讀取對應的 key 和 value 內容:

func (l *leafPageElement) key() []byte {
    buf := (*[maxAllocSize]byte)(unsafe.Pointer(l))
    return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[l.pos]))[:l.ksize]
}
func (l *leafPageElement) value() []byte {
    buf := (*[maxAllocSize]byte)(unsafe.Pointer(l))
    return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[l.pos+l.ksize]))[:l.vsize]
}

3 緩存 & 持久化

從本章開始,我們從讀、寫流程的視角,來梳理 boltdb 中有關數據緩存與持久化的機制.

3.1 mmap

3.1.1 核心概念

boltdb 的讀流程基於 mmap 技術實現.

所謂 mmap,全稱 memory mapping,這種技術能夠實現磁盤文件與內存空間的映射,屏蔽磁盤 io 的細節,使得使用方能夠像訪問內存中的字節數組一樣去訪問磁盤文件內容.

3.1.2 流程源碼

boltdb 中 mmap 的大小上限爲 256TB,mmap 映射後,對應的內容通過 db.data 字段獲取,並且通過一把讀寫鎖 mmaplock 保證其併發安全性

const maxMapSize = 0xFFFFFFFFFFFF // 256TB


type DB struct {
    // ...
    path     string   // db 文件路徑
    openFile func(string, int, os.FileMode) (*os.File, error) // 打開 db 文件方法
    file     *os.File  // db 文件
    // ...
    dataref  []byte // mmap 原始字節數組,爲只讀模式,不可寫
    data     *[maxMapSize]byte  // mmap 映射 db 文件得到的字節數組
    datasz   int  // mmap size
    // ...
    mmaplock sync.RWMutex // 保護 mmap 操作併發安全的讀寫鎖
    // ...
}

在 db 啓動或者提交讀寫事務發現可用 page 不足時,都會執行 mmap 操作. 前者是初始化,後者是擴容:

// 啓動 boltdb
func Open(path string, mode os.FileMode, options *Options) (*DB, error) {
    // 1 構造 db 實例 ...


    // 2 讀取配置 ...
    
    // 3 打開 db 文件 ...
    
    // 4 初始化 db 文件頭 4 個 page ...


    // 5 執行 mmap 操作
    if err := db.mmap(options.InitialMmapSize); err != nil {
        _ = db.close()
        return nil, err
    }


    // 6 返回 db 實例 ...
}
// 提交讀寫事務時,需要爲事務分爲指定數量的 page
func (db *DB) allocate(txid txid, count int) (*page, error) {
    // 1 嘗試取 freelist ...
    
    // 2 freelist 空間不足,則通過 mmap 擴容
    p.id = db.rwtx.meta.pgid
    var minsz = int((p.id+pgid(count))+1) * db.pageSize
    if minsz >= db.datasz {
        if err := db.mmap(minsz); err != nil {
            return nil, fmt.Errorf("mmap allocate error: %s", err)
        }
    }


    // 3 更新 meta 副本中的 pgid 範圍...
    return p, nil
}

mmap 操作的入口是 db.mmap 方法,核心步驟爲:

// 執行 mmap 操作
func (db *DB) mmap(minsz int) (err error) {
    // 1 加鎖保證併發安全
    db.mmaplock.Lock()
    defer db.mmaplock.Unlock()


    // ...
    // 2 調整合適的 mmap size
    size, err = db.mmapSize(size)
    
    // ...
    // 3 如果有讀寫事務在運行,需要更新其引用的 b+ 樹節點(即將新一輪 mmap,節點對應 page 可能需要調整)
    // 做法就是將這些節點都反序列化到內存,這樣在溢寫落盤時,就會有更新的機會
    if db.rwtx != nil {
        db.rwtx.root.dereference()
    }


    // 4 如果之前進行過 mmap,需要解除映射
    if err = db.munmap(); err != nil {
        return err
    }


    // 5 建立新的 mmap 映射
    if err = mmap(db, size); err != nil {
        return err
    }


    // ...
    
    // 6 校驗 meta page 合法性
    db.meta0 = db.page(0).meta()
    db.meta1 = db.page(1).meta()
    
    err0 := db.meta0.validate()
    err1 := db.meta1.validate()
    if err0 != nil && err1 != nil {
        return err0
    }


    return nil
}

mmap 系統調用的實現與操作系統版本有關,這裏我簡單給出 unix 系統的實現:

// mmap memory maps a DB's data file.
func mmap(db *DB, sz int) error {
    // Map the data file to memory.
    b, err := unix.Mmap(int(db.file.Fd()), 0, sz, syscall.PROT_READ, syscall.MAP_SHARED|db.MmapFlags)
    if err != nil {
        return err
    }


    // Advise the kernel that the mmap is accessed randomly.
    err = unix.Madvise(b, syscall.MADV_RANDOM)
    if err != nil && err != syscall.ENOSYS {
        // Ignore not implemented error in kernel because it still works.
        return fmt.Errorf("madvise: %s", err)
    }


    // Save the original byte slice and convert to a byte array pointer.
    db.dataref = b
    db.data = (*[maxMapSize]byte)(unsafe.Pointer(&b[0]))
    db.datasz = sz
    return nil
}

3.1.3 mmap size

由於 mmap 系統調用的成本較高,爲了減少執行頻率,boltdb 在每輪執行 mmap 時會預留一部分空間:在新容量小於 1GB 時,則從 32KB 基礎上持續倍增找到首個滿足條件容量;在新容量超過 1GB 時,則基於 GB 向上取整.

// 獲取合適的 mmap 大小
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
        }
    }


    // ...


    // 大於 1GB,則基於 GB 向上取整
    sz := int64(size)
    if remainder := sz % int64(maxMmapStep); remainder > 0 {
        sz += int64(maxMmapStep) - remainder
    }


    // ...
}


const maxMmapStep = 1 << 30 // 1GB

3.2 pwrite+fdatasync

3.2.1 核心概念

boltdb 寫流程採用 pwrite + fdatasync 技術實現:

3.2.2 流程源碼

pwrite 操作默認爲文件的 writeAt 方法,入口是 db 的 ops 成員屬性:

type DB struct {
    // ...


    ops struct { // 將數據寫到 db 文件指定位置的 pwrite 操作
        writeAt func([]byte, off int64) (n int, err error)
    }


    // ...
}

在讀寫事務提交過程中,不論是髒數據頁還是 meta 副本的溢寫落盤流程,都會應用到 pwrite + fdatasync 指令:

func (tx *Tx) Commit() error {
    // 1 調整各個 bucket 下的 b+ 樹,保證其平衡性


    // ...
    // 2 倘若 b+ 樹平衡性調整後,page 空間不足,則需要進行擴容...


    // 3 髒數據溢寫落盤
    if err := tx.write(); err != nil {
        tx.rollback()
        return err
    }


    // ...
    // 4 meta page 溢寫落盤
    if err := tx.writeMeta(); err != nil {
        tx.rollback()
        return err
    }
    
    // 5 關閉事務...
    // 6 執行 commit 回調函數...


    return nil
}
// 髒頁溢寫落盤
func (tx *Tx) write() error {
    // 1 獲取髒頁並排序...


    // 遍歷每個髒頁,依次寫入到 db 文件中
    for _, p := range pages {
        // ...
        // 如果 page 過大,則需要拆分多個批次寫入
        for {
            // ...


            // pwrite 操作
            if _, err := tx.db.ops.writeAt(buf, offset); err != nil {
                return err
            }


            // ...         
        }
    }


    // 執行 fdatasync 操作,保證髒頁成功落盤
    if !tx.db.NoSync || IgnoreNoSync {
        if err := fdatasync(tx.db); err != nil {
            return err
        }
    }


    // ...
    return nil
}
func (tx *Tx) writeMeta() error {
    // ...
    // pwrite
    if _, err := tx.db.ops.writeAt(buf, int64(p.id)*int64(tx.db.pageSize)); err != nil {
        return err
    }
    
    // fdatasync
    if !tx.db.NoSync || IgnoreNoSync {
        if err := fdatasync(tx.db); err != nil {
            return err
        }
    }


    // ...
    return nil
}

pwrite 操作爲 os/file 標準庫下的 WriteAt 方法:

package os/file


func (f *File) WriteAt([]byte, off int64) (n int, err error) {
    // ...
    for len(b) > 0 {
        m, e := f.pwrite(b, off)
        if e != nil {
            err = f.wrapErr("write", e)
            break
        }
        n += m
        b = b[m:]
        off += int64(m)
    }
    return
}

unix 系統下,fdatasync 的實現爲 file.sync 方法:

// fdatasync flushes written data to a file descriptor.
func fdatasync(db *DB) error {
    return db.file.Sync()
}


func (f *File) Sync() error {
    // ...
    if e := f.pfd.Fsync(); e != nil {
        return f.wrapErr("sync", e)
    }
    return nil
}

3.3 copy on write

縱觀整個 boltdb 的交互流程,都貫徹了對 copy-on-write 策略的應用,下面我們就以一個事務開啓和提交的流程,來總結一下其中哪些環節提現了寫時複製的思路.

3.3.1 meta&bucket 副本

在一個新事務初始化時,會拷貝出一份 meta page 和 bucket 副本後續涉及到對這部分內容的修改時,都在副本的基礎上執行,再通過事務提交環節一次性實現修改內容的覆蓋生效.

func (tx *Tx) init(db *DB) {
    // ...
    // 爲當前事務複製一份 meta 副本
    tx.meta = &meta{}
    db.meta().copy(tx.meta)


    // 深拷貝複製出一份 bucket 實例
    tx.root = newBucket(tx)
    tx.root.bucket = &bucket{}
    *tx.root.bucket = tx.meta.root
    // ...
}
// 深拷貝一份 meta 副本
func (m *meta) copy(dest *meta) {
    *dest = *m
}

3.3.2 髒數據 page 副本

在提交讀寫事務過程中,會基於 copy on write 機制,將所有涉及變更的節點序列化成一份新的 page 副本,而不是在原 page 上直接更新

// 提交事務
func (tx *Tx) Commit() error {
    // 1 rebalance ...


    // 2 記錄原本已分配的 page 範圍
    // ...
    // 3 spill spill 時,還會針對改動的部分,基於 copy on write 機制生成一系列髒數據的 page 副本
    if err := tx.root.spill(); err != nil {
        tx.rollback()
        return err
    }
    
    // ...
    // ...


    // 4 髒頁落盤持久化
    if err := tx.write(); err != nil {
        tx.rollback()
        return err
    }


    // ...


    // 5 meta page 落盤持久化
    if err := tx.writeMeta(); err != nil {
        tx.rollback()
        return err
    }
    
    // 至此,代表事務已經提交成功了,數據已經成功更新!
    // ...


    return nil
}
// 針對當前 node 進行拆分
func (n *node) spill() error {
    // ...
    // 遍歷所有涉及變更的節點
    for _, node := range nodes {
        // ...
        
        // 分配對應數量的 page 副本
        p, err := tx.allocate((node.size() / tx.db.pageSize) + 1)
        if err != nil {
            return err
        }


        // ...
        // 將變更後的 node 內容寫入到 page 副本中中
        node.pgid = p.id
        node.write(p)
        node.spilled = true


        // ...
    }


    // ...
}

3.3.3 副本覆蓋生效

在事務提交的持久化環節,隨着一些髒數據 page 副本的溢寫落盤,其中的內容已經得到了持久化保證:

func (tx *Tx) write() error {
    // ...
    // 髒數據 page pwrite
    for _, p := range pages {
        // ...
        for {
            // ...
            // pwrite
            if _, err := tx.db.ops.writeAt(buf, offset); err != nil {
                return err
            }
            // ...
        }
    }


    // fdatasync
    if !tx.db.NoSync || IgnoreNoSync {
        if err := fdatasync(tx.db); err != nil {
            return err
        }
    }


    // ...
}

但大家需要注意,截止到此時,這部分數據還不會被 boltdb 所認可,因爲 db 中的 meta 還是老版本,其中的 b+ 樹也還是原本的引用,接下來就是真正 “畫龍點睛” 的一步——讀寫事務 meta 副本落盤:

// 讀寫事務 meta 副本落盤
func (tx *Tx) writeMeta() error {
    // pwrite
    if _, err := tx.db.ops.writeAt(buf, int64(p.id)*int64(tx.db.pageSize)); err != nil {
        return err
    }
    
    // fdatasync
    if !tx.db.NoSync || IgnoreNoSync {
        if err := fdatasync(tx.db); err != nil {
            return err
        }
    }


    // ...
    return nil
}

隨着新版本 meta 的落盤持久化,由其所指向的新版 bucket 以及其中一系列修改後的 b+ 樹節點也就真正得到了 “正名”.

只要該 meta 落盤成功,那麼其中的 tx id 就是全局最大的,未來在新的讀寫流程中,都會以該版本 meta 爲準,一輪 copy-on-write 策略的實施至此完美收官!

3.4 擴容

在讀寫事務提交時,倘若發現可用 page 不足,則會推動 boltdb 進行擴容:

func (tx *Tx) Commit() error {
    // 1 調整各個 bucket 下的 b+ 樹,保證其平衡性
    // 1 rebalance ...
    
    // 2 記錄原本已分配的 page 範圍
    opgid := tx.meta.pgid


    // ...
    // 3 spill spill 時,還會針對改動的部分,基於 copy on write 機制生成一系列髒數據的 page 副本
    if err := tx.root.spill(); err != nil {
        tx.rollback()
        return err
    }
    // ...
    // 4 倘若 b+ 樹平衡性調整後,page 範圍右擴,需要對文件對應調整
    if tx.meta.pgid > opgid {
        if err := tx.db.grow(int(tx.meta.pgid+1) * tx.db.pageSize); err != nil {
            tx.rollback()
            return err
        }
    }


    // ...
    return nil
}

前文已經針對上述的 spill 流程做過一些介紹,在申請可用 page 作爲髒數據 page 副本時,可能存在 freelist 資源不足的情況,此時則會推動 boltdb 發動新一輪 mmap 操作,並且更新其 meta 副本中的 pgid 值.

於是,db.grow 方法得以執行,其中會通過 truncate 方法實現文件截斷操作,實現 “擴容” 語義,並通過 fdatasync 確保其生效:

// db 擴容
func (db *DB) grow(sz int) error {
    // 1 調整合適的擴容大小 ...    
    if db.datasz <= db.AllocSize { // AllocSize 默認值爲 16M
        sz = db.datasz
    } else {
        // 超過 16M,則每次增長 16M
        sz += db.AllocSize
    }    


    // 2 通過 truncate + fsync 操作,確保擴容完成
    if !db.NoGrowSync && !db.readOnly {
        if runtime.GOOS != "windows" {
            if err := db.file.Truncate(int64(sz)); err != nil {
                return fmt.Errorf("file resize error: %s", err)
            }
        }
        // fdatasync
        if err := db.file.Sync(); err != nil {
            return fmt.Errorf("file sync error: %s", err)
        }
        // ...
    }


    // 3 新的文件大小
    db.filesz = sz
    return nil
}

4 b+ tree

本章我們簡單點一些有關 b+ 樹部分的內容(聚焦內存部分),詳細內容我們在下一篇——etcd 存儲引擎之 b + 樹實現 中展開.

4.1 設定

在 boltdb 中,採用 b+ 樹模型進行數據的存儲. 實際上,依附於不同的存儲介質,我們也同樣可以從兩個視角出發來看待其針對 b+ 樹的實現.

4.2 node

node 是內存中反序列化得到的 b+ 樹節點類,其核心成員屬性定義爲:

// 內存中的 b+ tree 節點,對應磁盤上的一個 branch/leaf element page
type node struct {
    bucket     *Bucket // 該節點從屬的 bucket
    isLeaf     bool    // 是否爲 b+ tree leaf 節點,爲 false 則爲 branch 節點
    unbalanced bool    // 該節點是否刪除過數據,是的話需要在落盤前進行 rebalance 操作
    spilled    bool    // 該節點是否執行過 spill 操作
    key        []byte  // 該節點下的首個 key (最小的 key)
    pgid       pgid    // 該節點對應 page  id
    parent     *node   // 父節點
    children   nodes   // 子節點列表,該節點爲分支節點時使用
    inodes     inodes  // kv 數據列表,該節點爲葉子節點時使用
}

inode 標識某個 node 中的一筆 kv 數據(廣義的 kv,如爲 branch 類型,則此處的 “v” 指子節點):

// b+ tree 葉子節點中的一個 kv 對
type inode struct {
    flags uint32 // 標識其是否是 bucket
    pgid  pgid   // 從屬的 page id
    key   []byte // key
    value []byte // value
}

4.3 序列化

在讀寫事務提交時,需要將所有 node 序列化到 page 副本中 (會被反序列化成 node 的節點往往都發生過變更),對應方法爲 node.write:

func (n *node) write(p *page) {
    // ...
    // 設置對應的 page flag,標識是分支還是葉子節點
    if n.isLeaf {
        p.flags = leafPageFlag
    } else {
        p.flags = branchPageFlag
    }


    // 記錄節點的 key 數量
    p.count = uint16(len(n.inodes))


    // 沒有 key 數據,直接返回
    if p.count == 0 {
        return
    }


    // 下面針對每筆 kv (inode) 進行序列化
    // 記錄 offset 偏移量
    off := unsafe.Sizeof(*p) + n.pageElementSize()*uintptr(len(n.inodes))
    for i, item := range n.inodes {
        // ...
        // key + value 的大小
        sz := len(item.key) + len(item.value)
        // 用於存儲 key + value 內容的容器
        b := unsafeByteSlice(unsafe.Pointer(p), off, 0, sz)
        // 累加偏移量
        off += uintptr(sz)


        // 針對葉子節點的 key-value 數據,對 leafPageElement 進行賦值,標識數據元信息
        if n.isLeaf {
            elem := p.leafPageElement(uint16(i))
            elem.pos = uint32(uintptr(unsafe.Pointer(&b[0])) - uintptr(unsafe.Pointer(elem)))
            elem.flags = item.flags
            elem.ksize = uint32(len(item.key))
            elem.vsize = uint32(len(item.value))
        // 針對分支節點的 key-child node 數據,對 branchPageElement 進行賦值,標識數據元信息
        } else {
            elem := p.branchPageElement(uint16(i))
            elem.pos = uint32(uintptr(unsafe.Pointer(&b[0])) - uintptr(unsafe.Pointer(elem)))
            elem.ksize = uint32(len(item.key))
            elem.pgid = item.pgid
            // ...
        }


        // 深拷貝 key value 數據到對應的 offset 位置
        l := copy(b, item.key)
        copy(b[l:], item.value)
    }
}

4.4 反序列化

讀寫事務運行過程中,倘若涉及到對某個 b+ 樹節點的調整,則需要將其反序列化爲 node 實例,在內存中完成修改:

// 從 page 中讀取節點內容,將其反序列化到 node 實例中
func (n *node) read(p *page) {
    // 傳承 page 的 id
    n.pgid = p.id
    // 標識節點是否爲葉子節點
    n.isLeaf = ((p.flags & leafPageFlag) != 0)
    // 反序列化對應數量的 inode
    n.inodes = make(inodes, int(p.count))


    // 針對每個 inode 進行反序列化
    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()
        }
    }


    // 冗餘記錄最小的 key
    if len(n.inodes) > 0 {
        n.key = n.inodes[0].key
    } else {
        n.key = nil
    }
}

4.5 cursor

遊標 cursor 是 boltdb 中針對 b+ 樹實現的一個改造點,它會通過一個棧結構記錄某次操作在 b+ 樹上的移動路徑,以此來輔助完成一些複雜的回溯檢索操作.

cursor 實現代碼如下:

type Cursor struct {
    bucket *Bucket
    stack  []elemRef // 通過棧記錄移動路徑. 最後一個元素代表此時所在位置
}

elemRef 對應爲移動路徑涉足的一個 b+ 樹節點,由於內存中的 b+ 樹 node 是懶加載機制,因此倘若移動路徑涉足到一些未反序列化的節點(沒有數據變更),則可能會通過 page 字段來標識

// 由於 b+ 樹採用懶加載機制,因此樹中的節點可能是 page,也可能是 node
type elemRef struct {
    page  *page
    node  *node
    index int
}

cursor 一定是依附於 bucket 存在的,其構造方法如下:

// 返回對應的遊標卡尺
func (b *Bucket) Cursor() *Cursor {
    // ...
    // 返回遊標實例
    return &Cursor{
        bucket: b,
        // 遊標對應的棧
        stack: make([]elemRef, 0),
    }
}

5 bucket

5.1 設定

在 boltdb 中引入了桶 bucket 的設定,作用是實現業務數據的隔離, 可以簡單把 bucket 類比於數據庫中的表,只不過 bucket 的形式會更加靈活一些,還能支持嵌套式的拓撲關係,形如上圖,school 和 school-class 是兩個合法的 bucket,且彼此爲父子關係.

在邏輯意義上,每個 bucket 會有一棵獨立的 b+ 樹,用於存放當前 bucket 範圍內的 kv 數據.

5.2 類定義

Bucket 類定義代碼如下:

// 內存中的 Bucket 實例,一定是基於某個 tx 創建的
type Bucket struct {
    *bucket                     // bucket header,記錄元信息
    tx       *Tx                // 創建當前桶實例的事務
    buckets  map[string]*Bucket // 子桶
    page     *page              // 如果爲 inline bucket,則爲對應 page 的引用
    rootNode *node              // 當前桶下 b+ 樹的根節點
    nodes    map[pgid]*node     // 當前桶下已反序列化後緩存的 node 節點


    // ...
}


// bucket header. 需要序列化部分的元信息
type bucket struct {
    root     pgid   // bucket 中根節點對應的 page id
    sequence uint64 // bucket 的序列號,全局單調遞增
}

5.3 內聯

正如上文所述,每個 bucket 都有一顆獨立的 b+ 樹,而每個 b+ 樹至少有一個節點,即 boltdb 至少要爲每個 bucket 分配一個 page.

然而在實際使用場景中,很多 bucket 存儲的數據量可能遠小於一個 page,爲避免造成空間浪費,boltdb 針對數據量小於 pageSize/4 且只需要單個 page 的 bucket 採取 inline bucket 策略——借鑑虛擬內存實現思路,在緊挨着 Bucket header 的位置取出一塊小於 page 但仍按照 page 格式組織的空間,組成一個邏輯意義的 “page” 進行數據存儲.

判斷一個 Bucket 是否需要採取 inline 模式的方法如下:

// 判斷 bucket 是否爲 inline 類型
func (b *Bucket) inlineable() bool {
    var n = b.rootNode


    // 只有某個 bucket 僅包含一個葉子類型的根節點時,纔有可能是 inline bucket
    if n == nil || !n.isLeaf {
        return false
    }


    // 統計一下 bucket 的總大小
    var size = pageHeaderSize
    for _, inode := range n.inodes {
        // 累加每個 inode 的大小
        size += leafPageElementSize + len(inode.key) + len(inode.value)
        // 如果存在 inode 不是 leaf element,則當前 node 不是 inline bucket
        if inode.flags&bucketLeafFlag != 0 {
            return false
            // 判斷總大小是否超過 inline bucket 限制(pageSize/4)
        } else if size > b.maxInlineBucketSize() {
            return false
        }
    }


    return true
}


func (b *Bucket) maxInlineBucketSize() uintptr {
    return uintptr(b.tx.db.pageSize / 4)
}

5.4 序列化

每個 Bucket 在剛被創建出來時,都是一個 inline bucket,此時需要對其進行序列化,將其數據組裝成 kv 對的形式寫入到其父 Bucket 的 b+ 樹中. inline bucket 序列化方法是 bucket.write

func (b *Bucket) CreateBucket(key []byte) (*Bucket, error) {
    // ...


    // 創建 bucket 實例(此時爲 inline bucket)
    var bucket = Bucket{
        bucket:      &bucket{},
        rootNode:    &node{isLeaf: true},
        FillPercent: DefaultFillPercent,
    }
    
    // 序列化 bucket
    var value = bucket.write()


    // 組裝成 kv 對形式寫入到父 bucket b+ 樹中
    key = cloneBytes(key)
    c.node().put(key, key, value, 0, bucketLeafFlag)


    // ...
}

此外,在讀寫事務提交時,也會涉及對 Bucket 的序列化操作,此時針對常規 bucket 會直接對 Bucket header 部分進行深拷貝,針對 inline bucket 則同樣會調用 bucket.write 方法作序列化:

// spill writes all the nodes for this bucket to dirty pages.
func (b *Bucket) spill() error {
    // 處理每個子 bucket
    for name, child := range b.buckets {
        // 記錄子 bucket 序列化數據
        var value []byte
        // inline bucket 序列化
        if child.inlineable() {
            // ...
            value = child.write()
        // 普通 bucket 序列化
        } else {
            // ...
            // 直接進行 bucket 深拷貝
            value = make([]byte, unsafe.Sizeof(bucket{}))
            var bucket = (*bucket)(unsafe.Pointer(&value[0]))
            *bucket = *child.bucket
        }


        // ...
    }
    // ...
    return nil
}

inline bucket 的序列化方法如下:

func (b *Bucket) write() []byte {
    // 獲取數據大小
    var n = b.rootNode
    var value = make([]byte, bucketHeaderSize+n.size())


    // 深拷貝出 bucket header 部分內容
    var bucket = (*bucket)(unsafe.Pointer(&value[0]))
    *bucket = *b.bucket


    // 將數據部分寫入到緊挨着 bucket header 的後繼部分
    var p = (*page)(unsafe.Pointer(&value[bucketHeaderSize]))
    n.write(p)


    // 返回序列化結果
    return value
}

5.5 反序列化

當用戶需要獲取某個 Bucket 時,需要對其進行反序列化:

func (b *Bucket) Bucket(name []byte) *Bucket {
    // ... 如果子 bucket 已反序列化過,進行復用


    // 反序列化對應的子 bucket
    var child = b.openBucket(v)
    
    // ... 緩存反序列化後的子 bucket
    return child
}

反序列化核心方法爲 Bucket.openBucket,其中主語的 Bucket 是擬獲取 Bucket 的 parent

// 反序列化子 bucket
func (b *Bucket) openBucket(value []byte) *Bucket {
    創建一個空白的 bucket 實例
    var child = newBucket(b.tx)


    // ...
    // 針對讀寫事務,需要對 bucket 副本拷貝 copy-on-write
    if b.tx.writable && !unaligned {
        child.bucket = &bucket{}
        *child.bucket = *(*bucket)(unsafe.Pointer(&value[0]))
    } else { // 針對只讀事務,直接複用 bucket
        child.bucket = (*bucket)(unsafe.Pointer(&value[0]))
    }


    // 針對內聯 bucket,讀取對應的 page
    if child.root == 0 {
        child.page = (*page)(unsafe.Pointer(&value[bucketHeaderSize]))
    }


    return &child
}
func newBucket(tx *Tx) Bucket {
    var b = Bucket{tx: tx, FillPercent: DefaultFillPercent}
    // 針對讀寫事務反序列化的 bucket,需要初始化對應的緩存 map
    if tx.writable {
        b.buckets = make(map[string]*Bucket)
        b.nodes = make(map[pgid]*node)
    }
    return b
}

至此正文結束. 祝賀走到這裏各位朋友,我們又一起打贏了一場硬仗!

6 展望

本文是 etcd 存儲引擎系列的第二篇,帶着大家一起深入瞭解了 boltdb 的存儲設計原理. 在此回顧整個系列的研究進程並對後續內容進行展望:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nFlcRJagr-UG6LhXmsp4eA