前言
适用场景:数据量大(百万/千万+)、分页翻到很后面(page 很大)、
limit offset, size越来越慢。
1. 为什么limit offset, size会慢
典型写法:
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
select * from orders where mch_no = ? and id < ? order by id desc limit ?;
- 第一页:不传 lastid(或传一个超大值)
- 下一页:把上一页最后一条的
id作为lastid
索引建议:
create index idx_orders_mch_id on orders(mch_no, id);
3.3 复合排序:按时间 + id(更通用)
时间排序常见,但 create_time 不唯一,所以要加 id 做 tie-breaker。
select *
from orders
where mch_no = ?
and (
create_time < ?
or (create_time = ? and id < ?)
)
order by create_time desc, id desc
limit ?;
索引建议:
create index idx_orders_mch_time_id on orders(mch_no, create_time, id);
注意:where 条件和 order by 的字段顺序尽量和索引一致,减少 filesort。
3.4 spring boot + mybatis 示例
dto:分页请求/响应
@data
public class seekpagereq {
private string mchno;
private integer pagesize = 20;
// 单字段游标
private long lastid;
// 复合游标(时间 + id)
private localdatetime lastcreatetime;
private long lasttieid;
}
mapper(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:返回下一页游标
public class seekpageresp<t> {
private list<t> list;
private boolean hasmore;
private localdatetime nextcreatetime;
private long nexttieid;
private long nextid;
}
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,再回表查详情。
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 索引建议
create index idx_orders_mch_id on orders(mch_no, id);
4.3 为什么有效
- 子查询阶段只扫描索引叶子节点(更轻)
- 回表只回 20 行,而不是回表 offset+size 行
仍然会扫描 offset 行的索引,但比
select * limit offset好很多,尤其列多、行宽时收益明显。
5. 方案三:分段/范围分页(适合按时间分区或业务天然分桶)
如果你的查询大多按时间,比如订单只看近 3 个月:
5.1 强制加时间范围(让查询天然变小)
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 排序,否则同一秒插入多条会导致翻页重复/漏数据。
✅ 正确:
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 写法示例
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。
如果产品硬要跳页:覆盖索引 + 延迟关联 来兜底。
数据特别大:分区/分表 才是长期方案。
总结
到此这篇关于java项目中mysql深度分页解决方案大全的文章就介绍到这了,更多相关mysql深度分页内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论