分页查询接口设计最佳实践

1,600 阅读10分钟

0. 结论

优点缺点适用场景设计关注点
基于页码的分页(Offset-based pagination)1. 支持任意页跳转,灵活性高 2. 支持多页同时查询3. 实现简单,理解和开发成本低1. 深翻页时性能差 2. 数据频繁变化,查询时可能导致数据丢失1. 数据量小于1w的系统 2. 数据变化不频繁
基于游标的分页(Cursor-based Pagination)1. 性能稳定 2. 游标规则灵活,API不变的情况下可以拓展游标语义 3. 保证数据一致性,适用于数据频繁变化场景1. 无法跳页,只能顺序访问 2. 不支持多页同时查询3. 实现更为复杂,需考虑唯一性、游标设计1. 数据量大的系统 2. 数据变化频繁1. MaxResults != PageSize 2. NextToken != ID

1. 分页查询接口

1.1 基于页码的分页(Offset-based Pagination)

基于页码的分页(Offset-based Pagination)通常通过指定页码(PageNumber)和每页的数据量(PageSize)来实现。客户端请求指定页码的内容,服务端则根据 PageNumber 和 PageSize 计算出查询范围,并返回 TotalCount。例如,假设每页显示10条数据,查询第3页时,offset = (3 - 1) * 10 = 20。查询结果为从第21条到第30条数据。

offset 简单地说是你希望跳过的记录数。随着记录数量的增加,这种方法会变得越来越慢,因为数据库仍然需要读取到 offset 指定的行数,才能知道它应该从哪里开始选择数据。这通常被描述为 O(n) 复杂度,意味着这通常是最坏的情况。以下面的查询 SQL 为例:

select * from xxx order by id ASC limit yyy offset zzz;
查询场景SQL执行耗时
Offset 0, Limit 100.025ms
Offset 10000, Limit 103.138ms
Offset 50000, Limit 1016.933ms
Offset 100000, Limit 1030.166ms

此外,在大型数据库中,数据集频繁变化,结果窗口通常会不准确,您可能会完全错过结果,或者在不同页面上看到重复的结果,因为结果已经被添加到了前一页。例如在查询第3页时,如果第二页最后一个数据被删除了,那么原本属于第3页第一个的数据,则移动到了第2页最后一个,导致查询出现数据丢失。

通过 TotalCount 和 PageSize 可以计算出总的页数,但在数据量大的情况下,计算 TotalCount 会带来显著的性能问题。

全表扫描在计算 TotalCount 时,数据库一般执行 COUNT(*) 查询,这可能触发全表扫描,尤其是在没有合适的索引时。即使有索引,随着数据量增大,COUNT 查询的成本也会增加。
影响查询效率即使分页查询本身速度很快,TotalCount 的计算可能变成瓶颈,导致整体请求耗时显著增加。尤其在高并发环境下,频繁的 TotalCount 查询会加重数据库的负载,从而影响其他查询的性能。
索引无效化在大数据表上,即便有索引优化,某些复杂查询条件下 COUNT 依然会导致索引失效,使得查询效率大幅下降。例如,当查询条件包含 JOIN 或 GROUP BY 等复杂逻辑时,计算总数会变得更加耗时。
一致性问题在动态更新的数据场景下,实时计算 TotalCount 可能带来数据一致性问题,例如页面显示的数据条数与 TotalCount 不符。

1.2 基于游标的分页 (Cursor-based Pagination)

基于游标的分页(Cursor-based Pagination)通常依赖唯一且有序的字段(如ID)来表示“游标”,用于标记数据查询的起始位置。客户端通过游标传递分页的上下文,而非页码,通常的实现方式是每次查询会返回一个 NextToken 字段,用于指向下一页的数据起点。然后通过 MaxResults 来指定单次请求返回的最大数据量。

通过使用游标,我们通过在查询中使用 WHERE 子句来消除阅读我们已经看过的行的需要(使读取数据更快,因为它是恒定的,即 O(1) 时间复杂度),并且我们通过始终在特定行之后读取来解决结果不准确的问题,而不是依赖于记录的位置保持不变。以下面的查询 SQL 为例:

select * from xxx where id > yyy order by id ASC limit zzz;
查询场景基于页码的分页 SQL 执行耗时基于游标的分页 SQL 执行耗时
Offset/Cursor 0, Limit 100.025ms0.025ms
Offset/Cursor 10000, Limit 103.138ms0.026ms
Offset/Cursor 50000, Limit 1016.933ms0.027ms
Offset/Cursor 100000, Limit 1030.166ms0.027ms

在上述性能测试中,随着 offset 值的增加,基于页码的分页执行时间也在增长。然而,对于基于游标的分页,即使游标发生变化,执行时间仍然相对稳定。这突显了基于游标的分页在高效处理大型数据集方面的一个关键优势。

2. MaxResults 和 NextToken 设计最佳实践

基于游标的分页方案,我们想要达成的核心目的就是在数据规模不断变大、业务复杂度不断上升的情况下,接口性能依然稳定。因此作为基于游标的分页方案中的两个重要参数:MaxResults 和 NextToken ,在设计上就尤为重要。

2.1 MaxResults != PageSize

在 MaxResults 的设计上容易出现的一个问题就是把 MaxResults 理解成了基于页码的分页中的 PageSize。例如现在有450条数据符合筛选条件,PageSize为100,那么在基于页码的分页方案中,返回结果如下:

 共5页,其中:
 第1页:0~99数据记录
 第2页:100~199数据记录
 第3页:200~299数据记录
 第4页:300~399数据记录
 第5页:400~449数据记录

