分页器是 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 接口来查询一下其中是否存在违规内容,并把违规内容删除掉。

这种实现会遇到一种问题,客户端向我们请求 10 篇文章而服务端只返回了 8 篇,甚至某一页可能一篇不剩。这些情况可能在客户端导致一些会被用户注意到的体验问题,比如上滑浏览 feed 流时出现卡顿、闪烁。

聪明的读者可能会想这个问题好办,如果请求 10 篇文章过滤后只剩下 8 篇,那我们再从数据库中取出 10 篇只要过滤后剩下 2 篇以上是不是就可以满足客户端的请求了?

好了现在问题又来了,客户端请求第一页 10 篇文章而我们已经从数据库中取出了 14 行记录,当客户端请求第二页时 offset 应为 14, 请求第三页时 offset 应为 26。。。。根据客户端发来的页码找到的 offset 是几乎不可能的事情。

分页接口通常需要告知客户端结果总数或者总页数,以便客户端判断是否到达最后一页。而使用了后置过滤的查询几乎不可能查出结果总数。

深度分页带来的性能消耗

MySQL 深度分页的性能问题以及使用自增主键优化深度分页已经广为人知,这里我们不再讨论。

与此类似,查询客户端结果总数或者总页数同样是很耗时的操作。在移动互联网时代,像博客园这样显示页码的场景已经不多,更多的是各种流,客户端并不需要知道有多少页,只需要知道是否到达最后一页即可。

解决方案

解决分页器麻烦最好的方案就是避免分页(手动滑稽

当然大多数情况无法避免分页,所以我们还是需要研究一下怎么解决上面提到的各种问题

游标分页器

游标分页器的思路和 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 真是分页器的老受害者了

Web 后端的一生之敌:分页器的更多相关文章

  1. 实现 Web 后端和客户端之间的分布式和认证通讯

    stack.io 是一个用于实现 Web 后端和客户端之间的分布式和认证通讯. 服务器端进程之间的通讯是非常高效的,因为没有中间的代理.而来自客户端的请求通过 socket.io 进入 Node.js ...

  2. 8.app后端和web后端的区别

    很多从web后端转到app后端的小伙伴经常很茫然,不知道这两者之间有啥区别.本文通过例子,分析web后端和app后端的区别,使各位更好地把握app后端的架构. (1) app后端要慎重考虑网络传输的流 ...

  3. 杂记:腾讯暑期实习 Web 后端开发面试经历

    今天面试(一面)腾讯暑期实习 Web 后端开发,一言难尽. 第一部分,常规的自我介绍. 介绍完,面试官问我对人工智能有什么理解?深度学习和机器学习的区别?对调参有什么见解?语音识别中怎样运用了机器学习 ...

  4. Web前端 Web前端和Web后端的区分

    一.绪论 1. 前台:呈现给用户的视觉和基本的操作. 后台:用户浏览网页时,我们看不见的后台数据跑动.后台包括前端.后端. 前端:对应我们写的html.css.javascript 等网页语言作用在前 ...

  5. 转载关于Python Web后端开发面试心得

    先介绍下我的情况:通信背景,工作一年多不到两年.之前一直在做C++的MFC软件界面开发工作.公司为某不景气的国企研究所.(喏,我的工作经验很水:1是方向不对:2是行业有偏差).然后目前是在寻找Pyth ...

  6. Web前端和Web后端的区分

    版权声明:本文为CSDN博主「十豆三展」的原创文章,遵循CC 4.0 by-sa版权协议,转载请附上原文出处链接及本声明.原文链接:https://blog.csdn.net/zz1399590022 ...

  7. 单机Web后端接口服务压力测试

    单机Web后端接口服务压力测试 工具:Apache jmeter 环境:Window 10 语言:Kotlin + java 架构:SpringBoot + + Mysql + redis + Spr ...

  8. Serverless与Web后端天生不合?

    Serverless/Faas/BaaS 等概念在这几年的技术圈中是绝对的热点词汇之一,国内外众多云厂商也纷纷推出自家的 Serverless 和函数计算产品,微信也依托腾讯云推出了基于 Server ...

  9. 基于 Sequelize.js + Express.js 开发一套 Web 后端服务器

    什么是 Sequelize 我们知道 Web 应用开发中的 Web 后端开发一般都是 Java.Python.ASP.NET 等语言.十年前,Node.js 的出现使得原本仅限于运行在浏览器中的 Ja ...

随机推荐

  1. ModelSerializer序列化器实战

    目录 ModelSerializer序列化器实战 单表操作 序列化器类 视图类 路由 模型 多表操作 models.py serializer.py views.py urls.py ModelSer ...

  2. Python中查看变量的类型,内存地址,所占字节的大小

    查看变量的类型 #利用内置type()函数 >>> nfc=["Packers","49"] >>> afc=[" ...

  3. simulink仿真过程

    Simulink求解器 Simulink仿真过程 Simulink 模型的执行分几个阶段进行.首先进行的是初始化阶段,在此阶段,Simulink 将库块合并到模型中来,确定传送宽度.数据类型和采样时间 ...

  4. 顺利通过EMC实验(6)

  5. Architecture Review Board

    Architecture Review Board What's an Architecture Review? Architecture design is not a one-time final ...

  6. 从ES6重新认识JavaScript设计模式(三): 建造者模式

    1 什么是建造者模式? 建造者模式(Builder)是将一个复杂对象的构建层与其表示层相互分离,同样的构建过程可采用不同的表示. 建造者模式的特点是分步构建一个复杂的对象,可以用不同组合或顺序建造出不 ...

  7. 前端网络安全——前端XSS

    XSS攻击:Cross Site Scripting(跨站脚本攻击) XSS攻击原理:程序+数据=结果,如果数据中包含了一部分程序,那么结果就会执行不属于站点的程序. XSS攻击能干什么?能注入Scr ...

  8. 【Android开发】简单好用的阴影库 ShadowLayout

    先来看一张使用 ShadowLayout 库实现的各种阴影的效果图,如下图所示: 如上图所示,通过使用 ShadowLayout 可以控制阴影的颜色.范围.显示边界(上下左右四个边界).x 轴和 y ...

  9. 在react项目中使用redux-thunk,react-redux,redux;使用总结

    先看是什么,再看怎么用: redux-thunk是一个redux的中间件,用来处理redux中的复杂逻辑,比如异步请求: redux-thunk中间件可以让action创建函数先不返回一个action ...

  10. 【Oracle】EXPDP和IMPDP数据泵进行导出导入的方法

    一.expdp/impdp和exp/imp 客户端工具 1.exp和imp是客户端工具程序,它们既可以在客户端使用,也可以在服务端使用. 服务端工具 2.expdp和impdp是服务端的工具程序,他们 ...