分页机制

Pagination

对于拉取的数据的API,无论数据量大小应该都一开始设计,就需要考虑到分页机制,因为后续添加分页机制,对于 API 接口来讲,都是 break-change 的行为破坏了相关的兼容性

参考:Better RESTful API实践

两种基本方式:

  1. 基于 limit && offset 或 time

  2. 基于 cursor,通过pageToken的方式来下次查询的必要条件都封装到token中,最后一页不提供Token

为了讨论方便,不特殊提及本文讨论的主体数据都可以按照时间或者ID进行严格排序

API Pagination is typically implemented one of two different ways:

Offset-based paging. This is traditional paging where skip and limit parameters are passed on the url (or some variation such as page_num and count). The API would return the results and some indication of whether there is a next page, such as has_more on the response. An issue with this approach is that it assumes a static data set; if collection changes while querying, then results in pages will shift and the response will be wrong.

Cursor-based paging. An improved way of paging where an API passes back a "cursor" (an opaque string) to tell the caller where to query the next or previous pages. The cursor is usually passed using query parameters next and previous. It's implementation is typically more performant that skip/limit because it can jump to any page without traversing all the records. It also handles records being added or removed because it doesn't use fixed offsets.

From: https://github.com/mixmaxhq/mongo-cursor-pagination

针对分页的数据的情况大概可以分成 3 类:

  1. 静态数据,按照某一个条件进行顺序排列,例如时间线
  2. 准静态数据,类似于静态数据,但是中间可能会面临频率较小的变动,影响到分页的数据排序规则
  3. 实时变化数据,数据实时会变化,而且变化的属性会影响到排序规则

根据分页的数据源头可以分成 2 类:

  1. 单一数据源,分页访问的数据来源比较单一,单一的表或者可以多表聚合
  2. 分布式数据源,数据来源来自于多个系统,例如 sharding 系统或者 ElasticSearch 这种分布式存储

将两种情况进行汇总,那么复杂度大致如下:

数据来源/数据类型 静态数据 准静态数据 实时变化数据
单一数据源 简单 一般 复杂
分布式数据源 一般 一般 复杂

分布式数据源多数采用Qeury && Fetch 的方式,比如 Elasticsearch 的做法,做法类似于单一数据源。

单一数据源

静态有序

总之对于单一来源的静态数据,分页最简单,即一般的传统分页机制(包括首页、上一页、下一页、最后一页)

[<<] [<] [1] 2 [3] ... [>] [>>]

如果不考虑性能采用 offset 和 limit 的方式,即可满足对应的要求。

如果每页的数据大小为 page_size, 第 n 页的查询条件如下:

/api/data?page=n&page_size=m&offset=(n-1)*page_size

SELECT * FROM comments ORDER BY date DESC LIMIT (n-1)*page_size, n*page_size

但是考虑到性能的话,上述的分页方式就不能够很好处理大数据了的情况,因为一般的数据库系统(包括NoSql) 一般都是查询出来 n*page_size 的数据,然后很粗暴地将排在前面的 (n-1) * page_size 丢弃掉,所以会造成分页也靠后查询反映时间就越长的情况,如果按照时间单调排序的数据,可以通过增加唯一递增的 time 进行:

其中 time_end 为 n-1 页数据的最后结束时间,因时间降序排序,因此后面的数据需要 time < time_end 即可满足条件,通过这种机制可以达到利用数据索引机制,每次查询数据量一致,达到访问不同的页,时间基本一致:

/api/data?page=n&page_size=m&offset=(n-1)*page_size&time_end=xxxxx

SELECT * FROM comments ORDER BY date DESC where time < time_end LIMIT 0, page_size

如果业务数据能够保证 time 绝对单一的话,上述情况就能够比较合理,如果出现时间在数据库中出现了重复,那么就必须依赖另外一个单调有序且不重复的字段作为补充,例如自递增的 id。

