首页/数据/SQL 速查/深分页优化:keyset 分页
性能与执行计划72 条

深分页优化:keyset 分页

OFFSET 很大时数据库仍要逐行跳过:改用“上一页最后一条的键”做条件,让每页都走索引定位。

SQL 速查数据更新于 2026-10-13

keyset 分页是什么

深分页慢的根源是 OFFSET:页码越大,数据库仍要先定位并跳过前面所有行,代价随页码线性增长。keyset 分页(游标分页)改用「上一页最后一条的排序键」作为下一页起点,让每次查询都退化成一次索引范围查找。前提是排序键能唯一确定顺序——排序列不唯一时必须拼上主键,写成 (created_at, id) < (值1, 值2) 的行构造器比较,MySQL 与 PostgreSQL 都支持这种写法。

基本信息

适用平台MySQL 8.0 / PostgreSQL 8.2+
行构造器比较(a, b) < (c, d) 两库都支持,按字典序逐列比较
前提条件排序键能唯一确定顺序,非唯一列要追加主键
适用范围只适合顺序翻页;跳页需要额外的键映射或首末页方案
索引配合(created_at, id) 复合索引让下一页定位成为一次范围查找

典型使用场景

  • 帖子、动态列表的无限滚动加载
  • 消息流按时间倒序拉取增量
  • 大表数据导出:分批拉取而不越翻越慢
  • 榜单类分页:按分数与 id 组合排序后翻页
  • 排查线上大 OFFSET 慢查询,用游标方案替换

两种分页写法对比

两种分页写法对比
-- 深分页:OFFSET 100000 需要先跳过 10 万行
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- keyset:从上一页最后一条的 (created_at, id) 继续
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-06-01 12:00:00', 88888)
ORDER BY created_at DESC, id DESC
LIMIT 20;

索引与升序翻页

索引与升序翻页
-- 让下一页的定位变成一次范围扫描
CREATE INDEX idx_posts_created_id ON posts (created_at DESC, id DESC);

-- 升序翻页(下一页取更大的键)
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > ('2026-06-01 12:00:00', 88888)
ORDER BY created_at ASC, id ASC
LIMIT 20;

-- 客户端只需记住上一页最后一行的两个排序键,无需记录偏移量

两种分页方式对比

对比项OFFSET 分页keyset 分页
跳页支持任意页只能顺序前后翻
深页成本随页码线性增长基本恒定
并发写入影响新插入的数据会导致重复或漏行基于键定位,结果更稳定
参数可读性page=5000 直观游标参数不直观
适用场景后台管理、页数有限前台无限滚动、大表导出
行构造器比较 (a, b) < (c, d) 等价于 a < c OR (a = c AND b < d),语义是按字典序逐列比较;用行构造器写法更简短,也不容易漏掉相等值分支。
排序键不唯一就直接做 keyset,相等值的行会在页边界被漏读或重复;务必把主键拼进游标,并保证 ORDER BY 的列顺序与方向与索引一致。

易错点

  • 继续用大 OFFSET 翻页,跳过行数的成本随页码增长,热点表上很快变慢
  • 排序键不唯一就做 keyset,相等值行在页边界漏读或重复
  • 加了游标条件但 ORDER BY 与索引方向不一致,索引无法用于定位
  • 把 keyset 用在需要任意跳页的后台列表上,用户无法直接翻到目标页

数据来源与参考

  • MySQL 8.0 Reference Manual · Row Constructor Expressions
  • PostgreSQL Documentation · Row and Array Comparisons
​‌‌​​‌​​​‌‌​‌‌‌‌​‌‌​​​‌‌​‌‌‌​​‌‌​​‌​‌‌‌​​‌‌‌​​​‌​‌‌​‌​​‌​‌‌‌​​​‌​‌‌​‌​​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌‌‌‌​​‌​‌‌‌​​‌‌​​​‌‌​‌‌​‌‌‌‌​‌‌​‌‌​‌