【旧】分页规范详解
【旧】分页规范详解.md
重定向到 分页.md
前言
2017-08-11 larry
经过一段时间的研究讨论,我们意识到,在成本可控的情况下,分页不是件简单的事,不信请接着往下看。
地球上常见的分页方式
查阅了众多资料后我们发现,常见的分页方式有两种,offset-base和key-base。
offset-base
offset-base,就是最常见的方式,不断用offset的前移来翻页。请求参数就是offset和limit两个。参照点永远是第一条数据。
优点:使用方式简单,容易理解。缺点:数据量越大性能越差。因为查询总是要从第一条开始。
key-base
key-base的理念是,下一页的数据参照起点,是前一页返回的最后一条数据。
优点:查询时可以直接定位到新的起点,用上一页的最后一条数据做参照起点,避免了无用的查询浪费性能,也不会因为数据量的变化而导致性能变化。
缺点:如果查询过程中,前面某一页的数据增加了或者删除了,向前翻页的时候会导致第一页不满页。
接口设计进化史
下面让我们看看,分页功能是如何一步步变复杂的。
版本1 -- 最初(offset-base)
METHOD GET
请求
offset O int 查询起始行序号,默认第0行,返回不包含当前行
limit O int 返回条数,默认10
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
}],
}
最初也是最开始,所有人直觉上的分页模式,甚至是大多数web框架直接内嵌支持的,就是这个版本,offset-base。
这个版本使用起来比较简单,不解释用法了。但是这个版本在大数据量下性能差。假如offset=1000,limit=10,实际的查询是,db先查出1010条数据,然后返回最后10条,前面的数据丢掉。可想而知,当offset为1万,10万,甚至更多的时候会发生什么。这种成本和收益是不可接受的。
为什么db会这样,因为这个offset是基于查询结果做的偏移,不是基于全量数据,结果集在不同的条件下不同,所以即使想优化也没法下手,因为每条记录在不同查询条件下的offset值是不同的。
版本2 -- 改换门庭(key-base)
为了解决offset导致的性能问题,我们采用key-base的方案,每次查询的时候,传入上一次查询结果的尾部记录的key值,在这里叫__cursor,这样在db查询的时候,就可以直接定位到前一条记录,然后接着查询limit条数据,就不存在offset那种需要从头开始查的问题了。
所以接口设计如下:
METHOD GET
请求
from O string 查询起始行__cursor,默认第0行,返回不包含当前行
limit O int 返回条数,默认10
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
__cursor: M string 当前行索引信息
}],
}
这个版本的返回中,会在每条数据里增加一个__cursor字段,表示这条记录的key,这个key是后端根据数据内容计算出的这条记录在db中的索引值,前端不用关心内容,只需要在下次查询时把最后一条数据的__cursor传给请求参数里的from字段即可。
版本3 -- 要更多(peek)
有些时候,我们需要提前知道前方有没有足够多的数据,但是并不需要返回更多数据。比如常见的页码展现,在第一页的时候,需要给用户展现总共有有多少页,或者站展现至少10页的页码,但是不能因此就一次查询10页的数据回来。所以我们设计了peek字段,目的就是在查询的时候顺便向更前方“窥探”一下,是否有peek条数据,以方便前端生成页码按钮。
METHOD GET
请求
from O string 查询起始行__cursor,默认第0行,返回不包含当前行
limit O int 返回条数,默认10
peek O int 希望peek(窥探)的条数,不传就不做peek。peek表示希望接口顺便检查,到底有没有peek条数据,但不需要返回对应的数据。peek必须大于limit。
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
"__cursor": M string 当前行索引信息
}],
}
pagination: { M dict 分页信息
"peek" O int 实际peek到的条数。
}
假如请求中peek=100,分几种情况。
- 实际有120条数据:返回peek=100,表示peek到了100条。
- 实际有70条数据:返回peek=70,表示peek到了70条。
如果请求中没有peek,表示不想用这个功能,返回中也不会有peek字段。
peek必须大于limit,因为小于或者等于limit没有意义,这时候不需要peek。
版本4 -- 能向后也要能向前(reverse)
翻页过程中经常需要做反向翻页,比如向前翻页。我们增加了reverse字段,表示反向。这里的反向是与db的自然顺序相对比的,在db查询中,如果不特殊设置,默认会有一个方向。
METHOD GET
请求
from O string 查询起始行__cursor,默认第0行,返回不包含当前行
limit O int 返回条数,默认10
peek O int 希望peek(窥探)的条数,不传就不做peek。peek表示希望接口顺便检查,到底有没有peek条数据,但不需要返回对应的数据。peek必须大于limit。
reverse O bool 是否反向查询。1:是,0:不是
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
"__cursor": M string 当前行索引信息
}],
}
pagination: { M dict 分页信息
"peek" O int 实际peek到的条数。
}
版本5 -- 自由的跳(offset again)
走到这里,key-base方案已经接近完美,但是key-base方案有个缺陷,就是不能跳页。比如当前是第1页,页面通过peek功能展示出了8个页码的按钮,用户直接点击第7页,因为key-base方案只能一页接着一页来拉数据,前端就被迫要拉出中间所有页面的数据,然后抛弃掉,只展示第7页需要的。
为了优化这种问题,我们又把offset字段加回来了。这里offset的意义,表示从from传入的那个记录为参照起点,跳过offset条记录,返回limit条记录。
METHOD GET
请求
from O string 查询起始行__cursor,默认第0行,返回不包含当前行
offset O int 查询起点偏移,默认0
limit O int 返回条数,默认10
peek O int 希望peek(窥探)的条数,不传就不做peek。peek表示希望接口顺便检查,到底有没有peek条数据,但不需要返回对应的数据。peek必须大于limit。
reverse O bool 是否反向查询。1:是,0:不是
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
"__cursor": M string 当前行索引信息
}],
}
pagination: { M dict 分页信息
"peek" O int 实际peek到的条数。
}
版本6 -- 要美观(no_more)
无论是key-base还是offset-base方案,如果不返回查询结果的总数量,那么最后一页就可能会出现刚好满页,下一页没数据,但是前端不知道,只有再次查询得到空返回才知道没数据了,也就是说最后一个“下一页”按钮无法正确的去掉。所以我们在每次的查询返回中增加了一个no_more字段,提示前端后面是否还有数据,这样前端就可以根据这个字段的值来判断,是否需要展示“下一页”按钮。
METHOD GET
请求
from O string 查询起始行__cursor,默认第0行,返回不包含当前行
offset O int 查询起点偏移,默认0
limit O int 返回条数,默认10
peek O int 希望peek(窥探)的条数,不传就不做peek。
peek表示希望接口顺便检查,到底有没有peek条数据,但不需要返回对应的数据。
peek必须大于limit。
reverse O bool 是否反向查询。1:是,0:不是
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
__cursor: M string 当前行索引信息
}],
}
pagination: { M dict 分页信息
peek O int 实际peek到的条数。
no_more M bool 是否到头,1:没有更多数据,0:有更多
}
版本7 -- 小优化(head/tail)
由于后端实现上的问题,需要做一个设计上的妥协。不在返回的每条数据里放cursor,而是只返回首尾两条数据的cursor。这样做会有一个缺点,就是前端不能在不经过后端的情况下随意调整每页的数量,如果要调整,一定要走一次后端查询才可以,也就是不能从页面中间某条数据做起点发起查询。
METHOD GET
请求
from O string 查询起始行__cursor,默认第0行,返回不包含当前行
offset O int 查询起点偏移,默认0
limit O int 返回条数,默认10。传0表示返回所有数据。
peek O int 希望peek(窥探)的条数,不传就不做peek。
peek表示希望接口顺便检查,到底有没有peek条数据,但不需要返回对应的数据。
peek必须大于limit。
reverse O bool 是否反向查询。1:是,0:不是
响应
code M int 返回码,0表示成功,其他表示错误
msg M string 错误信息
data M string 返回json数据
{
"<data list>": [{ M list 数据
key1: M string
key2: M string
...
}],
}
pagination: { M dict 分页信息
peek O int 实际peek到的条数。
no_more O bool 是否到头,1:没有更多数据,0:有更多(默认)
head M string 第一条数据的__cursor
tail M string 最后一条数据的__cursor
}
一句话解释参数
从from那条数据开始(不包含from),跳过offset条数据,返回随后的limit条数据。如果有peek,顺便检查offset后是否有peek条数据。如果有reverse,把整个查询反向。
一个典型例子
第一次请求
用户:
用户第一次进入页面,什么也没点
请求:
from: 不传
offset: 不传
limit: 10
peek: 100
reverse: 0
响应:
data: {
orders: [(10 条订单数据)]
}
pagination: {
peek: 100
no_more: false
head: "{id: 1}"
tail: "{id: 10}"
}
UI界面:
第二次请求
用户:
用户点击 下一页
请求:
from: "{id: 10}" (上一次响应中的 pagination['tail'])
offset: 不传
limit: 10
peek: 90(提前知道前方的数据够不够填满 2-10 页共 9 页)
reverse: 0
响应:
data: {
orders: [(10 条订单数据)]
}
pagination: {
peek: 90(前方的数据够填满 2-10 页,所以 2-10 这 9 个页码按钮都要显示)
no_more: false
head: "{id: 11}"
tail: "{id: 20}"
}
UI界面:
第三次请求
用户:
用户点击第 8 页
请求:
from: "{id: 32}" (上一次响应中的 pagination['tail'])
offset: 50(用户跳过了 5 页)
limit: 10
peek: 50(提前知道前方的数据够不够填满 8-12 页共 5 页)
reverse: 0
响应:
data: {
orders: [(10 条订单数据)]
}
pagination: {
peek: 50(前方的数据够填满 8-12 页,所以 8-12 这 5 个页码按钮都要显示)
no_more: false
head: "{id: 71}"
tail: "{id: 80}"
}
UI界面:
第四次请求
用户:
用户点击第 12 页
请求:
from: "{id: 80}" (上一次响应中的 pagination['tail'])
offset: 50(用户跳过了 5 页)
limit: 10
peek: 50(提前知道前方的数据够不够填满 12-16 共 5 页)
reverse: 0
响应:
data: {
orders: [(10 条订单数据)]
}
pagination: {
peek: 25(前方的数据只够显示 12-14 共 3 页)
no_more: false
head: "{id: 111}"
tail: "{id: 120}"
}
UI界面:
第五次请求
用户:
用户点击第 14 页
请求:
from: "{id: 120}" (上一次响应中的 pagination['tail'])
offset: 10
limit: 10
peek: 50
reverse: 0
响应:
data: {
orders: [(5 条订单数据)]
}
pagination: {
peek: 5
no_more: true(到头了,不要显示下一页按钮)
head: "{id: 131}"
tail: "{id: 135}"
}
UI界面:
第六次请求
用户:
用户点击第 12 页
请求:
from: "{id: 131}" (上一次响应中的 pagination['head'])
offset: 10
limit: 10
peek: 80(提前知道前面的数据够不够填满 5-12 共 8 页。换句话说,提前知道 id<131 的数据是否多于 80 条)
reverse: 1
响应:
data: {
orders: [(10 条订单数据)]
}
pagination: {
peek: 80
no_more: false
head: "{id: 111}"
tail: "{id: 120}"
}
UI界面:
第七次请求
用户:
异地用户删除了 1-10 页共 100 数据,即删除了 id 为 1-100 的数据;
随后,本地用户点击 上一页 按钮
请求:
from: "{id: 111}" (上一次响应中的 pagination['head'])
offset: 不传
limit: 10
peek: 70(提前知道前面的数据够不够填满 5-11 共 7 页。换句话说,提前知道 id<111 的数据是否多于 70 条)
reverse: 1
响应:
data: {
orders: [(10 条订单数据)]
}
pagination: {
peek: 10(id<111 的数据只有 10 条,只能显示 1 页,即第一页)
no_more: true
head: "{id: 101}"
tail: "{id: 110}"
}
UI界面:
前端使用建议
当反向走到第一页,可能会出现半页的情况,这时候建议在走到第一页的时候,做个特殊处理,直接用从0正向查询的模式展示第一页。
总结
上面总结了整个接口的设计演化历史,说明了接口是如何一步步走向最后一个版本的,每个字段是如何被设计出来的。截至目前,还有继续优化的可能。我都写累了,先这样吧。