简介

buntdb是一个完全用 Go 语言编写的内存键值数据库。它支持 ACID、并发读、自定义索引和空间信息数据。buntdb只用一个源码文件就实现了这些功能,对于想要学习数据库底层知识的童鞋更是不容错过。

感谢@kiyonlin推荐!

快速使用

先安装:

  1. $ go get github.com/tidwall/buntdb

后使用:

  1. package main
  2. import (
  3. "fmt"
  4. "log"
  5. "github.com/tidwall/buntdb"
  6. )
  7. func main() {
  8. db, err := buntdb.Open(":memory:")
  9. if err != nil {
  10. log.Fatal(err)
  11. }
  12. defer db.Close()
  13. db.Update(func(tx *buntdb.Tx) error {
  14. oldValue, replaced, err := tx.Set("testkey", "testvalue", nil)
  15. if err != nil {
  16. return err
  17. }
  18. fmt.Printf("old value:%q replaced:%t\n", oldValue, replaced)
  19. return nil
  20. })
  21. db.View(func(tx *buntdb.Tx) error {
  22. value, err := tx.Get("testkey")
  23. if err != nil {
  24. return err
  25. }
  26. fmt.Println("value is:", value)
  27. return nil
  28. })
  29. }

buntdb在使用方式上与我们熟知的sqlite有些类似,只是前者支持的是键值对,后者支持的关系型数据。首先,我们要打开一个数据库,buntdb支持将数据存储到文件和内存,将数据保存在磁盘上的文件中,断电不会丢失。直接存放在内存中,程序退出后数据就丢失了。调用buntdb.Open()方法需要传入一个文件名的参数,指定数据保存的文件路径。如果传入特殊字符串:memory:,则buntdb不会将数据保存到磁盘。

buntdb中,所有的读写操作都必须在一个事务中执行。同一时间只能存在一个写事务,但是可以同时存在多个并发的读事务。如果只需要读取数据,那么调用db.View()方法。方法接收一个类型为func (tx *buntdb.Tx) error的函数作为参数,db.View()方法内部会生成一个事务对象tx,然后将这个tx作为参数传给该函数。在此函数中使用事务对象txGet()方法执行读取的逻辑:

  1. db.View(func(tx *buntdb.Tx) error {
  2. value, err := tx.Get("testkey")
  3. if err != nil {
  4. return err
  5. }
  6. fmt.Println("value is:", value)
  7. return nil
  8. })

如果需要读写数据,那么使用db.Update()方法。同样地,也需要传入一个类型为func (tx *buntdb.Tx) error的函数,在此函数中使用事务对象txSet方法执行写入逻辑。tx.Set()方法返回 3 个值。如果Set()替换了当前值,则返回替换之前的值和true。如果此函数返回非空错误,db.Update()会回退此前所做的修改,反之会提交此次修改。

如果运行两次上面的程序,我们会看到下面的输出:

  1. // 第一次运行
  2. $ go run main.go
  3. old value:"" replaced:false
  4. value is: testvalue
  5. // 第二次运行
  6. $ go run main.go
  7. old value:"testvalue" replaced:true
  8. value is: testvalue

注意:

  • 数据库操作很容易出错,所以基本上所有的方法都会返回错误,在实际中需要处理每个可能的错误。示例中为了代码简洁,有点地方忽略了;
  • 在传入db.View()db.Update()的函数中不要直接使用db对象,否则可能会导致程序死锁;
  • 默认情况下,若键对应的值不存在,则返回ErrNotFound错误。

遍历

buntdb中存储的数据是根据键排序的,我们可以按顺序依次遍历这些数据。由于遍历是读取操作,我们用db.View()方法。buntdb提供了很多遍历的方法,基本形式都差不多,这里只介绍一个基本的Ascend()方法:

  1. func (tx *Tx) Ascend(index string, iterator func(key, value string) bool) error

