Mysql

关注公众号 jb51net

关闭
首页 > 数据库 > Mysql > mysql深度分页

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

作者:CodeAmaz

深度分页的优化核心在于减少MySQL扫描的数据量,避免全表扫描,通过使用索引、游标分页、延迟关联等技术,可以显著提升分页查询的性能,这篇文章主要介绍了Java项目中mysql深度分页的相关资料,需要的朋友可以参考下

前言

适用场景:数据量大(百万/千万+)、分页翻到很后面(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 很大时,扫描行数巨大,可能触发:

结论:深度分页的本质是“跳过大量数据”带来的扫描成本。

2. 总原则(你只要记住这三条)

  1. 能不用 offset 就不用:优先用“游标/seek”分页(Keyset Pagination)。
  2. 必须 offset 时,让 offset 扫描尽量走索引且少回表:覆盖索引 + 延迟关联(Delayed Join)。
  3. 分页必须稳定:排序字段要唯一或加唯一补充键(例如 create_time DESC, id DESC)。

3. 方案一:游标分页(Keyset / Seek Method)最推荐

3.1 思路

不用“第 N 页”这种随机跳转思维,而是“给我下一页”,用上一页最后一条记录的排序键作为游标:

这样每页只扫 size 附近的数据,复杂度接近 O(size)。

3.2 单字段排序:按自增/雪花 id

SELECT *
FROM orders
WHERE mch_no = ?
  AND id < ?
ORDER BY id DESC
LIMIT ?;

索引建议:

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 为什么有效

仍然会扫描 offset 行的索引,但比 SELECT * LIMIT offset 好很多,尤其列多、行宽时收益明显。

5. 方案三:分段/范围分页(适合按时间分区或业务天然分桶)

如果你的查询大多按时间,比如订单只看近 3 个月:

5.1 强制加时间范围(让查询天然变小)

WHERE create_time >= NOW() - INTERVAL 90 DAY

5.2 物理分区(Partition)或按月分表

这样深度分页变成“在更小的数据集上分页”。

这不是“分页技巧”,而是“数据治理”,效果通常是最猛的。

6. 方案四:先给用户“可用的跳页”,再用游标实现(产品层折中)

现实里用户想要:

你可以这样折中:

  1. UI 上保留页码,但后端用游标分页(每次翻页携带 token)
  2. “跳到第 N 页”变成:先定位锚点(anchor)再 seek

定位锚点的方法:

7. 统计总数(COUNT)怎么做更靠谱

深度分页通常伴随 SELECT COUNT(*) 慢的问题。

7.1 你真的需要精确总数吗?

很多列表:用户只想“有多少大概”,或只要“是否还有更多”。

替代方案:

7.2 精确 COUNT 的索引建议

8. 关键细节(别踩坑)

8.1 排序必须稳定

不要只按 create_time 排序,否则同一秒插入多条会导致翻页重复/漏数据。

✅ 正确:

ORDER BY create_time DESC, id DESC

8.2 避免SELECT *

列表页只查需要的列,能减少 IO 和回表成本。

8.3 用 EXPLAIN 看有没有 filesort / 临时表

8.4 InnoDB 二级索引回表成本

二级索引叶子存的是主键,需要回表拿其他列。
所以覆盖索引、延迟关联就是在对抗回表。

9. 推荐组合(直接抄)

9.1 默认列表分页(APP/后台)

9.2 管理后台“跳到第 N 页”

9.3 超大历史数据

10. 快速检查清单(上线前 2 分钟自检)

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深度分页内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文