mysql深度分页解决方案大全

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. 总原则(你只要记住这三条)

  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

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. 方案四:先给用户"可用的跳页",再用游标实现(产品层折中)

现实里用户想要:

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

你可以这样折中:

  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 排序,否则同一秒插入多条会导致翻页重复/漏数据。

✅ 正确:

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。

如果产品硬要跳页:覆盖索引 + 延迟关联 来兜底。

数据特别大:分区/分表 才是长期方案。


相关推荐
小唐同学爱学习1 天前
如何解决海量数据存储
java·数据库·spring boot·mysql
小白爱运维1 天前
MySQL升级8.0.44后登录报错-系统表不支持'MyISAM'存储引擎
数据库·mysql
北海屿鹿1 天前
【MySQL】内置函数
android·数据库·mysql
xixingzhe21 天前
MySQL CDC实现方案
数据库·mysql
云游云记1 天前
php 防伪溯源项目:防伪码生成与批量写入实践
mysql·php·唯一字符串
爪哇天下1 天前
Mysql实现经纬度距离的排序(粗略的城市排序)
数据库·mysql
独自破碎E1 天前
MySQL中有哪些日志类型?
数据库·mysql
小唐同学爱学习1 天前
短链接修改之写锁
spring boot·redis·后端·mysql
luoluoal1 天前
基于python的web渗透测试工具(源码+文档)
python·mysql·django·毕业设计·源码
袁煦丞 cpolar内网穿透实验室2 天前
mysql_exporter+cpolar远程监控 MySQL 不卡壳!cpolar 内网穿透实验室第 712 个成功挑战
服务器·数据库·mysql·远程工作·内网穿透·cpolar