Ascend()方法接收一个索引名,然后以该索引定义的顺序遍历所有键值对,将遍历到的键值对传给iterator函数处理,如果iterator返回false,终止遍历。另外,如果未指定索引名,则根据键升序遍历:

  1. func main() {
  2. db, err := buntdb.Open(":memory:")
  3. if err != nil {
  4. log.Fatal(err)
  5. }
  6. defer db.Close()
  7. db.Update(func(tx *buntdb.Tx) error {
  8. data := map[string]string{
  9. "a": "apple",
  10. "b": "banana",
  11. "p": "pear",
  12. "o": "orange",
  13. }
  14. for key, value := range data {
  15. tx.Set(key, value, nil)
  16. }
  17. return nil
  18. })
  19. db.View(func(tx *buntdb.Tx) error {
  20. var count int
  21. tx.Ascend("", func(key, value string) bool {
  22. fmt.Printf("key:%s value:%s\n", key, value)
  23. count++
  24. if count >= 3 {
  25. return false
  26. }
  27. return true
  28. })
  29. return nil
  30. })
  31. }

上面代码中,我们按键升序遍历(因为传入索引名为""),在处理完第三个键值对后,iterator函数返回false,停止遍历。最终输出:

  1. key:a value:apple
  2. key:b value:banana
  3. key:o value:orange

索引

buntdb将所有数据都存储在一个B-tree中,每组数据都有一个键和值。所有数据是根据键来排序的。我们也可以创建自定义索引,这样就可以对值进行排序了。创建索引需要调用db.CreateIndex()方法,该方法签名如下:

  1. func (db *DB) CreateIndex(name, pattern string, less ...func(a, b string) bool) error

name为索引名,在上一节介绍遍历的时候,我们说过遍历时需要传入索引名,以便按照该索引所定义的顺序遍历。pattern为模式,指定索引对哪些键生效,可以只对某些特定模式的键创建索引。*表示所有键,user:*:name表示键名是user::name之间有任意字符的键。通过less函数,我们可以自定义排序规则。buntdb内置了一些排序规则,如IndexString对值进行大小写不敏感的排序,IndexInt/IndexUint/IndexFloat执行数值类型的排序。

  1. func main() {
  2. db, err := buntdb.Open(":memory:")
  3. if err != nil {
  4. log.Fatal(err)
  5. }
  6. defer db.Close()
  7. db.CreateIndex("names", "user:*:name", buntdb.IndexString)
  8. db.Update(func(tx *buntdb.Tx) error {
  9. tx.Set("user:1:name", "tom", nil)
  10. tx.Set("user:2:name", "Randi", nil)
  11. tx.Set("user:3:name", "jane", nil)
  12. tx.Set("user:4:name", "Janet", nil)
  13. tx.Set("user:5:name", "Paula", nil)
  14. tx.Set("user:6:name", "peter", nil)
  15. tx.Set("user:7:name", "Terri", nil)
  16. return nil
  17. })
  18. db.View(func(tx *buntdb.Tx) error {
  19. tx.Ascend("names", func(key, value string) bool {
  20. fmt.Printf("%s: %s\n", key, value)
  21. return true
  22. })
  23. return nil
  24. })
  25. }

我们先为键名满足模式user:*:name的数据创建一个名为names的索引,执行大小写不敏感的排序(buntdb.IndexString)。然后向buntdb中写入几组数据。最后,我们使用Ascend()方法,传入索引名names按该索引指定次序遍历键值对(这里只是遍历满足模式user:*:name的键值对)。

如果我们的键只有user:*:name这种模式的,也可以直接使用模式*user:*

对于整数等非字符串类型的排序,我们需要注意一点:因为buntdb存储的键值都是字符串,所以自定义的排序函数需要执行相应的类型转换。一般需求的数值排序,内置函数就可以满足要求了:

  1. func main() {
  2. db, err := buntdb.Open(":memory:")
  3. if err != nil {
  4. log.Fatal(err)
  5. }
  6. defer db.Close()
  7. db.CreateIndex("ages", "user:*:age", buntdb.IndexInt)
  8. db.Update(func(tx *buntdb.Tx) error {
  9. tx.Set("user:1:age", "16", nil)
  10. tx.Set("user:2:age", "35", nil)
  11. tx.Set("user:3:age", "24", nil)
  12. tx.Set("user:4:age", "32", nil)
  13. tx.Set("user:5:age", "25", nil)
  14. tx.Set("user:6:age", "28", nil)
  15. tx.Set("user:7:age", "31", nil)
  16. return nil
  17. })
  18. db.View(func(tx *buntdb.Tx) error {
  19. tx.Ascend("ages", func(key, value string) bool {
  20. fmt.Printf("%s: %s\n", key, value)
  21. return true
  22. })
  23. return nil
  24. })
  25. }

