BoltDB 一個簡單的純 Go key/value 儲存 [譯]
作者:wolf4j
連結:https://www.jianshu.com/p/cb1b05aa7dd2
boltDB
Blot
Bolt 是由 Howard Chu 的 LMDB 專案啟發的一個純粹的 Go key/value資料庫。 該專案的目標是為不需要完整資料庫伺服器(如Postgres或MySQL)的專案提供一個簡單,快速和可靠的資料庫。
由於 Bolt 是用來作為這樣一個低層次的功能,簡單是關鍵。 該API將是小的,只專注於獲取值和設定值而已。
專案狀態
Blot 穩定,API固定,檔案格式固定。 使用完整的單元測試覆蓋率和隨機黑箱測試來確保資料庫一致性和執行緒安全性。 Blot 目前用於高達1TB的高負載生產環境。 Shopify 和 Heroku等許多公司每天都使用 Bolt 來支援服務。
A message from the author
Bolt 最初的目標是提供一個簡單的純 Go key/value 儲存,並且不會使程式碼具有多餘的特性。為此,該專案取得了成功。但是,這個範圍有限也意味著該專案已經完成。
維護一個開源資料庫需要大量的時間和精力。 對程式碼的更改可能會產生意想不到的甚至是災難性的影響,因此即使是簡單的更改也需要經過數小時的仔細測試和驗證。
不幸的是,我不再有時間或精力來繼續這項工作。 Blot 處於穩定狀態,並有多年的成功生產使用。因此,我覺得把它放在現在的狀態是最謹慎的做法。
如果你有興趣使用一個更有特色的Bolt版本,我建議你看一下名為 bbolt的 CoreOS fork。
Getting Started
安裝
為了使用 Blot,首先安裝 go 環境,然後執行如下命令:
$ go get github.com/boltdb/bolt/...
該命令將檢索庫並將Blot可執行檔案安裝到 $GOBIN 路徑中。
開啟BlotDB
Bolt中的頂級物件是一個DB。它被表示為磁碟上的單個檔案,表示資料的一致快照。
要開啟資料庫,只需使用 bolt.Open()
函式:
package main import ( "log" "github.com/boltdb/bolt" ) func main() { // Open the my.db data file in your current directory. // It will be created if it doesn't exist. db, err := bolt.Open("my.db", 0600, nil) if err != nil { log.Fatal(err) } defer db.Close() ... }
請注意:
Bolt 會在資料檔案上獲得一個檔案鎖,所以多個程序不能同時開啟同一個資料庫。 開啟一個已經開啟的 Bolt 資料庫將導致它掛起,直到另一個程序關閉它。為防止無限期等待,您可以將超時選項傳遞給Open()
函式:
db, err := bolt.Open("my.db", 0600, &bolt.Options{Timeout: 1 * time.Second})
事務
Bolt 一次只允許一個讀寫事務,但是一次允許多個只讀事務。 每個事務處理都有一個始終如一的資料檢視。
單個事務以及從它們建立的所有物件(例如bucket,key)不是執行緒安全的。 要處理多個goroutine 中的資料,您必須為每個 goroutine 啟動一個事務,或使用鎖來確保一次只有一個 goroutine 訪問事務。 從 DB 建立事務是執行緒安全的。
只讀事務和讀寫事務不應該相互依賴,一般不應該在同一個例程中同時開啟。 這可能會導致死鎖,因為讀寫事務需要定期重新對映資料檔案,但只有在只讀事務處於開啟狀態時才能這樣做。
讀寫事務
為了啟動一個讀寫事物,你可以使用DB.Update()
函式:
err := db.Update(func(tx *bolt.Tx) error {
...
return nil
})
在閉包內部,您有一個一致的資料庫檢視。 您通過返回零來完成交易。 您也可以通過返回錯誤來隨時回滾事務。 所有的資料庫操作都允許在一個讀寫事務中。
總是檢查返回錯誤,因為它會報告任何可能導致您的交易不完成的磁碟故障。如果你在閉包中返回一個錯誤,它將被傳遞。
只讀事務
為了啟動一個只讀事務,你可以使用DB.View()
函式:
err := db.View(func(tx *bolt.Tx) error {
...
return nil
})
您也可以在此閉包中獲得資料庫的一致檢視,但是,在只讀事務中不允許進行變異操作。您只能檢索儲存區,檢索值,或者在只讀事務中複製資料庫。
批量讀寫事務
每個 DB.Update()
等待磁碟提交寫入。通過將多個更新與 DB.Batch()
函式結合,可以最小化這種開銷:
err := db.Batch(func(tx *bolt.Tx) error {
...
return nil
})
併發批量呼叫可以組合成更大的交易。 批處理僅在有多個 goroutine 呼叫時才有用。
如果部分事務失敗,batch 可以多次呼叫給定的函式。 該函式必須是冪等的,只有在成功從 DB.Batch()
返回後才能生效。
例如:不要在函式內部顯示訊息,而是在封閉範圍內設定變數:
var id uint64
err := db.Batch(func(tx *bolt.Tx) error {
// Find last key in bucket, decode as bigendian uint64, increment
// by one, encode back to []byte, and add new key.
...
id = newValue
return nil
})
if err != nil {
return ...
}
fmt.Println("Allocated ID %d", id)
手動管理交易
DB.View()
和DB.Update()
函式是DB.Begin()
函式的包裝器。 這些幫助函式將啟動事務,執行一個函式,然後在返回錯誤時安全地關閉事務。 這是使用 Bolt 交易的推薦方式。
但是,有時您可能需要手動開始和結束交易。 您可以直接使用DB.Begin()
函式,但請務必關閉事務。
// Start a writable transaction.
tx, err := db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// Use the transaction...
_, err := tx.CreateBucket([]byte("MyBucket"))
if err != nil {
return err
}
// Commit the transaction and check for error.
if err := tx.Commit(); err != nil {
return err
}
DB.Begin()
的第一個引數是一個布林值,說明事務是否可寫。
使用 buckets
儲存區是資料庫中 key/value 對的集合。 bucket 中的所有 key 必須是唯一的。 您可以使用 DB.CreateBucket()
函式建立一個儲存 bucket:
db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucket([]byte("MyBucket"))
if err != nil {
return fmt.Errorf("create bucket: %s", err)
}
return nil
})
只有在使用 Tx.CreateBucketIfNotExists()
函式不存在的情況下,可以建立一個 bucket 。 在開啟資料庫之後,為所有頂級 bucket 呼叫此函式是一種常見模式,因此您可以保證它們存在以備將來事務處理。
要刪除一個 bucket,只需呼叫 Tx.DeleteBucket()
函式即可。
使用 key/value 對
要將 key/value 對儲存到 bucket,請使用 Bucket.Put()
函式:
db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
err := b.Put([]byte("answer"), []byte("42"))
return err
})
這將在 MyBucket 的 bucket 中將 “answer” key的值設定為“42”。 要檢索這個value,我們可以使用 Bucket.Get()
函式:
db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
v := b.Get([]byte("answer"))
fmt.Printf("The answer is: %s\n", v)
return nil
})
Get()
函式不會返回錯誤,因為它的操作保證可以正常工作(除非有某種系統失敗)。 如果 key 存在,則它將返回其位元組片段值。 如果不存在,則返回零。 請注意,您可以將零長度值設定為與不存在的鍵不同的鍵。
使用 Bucket.Delete()
函式從 bucket 中刪除一個 key。
請注意,從 Get()
返回的值僅在事務處於開啟狀態時有效。 如果您需要在事務外使用一個值,則必須使用 copy()
將其複製到另一個位元組片段。
自動增加 bucket 的數量
通過使用 NextSequence()
函式,您可以讓 Bolt 確定一個可用作 key/value 對唯一識別符號的序列。看下面的例子。
// CreateUser saves u to the store. The new user ID is set on u once the data is persisted.
func (s *Store) CreateUser(u *User) error {
return s.db.Update(func(tx *bolt.Tx) error {
// Retrieve the users bucket.
// This should be created when the DB is first opened.
b := tx.Bucket([]byte("users"))
// Generate ID for the user.
// This returns an error only if the Tx is closed or not writeable.
// That can't happen in an Update() call so I ignore the error check.
id, _ := b.NextSequence()
u.ID = int(id)
// Marshal user data into bytes.
buf, err := json.Marshal(u)
if err != nil {
return err
}
// Persist bytes to users bucket.
return b.Put(itob(u.ID), buf)
})
}
// itob returns an 8-byte big endian representation of v.
func itob(v int) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(v))
return b
}
type User struct {
ID int
...
}
迭代keys
Bolt 將 keys 以位元組排序的順序儲存在一個 bucket 中。這使得對這些 keys 的順序迭代非常快。要遍歷 key,我們將使用一個 Cursor:
db.View(func(tx *bolt.Tx) error {
// Assume bucket exists and has keys
b := tx.Bucket([]byte("MyBucket"))
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
fmt.Printf("key=%s, value=%s\n", k, v)
}
return nil
})
Cursor允許您移動到鍵列表中的特定點,並一次向前或向後移動一個鍵。
Cursor上有以下功能:
First() Move to the first key.
Last() Move to the last key.
Seek() Move to a specific key.
Next() Move to the next key.
Prev() Move to the previous key.
每個函式都有(key [] byte,value [] byte)的返回簽名。 當你迭代到遊標的末尾時,Next()
將返回一個零鍵。 在呼叫 Next()
或 Prev()
之前,您必須使用 First()
,Last()
或 Seek()
來尋找位置。 如果你不尋找位置,那麼這些函式將返回一個零鍵。
在迭代期間,如果 key 非零,但是 value 為零,則意味著 key 指的是一個 bucket而不是一個 value。 使用 Bucket.Bucket()
訪問子 bucket。
字首掃描
迭代關鍵字字首,可以將 Seek()
和 bytes.HasPrefix()
組合起來:
db.View(func(tx *bolt.Tx) error {
// Assume bucket exists and has keys
c := tx.Bucket([]byte("MyBucket")).Cursor()
prefix := []byte("1234")
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
fmt.Printf("key=%s, value=%s\n", k, v)
}
return nil
})
範圍掃描
另一個常見的用例是掃描一個範圍,如時間範圍。如果您使用可排序的時間編碼(如RFC3339),那麼您可以查詢特定的日期範圍,如下所示:
db.View(func(tx *bolt.Tx) error {
// Assume our events bucket exists and has RFC3339 encoded time keys.
c := tx.Bucket([]byte("Events")).Cursor()
// Our time range spans the 90's decade.
min := []byte("1990-01-01T00:00:00Z")
max := []byte("2000-01-01T00:00:00Z")
// Iterate over the 90's.
for k, v := c.Seek(min); k != nil && bytes.Compare(k, max) <= 0; k, v = c.Next() {
fmt.Printf("%s: %s\n", k, v)
}
return nil
})
請注意,儘管RFC3339是可排序的,但RFC3339Nano的Golang實現不會在小數點後使用固定數量的數字,因此無法排序。
ForEach()
你也可以使用 ForEach()
函式,如果你知道你將迭代 bucket 中的所有 keys:
db.View(func(tx *bolt.Tx) error {
// Assume bucket exists and has keys
b := tx.Bucket([]byte("MyBucket"))
b.ForEach(func(k, v []byte) error {
fmt.Printf("key=%s, value=%s\n", k, v)
return nil
})
return nil
})
請注意,ForEach()
中的鍵和值僅在事務處於開啟狀態時有效。如果您需要使用事務外的鍵或值,則必須使用copy()
將其複製到另一個位元組片。
巢狀的 bucket
您也可以在一個 key 中儲存一個 bucket 來建立巢狀的 bucket 。 API 與 DB
物件上的儲存區管理 API 相同:
func (*Bucket) CreateBucket(key []byte) (*Bucket, error)
func (*Bucket) CreateBucketIfNotExists(key []byte) (*Bucket, error)
func (*Bucket) DeleteBucket(key []byte) error
假設您有一個多租戶應用程式,其中根級 bucket 是帳戶 bucket。 這個 bucket 裡面是一系列的帳戶,它們本身就是一個 bucket。 而在序列的 bucket 中,可以有許多與賬戶本身相關的儲存區(使用者,備註等)將資訊分隔成邏輯分組。
// createUser creates a new user in the given account.
func createUser(accountID int, u *User) error {
// Start the transaction.
tx, err := db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// Retrieve the root bucket for the account.
// Assume this has already been created when the account was set up.
root := tx.Bucket([]byte(strconv.FormatUint(accountID, 10)))
// Setup the users bucket.
bkt, err := root.CreateBucketIfNotExists([]byte("USERS"))
if err != nil {
return err
}
// Generate an ID for the new user.
userID, err := bkt.NextSequence()
if err != nil {
return err
}
u.ID = userID
// Marshal and save the encoded user.
if buf, err := json.Marshal(u); err != nil {
return err
} else if err := bkt.Put([]byte(strconv.FormatUint(u.ID, 10)), buf); err != nil {
return err
}
// Commit the transaction.
if err := tx.Commit(); err != nil {
return err
}
return nil
}
資料庫備份
Blot 是一個單一的檔案,所以很容易備份。 您可以使用Tx.WriteTo()
函式將資料庫的一致檢視寫入目的地。 如果您從只讀事務中呼叫它,它將執行熱備份而不會阻止其他資料庫的讀寫操作。
預設情況下,它將使用一個常規的檔案控制代碼來利用作業系統的頁面快取。 有關優化大於RAM資料集的資訊,請參閱Tx文件。
一個常見的用例是通過HTTP進行備份,因此您可以使用像cURL這樣的工具來進行資料庫備份:
func BackupHandleFunc(w http.ResponseWriter, req *http.Request) {
err := db.View(func(tx *bolt.Tx) error {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="my.db"`)
w.Header().Set("Content-Length", strconv.Itoa(int(tx.Size())))
_, err := tx.WriteTo(w)
return err
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
那麼你可以用這個命令備份:
$ curl http://localhost/backup > my.db
或者你可以開啟你的瀏覽器到http://localhost/backup
,它會自動下載。
如果你想備份到另一個檔案,你可以使用Tx.CopyFile()
輔助函式。
與其他資料庫比較
Postgres, MySQL, & other relational databases
關係資料庫將資料結構化為行,並且只能通過使用SQL來訪問。 這種方法提供瞭如何儲存和查詢資料的靈活性,但也會導致解析和規劃SQL語句的開銷。 Bolt通過位元組切片鍵訪問所有資料。 這使得 Bolt 可以快速讀寫資料,但不提供內建的連線值的支援。
大多數關係資料庫(SQLite除外)都是獨立於伺服器的獨立伺服器。 這使您的系統可以靈活地將多個應用程式伺服器連線到單個數據庫伺服器,但是也增加了通過網路序列化和傳輸資料的開銷。 Bolt 作為應用程式中包含的庫執行,因此所有資料訪問都必須通過應用程式的程序。 這使資料更接近您的應用程式,但限制了多程序訪問資料。
LevelDB, RocksDB
LevelDB 及其衍生產品(RocksDB,HyperLevelDB)與 Bolt 類似,它們被繫結到應用程式中,但是它們的底層結構是日誌結構合併樹(LSM樹)。 LSM 樹通過使用提前寫入日誌和稱為 SSTables 的多層排序檔案來優化隨機寫入。 Bolt 在內部使用 B+ 樹,只有一個檔案。 兩種方法都有折衷。
如果您需要高隨機寫入吞吐量(> 10,000 w / sec)或者您需要使用旋轉磁碟,則 LevelDB可能是一個不錯的選擇。 如果你的應用程式是重讀的,或者做了很多範圍掃描,Bolt 可能是一個不錯的選擇。
另一個重要的考慮是 LevelDB 沒有交易。 它支援批量寫入鍵/值對,它支援讀取快照,但不會使您能夠安全地進行比較和交換操作。 Bolt 支援完全可序列化的 ACID 事務。
LMDB
Bolt 最初是 LMDB 的一個類似實現,所以在結構上是相似的。 兩者都使用 B+ 樹,具有完全可序列化事務的 ACID 語義,並且使用單個writer 和多個 reader 來支援無鎖 MVCC。
這兩個專案有些分歧。 LMDB 主要關注原始效能,而 Bolt 專注於簡單性和易用性。 例如,LMDB 允許執行一些不安全的操作,如直接寫操作。 Bolt 選擇禁止可能使資料庫處於損壞狀態的操作。 這個在 Bolt 中唯一的例外是 DB.NoSync。
API 也有一些差異。 開啟 mdb_env 時 LMDB 需要最大的 mmap 大小,而Bolt將自動處理增量式 mmap 大小調整。 LMDB 用多個標誌過載 getter 和 setter 函式,而 Bolt 則將這些特殊的情況分解成它們自己的函式。
注意事項和限制
選擇正確的工具是非常重要的,Bolt 也不例外。在評估和使用 Bolt 時,需要注意以下幾點:
- Bolt 適合讀取密集型工作負載。順序寫入效能也很快,但隨機寫入可能會很慢。您可以使用
DB.Batch()
或新增預寫日誌來幫助緩解此問題。 - Bolt在內部使用B +樹,所以可以有很多隨機頁面訪問。與旋轉磁碟相比,SSD可顯著提高效能。
- 儘量避免長時間執行讀取事務。 Bolt使用
copy-on-write
技術,舊的事務正在使用,舊的頁面不能被回收。 - 從 Bolt 返回的位元組切片只在交易期間有效。 一旦事務被提交或回滾,那麼它們指向的記憶體可以被新頁面重用,或者可以從虛擬記憶體中取消對映,並且在訪問時會看到一個意外的故障地址恐慌。
- Bolt在資料庫檔案上使用獨佔寫入鎖,因此不能被多個程序共享
- 使用
Bucket.FillPercent
時要小心。設定具有隨機插入的 bucket 的高填充百分比會導致資料庫的頁面利用率很差。 - 一般使用較大的 bucket。較小的 bucket 會導致較差的頁面利用率,一旦它們大於頁面大小(通常為4KB)。
- 將大量批量隨機寫入載入到新儲存區可能會很慢,因為頁面在事務提交之前不會分裂。不建議在單個事務中將超過 100,000 個鍵/值對隨機插入單個新 bucket
中。 - Bolt使用記憶體對映檔案,以便底層作業系統處理資料的快取。 通常情況下,作業系統將快取儘可能多的檔案,並在需要時釋放記憶體到其他程序。 這意味著Bolt在處理大型資料庫時可以顯示非常高的記憶體使用率。 但是,這是預期的,作業系統將根據需要釋放記憶體。 Bolt可以處理比可用物理RAM大得多的資料庫,只要它的記憶體對映適合程序虛擬地址空間。 這在32位系統上可能會有問題。
- Bolt資料庫中的資料結構是儲存器對映的,所以資料檔案將是endian特定的。 這意味著你不能將Bolt檔案從一個小端機器複製到一個大端機器並使其工作。 對於大多數使用者來說,這不是一個問題,因為大多數現代的CPU都是小端的。
- 由於頁面在磁碟上的佈局方式,Bolt無法截斷資料檔案並將空閒頁面返回到磁碟。 相反,Bolt 在其資料檔案中保留一個未使用頁面的空閒列表。 這些免費頁面可以被以後的交易重複使用。 由於資料庫通常會增長,所以對於許多用例來說,這是很好的方法 但是,需要注意的是,刪除大塊資料不會讓您回收磁碟上的空間。
本文由 Copernicus 團隊 冉小龍
翻譯,轉載無需授權。