使用 mgo 在 MongoDB 中进行高效分页

Efficient paging in MongoDB using mgo

我已经搜索并没有找到解决问题的 Go 解决方案,无论是否使用 mgo.v2, not on Whosebug and not on any other site. This Q&A is in the spirit of knowledge sharing / documenting


假设我们在 MongoDB 中有一个 users 集合使用 Go struct:

建模
type User struct {
    ID      bson.ObjectId `bson:"_id"`
    Name    string        `bson:"name"`
    Country string        `bson:"country"`
}

我们想根据一些标准对用户进行排序和列出,但由于预期的结果列表很长,所以实现了分页。

实现某些查询结果的分页,MongoDB和mgo.v2 driver package has built-in support in the form of Query.Skip() and Query.Limit(),例如:

session, err := mgo.Dial(url) // Acquire Mongo session, handle error!

c := session.DB("").C("users")
q := c.Find(bson.M{"country" : "USA"}).Sort("name", "_id").Limit(10)

// To get the nth page:
q = q.Skip((n-1)*10)

var users []*User
err = q.All(&users)

然而,如果页码增加,这会变慢,因为 MongoDB 不能 "magically" 跳转到结果中的第 xth 个文档,它必须遍历所有结果文档并省略(不是 return)需要跳过的第一个 x

MongoDB提供了正确的解决方案:如果查询对索引进行操作(它必须对索引进行操作),cursor.min()可以用来指定第一个索引条目 开始列出结果。

这个 Stack Overflow 答案展示了如何使用 mongo 客户端来完成:How to do pagination using range queries in MongoDB?

注意:上述查询所需的索引为:

db.users.createIndex(
    {
        country: 1,
        name: 1,
        _id: 1
    }
)

但是有一个问题:mgo.v2 包不支持指定此 min()

我们如何使用 mgo.v2 驱动程序实现使用 MongoDB 的 cursor.min() 功能的高效分页?

不幸的是mgo.v2 driver does not provide API calls to specify cursor.min()

但是有一个解决办法。 mgo.Database type provides a Database.Run() method to run any MongoDB commands. The available commands and their documentation can be found here: Database commands

从MongoDB 3.2开始,可以使用新的find命令来执行查询,它支持指定min参数,表示第一个索引条目到开始列出结果。

很好。我们需要做的是在每个批次(一页的文档)之后从查询结果的最后一个文档生成 min 文档,其中必须包含用于执行查询的索引条目的值,并且那么下一批(下一页的文档)可以通过在执行查询之前设置这个最小索引条目来获取。

这个索引条目——从现在起我们称它为游标——可能会被编码为string并与结果一起发送给客户端,当客户想要下一页,他发回 cursor 说他想要在此光标之后开始的结果。

手动执行("hard" 方式)

要执行的命令可以有不同的形式,但命令名称(find)必须在编组结果中排在第一位,所以我们将使用bson.D (which preserves order in contrast to bson.M):

limit := 10
cmd := bson.D{
    {Name: "find", Value: "users"},
    {Name: "filter", Value: bson.M{"country": "USA"}},
    {Name: "sort", Value: []bson.D{
        {Name: "name", Value: 1},
        {Name: "_id", Value: 1},
    },
    {Name: "limit", Value: limit},
    {Name: "batchSize", Value: limit},
    {Name: "singleBatch", Value: true},
}
if min != nil {
    // min is inclusive, must skip first (which is the previous last)
    cmd = append(cmd,
        bson.DocElem{Name: "skip", Value: 1},
        bson.DocElem{Name: "min", Value: min},
    )
}

使用 Database.Run() 执行 MongoDB find 命令的结果可以使用以下类型捕获:

var res struct {
    OK       int `bson:"ok"`
    WaitedMS int `bson:"waitedMS"`
    Cursor   struct {
        ID         interface{} `bson:"id"`
        NS         string      `bson:"ns"`
        FirstBatch []bson.Raw  `bson:"firstBatch"`
    } `bson:"cursor"`
}

db := session.DB("")
if err := db.Run(cmd, &res); err != nil {
    // Handle error (abort)
}

我们现在有了结果,但是在 []bson.Raw 类型的切片中。但是我们希望它在 []*User 类型的切片中。这是 Collection.NewIter() comes handy. It can transform (unmarshal) a value of type []bson.Raw into any type we usually pass to Query.All() or Iter.All() 的地方。好的。让我们看看:

firstBatch := res.Cursor.FirstBatch
var users []*User
err = db.C("users").NewIter(nil, firstBatch, 0, nil).All(&users)

我们现在有下一页的用户。只剩下一件事:生成游标以用于在我们需要时获取后续页面:

if len(users) > 0 {
    lastUser := users[len(users)-1]
    cursorData := []bson.D{
        {Name: "country", Value: lastUser.Country},
        {Name: "name", Value: lastUser.Name},
        {Name: "_id", Value: lastUser.ID},
    }
} else {
    // No more users found, use the last cursor
}

一切都很好,但是我们如何将 cursorData 转换为 string,反之亦然?我们可以使用 bson.Marshal() and bson.Unmarshal() combined with base64 encoding; the use of base64.RawURLEncoding 给我们一个网络安全的游标字符串,可以添加到 URL 查询而无需转义。

这是一个示例实现:

// CreateCursor returns a web-safe cursor string from the specified fields.
// The returned cursor string is safe to include in URL queries without escaping.
func CreateCursor(cursorData bson.D) (string, error) {
    // bson.Marshal() never returns error, so I skip a check and early return
    // (but I do return the error if it would ever happen)
    data, err := bson.Marshal(cursorData)
    return base64.RawURLEncoding.EncodeToString(data), err
}

// ParseCursor parses the cursor string and returns the cursor data.
func ParseCursor(c string) (cursorData bson.D, err error) {
    var data []byte
    if data, err = base64.RawURLEncoding.DecodeString(c); err != nil {
        return
    }

    err = bson.Unmarshal(data, &cursorData)
    return
}

我们终于有了高效但不那么短的 MongoDB mgo 分页功能。继续阅读...

使用github.com/icza/minquery("easy"方式)

手动方式比较冗长;它可以通用自动化。这是 github.com/icza/minquery comes into the picture (disclosure: I'm the author). It provides a wrapper to configure and execute a MongoDB find command, allowing you to specify a cursor, and after executing the query, it gives you back the new cursor to be used to query the next batch of results. The wrapper is the MinQuery type which is very similar to mgo.Query 但它支持通过 MinQuery.Cursor() 方法指定 MongoDB 的 min

上面使用 minquery 的解决方案如下所示:

q := minquery.New(session.DB(""), "users", bson.M{"country" : "USA"}).
    Sort("name", "_id").Limit(10)
// If this is not the first page, set cursor:
// getLastCursor() represents your logic how you acquire the last cursor.
if cursor := getLastCursor(); cursor != "" {
    q = q.Cursor(cursor)
}

var users []*User
newCursor, err := q.All(&users, "country", "name", "_id")

仅此而已。 newCursor 是用于获取下一批的游标。

注意#1:调用MinQuery.All()时,您必须提供游标字段的名称,这将用于构建游标数据(最终光标字符串)来自.

注意 #2: 如果您要检索部分结果(通过使用 MinQuery.Select()),则必须包括属于游标的所有字段 (索引条目)即使您不打算直接使用它们,否则 MinQuery.All() 将不会拥有游标字段的所有值,因此将无法创建正确的游标值。

在此处查看 minquery 的包文档:https://godoc.org/github.com/icza/minquery,它相当简短,希望是干净的。