Web 后端的一生之敌:分页器
分页器是 Web 开发中常见的功能,看似简单的却经常隐藏着各种奇怪的坑,堪称 WEB 后端开发的一生之敌。
常见问题
边翻页边写入导致内容重复
某位用户正在浏览我的博客,他看到第一页最后一篇文章是 《Redis 缓存更新一致性》:
在他浏览第一页的过程中,我发布了一篇新文章。他继续浏览,发现第二页的第一篇文章仍然是 《Redis 缓存更新一致性》:
博客园使用的是时间倒序排列和limit..offset分页器,用 SQL 来描述就是:
select * from posts where user_id = ? order by publish_time desc limit 10 offset 10;
在用户浏览第一页时《Redis 缓存更新一致性》按时间倒序排列在第 10 位,当发布新文章后它被挤到了第 11 位。读者使用 limit 10 offset 10
查询第二页时它便会再次出现。
上述情况只是在浏览过程中在头部追加了新的数据,在搜索引擎这类条件很多、排序算法复杂的场景中,第一次查询和第二次查询的顺序可能完全不同,分页器也难以实现。
后置过滤
一般情况下我们可以使用 where 语句过滤出我们需要的记录,然而在工作中也经常碰到 MySQL 不能完成所有过滤的情况。比如我们需要在返回结果前调用一下 rpc 接口来查询一下其中是否存在违规内容并把违规内容过滤掉, 或者有朋友在 mysql 中存储了 json 字符串而且使用的是 MySQL 5.7 之前的版本,只能在业务逻辑中解析 json 并进行过滤了。
后置过滤会遇到一种问题,客户端向我们请求 10 篇文章而服务端过滤后只剩下了 8 篇甚至某一页可能一篇不剩。这可能会在客户端导致一些会被用户注意到的体验问题,比如上滑浏览 feed 流时出现卡顿、闪烁。
聪明的读者可能会想这个问题好解决,如果请求 10 篇文章过滤后只剩下 8 篇,那我们再从数据库中取出 10 篇只要过滤后剩下 2 篇以上是不是就可以满足客户端的请求了?
ok, 我们照此实现,于是问题又来了。客户端请求第一页 10 篇文章而我们已经从数据库中读到了第 14 行,所以客户端请求第二页时 offset 应为 14。 依次类推请求第 3 页时 offset 应为 26, 第 4 页的 offset 应为 44。。。。根据客户端发来的页码找到的 offset 是几乎不可能的事情。
另一个问题是分页接口通常需要告知客户端结果总数或者总页数以便客户端判断是否到达最后一页,而使用了后置过滤的查询几乎不可能查出结果总数,emmm
深度分页带来的性能消耗
MySQL 深度分页的性能问题以及使用自增主键优化深度分页已经广为人知,这里我们不再讨论。
与此类似,查询客户端结果总数或者总页数同样是很耗时的操作。在移动互联网时代像博客园这样显示页码的场景已经不多,更多的是各种样式的信息流。客户端并不需要知道有多少页只需要知道是否到达最后一页即可, 这为我们优(tao)化(ke)留下很大空间。
解决方案
解决分页器麻烦最好的方案就是避免分页(手动滑稽
当然大多数情况无法避免分页,所以我们还是需要研究一下怎么解决上面提到的各种问题
游标分页器
游标分页器的思路和 MySQL 使用自增主键优化深度分页相同,我们不再使用 offset 表示拉取进度而是使用上次返回的最后一条结果的自增 id 作为游标。
以上文中提到的博客重复的问题为例,若 post 表使用自增主键 id, 那么我们可以使用如下SQL 查询:
select * from posts where id < ? order by id desc limit 10;
用户浏览第一页时记住最后一篇文章《Redis 缓存更新一致性》的 id=233, 在拉取第二页时只需要进行查询:
select * from posts where id < 233 order by id desc limit 10;
游标分页器也可以解决上文提到的后置过滤的问题。客户端请求第一页 10 条内容,我们实际上从数据库中取出了 14 条,只需要将从数据库中取出的最后一条的 id 作为游标发给客户端。查询下一页时只要查询 id < cursor (升序排列时为 id > cursor) 即可。
除了自增 id 外只要是不重复的排序字段都可以作为游标,比如时间戳也可以作为游标。在无法保证时间戳不重复时我们可以使用时间戳作为整数部分、id 作为小数部分的方法来构造不会重复的时间戳。如下面的示例代码:
// 对于时间戳相同的 post 我们并不关心谁前谁后,我们只要求排序稳定
// 若 post1.CreatedAt == post2.CreatedAt,查询第一页时 post1 在前 post2 在后,查询第二页时变成了 post2 在前 post1 在后,那么 post1 会出现两次,post2 会被漏掉
// 所以我们需要查询结果是稳定的,post1 始终在 post2 之前或者 post2 始终在 post1 之前
func GetUniqueTime(post *Post) float64 {
intPart := strconv.FormatInt(post.CreatedAt.Unix(), 10)
decimalPart := strconv.FormatUint(post.ID, 10) // 只要求 ID 唯一,并不要求 ID 有序
str := intPart + "." + decimalPart
f, _ := strconv.ParseFloat(str, 64)
return f
}
能使用游标分页器的数据库也不仅限于 MySQL 等关系型数据库,Redis 的 SortedSet 或者 ElasticSearch 的 search_after 都可以使用游标分页器。
游标分页器中不再有具体的页码概念也不再需要总页数,只需要知道当前是否为最后一页即可。我们可以在查询数据库时可以将 limit 加 1 来方便地判断当前是否是最后一页。 比如客户端请求 10 篇文章,我们查询数据库时 limit 设为 11,若数据库返回 11 条记录说明还有下一页,若数据库返回 10 条或 10 条以下的记录则说明当前已到最后一页。
limit 加 1 的目的是为了避免最后一页恰好有 10 条记录的情况,若 limit = 10 且数据库返回 10 条记录我们会认为还有下一页,而客户端继续查询下一页时只能返回空结果。这不仅会空耗资源更重要的是可能会出现一些体验上的问题,比如客户端提示「上滑加载更多」而用户上滑后并无新内容出现的尴尬局面。
游标分页器只适用于元素之间的相对顺序(即A始终在B前)不会发生改变,结果集中只会插入新元素或删除部分元素的情况。
快照
对于搜索引擎这种两次查询中相对顺序可能发生改变的场景,游标分页器也无能为力。若无法避免分页则只能采取快照的方式,在搜索完毕后将整个搜索结果缓存下来,拉取后续内容时不重新搜索而是拉取快照的剩余内容。
使用快照的典型的例子是 ElasticSearch 的 Scroll API:
POST /twitter/_search?scroll=1m
{
"size": 100,
"query": {
"match" : {
"title" : "elasticsearch"
}
}
}
在查询时创建一个有效期为 1m 的快照,使用返回的 scroll id 获取下一页:
GET /_search/scroll
{
"scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
ES 真是分页器的老受害者了