后端分页技术探讨

目前对于网页或者 APP 大致分为两种分页模式,一种是传统的通过页码进行分页,另一种通过流式分页,所谓流式分页即滚动加载,当页面滚动到屏幕底部时,自动获取下一页,而不能通过点击跳转的方式获取某一页的内容。

传统分页(百度搜索的底部)
百度的分页

流式分页(京东 app 的首页下滑)
京东app的分页

你可能会问,为什么不都采用传统的分页呢?

一方面可能是因业务经理的要求,某块业务不希望用户可以进行跳转搜索,增加产品的曝光率。比如我付钱投广告了,希望排在靠前一点,那么如果用传统分页的话,浏览器用户可能会通过跳转页码而没能访问到对应的商品。

另一方面原因是因为 PC 端的可视范围比较大,可以展示更多的页码,而手机屏幕才这么一点屏幕尺寸,而且由于触屏手机,点击这么小的分页框还是相对困难的,从整个用户体验考虑来说也是并不合适的。

技术实现

前端

一般情况下,在进行前后端交互的时候,前后端都会约定好参数的规范,比如:https://yigger.cn/list 接口,后端通常希望前端传入两个参数来达到分页的效果。https://yigger.cn/list?page=1&per=10 代表第 1 页,取10条数据

参数 含义
page 代表第几页
per 一次取多少条数据

后端

在一般的网页中,我们采用 mysql 进行分页,通常 sql 是这样的

1
2
3
4
SELECT * from users limit 10 offset 0    // 第一页,理解为从[0-9]取出
SELECT * from users limit 10 offset 10 // 第二页,理解为从[10-20]取出
SELECT * from users limit 10 offset 20 // 第三页,理解为从[21-30]取出
SELECT * from users limit 10 offset 30 // 第四页,理解为从[31-40]取出

但 SQL 这里的 offset 和 limit 参数跟前端传来的 page 和 per 参数并没关系啊?别急,我们可以通过等式推到出来
offset: (page - 1) * per ps: page > 1
limit: per

存在的技术缺陷

1. 数据缺失

为了简单说明问题,我们用数据来模拟分页的情况,假设存在以下数组,并要对此数组进行分页
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
第一页,offset=0,limit=10,从数组索引为 0 的位置开始获取10个元素,得到的结果为

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

假设此时把 9 从数组中移出,那么原数据就会变为

