导读:本期聚焦于星河创作的《AI生成代码总在分页上翻车?游标分页与Keyset分页的正确打开方式》,敬请观看详情。让AI帮忙生成列表接口代码时,你会发现它十有八九会给出LIMIT OFFSET这种写法。数据量一小没问题,可一旦表里的记录涨到百万级,深翻页的查询耗时就会急剧上升,还可能出现重复数据或漏数据的尴尬。本文围绕这个常见痛点展开,先解释OFFSET机制为什么会越翻越慢,再对比游标分页和Keyset分页两种主流替代方案,分析各自的适用场景、排序字段选择技巧,以及如何处理上一页、跳页等交互需求,最后给出可直接套用的SQL与接口设计示例,帮助你在实际项目里把分页接口的性能和稳定性一起做扎实。

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

AI生成代码总在分页上翻车?游标分页与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写分页代码之前,先想清楚你的数据量级和交互需求,再决定让它生成哪一种。

游标分页Keyset分页API分页优化修改时间:2026-09-09 09:06:49

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260909/53310.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。