后端分页技术探讨
目前对于网页或者 APP 大致分为两种分页模式,一种是传统的通过页码进行分页,另一种通过流式分页,所谓流式分页即滚动加载,当页面滚动到屏幕底部时,自动获取下一页,而不能通过点击跳转的方式获取某一页的内容。
传统分页(百度搜索的底部)
流式分页(京东 app 的首页下滑)
你可能会问,为什么不都采用传统的分页呢?
一方面可能是因业务经理的要求,某块业务不希望用户可以进行跳转搜索,增加产品的曝光率。比如我付钱投广告了,希望排在靠前一点,那么如果用传统分页的话,浏览器用户可能会通过跳转页码而没能访问到对应的商品。
另一方面原因是因为 PC 端的可视范围比较大,可以展示更多的页码,而手机屏幕才这么一点屏幕尺寸,而且由于触屏手机,点击这么小的分页框还是相对困难的,从整个用户体验考虑来说也是并不合适的。
技术实现
前端
一般情况下,在进行前后端交互的时候,前后端都会约定好参数的规范,比如:https://yigger.cn/list 接口,后端通常希望前端传入两个参数来达到分页的效果。https://yigger.cn/list?page=1&per=10 代表第 1 页,取10条数据
参数 | 含义 |
---|---|
page | 代表第几页 |
per | 一次取多少条数据 |
后端
在一般的网页中,我们采用 mysql 进行分页,通常 sql 是这样的
1 | SELECT * from users limit 10 offset 0 // 第一页,理解为从[0-9]取出 |
但 SQL 这里的 offset 和 limit 参数跟前端传来的 page 和 per 参数并没关系啊?别急,我们可以通过等式推到出来offset: (page - 1) * per
ps: page > 1limit: 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 | 数组: [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 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 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
解决方案
出现以上原因是因为我们每次分页时,数组都是在动态变化的,这种情况在大型网站很常见,比如淘宝,你在浏览第一页数据的时候,此时或者已经插入或删除了几十条数据,那么当你点击第二页的时候,按照传统排序来说第二页的数据是不那么准确的。当然,淘宝肯定通过复杂的技术解决了这个问题
那么对于大多数情况下,我们可以考虑以下方式进行解决:
a. 通过记录时间戳进行解决
首次获取数据时,后端除了返回数据内容以外,还需返回当前系统时间戳,前端下次分页查询时,把此时间戳传回给后端,后端就可以通过时间去筛选在这期间新增的数据内容,举例说明
1 | create table users { |
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 | SELECT * from users where id > 10 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 其实蕴含着大问题
- 使用了*返回字段,全字段返回的问题就是要扫描全表
- 进行了ORDERBY排序,我测试的这个表只有几百万数据
- 最后分页是取的 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
install_url
to use ShareThis. Please set it in _config.yml
.