一位开发者在生产环境遇到了一个奇怪的问题:分页查询在前100页运行良好,但当用户翻到第500页时,原本2秒的查询变成了2分钟。数据库CPU飙升,整个系统响应变慢。罪魁祸首?那个看起来人畜无害的 LIMIT 10000, 20

这不是个例。Slack在2017年公开分享了他们从无分页到OFFSET分页再到Cursor分页的演进历程——因为他们发现某些API端点从返回几百条记录变成了返回几十万条。当数据规模膨胀,分页这个看似简单的功能,成了系统最脆弱的一环。

OFFSET的本质:逐行丢弃的代价

LIMIT offset, count 的语义很直观:跳过前 offset 条记录,返回接下来的 count 条。但"跳过"在数据库引擎层面意味着什么?

SQL标准对此有明确定义:记录首先根据ORDER BY子句排序,然后通过丢弃从开头开始的指定数量行来限制结果。关键词是丢弃——数据库必须先读取、排序这些记录,然后再扔掉。

以MySQL为例,执行 SELECT * FROM users ORDER BY id LIMIT 1000000, 20 时:

  1. 根据ORDER BY id对记录排序
  2. 遍历结果集,逐条读取并丢弃前100万条记录
  3. 到达第1000001条后,才开始读取目标记录

这不是MySQL的实现缺陷,而是OFFSET语义的必然结果。PostgreSQL、SQL Server、Oracle的行为完全相同。

-- OFFSET 1000000 意味着数据库必须扫描并丢弃前100万行
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000000;

这种机制带来的性能下降是线性的。Milan Jovanovic在100万条记录上的测试显示:OFFSET分页的查询时间随着页码增加呈线性增长,而Cursor分页保持恒定。在offset=900000时,性能差距达到17倍。

Sentry团队记录了一个更极端的案例:一个简单的分页查询在第321页时耗时3.85秒。他们的诊断结论是:大偏移量 + 缺少索引 + OFFSET分页 = 性能灾难。修复方案是添加复合索引并改用Cursor分页,查询时间从8秒降到13毫秒。

索引的双刃剑:为什么OFFSET无法利用索引跳转

既然OFFSET需要遍历前面的记录,为什么不能利用索引直接跳到目标位置?

B+树索引确实提供了O(log n)的查找能力。对于 WHERE id > 1000000 LIMIT 20 这样的查询,数据库可以从根节点直接定位到id=1000000的位置,然后顺序读取20条记录。这就是Keyset Pagination(也称为Seek Method)的核心原理。

LIMIT 20 OFFSET 1000000 不同。数据库知道要跳过100万条记录,但索引结构不支持"跳到第100万条之后"这种操作。索引只能按值查找,不能按位置跳转。数据库必须从索引的起点开始,逐条遍历并计数,直到跳过100万条。

-- 索引可以快速定位到特定值
SELECT * FROM users WHERE id > 1000000 ORDER BY id LIMIT 20;
-- 但无法跳过特定数量的行
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000000;

更深层的挑战是:行在存储层的大小不固定,有些行可能已被标记删除但尚未清理。数据库无法用简单的算术运算确定第100万行在磁盘上的确切位置——它必须真的读出来才知道。

数据漂移:OFFSET的另一重困境

性能只是OFFSET的问题之一。更隐蔽的是数据一致性问题。

假设一个用户正在浏览按创建时间排序的消息列表。当他从第1页翻到第2页时,新消息不断插入:

  1. 请求第1页:返回消息1-10
  2. 此时5条新消息插入,成为最新的消息1-5
  3. 请求第2页:OFFSET 10,返回消息11-20

问题是:原来的消息1-10现在变成了消息6-15。用户请求第2页时,实际得到的是消息11-20——消息6-10被跳过了,而消息1-5永远不会出现在后续页面中。

反过来,如果消息被删除,用户可能看到重复内容。这就是Slack在博客中描述的"分页窗口不可靠"问题。

Cursor分页天然解决了这个问题。游标记录的是具体的参考点(如最后一条消息的ID),而不是相对位置。无论数据如何增删,游标始终指向同一位置之后的数据。

非唯一排序:一个容易被忽视的陷阱

