Skip to content
<

基于游标的分页

本文介绍了基于游标的分页模式,并使用 Go 语言实现了一个简版的游标分页功能。

分页

我们平常看的书都是一页一页的装订的,因为我们没必要把一本书的完整内容展示在一个页面。想象一下当我们软件系统中的某个资源有大量的数据,通过一次 API 请求返回所有的数据也是不现实的。例如电商网站的商品通常都是分成一页页进行展示。将类似的内容拆分成一系列称为页面的数据集就是分页(pagination/paging)。

目前主流的分页模式有两种——基于偏移量的分页和基于游标的分页。

基于偏移量的分页

这是我们最常见的一种分页模式。例如,我们会看到类似下面的 API 请求链接。

sh
GET liwenzhou.com/api/v1/books?page=1

sh
GET liwenzhou.com/api/v1/books?page=1&size=10

客户端需要提供本次请求每页所需的结果数(limit)和偏移量(offset),偏移量通常由服务端通过 page 和 size 计算得出。这种分页方式十分简单,只需跳过前面Offset指定的结果数,按需返回Limit个结果数就可以了,它很容易与数据库查询语句对应。

你有 100 本书,每一页展示 10 本,那么基于偏移量的分页方式如下。

  • 第一页:Offset:0, Limit:10
  • 第二页:Offset:10, Limit:10
  • 第三页:Offset:20, Limit:10
  • ...
  • 第十页:Offset:90, Limit:10

我们可以写出查询第二页图书的 SQL:

sql
SELECT id, title FROM books ORDER BY id ASC LIMIT 10 OFFSET 10;

优势

  1. 简单
  2. 支持跳页访问

劣势

  1. 基于偏移量的分页在数据量很大的场景下,查询效率会比较低。通常 OFFSET 越高,查询时间就越长。
  2. 在并发场景下会出现元素重复(offset 在第二页时有人在第一页新插入一个数据)或被跳过(offset 在第二页时有人在第一页删掉了一个数据)。
  3. 显式的 page 参数在支持跳页的同时也会被爬虫并发请求。

基于游标的分页/基于令牌的分页

基于游标的分页是指接口在返回响应数据的同时返回一个cursor——通常是一个不透明字符串。它表示的是这一页数据的最后那个元素(这就像是我们玩单机游戏的存档点,这一次我们从这里离开,下一次将从这里继续),通过这个cursorAPI 就能准确的返回下一页的数据。

用于有表的字段必须是唯一的,连续的列,数据集将基于该列进行排序。在处理实时数据时使用基于游标的分页。第一页请求不需要提供游标,但是后续的请求必须携带游标。

  • 第一页:Limit:10
  • 第二页:cursor:10, Limit:10
  • 第三页:cursor:20, Limit:10
  • ...
  • 第十页:cursor:90, Limit:10

查询第二页图书的 SQL 可能是:

sql
SELECT id, title FROM books WHERE id > 10 ORDER BY id ASC LIMIT 10;

优势

  1. 性能好
  2. 并发安全
  3. 防止被无脑批量爬取

劣势

  1. 实现稍复杂
  2. 不支持跳页(但现在流行无限滑动翻页)
  3. 不太适合多检索条件的场景

我们在使用基于游标的分页时,通常并不会把具体的cursor数据显式拼接到 API URL 中,而是使用通常会被命名为nextnext_cursorafterpage_token的不透明字符串。

下面是 Github Stars API,使用的是after=Y3Vyc29yOnYyOpK5MjAyMC0wOC0wN1QxNzo1OTowOSswODowMM4N3Lew表示分页 cursor 信息。

https://github.com/Q1mi?after=Y3Vyc29yOnYyOpK5MjAyMC0wOC0wN1QxNzo1OTowOSswODowMM4N3Lew&tab=stars

基于游标的分页实现方案

我们定义一个代码分页信息的结构体。

go
type Page struct {
    NextID        string `json:"next_id"`
    NextTimeAtUTC int64  `json:"next_time_at_utc"`
    PageSize      int64  `json:"page_size"`
}

其中:

  • NextID 就是 cursor
  • NextTimeAtUTC 记录分页发生的时间点
  • PageSize 表示每一页的元素个数

它有一个Encode方法,生成一个使用 Base-64 编码的令牌。

go
// Encode 返回分页 token
func (p Page) Encode() Token {
    b, err := json.Marshal(p)
    if err != nil {
        return Token("")
    }
    return Token(base64.StdEncoding.EncodeToString(b))
}

Token代表的是分页令牌,本质上是一个字符串。

go
type Token string

它有一个Decode方法,用来从字符串令牌中解析得到分页信息。

go
// Decode 解析分页信息
func (t Token) Decode() Page {
	var result Page
	if len(t) == 0 {
		return result
	}

	bytes, err := base64.StdEncoding.DecodeString(string(t))
	if err != nil {
		return result
	}

	err = json.Unmarshal(bytes, &result)
	if err != nil {
		return result
	}

	return result
}

这样一个简单的基于游标的分页功能就实现好了。

我们没有直接在 API 请求链接中使用真实数据的主键等信息,而是使用 JSON 序列化并使用 Base-64 编码的字符串来作为 token 来使用,这样做能防止用户直接解密我们的系统,那样会带来风险。对于 RPC API 系统可以通过使用任何所需数据定义内部 protocol buffer message 来混淆页面令牌,并发送序列化的 Base-64 编码的 pb 内容。

至于为什么要在分页信息中记录时间,是为了防止 token 泄露后被无限使用。我们可以限制 token 在一个合理时间后失效。

参考链接

  1. https://bojithapiyathilake.medium.com/pagination-offset-vs--in-mysql-92cbf1a02cfa
  2. https://www.mixmax.com/engineering/api-paging-built-the-right-way
  3. https://google.aip.dev/158
  4. https://stackoverflow.com/questions/38017054/mysql-cursor-based-pagination-with-multiple-columns