MySQL 深分页优化:2026 年的工程答案
上篇我们验证了
LIMIT offset的性能瓶颈,以及用主键范围查询绕开它的基本思路。这篇接着聊——到 2026 年,这个问题有没有更系统的解法?
先把问题说清楚
LIMIT offset 慢的根源没有变:InnoDB 的 B+ 树叶子节点存储完整行数据(聚簇索引),执行大偏移时 MySQL 必须从头遍历叶子链表,把前 N 行数据读出来再整体丢弃,偏移越大,白做的 I/O 越多。
这不是 MySQL 的 bug,而是关系型数据库的结构性约束。所以 2026 年并没有出现什么"引擎层的魔法补丁"——解法依然在应用层,只是实践上更系统了。
实验环境
在 MySQL 8.0.46 / InnoDB 上造了 200 万行的 logs 表,字段包括自增主键 id、level、message 和 created_at,每组数据取 3 次均值,尽量排除缓存干扰。
方案一:Keyset Pagination(游标分页)
原理是把"跳过 N 行"改成"从上次停的地方继续",完全消灭 OFFSET:
-- 传入上一页最后一条的 id
SELECT * FROM logs WHERE id > :last_seen_id ORDER BY id LIMIT 20;
按主键排序,实测数据:
| 偏移量 | OFFSET 方案 | Keyset 方案 | 加速比 |
|---|---|---|---|
| 1,000 | 0.7 ms | 0.3 ms | 2x |
| 100,000 | 23.8 ms | 0.3 ms | 80x |
| 500,000 | 120.6 ms | 0.3 ms | 371x |
| 1,000,000 | 252.0 ms | 0.6 ms | 413x |
| 1,900,000 | 503.6 ms | 0.3 ms | 1805x |
Keyset 的响应时间不随偏移量增长——从第 1 条翻到第 190 万条,延迟始终在 1ms 以内。这正是它的核心价值:O(log N) 的索引定位,不存在退化。
非主键排序怎么办?
现实中经常需要 ORDER BY created_at,这时 cursor 要携带两个值,用 OR 展开写法保证能命中复合索引 (created_at, id):
-- 需要先建复合索引
CREATE INDEX idx_created_at_id ON logs (created_at, id);
-- 多列 cursor:展开写法,比 Row Value Syntax 更稳定地走 range scan
SELECT * FROM logs
WHERE created_at > :last_dt
OR (created_at = :last_dt AND id > :last_id)
ORDER BY created_at, id
LIMIT 20;
⚠️ 关于 Row Value Syntax
MySQL 8 支持
WHERE (created_at, id) > (:dt, :id)这种写法,语法更简洁,但实测发现 MySQL 8.0 优化器有时会把它降级成type: index(全索引扫描)而不是type: range,需要用EXPLAIN验证执行计划。如果计划不理想,回退到 OR 展开写法更保险。
按 created_at 排序,实测数据(OR 展开写法):
| 偏移量 | OFFSET 方案 | Keyset 方案 | 加速比 |
|---|---|---|---|
| 100,000 | 1101 ms | 0.9 ms | 1290x |
| 500,000 | 1083 ms | 0.9 ms | 1183x |
| 1,000,000 | 1115 ms | 0.3 ms | 4100x |
| 1,900,000 | 1326 ms | 0.3 ms | 4358x |
非主键排序的 OFFSET 方案比主键排序还慢得多(1100ms vs 250ms),因为 ORDER BY created_at 无法利用聚簇索引,需要额外的排序开销。Keyset 在这种场景下的收益更加显著。
Keyset 的约束:
- 不支持随机跳页("跳到第 500 页"做不到)
- cursor 字段必须稳定唯一,非唯一字段要加主键做 tiebreaker
- 前后端接口需要改造,传递 cursor 而不是页码
适合:无限滚动、Feed 流、日志查询、API 游标翻页。这覆盖了绝大多数大表分页的真实需求。
方案二:Deferred Join(延迟关联)
对于"必须支持页码跳转"的场景,Deferred Join 是目前最成熟的折中方案。
思路是把一次查询拆成两步:先用覆盖索引只取主键(子查询走纯索引,不回表),再用主键 JOIN 原表取完整行,回表次数从 offset 次压缩到只有 20 次。
SELECT l.*
FROM logs l
INNER JOIN (
SELECT id FROM logs ORDER BY created_at LIMIT 20 OFFSET :offset
) AS ids ON l.id = ids.id;
按 created_at 排序,与原始 OFFSET 对比:
| 偏移量 | OFFSET 方案 | Deferred Join | 加速比 |
|---|---|---|---|
| 100,000 | 1101 ms | 14.4 ms | 77x |
| 500,000 | 1083 ms | 67.6 ms | 16x |
| 1,000,000 | 1115 ms | 135.5 ms | 8x |
| 1,900,000 | 1326 ms | 245.1 ms | 5x |
Deferred Join 比原始 OFFSET 快了一个数量级,但注意它的延迟仍随偏移量线性增长——偏移 190 万时已经到 245ms,这是它无法逾越的上限。加速比也随偏移量的加深而收窄(77x → 5x),因为子查询阶段的索引扫描开销仍然存在,只是去掉了大量回表。
两种方案的适用边界
│ 支持随机跳页 │ 深翻不退化 │ 改造成本
─────────┼───────────────┼─────────────┼──────────
Keyset │ ✗ │ ✓ │ 中(接口改)
Deferred│ ✓ │ ✗ │ 低(SQL 改)
实践中常见的组合策略:浅页用 Deferred Join,深页截断或引导缩小范围。大多数产品的真实用户几乎不会翻到第 500 页以后,设一个合理的深度上限(如最多 100 页),配合 Deferred Join,是成本最低、效果足够的方案。
方案三:Table Partitioning(超大表)
当表膨胀到数十 GB 乃至 TB 级,上面两种方案都只是延缓问题。加列、重建索引、备份都会变成数小时的阻塞操作。这时候需要分区。
最常见的是按时间做 RANGE 分区:
ALTER TABLE logs
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
查询时带上时间条件,MySQL 会做 Partition Pruning,直接跳过不相关分区,相当于把一张大表拆成多张小表来查。
分区不会改变 SQL 写法,对应用透明,但有一个约束:分区键必须包含在所有唯一索引中,这会影响索引设计,上线前需要仔细评估。
小结
| 方案 | 核心原理 | 适用场景 | 主要约束 |
|---|---|---|---|
| Keyset Pagination | 索引定位,消灭 OFFSET | 无限滚动、API 游标 | 不支持随机跳页 |
| Deferred Join | 覆盖索引取主键,减少回表 | 需要页码的管理后台 | 深翻仍有退化 |
| 分区表 | 物理拆分,Pruning 缩小扫描范围 | 超大表(数十 GB+) | 分区键约束,改造成本高 |
这个问题到 2026 年没有被"解决",但已经被"绕开"了——大多数需要大偏移分页的场景,换成 Keyset 之后问题从根本上消失;确实需要页码的场景,Deferred Join 的改造成本足够低,效果也足够好。
剩下的就是工程判断:你的产品真的需要用户跳到第 500 页吗?