现在假设 MaxResults 为100,那么基于游标的分页方案,返回结果会如何?以1.2章节中提供的 SQL 为例:

select * from xxx where id > yyy order by id ASC limit zzz;

只需要从 NextToken 解析出起始 ID,把 MaxResults 作为 Limit 值,我们依然可以做到前4个返回结果包含100个数据记录,最后一个返回结果包含50个数据记录。从这个角度看,MaxResults 和 PageSize 似乎并没有区别。但这里的前提条件是,我们的查询和过滤在单表内就可以完成。 现在考虑场景 CaseA :

  1. 数据规模大
  2. MaxResults 为 100
  3. 接口的过滤条件可能需要结合多张数据表(有时甚至是多个系统),假设过滤条件 FilterA 会从 TableA 中过滤出10000条数据,再结合其他过滤条件,最终符合过滤条件的数据为450条
  4. 出于系统稳定性的考虑,单次 SQL 查询限制返回1000条记录

在场景 CaseA 下,为了继续做到“前4个返回结果包含100个数据记录,最后一个返回结果包含50个数据记录”,我们需要:

  1. 使用过滤条件 FilterA 从 TableA 中过滤出1000条记录
  2. 对这1000条记录,再进行其他条件的过滤,最后得到 N 条记录,其中 N 的范围为 [0, 1000]
  3. 如果 N < 100,那么重复步骤 1 和 2,直到 N >= 100,截取前 100 个返回

可以看到,这里的重复次数,会根据数据分布的情况而不可预测,例如以下两种极端场景:

  1. 如果符合条件的450条记录正好落在 TableA 的前1000个,那么重复次数为0
  2. 如果符合符合条件的450条记录正好落在 TableA 的最后1000个,那么重复次数为9

此时接口性能是不稳定的,不符合基于游标的分页方案的设计原则。如何解决这个问题? 在这里插入图片描述 在 AWS 的分页接口说明中,可以清晰的看到 MaxResults != PageSize 的定义,即接口返回的数据量可能小于 MaxResults,也可能为0,但这都不代表翻页结束,只有 NextToken 为空,才表示翻页结束。

回到场景 CaseA,我们只需要:

  1. 使用过滤条件 FilterA 从 TableA 中过滤出1000条记录
  2. 对这1000条记录,再进行其他条件的过滤,最后得到 N 条记录,其中 N 的范围为 [0, 1000]
  3. 如果 N < 100,返回这 N 个数据;如果 N >= 100,截取前 100 个返回

此时可以保证接口性能的稳定性。因此使用游标分页的接口,在API说明文档上一定要特别强调“使用 MaxResults 和 NextToken 进行分页查询时,如果收到的数据量少于 MaxResults 个,甚至为零个,不代表数据已经查询完毕。你需要一直调用该接口,直到 NextToken 为空。”

2.2 NextToken != ID

在基于游标的分页方案中,通常将 ID 作为唯一且有序的字段,并且出于安全考虑,需要将 ID 进行编码或者加密,避免通过 ID 的增长情况暴露业务信息。

但在复杂场景下,NextToken 不仅仅是 ID,而是一个包含 ID 的可扩展对象,例如还可以包含如下内容(按需):

过滤条件Filter如果分页数据有特定过滤条件(如project、tag等),可以包含这些条件,确保游标生成时与查询条件一致。
排序条件如果分页基于特定排序字段(如时间戳 created_at 或价格 price 等),游标需要包含该排序字段的值,以确保在排序的连续性上不会出现遗漏或重复。
过期时间戳Timestamp有时会设置游标的过期时间,并将过期时间戳包含在 NextToken 中,使游标在特定时间后失效。例如防止缓存数据不一致,当数据更新频繁或分页结果可能受到业务状态影响时,过期游标可以避免调用方使用旧游标访问过期数据,确保获取到的数据是最新的。
版本号Version版本号可以帮助服务端识别并处理不同格式或结构的 NextToken,从而确保兼容性和稳定性。
回到 2.1 中的场景 CaseA,由于查询涉及到多张表,因此 NextToken 中可能需要包含多个 ID,例如:
{
     "TableAId": 113456,  //TableA的Id
     "TableBId": 223456//TableB的Id
     "Filter": {"tag":"value"}, //NextToken对应的过滤条件
     "Timestamp" "2024-10-30T10:15:00Z", // NextToken过期时间戳
     "Version": 1 // NextToken版本号
}

将这些信息编码或者加密后作为 NextToken 返回,可以确保分页状态的安全和连续性。

3. 实际案例

问题原因解法
ECS DescribeInstances Tag 过滤查询不支持资源数大于1000 www.volcengine.com/docs/6396/7…1. ECS 的接口行为会保证如果有足够数据,那么当前结果列表返回的数据量 = MaxResults,即 MaxResults = PageSize 的设计思想 2. Tag 和 Instance 数据位于两张表,因此 进行 Tag 过滤查询时,需要先查 Tag 表,再查 Instance 表 3. 当 Tag 过滤后的数据量太大时,可能导致接口性能不稳定1. 通过使用 MaxResults != PageSize 的设计思想,解决接口稳定性问题。 2. 由于存量用户使用姿势未知,存在用户使用 ResultSize == MaxResults 来判断是否要继续翻页的可能性,因此上述的改动存在不兼容风险,需要严谨灰度。 3. 在 API 文档上,强调“使用 MaxResults 和 NextToken 进行分页查询时,如果收到的数据量少于 MaxResults 个,甚至为零个,不代表数据已经查询完毕。你需要一直调用该接口,直到 NextToken 为空。”