面试题: Mysql中的深分页如何处理
这是一个非常经典的数据库性能优化面试题。在大量数据场景下,使用传统的 LIMIT offset, size
进行分页时,当 offset
非常大(如 LIMIT 1000000, 20
),就会出现所谓的“深分页问题(Deep Pagination)”,导致查询非常慢。
一、为什么深分页会慢?
🔍 传统分页 SQL:
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
🐌 执行过程:
- MySQL 需要先扫描前 1000020 条记录。
- 然后丢弃前 1000000 条,只返回第 1000001~1000020 条。
- 即使有索引,也需要遍历索引的前 100 万条指针,再回表。
⚠️ 问题本质:
OFFSET
越大,MySQL 扫描的数据越多,性能越差。
二、解决方案(由优到劣排序)
✅ 方案 1:基于游标的分页(Cursor-based Pagination)
又叫 “键集分页(Keyset Pagination)” 或 “无状态分页”
原理:
利用上一页的最后一条记录的排序字段值作为下一页的查询起点,避免使用 OFFSET
。
前提:
- 有有序且唯一的字段(如
id
、created_at
) - 按该字段排序
示例:
-- 第一页
SELECT * FROM orders WHERE id > 0 ORDER BY id LIMIT 20;-- 第二页:假设上一页最后一条 id = 1005
SELECT * FROM orders WHERE id > 1005 ORDER BY id LIMIT 20;-- 第三页:上一页最后 id = 1025
SELECT * FROM orders WHERE id > 1025 ORDER BY id LIMIT 20;
✅ 优点:
- 性能极佳,O(log n),几乎不随页码增长变慢
- 适合“下一页”场景(如信息流、日志)
❌ 缺点:
- 无法跳转到任意页码(如“第 100 页”)
- 不支持“上一页”需要额外逻辑(如逆序查)
💡 适用场景:
- 无限滚动(Infinite Scroll)
- 日志、订单流、消息列表
✅ 方案 2:延迟关联(Deferred Join)
利用索引覆盖减少回表次数
原理:
先通过索引查出主键,再通过主键回表,减少回表数据量。
示例:
-- 慢:直接 OFFSET
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;-- 快:先查主键,再回表
SELECT o.*
FROM orders o
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 20
) AS tmp ON o.id = tmp.id;
✅ 优点:
- 比直接
OFFSET
快很多 - 仍支持跳页
❌ 缺点:
OFFSET
很大时仍慢- 需要
id
有索引
💡 适用场景:
- 传统分页,但页码不会特别深
✅ 方案 3:记录上次位置(状态化分页)
类似游标,但由客户端维护状态
原理:
客户端保存上一页的最后一条记录的 id
或 timestamp
,用于下一次查询。
// 响应中返回下一页 token
{"data": [...],"next_page_token": "1005"
}
下一页请求:
SELECT * FROM orders WHERE id > 1005 ORDER BY id LIMIT 20;
✅ 优点:
- 高性能
- 可集成到 API 设计中
💡 适用场景:
- RESTful API 分页(如 Twitter、GitHub API)
✅ 方案 4:使用缓存预生成分页数据
适合数据变化不频繁的场景
方案:
- 使用 Redis 或 Elasticsearch 预先生成分页数据。
- 例如:将前 1000 页数据缓存为
page:1
,page:2
, …
示例:
// 从缓存读取
data, err := redis.Get("page:100")
✅ 优点:
- 查询极快(O(1))
- 减轻数据库压力
❌ 缺点:
- 数据实时性差
- 存储成本高
💡 适用场景:
- 热门榜单、商品分类页
✅ 方案 5:建立汇总表或物化视图
预计算分页结果
方案:
- 创建一个
orders_page_index
表,存储每页的起始id
和范围。 - 查询时先查索引表,再定位数据。
-- 预生成
INSERT INTO page_index (page, start_id, end_id) VALUES (100, 100000, 100019);
-- 查询第 100 页
SELECT * FROM orders
WHERE id BETWEEN (SELECT start_id FROM page_index WHERE page=100) AND (SELECT end_id FROM page_index WHERE page=100);
✅ 优点:
- 查询快
- 可支持跳页
❌ 缺点:
- 维护成本高(需定时更新)
- 数据可能不一致
⚠️ 不推荐方案:直接使用 LIMIT offset, size
- 仅适用于小数据量或浅分页
- 深分页性能极差
三、各方案对比总结
方案 | 性能 | 支持跳页 | 实现复杂度 | 推荐度 |
---|---|---|---|---|
游标分页(Keyset) | ⭐⭐⭐⭐⭐ | ❌ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
延迟关联 | ⭐⭐⭐⭐ | ✅ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
状态化分页(Token) | ⭐⭐⭐⭐⭐ | ❌ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
缓存预生成 | ⭐⭐⭐⭐⭐ | ✅ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
物化视图 | ⭐⭐⭐⭐ | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
四、面试回答模板(高分答案)
“深分页问题本质是
OFFSET
越大,MySQL 扫描的数据越多,导致性能下降。解决方案有:
- 游标分页:利用上一页最后一条记录的主键或时间戳作为下一页起点,避免
OFFSET
,性能最好,适合信息流场景。- 延迟关联:先查主键再回表,减少回表数据量,适用于传统分页优化。
- 状态化分页:API 返回
next_page_token
,客户端携带 token 查询,适合 RESTful 接口。- 缓存预生成:用 Redis 预存分页结果,适合数据变化不频繁的场景。
我推荐优先使用 游标分页 或 状态化分页,它们性能最好且可扩展性强。”
✅ 总结
问题 | 解决方案 |
---|---|
深分页慢 | 避免 LIMIT offset, size |
需要高性能 | 用 游标分页(WHERE id > last_id ) |
需要跳页 | 用 延迟关联 或 缓存 |
API 设计 | 用 next_page_token |