本文背景#
Etcd 是一個開源的分佈式鍵值存儲系統,它主要用於共享配置和服務發現。在現代的分佈式服務架構中,etcd 扮演著至關重要的角色,因為它提供了一個可靠、一致且高效的存儲解決方案,用於維護集群狀態和配置信息。
為什麼使用 Etcd 作為配置中心:#
- 一致性和可靠性:Etcd 使用 Raft 一致性算法,確保在分佈式環境中,即使在節點故障的情況下,配置數據也能保持一致性和可靠性。這對於需要高可用性的系統來說至關重要。
- 簡單性:Etcd 提供了一個簡單的 API,支持 gRPC 和 HTTP,使得客戶端可以輕鬆地與存儲系統交互。這降低了開發和維護分佈式系統的複雜性。
- 高性能:Etcd 設計用於處理大量的並發請求,它能夠快速響應客戶端的讀寫操作,這對於需要實時更新配置的分佈式服務來說非常重要。
- 服務發現:除了配置管理,etcd 還支持服務發現功能,允許服務實例在啟動時註冊自己,並在停止時註銷。這樣,其他服務可以通過查詢 etcd 來發現和連接到這些服務實例。
- 版本控制:Etcd 提供了版本控制機制,這意味著配置的每次更改都會被記錄和追蹤。這對於審計和回滾配置更改非常有用。
- 跨語言客戶端庫:Etcd 社區提供了多種語言的客戶端庫,這意味著開發者可以使用他們熟悉的編程語言與 etcd 交互。
Etcd 在分佈式服務中的重要性:#
- 集群協調:在分佈式系統中,服務之間的協調至關重要。Etcd 可以存儲集群狀態信息,幫助各個服務實例了解集群的整體狀態。
- 動態配置:在動態變化的分佈式環境中,服務的配置可能需要頻繁更新。Etcd 提供了一個中心化的配置存儲,使得配置的更新可以快速且一致地分發到所有相關服務。
- 故障恢復:在節點故障時,Etcd 可以確保配置數據不會丟失,並且可以快速恢復,這對於系統的穩定性和可靠性至關重要。
- 擴展性:隨著服務的擴展,管理配置的複雜性會增加。Etcd 的設計允許水平擴展,可以隨著服務的增長而增加更多的節點,以保持性能。
研究價值性:#
- 技術深度:研究 etcd 可以幫助團隊深入理解分佈式系統的核心概念,如一致性、容錯性和數據複製。
- 實踐應用:通過研究 etcd,團隊可以更好地設計和實現自己的分佈式服務,提高系統的可靠性和可維護性。
- 社區貢獻:參與 etcd 的研究和開發,可以為開源社區做出貢獻,同時也能從社區中獲得支持和最佳實踐。
- 行業趨勢:了解和掌握 etcd 這樣的關鍵技術,有助於團隊跟上雲計算和微服務架構的行業趨勢。
綜上所述,etcd 不僅在技術層面上為分佈式服務提供了強大的支持,而且在實際應用和研究價值上也具有重要意義。對於正在進行配置中心技術調研的團隊來說,深入研究 etcd 是一個值得投入的決策。
核心概念#
bolt 之於 etcd#
etcd 是一個具有強一致性的分佈式協調服務,基於 golang 實現,底層基於 raft 算法保證數據的強一致和高可用,對應的開源地址: github(etcd)
本篇文章將探討 etcd 中存儲層引擎 boltdb 的工作原理。在 etcd 的架構設計中,boltdb 佔據了一個特定的層次,其結構圖如上圖所示。boltdb 是一個使用 Go 語言編寫的單機鍵值存儲系統,它直接將數據持久化到磁碟上,具有以下特點:
- 單機操作:boltdb 專注於單機環境,無需處理複雜的分佈式共識機制,這使得其實現更為簡潔。
- 持久化存儲:數據以鍵值對的形式存儲在磁碟上,確保了數據的持久性和可靠性。
- 本地 I/O:在進行數據讀寫操作時,boltdb 直接與本地檔案系統進行互動,省去了客戶端與服務端之間的通信開銷,這使得數據訪問過程既直接又高效。
存儲設計#
在本文中,我們將從宏觀的角度探討 boltdb 的存儲技術實現。由於篇幅所限,我們將對 boltdb 的內部機制進行簡要的概述,以提供一個高層次的理解。
BoltDB 存儲技術實現概述:#
- 數據模型:BoltDB 使用鍵值對(KV)作為其核心的數據模型。這種模型簡單直觀,易於理解和操作。
- 事務支持:BoltDB 提供了事務支持,允許進行原子性的讀寫操作。這意味著在事務中的所有更改要麼全部成功,要麼全部失敗,保證了數據的一致性。
- 存儲結構:數據在磁碟上以 B 樹(B + 樹)的形式存儲,這種結構適合快速查找、插入和刪除操作,同時保持了高效的磁碟利用率。
- 寫入優化:為了提高寫入性能,BoltDB 使用了一種稱為 “寫入前優化” 的技術。在數據被寫入磁碟之前,它會在內存中進行合併和優化,減少磁碟 I/O 操作。
- 讀取性能:BoltDB 的讀取操作直接映射到磁碟上的 B 樹結構,這使得讀取操作非常快速,尤其是對於範圍查詢。
- 數據壓縮:為了節省存儲空間,BoltDB 對存儲的數據進行了壓縮。這不僅減少了磁碟佔用,也提高了數據傳輸的效率。
- 並發控制:雖然 BoltDB 是為單機設計的,但它提供了一定程度的並發控制,允許多個 goroutine 安全地訪問數據庫。
- 持久化與恢復:BoltDB 支持數據的持久化,即使在系統崩潰後也能恢復數據。它通過定期創建快照和記錄日誌來實現這一點。
- 版本控制:BoltDB 允許用戶在打開數據庫時指定版本號,這有助於在升級數據庫時保持兼容性。
- 安全性:雖然 BoltDB 本身不提供加密功能,但它允許用戶在存儲數據之前自行加密,以增強數據的安全性。
本文的討論旨在提供一個對 BoltDB 存儲技術的宏觀理解,為讀者提供一個框架,以便更好地理解其在 etcd 中的應用和價值。對於希望深入了解其內部實現的讀者,建議查閱 BoltDB 的官方文檔和源代碼。
讀寫#
boltdb 存儲依賴於磁碟,針對於存儲數據的交互,分為讀、寫流程:
- 在讀流程上:基於 mmap (memory-mapping) 技術實現,隱藏於磁碟交互細節,使用方能夠像訪問內存中的字節數組一樣讀取磁碟檔案中的內容。
- 在寫流程上:基於 pwrite+fdatasync, 實現數據落盤,兼顧效率和穩定
BoltDB 的存儲結構#
BoltDB 在存儲數據時採用了類似操作系統中內存與磁碟交換的 “頁”(page)概念。這種設計使得數據的組織和管理更加高效。在 BoltDB 中,頁分為幾種類型,每種類型承擔不同的角色:
- 元數據頁(Meta Page):存儲 BoltDB 的元數據,如版本號、校驗和等。此外,還包括全局遞增的事務 ID 記錄,這些信息屬於全局維度的內容。
- 空閒列表頁(Freelist Page):記錄哪些頁是空閒的,哪些頁將被事務釋放。這類似於 Go 語言中的堆(heap),採用空間換時間的策略,緩存並管理空閒頁以供重用,減少與操作系統的交互。
- 分支元素頁(Branch Element Page):存儲索引節點,對應於 B + 樹中的分支節點。這些頁提供了較細粒度的索引,與具體數據相關聯。
- 葉子元素頁(Leaf Element Page):存儲數據節點,對應於 B + 樹的葉子節點。這些頁同樣提供了細粒度的數據存儲,與數據實體直接相關。
在數據庫初始化時,BoltDB 會創建並持久化四個頁: - 元數據頁(Meta Page):初始化兩個元數據頁,這是為了實現效率與穩定性,與 BoltDB 採用的 copy-on-write 機制有關。
- 空閒列表頁(Freelist):初始化全局維度的空閒頁管理塊。
- 葉子元素頁(Leaf Element Page):作為 B + 樹的根節點,同時也是葉子節點,為數據庫提供一個空白的起點。
B + 樹的實現#
BoltDB 的數據存儲基於 B + 樹,這是一種優化過的 B 樹結構。B + 樹是一種多路平衡查找樹,其特點是所有數據都存儲在葉子節點中,而內部節點僅用於索引。BoltDB 在實現 B + 樹時進行了一些改造:
- 引用游標:由於葉子節點之間不是通過鏈表直接連接,BoltDB 使用一個游標工具來輔助範圍檢索,記錄移動路徑。
- 調整頻率:為了平衡操作效率和樹的平衡性,BoltDB 僅在數據溢寫到磁碟前進行一次性的 B + 樹平衡調整。
桶(Bucket)的概念#
BoltDB 引入了桶(Bucket)的概念,用於實現業務數據的隔離。可以將桶類比為數據庫中的表,但桶的形式更加靈活,支持嵌套式的拓撲關係。每個數據庫都有一個默認的根桶(root bucket),從這個根桶可以衍生出多叉樹結構。每個桶在邏輯上都有一棵獨立的 B + 樹,用於存儲該桶範圍內的鍵值對數據。這種設計使得數據的組織更加靈活,便於管理和擴展。
BoltDB 事務處理#
BoltDB 支持兩種類型的事務:只讀事務(read-only transactions)和讀寫事務(read-write transactions)。這兩種類型的事務在操作權限和並發處理上有所不同:
- 讀寫事務:這類事務允許執行修改數據的操作,如插入、更新和刪除。由於這些操作可能會改變數據庫狀態,因此讀寫事務不是幂等的。在任何時刻,只有一個讀寫事務可以執行,以避免並發寫操作導致的數據不一致。然而,讀寫事務可以與多個只讀事務並行執行。
- 只讀事務:這類事務僅包含查詢操作,不會修改數據庫內容。因此,只讀事務是幂等的,可以安全地與其他只讀事務或讀寫事務並行執行,而不會影響數據的一致性。
核心操作流程概覽#
在本節中,我們將簡要探討 BoltDB 中幾個核心操作的流程,但不會深入到源代碼的細節:
- 打開數據庫:在打開 BoltDB 數據庫時,會進行一系列的初始化操作,包括加載元數據頁、空閒列表頁和根節點頁。這些操作確保數據庫處於一致的狀態,並且準備好接受後續的讀寫請求。
- 事務開始:當發起一個事務時,BoltDB 會根據事務類型(只讀或讀寫)分配相應的資源。對於讀寫事務,會確保當前沒有其他讀寫事務在執行。
- 數據查詢:在只讀事務中,數據查詢操作可以直接訪問數據庫的 B + 樹結構,檢索所需的鍵值對。查詢操作不會鎖定任何數據,因此可以高效地並行執行。
- 數據修改:在讀寫事務中,數據的修改操作需要更複雜的處理。BoltDB 可能會創建新的頁來存儲修改後的數據,並更新 B + 樹的結構。在事務提交時,這些更改會被持久化到磁碟。
- 事務提交:無論是只讀還是讀寫事務,在提交時都需要確保所有更改都已經正確處理。對於讀寫事務,這可能包括更新元數據頁、重新平衡 B + 樹以及持久化所有新創建的頁。
- 事務回滾:如果事務在執行過程中遇到錯誤,或者顯式地被回滾,BoltDB 會撤銷所有未提交的更改,確保數據庫狀態的一致性。
通過這些核心操作流程,BoltDB 提供了一個高效且可靠的鍵值存儲解決方案,適用於需要高性能和數據一致性的應用場景。
db 定義#
首先介紹 boldb 中的一個核心類 --DB,對應為一個數據庫實例的代碼抽象,其中包含的核心成員屬性:
// boltdb 抽象的數據庫
type DB struct {
// ...
// 數據庫文件名稱
path string
// 打開文件方法
openFile func(string, int, os.FileMode) (*os.File, error)
// 數據庫文件,所有數據存儲於此
file *os.File
// 基於 mmap 技術映射的數據庫文件內容
data *[maxMapSize]byte
// ...
// 兩個輪換使用的 meta page
meta0 *meta
meta1 *meta
// 數據庫單個 page 的大小,單位 byte
pageSize int
// 數據庫是否已啟動
opened bool
// 全局唯一的讀寫事務
rwtx *Tx
// 一系列只讀事務
txs []*Tx
// freelist,管理空閒的 page
freelist *freelist
freelistLoad sync.Once
// 提高 page 字節數組重用率的對象池
pagePool sync.Pool
// ...
// 互斥鎖,保證讀寫事務全局唯一
rwlock sync.Mutex
// 保護 meta page 的互斥鎖
metalock sync.Mutex
// 保護 mmap 的讀寫鎖
mmaplock sync.RWMutex
// 數據落盤持久化時使用的操作方法,對應為 pwrite 操作
ops struct {
writeAt func(b []byte, off int64) (n int, err error)
}
// 是否已只讀模式啟動數據庫
readOnly bool
}
啟動#
主流程#
通過 open 方法可以啟動 db, 核心流程包括:
- 通過 db 實例,並讀取各項 option 完成配置
- 通過傳入的 path, 打開對應的數據庫文件(如果文件之前不存在,則會進行全新文件的創建)
- 倘若在創建新的數據庫文件,則需要完成 2 個 meta page, 1 個 freelist page 和 1 個 leaf element page 的初始化
- 構造 pagePool 對象池,後續可重用 page 的字節數組
- 執行 mmap 操作,完成數據庫文件和內存空間的映射
- 返回構造好的 db 實例
func Open(path string, mode os.FileMode, options *Options) (*DB, error) {
// 構造 db 實例
db := &DB{
opened: true,
}
// 啟用默認配置
if options == nil {
options = DefaultOptions
}
// ...
// 默認不啟用只讀模式
if options.ReadOnly {
flag = os.O_RDONLY
db.readOnly = true
} else {
// always load free pages in write mode
db.PreLoadFreelist = true
}
// 打開數據庫文件的操作方法
db.openFile = options.OpenFile
if db.openFile == nil {
db.openFile = os.OpenFile
}
// 打開數據庫文件
var err error
if db.file, err = db.openFile(path, flag|os.O_CREATE, mode); err != nil {
_ = db.close()
return nil, err
}
// 數據庫文件名稱賦值
db.path = db.file.Name()
// ...
// 數據落盤操作
db.ops.writeAt = db.file.WriteAt
// 數據 page 大小
if db.pageSize = options.PageSize; db.pageSize == 0 {
// 默認等於操作系統 page 大小
db.pageSize = defaultPageSize
}
// 倘若從零到一創建一個新的 db 文件,則需要進行初始化
if info, err := db.file.Stat(); err != nil {
_ = db.close()
return nil, err
} else if info.Size() == 0 {
// 初始化 db
if err := db.init(); err != nil {
// ...
_ = db.close()
return nil, err
}
}
// ...
// 對象池,用於重用 page 的字節數組
db.pagePool = sync.Pool{
New: func() interface{} {
return make([]byte, db.pageSize)
},
}
// 基於 mmap 建立數據庫文件和內存空間的映射
if err := db.mmap(options.InitialMmapSize); err != nil {
_ = db.close()
return nil, err
}
// 預加載 freelist
if db.PreLoadFreelist {
db.loadFreelist()
}
// ...
return db, nil
}
mmap#
通過 mmap 實現數據文件和內存映射,核心步驟包括:
- 加鎖保證 mmap 操作並發安全
- 設置合適的 mmap 空間大小
- 若之前已經執行過 mmap,則需要善後粗粒
- 執行新一輪 mmap 操作
func (db *DB) mmap(minsz int) (err error) {
// 互斥鎖,保護 mmap 並發安全
db.mmaplock.Lock()
defer db.mmaplock.Unlock()
info, err := db.file.Stat()
// ...
// 調整合適的 mmap 容量
fileSize := int(info.Size())
var size = fileSize
if size < minsz {
size = minsz
}
size, err = db.mmapSize(size)
if err != nil {
return err
}
// ...
// 倘若此前已經有讀寫事務在運行,此時因為要執行 mmap 操作,則需要對 bucket 內容進行重塑
if db.rwtx != nil {
db.rwtx.root.dereference()
}
// 解除之前建立的 mmap 映射
if err = db.munmap(); err != nil {
return err
}
// 建立新的 mmap 映射
if err = mmap(db, size); err != nil {
return err
}
// ...
return nil
}
注意:mmap 底層通過系統調用實現,不同的操作系統會有不同的實現細節。
建 bucket (表)#
一個 bucket 本質上是從屬於其父 bucket b + 樹中的一筆特殊的 kv 對數據。因此創建 bucket 的過程會和寫入 kv 數據的流程類似:
- 借助游標,找到 bucket key 從屬的父 bucket b + 樹的位置
- 創建子 bucket 實例,並取得序列化後的結果
- 將 bucket 名稱作為 key, bucket 序列化結果作為 value,以一組 kv 對的形式插入到父 bucket b + 樹中
func (b *Bucket) CreateBucket(key []byte) (*Bucket, error) {
// ...
// 獲取游標
c := b.Cursor()
// 借助游標找到桶名 key 對應的位置
k, _, flags := c.seek(key)
// 桶已存在
if bytes.Equal(key, k) {
if (flags & bucketLeafFlag) != 0 {
return nil, ErrBucketExists
}
return nil, ErrIncompatibleValue
}
// 創建新的桶實例
var bucket = Bucket{
bucket: &bucket{},
rootNode: &node{isLeaf: true},
FillPercent: DefaultFillPercent,
}
// 取得桶的序列化結果
var value = bucket.write()
// 將這個新桶對應的 kv 對數據寫入到 b+ 樹中
key = cloneBytes(key)
c.node().put(key, key, value, 0, bucketLeafFlag)
// ...
// 返回創建好的新桶
return b.Bucket(key), nil
}
查 bucket(表)#
通過名稱檢索 bucket 的流程,一定程度上和數據的查詢流程相類似:
- 查看父 bucket 的緩存 map,如果子 bucket 已反序列化過,則直接復用
- 通過游標 cursor 檢索父 bucket 的 b + 樹,找到對應子 bucket 的 kv 對數據
- 根據 kv 數據反序列化生成 bucket 實例
- 將子 bucket 添加到父 bucket 的緩存 map 中
- 返回檢索得到的子 bucket
func (b *Bucket) Bucket(name []byte) *Bucket {
// 如果 map 中已經緩存了對應的桶,直接返回
if b.buckets != nil {
if child := b.buckets[string(name)]; child != nil {
return child
}
}
// 借助游標在 b+ 樹中檢索 kv 對
c := b.Cursor()
k, v, flags := c.seek(name)
// ...
// 找到桶後,對其反序列化
var child = b.openBucket(v)
// 緩存到 map 中
if b.buckets != nil {
b.buckets[string(name)] = child
}
// 返回桶
return child
}
數據落盤#
在 boltdb 提交讀寫事務時,會一次性將更新的髒數據溢寫落盤:
- 通過 rebalance 和 spill 操作,保證 B + 樹的平衡性滿足要求
- 執行 pwrite+fdatasync 操作,完成髒數據的 page 的一些落盤
- 通過 pagepool 回收用於指向這部分 page 對應的字節數組
- 由於更新了事務進度,meta page 也需要溢寫落盤
- 關閉讀寫事務
func (tx *Tx) Commit() error {
// ...
// 數據溢寫磁碟前,需要調整一輪 b+ 樹,保證其平衡性
// rebalance 是為了避免因為 delete 操作,導致某些節點 kv 對數量太少,不滿足 b+ 樹平衡性要求
tx.root.rebalance()
// ...
// spill 是為了避免因為 put 操作,導致某些節點 kv 對數量太多,不滿足 b+ 樹平衡性要求
if err := tx.root.spill(); err != nil {
tx.rollback()
return err
}
// 事務更新到的髒數據溢寫落盤
if err := tx.write(); err != nil {
tx.rollback()
return err
}
// ...
// meta page 溢寫落盤
if err := tx.writeMeta(); err != nil {
tx.rollback()
return err
}
// ...
// 關閉事務
tx.close()
// ...
return nil
}
事務髒頁溢寫落盤
func (tx *Tx) write() error {
// 事務緩存的髒頁
pages := make(pages, 0, len(tx.pages))
for _, p := range tx.pages {
pages = append(pages, p)
}
// 清空緩存
tx.pages = make(map[pgid]*page)
// 對髒頁進行排序
sort.Sort(pages)
// 按照順序,將髒頁溢寫落盤
for _, p := range pages {
// page 總大小,包含 overflow 不分
rem := (uint64(p.overflow) + 1) * uint64(tx.db.pageSize)
// page 的 offset,可以根據 page id 推算得到
offset := int64(p.id) * int64(tx.db.pageSize)
var written uintptr
// Write out page in "max allocation" sized chunks.
for {
sz := rem
if sz > maxAllocSize-1 {
sz = maxAllocSize - 1
}
buf := unsafeByteSlice(unsafe.Pointer(p), written, 0, int(sz))
// 將 page 溢寫到文件對應 offset 的位置
if _, err := tx.db.ops.writeAt(buf, offset); err != nil {
return err
}
rem -= sz
// 一次性寫完了
if rem == 0 {
break
}
// 如果沒有一次性寫完,下一輪接著寫
offset += int64(sz)
written += uintptr(sz)
}
}
// fdatasync 操作,確保數據溢寫落盤完成
if !tx.db.NoSync || IgnoreNoSync {
if err := fdatasync(tx.db); err != nil {
return err
}
}
// 釋放這部分已落盤 page,倘若其不存在 overflow,說明是標準規格的字節數組,則清空內容,然後添加到對象池中進行重用
for _, p := range pages {
// Ignore page sizes over 1 page.
// These are allocated using make() instead of the page pool.
if int(p.overflow) != 0 {
continue
}
buf := unsafeByteSlice(unsafe.Pointer(p), 0, 0, tx.db.pageSize)
// See https://go.googlesource.com/go/+/f03c9202c43e0abb130669852082117ca50aa9b1
for i := range buf {
buf[i] = 0
}
tx.db.pagePool.Put(buf) //nolint:staticcheck
}
return nil
}
總結#
本文提供了對 BoltDB 架構和關鍵特性的高層次概覽。我們探討了 BoltDB 的存儲結構,包括不同類型的頁(如元數據頁、空閒列表頁、分支元素頁和葉子元素頁),以及它們在數據庫中的作用。同時,我們也介紹了 BoltDB 的事務處理機制,區分了只讀事務和讀寫事務,並簡要說明了它們的並發處理方式。
此外,我們還從宏觀角度審視了 BoltDB 的 B + 樹實現,包括其對 B 樹的優化和在實際應用中的調整。我們還提到了 BoltDB 中的桶(Bucket)概念,它為數據提供了靈活的隔離和組織方式。
儘管本文的內容較為淺顯,但它為讀者提供了一個框架,以便更好地理解 BoltDB 在分佈式存儲系統中的作用。未來,我們可以深入探討這些核心概念的具體實現細節,以及它們如何共同作用以提供高效、可靠的數據存儲解決方案。隨著對 BoltDB 更深層次的理解,我們可以進一步討論其在實際應用中的性能優化、故障恢復策略以及與其他分佈式系統的集成。