Matthew Brookson分享了一个生产事故:他们用分页从数据仓库批量导出数百万条记录,结果发现有些数据莫名其妙地丢失了。

根源在于排序字段不唯一。假设按last_name排序分页:

  • 第1页返回:Alice Chen, Bob Chen, Carol Davis
  • 第2页返回:David Davis, Eve Davis…

问题来了:如果有多个叫Chen或Davis的用户,数据库在不同查询中的返回顺序可能不一致(没有二级排序条件时,相同值的相对顺序是未定义的)。结果:某些行可能在多个页面重复出现,而另一些行永远不会出现。

解决方案是多列排序——在非唯一字段后追加唯一字段(通常是主键):

-- 危险:last_name可能不唯一
SELECT * FROM users ORDER BY last_name LIMIT 20 OFFSET 40;

-- 安全:追加id确保排序唯一性
SELECT * FROM users ORDER BY last_name, id LIMIT 20 OFFSET 40;

这条规则对Cursor分页同样适用。游标必须包含足够的信息来唯一标识位置,否则可能出现无限循环或数据遗漏。

COUNT的隐形开销

传统分页通常需要返回总记录数,用于计算总页数和显示"第X页/共Y页"这类信息。这意味着每次翻页都要执行两个查询:

-- 查询1:获取当前页数据
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 40;

-- 查询2:获取总记录数
SELECT COUNT(*) FROM users;

COUNT(*) 在大表上代价昂贵。InnoDB需要扫描整个表或索引来计数,无法像MyISAM那样直接读取元数据。在千万级表上,COUNT可能需要数秒。

很多开发者没有意识到这个开销。他们看到第一页加载很快(COUNT和LIMIT同时执行),误以为分页性能良好。实际上,每次翻页都在重复执行COUNT。

一种缓解策略是缓存COUNT结果,但带来数据一致性问题——缓存的计数可能已过时。另一种策略是只返回hasMore标志而非精确计数,这正是Cursor分页的常见做法。

Stripe的API设计值得参考:列表API不返回总数,只有hasMore字段;搜索API返回totalCount,但明确标注"精确度仅到10000条"。

Cursor分页:O(1)复杂度的秘密

Cursor分页的核心思想是用绝对位置代替相对偏移。典型实现:

-- 第一页
SELECT * FROM users ORDER BY id DESC LIMIT 21;

-- 后续页:使用上一页最后一条记录的id作为游标
SELECT * FROM users 
WHERE id < '上一页最后的id' 
ORDER BY id DESC LIMIT 21;

注意这里查21条而非20条。多出来那条不返回给客户端,而是提取其ID作为下一页的游标。如果返回不足21条,说明已到末尾。

这种查询可以利用B+树索引的seek操作:从根节点直接定位到游标位置,然后顺序读取。时间复杂度是O(log n + k),其中k是返回的记录数,与跳过的记录数无关。

编码游标:Stripe和Slack的设计智慧

Cursor分页的挑战之一是游标的设计。最简单的方案是直接使用记录ID:

GET /users?cursor=12345&limit=20

但这有局限性:如果排序条件复杂(如按created_at降序、再按priority升序),游标需要包含多个字段值。

Stripe的做法是让游标成为对象ID本身。他们的API接受starting_after和ending_before参数:

GET /v1/charges?limit=10&starting_after=ch_1234

Slack选择了编码游标。他们返回的next_cursor看起来是一串Base64:

{
  "members": [...],
  "response_metadata": {
    "next_cursor": "dXNlcjpXMDdRQ1JQQTQ="
  }
}

解码后是 user:W07QCRPA4。这种设计有几个好处:

  1. 客户端无需理解游标内容,只需透传
  2. 服务端可以在游标中编码任意信息(如多分片场景下的多个位置指针)
  3. 向后兼容:可以平滑地从OFFSET切换到Cursor,客户端API保持不变

何时选择哪种分页

Cursor分页并非万能药。它有一个根本限制:无法跳转到任意页。用户只能顺序浏览,不能直接跳到第100页。这对于无限滚动的社交媒体动态是合理的,但对于电商商品列表的分页导航则不合适。

选择框架:

