Java 项目中 MySQL 深度分页(Deep Pagination)解决方案大全
适用场景:数据量大(百万/千万+)、分页翻到很后面(page 很大)、
LIMIT offset, size越来越慢。
1. 为什么 LIMIT offset, size 会慢
典型写法:
sql
SELECT * FROM orders
WHERE mch_no = ?
ORDER BY id DESC
LIMIT 1000000, 20;
问题在于:MySQL 需要先"找到并丢弃"前 offset 条,再取后面的 size 条。
当 offset 很大时,扫描行数巨大,可能触发:
- 大量行扫描(CPU/IO 增加)
- 临时表 / filesort(尤其排序字段没索引或索引用不上)
- 回表次数爆炸(
SELECT *从二级索引回主键再回表)
结论:深度分页的本质是"跳过大量数据"带来的扫描成本。
2. 总原则(你只要记住这三条)
- 能不用 offset 就不用:优先用"游标/seek"分页(Keyset Pagination)。
- 必须 offset 时,让 offset 扫描尽量走索引且少回表:覆盖索引 + 延迟关联(Delayed Join)。
- 分页必须稳定 :排序字段要唯一或加唯一补充键(例如
create_time DESC, id DESC)。
3. 方案一:游标分页(Keyset / Seek Method)✅ 最推荐
3.1 思路
不用"第 N 页"这种随机跳转思维,而是"给我下一页",用上一页最后一条记录的排序键作为游标:
ORDER BY id DESC:用lastId做游标ORDER BY create_time DESC, id DESC:用(lastTime, lastId)做复合游标
这样每页只扫 size 附近的数据,复杂度接近 O(size)。
3.2 单字段排序:按自增/雪花 id
sql
SELECT *
FROM orders
WHERE mch_no = ?
AND id < ?
ORDER BY id DESC
LIMIT ?;
- 第一页:不传 lastId(或传一个超大值)
- 下一页:把上一页最后一条的
id作为lastId
索引建议:
sql
CREATE INDEX idx_orders_mch_id ON orders(mch_no, id);
3.3 复合排序:按时间 + id(更通用)
时间排序常见,但 create_time 不唯一,所以要加 id 做 tie-breaker。
sql
SELECT *
FROM orders
WHERE mch_no = ?
AND (
create_time < ?
OR (create_time = ? AND id < ?)
)
ORDER BY create_time DESC, id DESC
LIMIT ?;
索引建议:
sql
CREATE INDEX idx_orders_mch_time_id ON orders(mch_no, create_time, id);
注意:where 条件和 order by 的字段顺序尽量和索引一致,减少 filesort。
3.4 Spring Boot + MyBatis 示例
DTO:分页请求/响应
java
@Data
public class SeekPageReq {
private String mchNo;
private Integer pageSize = 20;
// 单字段游标
private Long lastId;
// 复合游标(时间 + id)
private LocalDateTime lastCreateTime;
private Long lastTieId;
}
Mapper(XML 方式示例:复合游标)
xml
<select id="selectOrdersSeek" resultType="com.demo.Order">
SELECT id, mch_no, create_time, amount, status
FROM orders
WHERE mch_no = #{mchNo}
<if test="lastCreateTime != null and lastTieId != null">
AND (
create_time <![CDATA[ < ]]> #{lastCreateTime}
OR (create_time = #{lastCreateTime} AND id <![CDATA[ < ]]> #{lastTieId})
)
</if>
ORDER BY create_time DESC, id DESC
LIMIT #{pageSize}
</select>
Service:返回下一页游标
java
public class SeekPageResp<T> {
private List<T> list;
private boolean hasMore;
private LocalDateTime nextCreateTime;
private Long nextTieId;
private Long nextId;
}
java
public SeekPageResp<Order> pageOrders(SeekPageReq req) {
List<Order> list = orderMapper.selectOrdersSeek(req);
SeekPageResp<Order> resp = new SeekPageResp<>();
resp.setList(list);
resp.setHasMore(list.size() == req.getPageSize());
if (!list.isEmpty()) {
Order last = list.get(list.size() - 1);
resp.setNextCreateTime(last.getCreateTime());
resp.setNextTieId(last.getId());
resp.setNextId(last.getId());
}
return resp;
}
4. 方案二:覆盖索引 + 延迟关联(Delayed Join)✅ 适合"必须跳页"的场景
有些产品硬要"跳到第 50000 页"。这时 offset 不可避免,但你可以把"丢弃 offset 行"的成本降到最低。
4.1 思路
先只查主键(走覆盖索引,避免回表),拿到一小段 id,再回表查详情。
sql
SELECT o.*
FROM orders o
JOIN (
SELECT id
FROM orders
WHERE mch_no = ?
ORDER BY id DESC
LIMIT 1000000, 20
) t ON o.id = t.id
ORDER BY o.id DESC;
4.2 索引建议
sql
CREATE INDEX idx_orders_mch_id ON orders(mch_no, id);
4.3 为什么有效
- 子查询阶段只扫描索引叶子节点(更轻)
- 回表只回 20 行,而不是回表 offset+size 行
仍然会扫描 offset 行的索引,但比
SELECT * LIMIT offset好很多,尤其列多、行宽时收益明显。
5. 方案三:分段/范围分页(适合按时间分区或业务天然分桶)
如果你的查询大多按时间,比如订单只看近 3 个月:
5.1 强制加时间范围(让查询天然变小)
sql
WHERE create_time >= NOW() - INTERVAL 90 DAY
5.2 物理分区(Partition)或按月分表
- MySQL Partition(按 range 分区)
- 业务分表:
orders_202501,orders_202502...
这样深度分页变成"在更小的数据集上分页"。
这不是"分页技巧",而是"数据治理",效果通常是最猛的。
6. 方案四:先给用户"可用的跳页",再用游标实现(产品层折中)
现实里用户想要:
- "快速跳到某个位置"
- "看到总页数"
- "页码随便点"
你可以这样折中:
- UI 上保留页码,但后端用游标分页(每次翻页携带 token)
- "跳到第 N 页"变成:先定位锚点(anchor)再 seek
定位锚点的方法:
- 用延迟关联查出该页第一条 id(只查 id)
- 或用缓存的"页锚点表"(每 1000 页存一次 anchor id)
7. 统计总数(COUNT)怎么做更靠谱
深度分页通常伴随 SELECT COUNT(*) 慢的问题。
7.1 你真的需要精确总数吗?
很多列表:用户只想"有多少大概",或只要"是否还有更多"。
替代方案:
- 只返回
hasMore - 返回
estimatedTotal(估算) - 或异步计算 total(缓存)
7.2 精确 COUNT 的索引建议
COUNT(*)会尽量走覆盖索引,但仍可能很慢(范围大)- 优化方式:让 WHERE 条件尽量命中高选择性索引,缩小范围
8. 关键细节(别踩坑)
8.1 排序必须稳定
不要只按 create_time 排序,否则同一秒插入多条会导致翻页重复/漏数据。
✅ 正确:
sql
ORDER BY create_time DESC, id DESC
8.2 避免 SELECT *
列表页只查需要的列,能减少 IO 和回表成本。
8.3 用 EXPLAIN 看有没有 filesort / 临时表
Using filesort:排序没走索引Using temporary:临时表开销大
8.4 InnoDB 二级索引回表成本
二级索引叶子存的是主键,需要回表拿其他列。
所以覆盖索引、延迟关联就是在对抗回表。
9. 推荐组合(直接抄)
9.1 默认列表分页(APP/后台)
- ✅ Keyset/Seek 分页
- 排序:
create_time DESC, id DESC - 索引:
(mch_no, create_time, id)
9.2 管理后台"跳到第 N 页"
- ✅ 延迟关联分页(子查询只查 id + 回表)
- 或 "页锚点缓存" + seek
9.3 超大历史数据
- ✅ 时间分区 / 分表 + seek
- COUNT 用异步/缓存/估算
10. 快速检查清单(上线前 2 分钟自检)
- 你是不是还在用
LIMIT offset, size翻到很后面? - 排序字段是不是唯一/稳定?(加 id 了吗)
- where + order by 的字段是不是能走同一个索引?
- 列表是不是还在
SELECT *? - EXPLAIN 有没有
Using filesort? - 是否能用 seek 分页替代"页码"?
11. 附:单字段 seek 的 MyBatis-Plus 写法示例
java
LambdaQueryWrapper<Order> qw = Wrappers.<Order>lambdaQuery()
.eq(Order::getMchNo, mchNo)
.lt(lastId != null, Order::getId, lastId)
.orderByDesc(Order::getId)
.last("LIMIT " + pageSize);
List<Order> list = orderMapper.selectList(qw);
12. 结论(一句话)
深度分页最强解:Keyset/Seek。
如果产品硬要跳页:覆盖索引 + 延迟关联 来兜底。
数据特别大:分区/分表 才是长期方案。