为什么在按量计费的数据库中,游标分页胜过 Skip 分页
`skip(N)` 会读取并丢弃它跳过的每个文档,而按读取次数计费的数据库会针对所有这些文档向您收费。下面是其计算方法、替代它的查询,以及保持顺序稳定的决胜条件。
偏移分页看似免费,直到你看到账单。在按读取文档数收费的数据库中,skip(N) 并非一个能直接向前跳转的快捷方式。它会读取并丢弃其跳过的每一个文档,而你则需要为每一个付费。本文将通过实际计算展示成本差异,介绍可完全取代偏移分页的基于游标的查询、用于确保排序完整性的决胜条件,以及普通 skip 依然是正确选择的唯一情况。
skip 的实际工作原理
skip(N) 看起来像是能直接跳转到第 N 个结果,但数据库在不遍历前 N 个文档的情况下,无法知道第 N 个文档在哪里。对于任意的筛选和排序,并不存在一个能直接指向“第 380 个匹配行”的现成索引。因此,引擎会从有序结果集的开头开始,跳过 N 个文档,然后才开始向你返回行。
在一个固定的集合上,如果你从不进行深度分页,没有人会注意到这一点。当两件事同时发生时,成本问题就会显现出来:集合很大,并且用户进行深度分页。在页面大小为 20 的情况下,访问第 20 页时,skip(380).limit(20) 会读取 400 个文档,以便向你提供 20 个。另外 380 个文档在你请求的页面开始返回之前,就被读取、计数,然后丢弃了。你没有请求它们,也看不到它们,但在一个按量计费的数据库中,你却需要为所有这些文档付费。
成本,用数字表示
我们将两种策略并列比较。重要的列是最后一列,因为它显示的是真实流量下账单上的数字。
| 方法 | 第 3 页的读取次数 | 第 20 页的读取次数 | 1 万次深层分页浏览的读取次数 |
|---|---|---|---|
| skip | 60 | 400 | ~1,200,000 |
| cursor | 20 | 20 | ~200,000 |
skip 这一行随着页面深度的增加而增长。cursor 这一行则不会。无论你是在第 3 页还是第 3,000 页,它每次都只读取页面大小的文档数量。并且随着网站的增长,它们之间的比率不会缩小,反而会扩大,因为集合越大,人们就越倾向于进行深层分页。skip 的最坏情况恰好在你因流量最多而损失最多钱的时候到来。
延迟方面的情况与成本方面如出一辙。读取并丢弃 380 个文档不仅会被计费,而且速度很慢,并且页面越深,速度越慢。游标分页的延迟是固定的,因为它每页执行的工作量是恒定的。
替代它的游标查询
你不再使用偏移量,而是沿用当前页最后一项的排序键,下一页则请求排在该键值之后的行。这其中不涉及任何越过计数的行为。数据库直接在索引中定位到游标位置,然后读取该页。
// Carry the last item's sort key forward. Each page reads exactly what it
// returns, no matter how deep you are.
filter := bson.M{"published_at": bson.M{"$lt": cursor}}
opts := options.Find().
SetSort(bson.D{{"published_at", -1}}).
SetLimit(20)
rows, err := coll.Find(ctx, filter, opts)
游标不是页码。它是你所显示的最后一行的排序字段的值,在此处即 published_at。下一个请求会说“给我比此时间戳更早的最新 20 行”,并且因为 published_at 上有索引,引擎会直接跳转到那个点并向前读取。页深与所完成的工作无关。
实现完整排序的决胜条件
第一个查询中隐藏着一个 bug,这也是大家会遇到的第二个问题。如果两个文档共享相同的 published_at(在真实系统中这很常见),那么“比此时间戳更早”的条件就会变得不明确。对时间戳使用严格的 $lt 可能会跳过与游标时间戳相同的行,而使用非严格的 $lte 则可能重复你刚刚显示过的行。无论哪种方式,页面边界都是错误的,用户会在页面之间的接缝处看到重复或缺失的项目。
解决方法是添加一个唯一的决胜条件来使排序完整,这样就不会有两个文档的比较结果相等。_id 是一个自然的选择,如果 id 本身能进行有意义的排序(就像 ULID 那样),那就更好了。游标变成一个偶对,比较也变成了复合比较。
// Compound cursor: (published_at, _id). No two rows compare equal, so the
// page boundary is exact. Works because _id is unique and ULIDs sort by time.
filter := bson.M{
"$or": []bson.M{
{"published_at": bson.M{"$lt": cur.PublishedAt}},
{"published_at": cur.PublishedAt, "_id": bson.M{"$lt": cur.ID}},
},
}
opts := options.Find().
SetSort(bson.D{{"published_at", -1}, {"_id", -1}}).
SetLimit(20)
可以将其解读为“所有严格更早的项,加上在相同时间戳内所有 id 更小的项”。因为 _id 是唯一的,所以组合排序是完整的,每一行都有一个确切的位置,页面 N 和页面 N+1 之间的边界也恰好落在一个地方。接缝处没有重复项,也没有丢失的行。这就是为什么能提供可排序、唯一 id 的数据库与游标分页如此契合。如果你的 id 是一个随机 ULID 或派生 ULID,那么你已经拥有了所需的决胜条件。关于稳定、可排序 id 的派生方法,在 确定性 ULID 中有介绍。
此查询所需的索引
只有当排序由索引支持时,游标分页才会快,并且复合游标需要一个字段顺序与排序顺序相同的复合索引。如果没有它,引擎会回退到扫描,而你只是将一个计费问题换成了一个延迟问题。
// The sort is (published_at desc, _id desc), so the index must match.
{Keys: bson.D{{"published_at", -1}, {"_id", -1}}, Name: "published_at_id"}
规则是索引键的顺序要与排序键的顺序一致。如果对齐正确,查询将是一次索引查找,然后是对恰好一个页面的顺序读取。如果搞错了,数据库会静默地进行扫描,这在按量计费的引擎上正是你试图避免的昂贵操作,只是换了一副面具而已。
你需要付出的代价,以及相应的变通方法
Cursor pagination 并非没有权衡取舍。其中最大的一个是你无法跳转到任意页面。没有“跳转到第 47 页”这样的功能,因为第 47 页的 cursor 是第 46 页的最后一行,除非你逐页访问到那里,否则你不会拥有这个 cursor。经典的“1 2 3 ... 47”这种带页码链接的分页器,不适用于此模型。
对于大多数现代界面来说,这没什么问题,因为主导模式是 next-page 和 infinite scroll,这两种模式都只需要紧邻下一页的 cursor。当你确实需要向后移动时,你会为当前页的第一行保留第二个 cursor,并反转比较和排序条件来运行一个镜像查询。两个 cursor,一个向前一个向后,覆盖了“下一页”和“上一页”的功能,这也正是实际的分页 UI 所使用的。
你真正失去的是廉价的总数统计,以及因此而来的廉价的“第 X 页,共 Y 页”功能。在一个大型集合中,无论是否计量,计算所有匹配的行本身就是一个昂贵的查询。通常,坦诚的做法是从 UI 中去掉总数,因为“第 3 页,共 2,847 页”是一个没人会据此采取行动的数字,而显示它需要在每次页面加载时都进行一次完整的计数。
何时 skip 仍然是正确的工具
这并不意味着偏移分页在任何情况下都是错误的。对于一个你永远不会深度分页的小型、有界集合,例如一个几百行的管理后台表格或一个设置列表,skip 的简单性更胜一筹,并且成本可以忽略不计,因为 N 永远不会变得很大。在这种情况下,采用复合游标和匹配的索引是过度设计。
经验法则是关于深度,而不仅仅是大小。如果页面深度有界且较小,skip 就可以,而且更简单。如果页面深度是无界的,意味着用户可以并且将会进行深度分页,那么就使用游标,因为这正是偏移分页成本无限增长的场景,而一个按量计费的数据库会将这种增长变成一个账单项目。针对每个集合,根据人们实际分页的深度来决定,这样你就能在每种工具真正更便宜的地方使用它。