不知道你有没有这样的经历:让AI帮忙写一个列表接口,它非常自然地吐出一句SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000,看起来简洁优雅,测试环境也跑得飞快。可是上线之后,随着数据量涨到几十万、上百万,这个接口的响应时间从几十毫秒一路飙到好几秒,数据库CPU也被拖得居高不下。问题不在于AI不会写代码,而在于它默认选择的OFFSET分页方案本身就有性能天花板。这篇文章就来聊聊为什么OFFSET会变慢,以及游标分页和Keyset分页这两条更靠谱的路线该怎么走。

为什么OFFSET分页在深翻页时会崩掉
OFFSET的工作原理决定了它的成本结构。当数据库执行LIMIT 20 OFFSET 100000时,它并不是直接跳到第100001条记录,而是老老实实扫描前100000条记录然后丢弃,只返回后面的20条。也就是说,你翻得越深,数据库做的无效功就越多,这个成本是线性增长的。在第10页的时候几乎无感,在第5000页的时候就是灾难。
更麻烦的是性能并不是唯一的问题。OFFSET分页依赖固定的行号位置,如果在你翻页的过程中有新数据插入或者旧数据被删除,行号就会发生偏移,用户很可能看到重复记录,或者某些记录被悄悄跳过。对于后台管理系统这种容忍度稍高的场景还能接受,但对于消息列表、动态流这类对数据完整性敏感的产品,就是明显的缺陷。
可以做个简单验证:在一张一百万行的表上分别执行OFFSET为0、50000、500000的查询,配合EXPLAIN观察扫描行数,你会清楚看到扫描行数随OFFSET线性增长。这也是为什么很多大厂的开放平台API(比如Twitter、Slack)早就放弃了页码参数,转而采用cursor参数。
游标分页:用不透明令牌标记位置
游标分页的核心思想是:服务端把当前位置编码成一个不透明的字符串(cursor)返回给客户端,客户端下次请求带上这个cursor,服务端据此定位继续读取。cursor里通常包含排序键的值、唯一标识符,有时还会加入编码或签名防止客户端篡改。下面是一个典型的接口设计和实现:
-- 第一页 SELECT id, created_at, title FROM articles ORDER BY created_at DESC, id DESC LIMIT 20; -- 下一页:cursor 解码后得到 last_created_at 和 last_id SELECT id, created_at, title FROM articles WHERE (created_at, id) < (last_created_at, last_id) ORDER BY created_at DESC, id DESC LIMIT 20;
注意这里使用了(created_at, id)这样的组合条件,原因是created_at可能重复,加上id作为tiebreaker才能保证排序稳定、分页不重不漏。很多AI生成的代码只用了单一字段做游标,一旦出现相同时间戳就会产生重复数据,这是实际项目里最常见的坑。
cursor的编码方式也有讲究。可以直接用Base64编码created_at|id这样的拼接串,服务端解码后使用;也可以在cursor里塞入过期时间和签名,防止客户端拿到很久之前的cursor发起攻击性请求。保持cursor对客户端不透明的好处是,服务端未来想换编码格式、加缓存都不会破坏兼容性。
Keyset分页:把定位条件交给客户端
Keyset分页(也叫seek method)本质上是游标分页的透明版本:客户端直接传排序键的值,服务端用WHERE条件定位。它的SQL形态就是上面例子中的核心查询部分,关键在于排序字段必须能利用索引。如果排序字段是created_at DESC, id DESC,那么必须建立对应的复合索引(created_at, id),否则数据库还是要全表扫描后再排序,性能优势荡然无存。
在MongoDB这类文档数据库里,Keyset分页写起来更直观:
// 第一页
const first = await db.collection('articles')
.find({})
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.toArray();
// 下一页
const next = await db.collection('articles')
.find({
$or: [
{ createdAt: { $lt: lastCreatedAt } },
{ createdAt: lastCreatedAt, _id: { $lt: lastId } }
]
})
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.toArray();Keyset分页最大的优势是每一页的查询成本恒定:不管你在第1页还是第100万条之后,数据库都只需要沿着索引走固定步数。实测中,深翻页场景下从数秒降到个位数毫秒是常见的。它的代价也很明确:不支持随机跳页,客户端只能上一页、下一页地顺序翻。如果你的产品交互上强制要求跳转到第N页,那Keyset分页单独无法满足,需要配合其他手段。
跳页需求怎么办:混合方案与工程取舍
真实的业务往往不会完全放弃页码交互,比如运营后台的导出功能就需要按页拉取。这时候可以采用混合策略:前若干页(比如前100页)使用OFFSET分页,因为浅翻页时OFFSET的性能完全可以接受;超过阈值后接口直接返回提示,建议用户改用筛选条件缩小范围,或者切换到无限滚动加载。这样既保留了用户体验,又守住了性能底线。
另一个常见问题是排序字段的选择。游标和Keyset都要求排序稳定且可比较,如果按updated_at排序,而记录在翻页过程中被更新导致位置移动,同样会出现重复或遗漏。解决办法有几种:按id排序天然稳定但不一定符合业务语义;按不可变的创建时间排序;或者在读取快照类场景里干脆使用created_at + id的组合,并接受被更新的记录位置变化这一事实。要根据业务对实时性的要求来定,没有万能答案。
最后给AI辅助编程提一点实用建议:在提示词里明确告诉它使用Keyset分页、要求排序字段带tiebreaker、要求建立对应复合索引,生成结果的质量会好很多。AI的默认输出反映的是最常见写法,而不是最优写法,把约束条件写进提示词,才是让人机协作真正高效的方式。
总结
分页接口的性能问题,根子往往在方案选择而不在实现细节。OFFSET分页适合小数据量的后台页,游标分页适合对外API和不透明令牌场景,Keyset分页则是大数据量列表的首选。选对方案、建好索引、处理好tiebreaker和排序稳定性,深翻页就不再是性能黑洞。下次让AI写分页代码之前,先想清楚你的数据量级和交互需求,再决定让它生成哪一种。