当前位置: 代码网 > it编程>编程语言>Java > Java项目中mysql深度分页解决方案大全

Java项目中mysql深度分页解决方案大全

2026年01月04日 Java 我要评论
前言适用场景:数据量大(百万/千万+)、分页翻到很后面(page 很大)、limit offset, size 越来越慢。1. 为什么limit offset, size会慢典型写法:select *

前言

适用场景:数据量大(百万/千万+)、分页翻到很后面(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. 总原则(你只要记住这三条)

  1. 能不用 offset 就不用:优先用“游标/seek”分页(keyset pagination)。
  2. 必须 offset 时,让 offset 扫描尽量走索引且少回表:覆盖索引 + 延迟关联(delayed join)。
  3. 分页必须稳定:排序字段要唯一或加唯一补充键(例如 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. 方案四:先给用户“可用的跳页”,再用游标实现(产品层折中)

现实里用户想要:

  • “快速跳到某个位置”
  • “看到总页数”
  • “页码随便点”

你可以这样折中:

  1. ui 上保留页码,但后端用游标分页(每次翻页携带 token)
  2. “跳到第 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深度分页内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com