场景 推荐方案 原因
无限滚动/动态流 Cursor 性能稳定,数据一致性好
管理后台/报表 OFFSET(限制页数) 需要页码导航,但可限制最大页数
API对外服务 Cursor + 编码游标 性能和扩展性兼顾
需要SEO的公开页面 OFFSET(小数据量) 搜索引擎需要URL中的页码
实时更新的数据 Cursor 避免数据漂移

GitHub的做法值得借鉴。他们的REST API使用Link Header提供分页导航:

link: <https://api.github.com/repos/.../issues?page=2>; rel="next",
      <https://api.github.com/repos/.../issues?page=515>; rel="last"

这种方式让客户端可以轻松遍历所有页面,同时服务端可以根据需要切换底层实现(从OFFSET到Cursor)而不影响客户端。

GraphQL Relay规范:工业级的分页抽象

Facebook的Relay框架定义了一套GraphQL分页规范,已成为事实标准。核心概念:

{
  user {
    friends(first: 10, after: "cursor") {
      edges {
        cursor
        node { id name }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
    }
  }
}

这个设计有几个精妙之处:

  1. Connection封装:分页结果包装在Connection类型中,包含edges和pageInfo
  2. Edge结构:每条记录包装在Edge中,附带独立的cursor,支持从任意位置继续分页
  3. 双向分页:支持first/after向前分页和last/before向后分页
  4. opaque cursor:游标必须是不透明字符串,防止客户端猜测其结构

这套规范被广泛采纳,不仅限于GraphQL。很多REST API也借鉴了这种设计模式。

实现检查清单

设计分页API时,以下检查点可以帮助避免常见陷阱:

排序唯一性

  • ORDER BY子句是否包含唯一字段?
  • 如果按非唯一字段排序,是否追加了主键作为二级排序?

索引设计

  • 排序字段是否有索引?
  • 对于复合排序(如created_at + id),是否有对应的复合索引?
  • 索引顺序是否与ORDER BY方向一致?

游标设计

  • 游标是否包含足够信息唯一标识位置?
  • 游标是否编码了所有排序条件?
  • 游标是否对客户端不透明?

边界情况

  • 空结果集是否正确处理?
  • 最后一条记录后请求是否返回空而非错误?
  • 并发写入时是否会出现重复或遗漏?

性能

  • 是否避免了不必要的COUNT查询?
  • 深分页场景是否使用Cursor?
  • 是否限制了最大页数或最大offset?

写在最后

分页是系统设计中最容易被低估的功能之一。它在小数据量下表现完美,问题只在数据规模增长后才暴露。OFFSET的性能下降是线性的,意味着今天正常运行的系统,明天用户量翻倍后可能突然崩溃。

更棘手的是数据一致性问题——重复记录、遗漏记录、漂移的分页窗口——这些往往在生产环境中才被发现,且难以复现和调试。

Cursor分页通过放弃"跳到任意页"的能力,换取了O(1)的性能和稳定的数据窗口。这不是免费午餐,而是需要根据业务场景权衡。如果用户确实需要随机跳页,就必须接受OFFSET的性能代价,或者引入额外的数据结构(如预计算的分页索引)。

Slack的工程师在文章末尾给出了一个建议:无论现在数据量多小,都为列表API设计分页。这是经验之谈——分页重构的成本远高于一开始就做对。


参考资料

  1. Slack Engineering. “Evolving API Pagination at Slack.” 2017.
  2. Stripe API Documentation. “Pagination.”
  3. GitHub REST API Documentation. “Using pagination in the REST API.”
  4. Milan Jovanovic. “Understanding Cursor Pagination and Why It’s So Fast.” 2025.
  5. Joe Nelson. “Five ways to paginate in Postgres, from the basic to the exotic.” Citus Data, 2016.
  6. Matthew Brookson. “Pagination Pitfalls: Preventing Data Loss.”
  7. Sentry Blog. “Paginating large datasets in production: Why OFFSET fails and cursors win.” 2026.
  8. Vlad Mihalcea. “SQL Seek Method or Keyset Pagination.” 2021.
  9. Relay GraphQL Cursor Connections Specification.
  10. Twitter/X API Documentation. “Pagination.”
  11. 知乎专栏. “分页的秘密:OFFSET 性能问题与游标分页.” 2024.
  12. Cybertec PostgreSQL. “Pagination and the problem of the total result count.” 2023.