首先,为键名满足user:*:age的键创建索引ages,因为在这些键对应的值中,我们存储的都是年龄(整数),故使用排序规则IndexInt

JSON 索引

buntdb提供了强大的 JSON 索引功能。如果存储的值是一个 JSON 字符串,buntdb可以对 JSON 串内部的键创建索引。buntdb.IndexJSON()实现了 JSON 索引的排序规则,我们需要传入键在 JSON 内部的路径,如name.firstcontact.email等:

  1. func main() {
  2. db, _ := buntdb.Open(":memory:")
  3. defer db.Close()
  4. db.CreateIndex("first_name", "user:*", buntdb.IndexJSON("name.first"))
  5. db.CreateIndex("age", "user:*", buntdb.IndexJSON("age"))
  6. db.Update(func(tx *buntdb.Tx) error {
  7. tx.Set("user:1", `{"name":{"first":"zhang","last":"san"},"age":18}`, nil)
  8. tx.Set("user:2", `{"name":{"first":"li","last":"si"},"age":27`, nil)
  9. tx.Set("user:3", `{"name":{"first":"wang","last":"wu"},"age":32}`, nil)
  10. tx.Set("user:4", `{"name":{"first":"sun","last":"qi"},"age":8}`, nil)
  11. return nil
  12. })
  13. db.View(func(tx *buntdb.Tx) error {
  14. fmt.Println("Order by first name")
  15. tx.Ascend("first_name", func(key, value string) bool {
  16. fmt.Printf("%s: %s\n", key, value)
  17. return true
  18. })
  19. fmt.Println("Order by age")
  20. tx.Ascend("age", func(key, value string) bool {
  21. fmt.Printf("%s: %s\n", key, value)
  22. return true
  23. })
  24. fmt.Println("Order by age range 18-30")
  25. tx.AscendRange("age", `{"age":18}`, `{"age":30}`, func(key, value string) bool {
  26. fmt.Printf("%s: %s\n", key, value)
  27. return true
  28. })
  29. return nil
  30. })
  31. }

JSON 给我们提供了一种很好的存储用户数据的格式。以user:后加上用户 ID 作为键名,用户数据以 JSON 格式存储在值中,如上所示。

我们分别为 JSON 内部的键name.firstage创建索引。然后分别以name.firstage定义的顺序遍历输出。值得一提的是最后一个遍历使用了AscendRange,可以只遍历指定范围内的数据,例子中为年龄在 18~30 之间。范围遍历并非 JSON 索引独有的,与普通的Ascend相比,AscendRange需要传入区间上下限minmax,所有处于[min, max)之间的数据都会被遍历到(注意不包含max)。

多重索引

细节的盆友应该发现了,创建索引的方法CreateIndex()接受可变数量的排序规则函数,如果第一个函数无法判断两个值的大小,则继续使用后一个函数,直到可以判断或没有其他函数了。这个就是多重索引。在上面的示例中,我们可以将first_nameage两个索引放在一起,先对name.first比较,如果相等,再比较age

  1. func main() {
  2. db, _ := buntdb.Open(":memory:")
  3. defer db.Close()
  4. db.CreateIndex("first_name_age", "user:*", buntdb.IndexJSON("name.first"), buntdb.IndexJSON("age"))
  5. db.Update(func(tx *buntdb.Tx) error {
  6. tx.Set("user:1", `{"name":{"first":"zhang","last":"san"},"age":18}`, nil)
  7. tx.Set("user:2", `{"name":{"first":"li","last":"si"},"age":27`, nil)
  8. tx.Set("user:3", `{"name":{"first":"wang","last":"wu"},"age":30}`, nil)
  9. tx.Set("user:4", `{"name":{"first":"sun","last":"qi"},"age":8}`, nil)
  10. tx.Set("user:5", `{"name":{"first":"li", "name":"dajun"},"age":20}`, nil)
  11. return nil
  12. })
  13. db.View(func(tx *buntdb.Tx) error {
  14. tx.Ascend("first_name_age", func(key, value string) bool {
  15. fmt.Printf("%s: %s\n", key, value)
  16. return true
  17. })
  18. return nil
  19. })
  20. }

