Skip to content

【旧】分页规范详解

【旧】分页规范详解.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,分几种情况。

  1. 实际有120条数据:返回peek=100,表示peek到了100条。
  2. 实际有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界面:

1

第二次请求

用户:

用户点击 下一页

请求:

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界面:

2

第三次请求

用户:

用户点击第 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界面:

8

第四次请求

用户:

用户点击第 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界面:

12

第五次请求

用户:

用户点击第 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界面:

14

第六次请求

用户:

用户点击第 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界面:

12

第七次请求

用户:

异地用户删除了 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界面:

1_2

前端使用建议

当反向走到第一页,可能会出现半页的情况,这时候建议在走到第一页的时候,做个特殊处理,直接用从0正向查询的模式展示第一页。

总结

上面总结了整个接口的设计演化历史,说明了接口是如何一步步走向最后一个版本的,每个字段是如何被设计出来的。截至目前,还有继续优化的可能。我都写累了,先这样吧。