[1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

此时,再获取第二页,offset=10,limit=10,从数组索引为 10 的位置开始获取 10 个元素:由于删除了一个元素,所以此时索引为 10 的元素是 12,我们从元素 12 的位置开始获取 10 个元素,得到的结果是

[12, 13, 14, 15, 16, 17, 18, 19, 20]

此时对比一下,我们就能发现元素 11 本应该出现在第二页,但是由于删除了原数组,所以在第二页也没有获取到

1
2
3
数组: [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
第一页:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
第二页:[12, 13, 14, 15, 16, 17, 18, 19, 20]

2. 重复数据

我们还是利用之前的数组做实验

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

第一页,offset=0,limit=10,从数组索引为 0 的位置开始获取10个元素,得到的结果为

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

假设此时在元素 4 后面插入一个新的元素 999,此时原数组就会变成

[1, 2, 3, 4, 999, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

此时,再获取第二页,offset=10,limit=10,从数组索引为 10 的位置开始获取 10 个元素,结果为

[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

对比第一页和第二页获取的数据,我们发现元素 10 重复出现在第一页和第二页

1
2
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

解决方案

出现以上原因是因为我们每次分页时,数组都是在动态变化的,这种情况在大型网站很常见,比如淘宝,你在浏览第一页数据的时候,此时或者已经插入或删除了几十条数据,那么当你点击第二页的时候,按照传统排序来说第二页的数据是不那么准确的。当然,淘宝肯定通过复杂的技术解决了这个问题

那么对于大多数情况下,我们可以考虑以下方式进行解决:

a. 通过记录时间戳进行解决

首次获取数据时,后端除了返回数据内容以外,还需返回当前系统时间戳,前端下次分页查询时,把此时间戳传回给后端,后端就可以通过时间去筛选在这期间新增的数据内容,举例说明

1
2
3
4
5
create table users {
id int
name varchar(256)
created_at timestamp
}
id name created_at
5 A 2020-03-07 00:05:00
4 B 2020-03-07 00:04:00
3 D 2020-03-07 00:03:00
2 E 2020-03-07 00:02:00

假设一页获取 2 条数据,当前时间是 00:05:00,此时 SQL

1
select * from users limit 0,2 order by created_at desc

返回给前端的数据:json: { data: users(name=A, name=B), timestamp: '2020-03-07 00:05:00' }

此时新增了一条数据,排序后数据集为

id name created_at
6 C 2020-03-07 00:09:00
5 A 2020-03-07 00:05:00
4 B 2020-03-07 00:04:00
3 D 2020-03-07 00:03:00
2 E 2020-03-07 00:02:00
1
select * from users where created_at < '2020-03-07 00:05:00' limit 2,2 order by created_at desc

此时返回的数据集:json: { data: users(name=D, name=E) }

到此,已经可以解决由于新增数据造成的重复读的问题了。但是,漏读的情况还是存在的,也即是说在获取分页数据过程中,前面的数据被删除了。

b. 通过游标分页解决

很简单,每次都记录当前分页最后的 id,比如获取第一页的时候,最后的 id = 10,那么在获取第二页时,通过以下查询条件

1
2
SELECT * from users where id > 10 order by id asc // 第二页
SELECT * from users where id > 20 order by id asc // 第三页

这样查询得到的结果解决了以上两种问题,数据缺失和数据重复的问题,理论上已经是很完美的一种方案了,但实际上存在一个明显的弊端,他十分依赖 id 排序,可以看到上述两条 SQL 都有order by id asc

为什么呢?我们可以通过反证法,假设数组不是按 id 进行排序的,考虑如下数组
[1, 3, 5, 17, 29, 32, 4, 46, 8, 98, 12, 14, 13, 11, 15 …]

第一次获取的结果:[1, 3, 5, 17, 29, 32, 4, 46, 8, 98],最后一个 id = 98
下一次获取的 SQL:SELECT * from users where id > 98,此时返回为空数组

c. 一次性下发所有 id,由前端进行分页

这种方式比较粗暴,在一开始的时候把所有 id 都下发给客户端,然后客户端每次都通过此 id 数组进行分页查询。

这是一个博主提出的方案,但是我猜实际上没人会用这种方式进行分页,因为在中大型网站商品表基本上都有几十万上百万条数据,那浏览器岂不是要获取几百万的数组进行分页?

但是我们可以采取折中的方式,一般情况下用户感兴趣的都只会翻几页,那么我们可以假设用户会翻 20 页的数据,那么我们可以一次性获取 20 页数据的 id ,假设每页 10 条,那么数据长度共计 20 * 10 = 200 个 id。

所以当在20页范围之内,我们可以采取前端传送 id 数组给后端获取数据,此时后端的 sql select * from users where id IN (1..20),当超过 20 页,再通过后端分页获取数据 select * from users offset 201 limit 10

d. 借助 redis sortedset 结构

Mysql 不能很好地解决从一个很长的序列中取出任意一段数据的问题,而造成这一问题的根源在于这些数据是存放在磁盘上的,磁盘不适合做此类的随机读的操作。所以想,如果能有一个程序,管理一些很大很大的放在内存中排序数组就好了,因为对内存中的数组做下标访问,是非常快速的。做了一下调查正好发现,redis提供了此类的功能。

ZADD key score member [[score member] [score member] …]

将一个或多个 member 元素及其 score 值加入到有序集 key 当中。如果某个 member 已经是有序集的成员,那么更新这个 member 的 score 值,并通过重新插入这个 member 元素,来保证该 member 在正确的位置上

对于复杂的排序,我们可以通过把数据都 load 到 redis 的 sortedset 结构里面,并通过 排序值 和 score 的映射关系进行排序,那么再每次进行分页的时候,我们都可以直接在 redis 里面进行数据的获取与分页,这在一定程度上其实也解决了下面所说的 mysql 分页存在的性能问题

分页是否存在性能问题?

通过 mysql 进行分页确实会存在性能问题

1
select * from users order by id desc limit 1000000,10

普通的一句 SQL 其实蕴含着大问题

  1. 使用了*返回字段,全字段返回的问题就是要扫描全表
  2. 进行了ORDERBY排序,我测试的这个表只有几百万数据
  3. 最后分页是取的 100 万开始的 10 条,等于是要扫描100万条数据才开始

上述SQL执行的速度大概在 2-3s 左右,为什么会如此之慢?

归根结底都是因为当用户指定翻到第n页时候,并没有直接方法寻址到该位置,而是需要从第一楼逐个count,scan到countpage时候,获取数据才真正开始,所以导致效率不高。对应的算法复杂度是O(n),n指 offset,也就是pagecount

实际上可以通过索引直接定位到相应的位置,我们可以优化一下此 SQL,变为

1
select * from users where id > 999999 order by id desc limit 1000000, 10

此时再执行 SQL,返回的时间只有几毫秒

总结

实际上,笔者在查询此分页的解决方案的时候,看了很多文章,大多数用户都表达出自己的见解,但是总结多种方案后发现其实也就以上那几种方案,无论哪种方法也好,都不能彻底解决分页存在的种种问题。建议在实际业务场景中,和产品经理和技术经理进行权衡,是否需要花费 80% 的时间来解决 20% 的系统性能?是否允许产品分页存在一些并不是很完美的存在呢?具体选用哪种技术实现方案还需要根据不同的业务场景进行看待。

参考链接

https://aotu.io/notes/2017/06/27/infinite-scrolling/index.html
https://cloud.tencent.com/developer/article/1019683
https://juejin.im/post/5db658faf265da4d500f8386
https://www.jianshu.com/p/13941129c826
https://timyang.net/data/key-list-pagination/
https://www.v2ex.com/t/328806

后端分页技术探讨

https://yigger.cn/2020/03/07/pages/

作者

yigger

发布于

2020-03-07

更新于

2024-05-27

许可协议

You need to set install_url to use ShareThis. Please set it in _config.yml.
You forgot to set the business or currency_code for Paypal. Please set it in _config.yml.

评论

You forgot to set the shortname for Disqus. Please set it in _config.yml.