由于user:2user:5name.first都是li,相等。故使用age的值排序,所以输出中user:5user:2前面。

降序

我们使用的内置函数都是升序规则。可以使用buntdb.Desc()将升序规则变为降序,拿前面整数排序的例子来说,只需要将buntdb.IndexInt变为buntdb.Desc(buntdb.IndexInt)即可:

  1. func main() {
  2. db, _ := buntdb.Open(":memory:")
  3. defer db.Close()
  4. db.CreateIndex("ages", "user:*:age", buntdb.Desc(buntdb.IndexInt))
  5. db.Update(func(tx *buntdb.Tx) error {
  6. tx.Set("user:1:age", "16", nil)
  7. tx.Set("user:2:age", "35", nil)
  8. tx.Set("user:3:age", "24", nil)
  9. tx.Set("user:4:age", "32", nil)
  10. tx.Set("user:5:age", "25", nil)
  11. tx.Set("user:6:age", "28", nil)
  12. tx.Set("user:7:age", "31", nil)
  13. return nil
  14. })
  15. db.View(func(tx *buntdb.Tx) error {
  16. tx.Ascend("ages", func(key, value string) bool {
  17. fmt.Printf("%s: %s\n", key, value)
  18. return true
  19. })
  20. return nil
  21. })
  22. }

过期

在向buntdb中设置键值时,我们可以通过选项buntdb.SetOptions指定过期时间,超过这个时间数据会自动从buntdb中移除。如果想要移除过期时间,重新使用nil选项设置该键值即可:

  1. func main() {
  2. db, _ := buntdb.Open(":memory:")
  3. defer db.Close()
  4. db.Update(func(tx *buntdb.Tx) error {
  5. tx.Set("testkey", "testvalue", &buntdb.SetOptions{Expires: true, TTL: time.Second})
  6. return nil
  7. })
  8. db.View(func(tx *buntdb.Tx) error {
  9. value, _ := tx.Get("testkey")
  10. fmt.Println("value is:", value)
  11. return nil
  12. })
  13. time.Sleep(time.Second)
  14. db.View(func(tx *buntdb.Tx) error {
  15. value, _ := tx.Get("testkey")
  16. fmt.Println("value is:", value)
  17. return nil
  18. })
  19. }

上面例子中,我们先写入数据,并设置过期时间为1s。然后立刻读取,这时可以读到刚刚设置的值。然后Sleep 1s 之后再次读取,读到空值,说明已被删除:

  1. value is: testvalue
  2. value is:

杂项

遍历时删除

buntdb不支持遍历时删除数据,一般迂回的做法是先记录需要删除的键,遍历结束后统一删除。下面将年龄 >= 30 的用户删掉(嗯,程序员年龄大了,干不动了):

  1. func main() {
  2. db, _ := buntdb.Open(":memory:")
  3. defer db.Close()
  4. db.Update(func(tx *buntdb.Tx) error {
  5. tx.Set("user:1:age", "16", nil)
  6. tx.Set("user:2:age", "35", nil)
  7. tx.Set("user:3:age", "24", nil)
  8. tx.Set("user:4:age", "32", nil)
  9. tx.Set("user:5:age", "25", nil)
  10. tx.Set("user:6:age", "28", nil)
  11. tx.Set("user:7:age", "31", nil)
  12. return nil
  13. })
  14. db.Update(func(tx *buntdb.Tx) error {
  15. // 先汇总
  16. deleteKeys := make([]string, 0)
  17. tx.Ascend("", func(key, value string) bool {
  18. age, _ := strconv.ParseUint(value, 10, 64)
  19. if age >= 30 {
  20. deleteKeys = append(deleteKeys, key)
  21. }
  22. return true
  23. })
  24. // 再删除
  25. for _, key := range deleteKeys {
  26. tx.Delete(key)
  27. }
  28. return nil
  29. })
  30. db.View(func(tx *buntdb.Tx) error {
  31. tx.Ascend("", func(key, value string) bool {
  32. fmt.Printf("%s: %s\n", key, value)
  33. return true
  34. })
  35. return nil
  36. })
  37. }

