根本原因是MySQL必须扫描并丢弃前offset行,而非跳过;例如LIMIT 100000,20需读取100020行再丢弃前10万行,若SELECT*触发回表,每丢弃一行都伴随随机I/O,导致CPU和I/O陡增。

深度分页慢的根本原因不是LIMIT,而是OFFSET
执行 SELECT * FROM orders ORDER BY id LIMIT 100000, 20 时,MySQL 并不会“跳到第100001行”,而是:先按索引扫描至少 100020 行,再丢弃前 100000 行,只返回最后 20 行。真正耗时的不是取 20 条,而是扫描并扔掉那 10 万行——尤其是当 SELECT * 触发回表时,每丢弃一行都可能伴随一次随机磁盘 I/O。
常见错误现象包括:接口响应从几十毫秒飙升到数秒、数据库 CPU 和 I/O 使用率陡增、慢查询日志频繁记录同类语句。
- 排序字段无索引 → 触发
filesort,内存不够时写磁盘,性能断崖式下跌 - 使用
SELECT *且索引不覆盖 → 每行都要回主键索引查全字段,放大随机 I/O - OFFSET 值由前端传入(如 page=5000&size=20),后端直接算出
OFFSET = (page-1)*size→ 无法规避扫描
游标分页(Keyset Pagination)怎么写才不漏数据
核心是用上一页最后一条记录的排序字段值作为下一页的查询起点,彻底绕过 OFFSET。但必须满足两个前提:排序字段有索引、值唯一或组合唯一。
例如第一页:SELECT id, created_at, title FROM posts WHERE status = 1 ORDER BY created_at DESC, id DESC LIMIT 20
假设返回的最后一条是 created_at = '2024-06-01 10:23:45'、id = 98765,第二页就该写成:SELECT id, created_at, title FROM posts WHERE status = 1 AND (created_at
- 必须用复合条件处理重复值,单靠
created_at 会漏掉同时间戳的其他记录 - WHERE 中的排序字段条件必须和 ORDER BY 完全一致,否则索引可能失效
- 前端必须把上一页最后一条的
created_at和id都传回来,不能只传一个 - 禁止用户输入任意页码(如“跳转到第 892 页”),否则无法生成有效游标
延迟关联(Deferred Join)在MyBatis里怎么安全落地
适用于无法改前端、又必须支持跳页的场景。本质是把“查全字段”拆成两步:先用覆盖索引查 ID,再用这些 ID 关联原表取详情。MyBatis 中需避免子查询里用 LIMIT #{offset}, #{size} —— 因为 MySQL 不允许子查询直接带参数化 LIMIT。
正确写法是用两个 SQL 或动态拼接子查询,例如:
SELECT t1.* FROM posts t1 INNER JOIN ( SELECT id FROM posts WHERE status = 1 ORDER BY created_at DESC LIMIT 20 ) t2 ON t1.id = t2.id
如果业务允许,更稳妥的做法是:先执行 SELECT id FROM ... LIMIT ? 得到 ID 列表,再用 IN 查询详情(注意 IN 参数数量限制,超 1000 个需分批)。
- 子查询中不能出现
#{offset},MyBatis 会报错;必须由 Java 层计算好 offset 后传入 - 复合索引要包含 WHERE 和 ORDER BY 字段,例如
INDEX(status, created_at) - 若表有千万级数据,
IN的 ID 列表不宜超过 500 个,否则解析和执行计划可能退化
哪些情况游标分页会出错,必须换方案
游标分页不是万能的。当排序字段高频更新(如订单 status 经常变更)、或大量写入导致游标值“漂移”时,用户可能看到重复或丢失数据。此时不能只依赖应用层逻辑。
典型风险场景:
- 用户 A 翻到第 3 页时,某条记录的
created_at被后台修改,下次刷新页面可能重复出现或跳过 - 按
updated_at排序,但该字段每秒更新数百次 → 游标值很快失效 - 业务要求支持关键词搜索后跳转任意页码 → 游标链断裂,必须回退到传统分页+覆盖索引
这种情况下,要么加读写分离+一致性快照(如 MySQL 8.0 的 START TRANSACTION WITH CONSISTENT SNAPSHOT),要么引入 Elasticsearch 做分页代理——但要注意 ES 和 DB 的最终一致性延迟。

