数据有序动态变化

由于在查询过程中的数据发生了变化,会导通过 limit 和 offset 的机制,查询的数据不再准确,例如一下情况:

存在 20 条记录按照时间排序,用户A,访问的情况如下:

但是在用户访问过程中的时候,用户 B 发布了 5 条新的记录,那么会导致用户A在第二次拉取的时候,拉取到第一页已经拉取到的数据, Record(11-15),出现了数据重复。

简单的解决方式可以参考我们在上面优化传统分页机制性能问题的解决方案,通过在分页中添加一个具备单调排序字段来解决,这个案例中可以采用 id 的方式, A 用户加载第一页的时候条件如下:

第1页访问: /api/data?page=1&page_size=10

返回数据

{

    total_count: 20,

    page_size:10,

    page_token: min_id=11,

        result: [….]

}

第2页访问: /api/data?page=2&page_size=10&min_id=11,或者添加更多的条件辅助。

select * from data where id < min_id limit page_size

通过这种机制能够保证用户 A 第 2 次访问的时候仍然能够拿到期望的

数据实时变化

如果数据排序的规则变化比较大,比如某个主题的投票排序,如果当期用户拉取数据的同时,还有用户持续在进行投票,这种情况下,会造成分页机制拉取的数据存在缺失或者重复,可以简单采用三种方式来处理:

  1. 前端如果是进行拉取滑动而不是传统分页的话,可以前端过滤掉重复数据,但是对于数据丢失的情况则无能为力
  2. 在后端将本次能够拉取的数据进行一层简单缓存,保证找用户拉取过程中的数据更新滞后,这种场景仅仅适用于数据量比较小的情况下
  3. 前端拉取每次拉取Top N的数据,动态提供刷新机制,而提供分页拉取,要具体业务场景具体分析

附加:Twitter Search接口分成了三个的能级:

  1. Standard search This search API searches against a sampling of recent Tweets published in the past 7 days. Part of the 'public' set of APIs.
  2. Premium search This search API searches against a sampling of recent Tweets published in the past 7 days. Part of the 'public' set of APIs.
  3. Enterprise search Paid (and managed) access to either the last 30 days of Tweets, or access to the entire Tweet archive. Provides full-fidelity data, direct account management support, and dedicated technical support to help with integration strategy.
https://api.twitter.com/1.1/search/tweets.json?q=php&since_id=24012619984051000&max_id=250126199840518145&result_type=recent&count=10

# 通过 max_id 和 since_id 组成了一个 cursor 
# 返回结果
"search_metadata": {
  "max_id": 250126199840518145,
  "since_id": 24012619984051000,
  "refresh_url": "?since_id=250126199840518145&q=php&result_type=recent&include_entities=1",

  "next_results": "?max_id=249279667666817023&q=php&count=10&include_entities=1&result_type=recent",

  "count": 10,
  "completed_in": 0.035,
  "since_id_str": "24012619984051000",
  "query": "php",
  "max_id_str": "250126199840518145"
}

更多的 search 相关的内容可以参见 https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets.html

参考

  1. Pagination: You're (Probably) Doing It Wrong.
  2. Identifying Issues in Real Time Data Pagination
  3. Optimizing Database Pagination Using Couchbase N1QL
  4. Firebase Live Pagination
  5. [firebase] How to sort, filter and paginate lists in the Firebase Realtime Database?
  6. Twitter Developer
  7. HOW COUCHBASE BEATS MONGODB
  8. https://github.com/mixmaxhq/mongo-cursor-pagination
  9. API Paging Built The Right Way
  10. Twitter Cursor
  11. Facebook Cursor
  12. Strip Cursor
  13. How-To: Paging with the Graph API and FQL
  14. API pagination best practices
  15. Facebook Websocket
  16. API Paging Built The Right Way Unique Orderable Immutable

发表评论

电子邮件地址不会被公开。 必填项已用*标注