一位开发者在生产环境遇到了一个奇怪的问题:分页查询在前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 时:
- 根据ORDER BY id对记录排序
- 遍历结果集,逐条读取并丢弃前100万条记录
- 到达第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-10
- 此时5条新消息插入,成为最新的消息1-5
- 请求第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。这种设计有几个好处:
- 客户端无需理解游标内容,只需透传
- 服务端可以在游标中编码任意信息(如多分片场景下的多个位置指针)
- 向后兼容:可以平滑地从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
}
}
}
}
这个设计有几个精妙之处:
- Connection封装:分页结果包装在Connection类型中,包含edges和pageInfo
- Edge结构:每条记录包装在Edge中,附带独立的cursor,支持从任意位置继续分页
- 双向分页:支持first/after向前分页和last/before向后分页
- opaque cursor:游标必须是不透明字符串,防止客户端猜测其结构
这套规范被广泛采纳,不仅限于GraphQL。很多REST API也借鉴了这种设计模式。
实现检查清单
设计分页API时,以下检查点可以帮助避免常见陷阱:
排序唯一性
- ORDER BY子句是否包含唯一字段?
- 如果按非唯一字段排序,是否追加了主键作为二级排序?
索引设计
- 排序字段是否有索引?
- 对于复合排序(如created_at + id),是否有对应的复合索引?
- 索引顺序是否与ORDER BY方向一致?
游标设计
- 游标是否包含足够信息唯一标识位置?
- 游标是否编码了所有排序条件?
- 游标是否对客户端不透明?
边界情况
- 空结果集是否正确处理?
- 最后一条记录后请求是否返回空而非错误?
- 并发写入时是否会出现重复或遗漏?
性能
- 是否避免了不必要的COUNT查询?
- 深分页场景是否使用Cursor?
- 是否限制了最大页数或最大offset?
写在最后
分页是系统设计中最容易被低估的功能之一。它在小数据量下表现完美,问题只在数据规模增长后才暴露。OFFSET的性能下降是线性的,意味着今天正常运行的系统,明天用户量翻倍后可能突然崩溃。
更棘手的是数据一致性问题——重复记录、遗漏记录、漂移的分页窗口——这些往往在生产环境中才被发现,且难以复现和调试。
Cursor分页通过放弃"跳到任意页"的能力,换取了O(1)的性能和稳定的数据窗口。这不是免费午餐,而是需要根据业务场景权衡。如果用户确实需要随机跳页,就必须接受OFFSET的性能代价,或者引入额外的数据结构(如预计算的分页索引)。
Slack的工程师在文章末尾给出了一个建议:无论现在数据量多小,都为列表API设计分页。这是经验之谈——分页重构的成本远高于一开始就做对。
参考资料
- Slack Engineering. “Evolving API Pagination at Slack.” 2017.
- Stripe API Documentation. “Pagination.”
- GitHub REST API Documentation. “Using pagination in the REST API.”
- Milan Jovanovic. “Understanding Cursor Pagination and Why It’s So Fast.” 2025.
- Joe Nelson. “Five ways to paginate in Postgres, from the basic to the exotic.” Citus Data, 2016.
- Matthew Brookson. “Pagination Pitfalls: Preventing Data Loss.”
- Sentry Blog. “Paginating large datasets in production: Why OFFSET fails and cursors win.” 2026.
- Vlad Mihalcea. “SQL Seek Method or Keyset Pagination.” 2021.
- Relay GraphQL Cursor Connections Specification.
- Twitter/X API Documentation. “Pagination.”
- 知乎专栏. “分页的秘密:OFFSET 性能问题与游标分页.” 2024.
- Cybertec PostgreSQL. “Pagination and the problem of the total result count.” 2023.