OFFSET + LIMIT 在大数据量下性能差且数据不一致,应改用游标分页;核心是用唯一单调字段(如id或created_at+id)作锚点,通过WHERE条件+ORDER BY实现稳定分页。

为什么 OFFSET + LIMIT 在大数据量下会翻车
OFFSET 越大,数据库越要先扫过前面所有行,性能断崖式下跌;更麻烦的是,如果数据实时增删,两次分页查询之间有新记录插入,就会跳过或重复显示某些行——这不是 bug,是 OFFSET 语义本身决定的。
真正稳定的分页,得靠“游标”(cursor-based pagination),核心思路是:不记“跳过多少行”,而是记住“上一页最后一条的唯一锚点”。
- 锚点必须是单调、唯一、非空的字段,比如
id(自增主键)或created_at+id组合 - 不能用
OFFSET,改用WHERE条件过滤锚点之后的数据 - 每次查询必须带
ORDER BY,且排序字段和锚点字段一致或兼容
用主键 ID 实现稳定分页(最常用场景)
假设表 orders 主键为 id,按 id DESC 分页,每页 20 条:
SELECT * FROM orders WHERE id < 100500 ORDER BY id DESC LIMIT 20;
这里 100500 是上一页最后一条的 id。下一页就把 WHERE id < 100500 换成 WHERE id < 新的 last_id 即可。
- 正序(
ASC)时用>,倒序(DESC)时用<,方向必须严格匹配ORDER BY - 如果
id不连续(比如有删除),完全不影响,因为只依赖大小关系 - 首次请求没有锚点?用
WHERE id < (SELECT MAX(id) FROM orders)或直接不加 WHERE
复合排序下的游标分页(比如按时间+ID)
当业务要求“按创建时间倒序,时间相同时按 ID 倒序”,就不能单靠 created_at 锚点——同一秒可能有多条记录。
正确做法是把两个字段打包成游标,例如上一页最后一条是 created_at = '2024-06-15 10:30:00'、id = 998877:
SELECT * FROM orders
WHERE (created_at, id) < ('2024-06-15 10:30:00', 998877)
ORDER BY created_at DESC, id DESC
LIMIT 20;注意括号里的元组比较是数据库原生支持的(PostgreSQL、MySQL 8.0+、SQL Server 都行),不是字符串拼接。
- MySQL 5.7 及更早不支持行构造器比较,得拆成:
created_at < '2024-06-15 10:30:00' OR (created_at = '2024-06-15 10:30:00' AND id < 998877) - 游标值必须和
ORDER BY字段顺序、方向完全一致,否则逻辑错乱 - 索引要覆盖
(created_at, id),否则性能退化
前端传参和后端校验容易漏掉的坑
游标分页不是把 SQL 写对就完事了——前后端约定稍有偏差,结果就不可控。
- 游标值必须原样透传,禁止 JSON 序列化时丢失精度(比如
id是 bigint,转成 JS number 会变零 - 后端收到游标参数,必须校验是否符合预期格式(如非空、能转成对应类型),空游标应走首页逻辑,而不是报错或返回空
- 不要在 SQL 里拼接游标值,一律用参数化查询,防止注入——尤其复合游标,字符串拼接极易出错
- 前端翻页按钮禁用时机:上一页没数据时禁用“上一页”,当前页不足满额时禁用“下一页”,但不能仅靠
COUNT(*)判断,应查LIMIT + 1看是否还有下一页
游标分页真正的复杂点不在 SQL 写法,而在前后端对“锚点”的全链路一致性维护——少一个空值判断,或一次隐式类型转换,就可能让某页数据消失或重复。

















