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

分页

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

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

基于偏移量的分页

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

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

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:

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

优势

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

劣势

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

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

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

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

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

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

查询第二页图书的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

基于游标的分页实现方案

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

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编码的令牌。

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

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

type Token string

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

// 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

扫码关注微信公众号