面试考察点

  • 能否解释大 OFFSET 仍需扫描并丢弃前置记录。
  • 是否会用覆盖索引延迟关联或游标分页。
  • 是否理解稳定排序和翻页一致性。

核心答案

LIMIT offset, size 的 offset 很大时,MySQL 通常仍要扫描 offset + size 条记录并丢弃前面部分;若排序或筛选不能由覆盖索引完成,还会产生大量回表。连续翻页优先使用基于稳定排序键的游标分页。

例如按 (created_at, id) 倒序,下一页带上上一页最后一条的两个值,用小于条件继续查询,复杂度不再随页码线性增长。

常见方案

覆盖索引加延迟关联可以先在窄索引上选出目标主键,再回表获取少量完整行。只支持跳到有限页时可限制最大页数;离线导出应按主键或时间区间分批,而不是不断增加 OFFSET。

OFFSET 的真实成本

SELECT id, order_no, created_at
FROM orders
WHERE tenant_id = 1001
ORDER BY created_at DESC, id DESC
LIMIT 1000000, 20;

即使最终只返回 20 行,引擎仍要沿满足条件的索引扫描约 1000020 个候选,跳过前 100 万行。若查询列不在索引中,扫描到的候选还可能逐行回表;若 ORDER BY 无法利用索引顺序,还会出现大范围排序或临时表。成本随页码线性增加,缓存命中时可能只是慢,缓存未命中或并发高时会直接挤占 I/O。

EXPLAIN ANALYZE 中应重点比较实际扫描行数、回表次数、排序时间和返回行数。不要只看 SQL 文本里 LIMIT 很小就假定开销很小。

游标分页的完整写法

-- 第一页
SELECT id, order_no, created_at
FROM orders
WHERE tenant_id = :tenantId
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 下一页,cursor 是上一页末条的 (created_at, id)
SELECT id, order_no, created_at
FROM orders
WHERE tenant_id = :tenantId
  AND (created_at < :lastCreatedAt
       OR (created_at = :lastCreatedAt AND id < :lastId))
ORDER BY created_at DESC, id DESC
LIMIT 20;

索引应与条件和排序一致,例如 (tenant_id, created_at DESC, id DESC)。游标不是简单的 id 字符串,而是“完整排序位置”;服务端可以把排序字段、查询条件版本和签名编码成不透明 token,防止客户端伪造或把 A 查询的游标用于 B 查询。

延迟关联什么时候有效

当列表页必须返回大字段而索引无法覆盖时,可先在窄索引中定位主键,再关联完整行:

SELECT o.*
FROM orders o
JOIN (
    SELECT id
    FROM orders FORCE INDEX (idx_tenant_time_id)
    WHERE tenant_id = :tenantId
    ORDER BY created_at DESC, id DESC
    LIMIT :offset, :size
) page ON page.id = o.id
ORDER BY o.created_at DESC, o.id DESC;

它并没有消除 offset 扫描,但把“扫描一百万行完整宽记录”缩小为“扫描一百万个索引条目后回表 20 行”。必须用真实执行计划验证,盲目 FORCE INDEX 可能在不同参数下退化。

翻页一致性如何定义

用户翻页期间数据会新增、更新、删除。普通游标分页提供的是沿当前排序继续读取,不保证整套结果是某个时刻的快照;新插入且排在游标之前的数据通常不会出现在后页,这往往符合无限滚动预期。

需要报表级稳定结果时,可固定查询上界(例如 created_at <= requestStartedAt)、使用版本号或在可承受的事务快照中导出。强快照会占用更多资源,不能默认给所有在线列表使用。

COUNT(*) 与任意跳页

产品若要求“跳到第 5000 页”和精确总数,数据库无法同时免费提供。精确 count 可能扫描大范围索引;可使用近似数、异步统计、按时间分段、限制最大页数,或者把检索需求交给更适合的搜索系统。技术方案必须和产品交互能力一起讨论。

线上治理清单

  • 为页面尺寸设置最大值,拒绝无限 size。
  • 对 offset 设置可接受上限,并提供“按条件继续加载”的替代体验。
  • 把导出、全量扫描移到异步任务,使用 seek 分批和限速。
  • 监控深分页 SQL 指纹、扫描行数、临时表和慢查询占比。
  • 索引变更前评估写放大、磁盘空间和在线 DDL 风险。

一致性设计

排序必须稳定且唯一,时间相同要追加主键。游标还应绑定查询条件和排序方向;数据实时变化时,需要接受弱一致翻页,或通过快照版本、时间上界固定结果集。

常见误区

只把 LIMIT 100000, 20 改成子查询不一定更快,关键是子查询能否覆盖索引、减少扫描列和回表。游标分页也不擅长任意跳转到第 N 页,这是产品能力上的取舍。

高频追问与参考回答

追问:为什么只用 id 作为游标可能不正确?

如果业务按时间或其他字段排序,id 顺序未必等同展示顺序;游标必须包含完整排序键,才能避免遗漏或重复。