Web 服务

buntdb只能在本地程序中操作,我们简单为它编写一个 Web 服务,可以通过 HTTP 请求操作远程的buntdb。代码如下:

  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "log"
  6. "net/http"
  7. "strconv"
  8. "time"
  9. "github.com/tidwall/buntdb"
  10. )
  11. var db *buntdb.DB
  12. func init() {
  13. var err error
  14. db, err = buntdb.Open("data.db")
  15. if err != nil {
  16. log.Fatal(err)
  17. }
  18. }
  19. func response(w http.ResponseWriter, err error, data interface{}) {
  20. bytes, _ := json.Marshal(map[string]interface{}{
  21. "error": err,
  22. "data": data,
  23. })
  24. w.Write(bytes)
  25. }
  26. func set(w http.ResponseWriter, r *http.Request) {
  27. key := r.FormValue("key")
  28. value := r.FormValue("value")
  29. expire, _ := strconv.ParseBool(r.FormValue("expire"))
  30. ttl, _ := time.ParseDuration(r.FormValue("ttl"))
  31. var setOption *buntdb.SetOptions
  32. if expire && ttl > 0 {
  33. setOption = &buntdb.SetOptions{Expires: true, TTL: ttl}
  34. }
  35. err := db.Update(func(tx *buntdb.Tx) error {
  36. _, _, err := tx.Set(key, value, setOption)
  37. return err
  38. })
  39. response(w, err, nil)
  40. }
  41. func get(w http.ResponseWriter, r *http.Request) {
  42. key := r.FormValue("key")
  43. var value string
  44. err := db.View(func(tx *buntdb.Tx) error {
  45. var err error
  46. value, err = tx.Get(key)
  47. return err
  48. })
  49. response(w, err, value)
  50. }
  51. type Pair struct {
  52. Key string
  53. Value string
  54. }
  55. func iterate(w http.ResponseWriter, r *http.Request) {
  56. index := r.FormValue("index")
  57. fmt.Println(index)
  58. var items []Pair
  59. err := db.View(func(tx *buntdb.Tx) error {
  60. err := tx.Ascend(index, func(key, value string) bool {
  61. fmt.Println(key, value)
  62. items = append(items, Pair{key, value})
  63. return true
  64. })
  65. return err
  66. })
  67. response(w, err, items)
  68. }
  69. func createIndex(w http.ResponseWriter, r *http.Request) {
  70. name := r.FormValue("name")
  71. pattern := r.FormValue("pattern")
  72. less := buntdb.IndexString
  73. err := db.CreateIndex(name, pattern, less)
  74. response(w, err, nil)
  75. }
  76. func main() {
  77. mux := http.NewServeMux()
  78. mux.HandleFunc("/get", get)
  79. mux.HandleFunc("/set", set)
  80. mux.HandleFunc("/iterate", iterate)
  81. mux.HandleFunc("/create_index", createIndex)
  82. server := &http.Server{
  83. Addr: ":8000",
  84. Handler: mux,
  85. }
  86. if err := server.ListenAndServe(); err != nil {
  87. log.Fatal(err)
  88. }
  89. }

我只编写了基本读取、设置、创建索引和遍历的功能,代码并不难理解。下面我们先运行程序,然后用浏览器请求:

请求localhost:8000/set?key=name&value=dj,返回:

  1. {"error":null, "data":null}

errornull表示无错误。

请求localhost:8000/set?key=dj&value=18,返回:

  1. {"error":null, "data":null}

请求localhost:8000/iterate,返回:

  1. {
  2. "data": [
  3. {
  4. "Key": "age",
  5. "Value": "18"
  6. },
  7. {
  8. "Key": "name",
  9. "Value": "dj"
  10. }
  11. ],
  12. "error": null
  13. }

感兴趣可以试着添加更多的功能。如果对 Go Web 编程不太了解,可以去看看我的Go Web 编程系列文章。

总结

本文介绍buntdb的读取、写入、创建索引等基本操作,最后编写一个简单的 web 服务可以在远程运行,其他程序通过 HTTP 与之交互。buntdb还支持空间索引等高级特性,感兴趣可自行研究。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. buntdb GitHub:https://github.com/tidwall/buntdb
  2. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib