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 接口来查询一下其中是否存在违规内容,并把违规内容删除掉。
这种实现会遇到一种问题,客户端向我们请求 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 后端的一生之敌:分页器的更多相关文章
- 实现 Web 后端和客户端之间的分布式和认证通讯
stack.io 是一个用于实现 Web 后端和客户端之间的分布式和认证通讯. 服务器端进程之间的通讯是非常高效的,因为没有中间的代理.而来自客户端的请求通过 socket.io 进入 Node.js ...
- 8.app后端和web后端的区别
很多从web后端转到app后端的小伙伴经常很茫然,不知道这两者之间有啥区别.本文通过例子,分析web后端和app后端的区别,使各位更好地把握app后端的架构. (1) app后端要慎重考虑网络传输的流 ...
- 杂记:腾讯暑期实习 Web 后端开发面试经历
今天面试(一面)腾讯暑期实习 Web 后端开发,一言难尽. 第一部分,常规的自我介绍. 介绍完,面试官问我对人工智能有什么理解?深度学习和机器学习的区别?对调参有什么见解?语音识别中怎样运用了机器学习 ...
- Web前端 Web前端和Web后端的区分
一.绪论 1. 前台:呈现给用户的视觉和基本的操作. 后台:用户浏览网页时,我们看不见的后台数据跑动.后台包括前端.后端. 前端:对应我们写的html.css.javascript 等网页语言作用在前 ...
- 转载关于Python Web后端开发面试心得
先介绍下我的情况:通信背景,工作一年多不到两年.之前一直在做C++的MFC软件界面开发工作.公司为某不景气的国企研究所.(喏,我的工作经验很水:1是方向不对:2是行业有偏差).然后目前是在寻找Pyth ...
- Web前端和Web后端的区分
版权声明:本文为CSDN博主「十豆三展」的原创文章,遵循CC 4.0 by-sa版权协议,转载请附上原文出处链接及本声明.原文链接:https://blog.csdn.net/zz1399590022 ...
- 单机Web后端接口服务压力测试
单机Web后端接口服务压力测试 工具:Apache jmeter 环境:Window 10 语言:Kotlin + java 架构:SpringBoot + + Mysql + redis + Spr ...
- Serverless与Web后端天生不合?
Serverless/Faas/BaaS 等概念在这几年的技术圈中是绝对的热点词汇之一,国内外众多云厂商也纷纷推出自家的 Serverless 和函数计算产品,微信也依托腾讯云推出了基于 Server ...
- 基于 Sequelize.js + Express.js 开发一套 Web 后端服务器
什么是 Sequelize 我们知道 Web 应用开发中的 Web 后端开发一般都是 Java.Python.ASP.NET 等语言.十年前,Node.js 的出现使得原本仅限于运行在浏览器中的 Ja ...
随机推荐
- 『现学现忘』Docker基础 — 37、ONBUILD指令介绍
目录 1.ONBUILD指令说明 2.演示ONBUILD指令的使用 3.补充:crul命令解释 1.ONBUILD指令说明 ONBUILD是一个特殊的指令,它后面跟的是其它指令,比如 RUN, COP ...
- div 底部固定方法(不用position定位)
方法一:全局增加一个负值下边距等于底部高度 <style> html, body { height: 100%; margin: 0; } .content { padding: 20px ...
- 微信小程序 MinUI 组件库系列之 price 价格组件
MinUI 是基于微信小程序自定义组件特性开发而成的一套简洁.易用.高效的组件库,适用场景广,覆盖小程序原生框架.小程序组件化框架等,并且提供了高效的命令行工具.MinUI 组件库包含了很多基础的组件 ...
- 设计模式之简单工厂SimpleFactory的实现
internal interface Chart { void Display(); } internal class LineChart : Chart { public LineChart() { ...
- 假期任务一:安装好JAVA开发环境并且在Eclipse上面成功运行HelloWorld程序
(本周主要做了java环境的安装,安装完jdk后又安装了eclipse,平均每天两小时Java吧,这周敲代码的时间比较少,大多是在b站看java入门视频和菜鸟教程的基础语法,也就打开eclipse验证 ...
- Struts bean:define标签用法
bean:define:有三个用途 一是定义新字符串常量: <bean:define id="foo" value="This is a new String&qu ...
- power app 解决方案中表导入问题
我们在powerapp中导出的表,解压后是会是乱码,导致在导入的时候会失败,或者导入数据不全. 使用 2 但是直接导入也会是乱码,所以需要将文件重新保存一下: 首先新建一个excel 选择要导入的那个 ...
- ip地址后面斜杠加数字的含义
案例如:10.121.246.8/29 1.概念: 首先这是一种划分ip的表示方式,叫做无分类域间路由选择(CIDR),区分于传统的划分ip方式(分类的ip划分,在这之后提出了划分子网,即将主机号借出 ...
- Unity通过脚本创建Mesh(网格)
##1.创建一个带Mesh的物体 Unity中的网格作为组件不能脱离物体单独存在 新建脚本CreateMesh public class CreateMesh: MonoBehaviour { voi ...
- SpringMVC 解析(五)URI链接处理
URI在网络请求中必不可少,Spring提供了一些工具类用于解析或者生成URL,比如根据参数生成GET的URL等.本文会对Spring MVC中的URI工具进行介绍,本文主要参考Spring官方文档. ...