追问:游标分页可以向上一页吗?

可以保存当前页首条的排序键,用相反比较符和反向排序查询前一批,再在应用层反转结果;实现比下一页复杂,要明确 token 中的方向和边界。

追问:深分页能靠分区表自动解决吗?

只有 WHERE 条件能有效裁剪到少量分区时才有帮助。单个分区内部的大 offset 仍要扫描,分区不替代正确索引和游标设计。

追问:为什么 SELECT * 更容易让深分页变慢?

宽行增加回表、页读取、网络传输和应用对象创建;列表接口只选必要列更容易实现覆盖索引,也减少总链路成本。

总结

深分页优化本质是减少无效扫描和回表:连续浏览用游标,特定场景用覆盖索引延迟关联,并明确一致性语义。

机制全景图

下面把「MySQL 深分页为什么慢?如何优化?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["接收页码与排序"]
    A --> B["索引扫描跳过 offset"]
    B --> C["逐行回表或过滤"]
    C --> D["取 limit 条记录"]
    D --> E["返回下一页定位信息"]

完整链路:从输入到结果

沿着「接收页码与排序 → 索引扫描跳过 offset → 逐行回表或过滤 → 取 limit 条记录 → 返回下一页定位信息」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 接收页码与排序

LIMIT offset,limit 需要找到并丢弃前 offset 行,页码越深工作量线性增长。

2. 索引扫描跳过 offset

若排序索引不覆盖返回列,跳过的每行仍可能回表,I/O 放大更严重。

3. 逐行回表或过滤

WHERE 过滤不能完全在索引完成时,执行器还需读取记录再判断。

4. 取 limit 条记录

真正返回的行数很少,但 rows examined 和临时排序空间可能巨大。

5. 返回下一页定位信息

游标分页把上一页最后排序键作为下一页起点,扫描量接近 limit,并能提供更稳定翻页语义。

源码与实现定位

入口 阅读重点
handler_read_next 顺序扫描行计数
EXPLAIN ANALYZE offset 丢弃行的真实工作量

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

SELECT id, created_at FROM orders
WHERE (created_at,id) < (?,?)
ORDER BY created_at DESC,id DESC LIMIT 100;

对 offset 0/10万/100万与游标分页画延迟曲线,并在并发插入下核对重复遗漏。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「扫描/返回」为主基线,记录值应满足「游标接近 1」;同时保存 rows examined/returned、回表次数,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「handler_read_next」确认请求确实进入「顺序扫描行计数」对应的实现,再沿「EXPLAIN ANALYZE」观察「offset 丢弃行的真实工作量」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「只用非唯一时间字段做游标导致遗漏」,并把单一变量逐级放大,直到「扫描/返回」越过「offset 线性上涨」。随后再分别验证「深分页加缓存仍计算昂贵」和「COUNT(*) 与数据页一起拖慢接口」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「使用复合唯一游标」,确认它能控制影响范围;第二轮应用「全量导出改异步检查点」,验证核心链路恢复;最后落实「限制 max page/offset」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「扫描/返回」回到「游标接近 1」、「单页 P99」回到「各页稳定」、「重复遗漏」回到「稳定游标为 0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
扫描/返回 游标接近 1 offset 线性上涨 改游标
单页 P99 各页稳定 随页码增长 深分页
重复遗漏 稳定游标为 0 出现非零 排序不唯一

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:后台导出任务拖慢线上查询

导出按页码每次 offset 增加 1000,到后期单页扫描数百万行。改为按 (created_at,id) 复合游标推进,并记录检查点后,单批成本稳定且可断点续跑。

失败模式 首要证据 第一处置动作
只用非唯一时间字段做游标导致遗漏 rows examined/returned 使用复合唯一游标
深分页加缓存仍计算昂贵 回表次数 全量导出改异步检查点
COUNT(*) 与数据页一起拖慢接口 页码与延迟曲线 限制 max page/offset

发布与回滚检查点

  • 发布前:确认「handler_read_next」对应实现和上述配置在目标版本仍然有效,并保存「扫描/返回」基线。
  • 灰度中:同时观察 rows examined/returned、回表次数、页码与延迟曲线;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「使用复合唯一游标」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「只用非唯一时间字段做游标导致遗漏」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
offset 分页 浅页、允许随机跳页 接口简单、总数展示自然 深页线性变慢且并发变更会漂移
Keyset 游标 顺序翻页与无限滚动 性能稳定、重复遗漏少 不能自然随机跳页
异步导出 全量或超大结果集 隔离在线请求、可断点 结果延迟且需任务治理

选型至少带上 数据规模、选择性、读写比、事务长度和峰值并发,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

游标排序必须稳定且唯一,通常使用业务排序字段加主键;过滤条件和排序方向也要编码进游标,防止跨查询误用。

工程落地遵循:正确性由约束和事务兜底,性能优化必须用执行计划与测量验证。回答时直接引用「handler_read_next」、配置实验和事故数据,比复述固定模板更有说服力。