☰
MySQL千万级数据分页优化实战方案
2026/10/28 4:11:45 网站建设 项目流程

1. 千万级数据分页的噩梦现场

那天下午三点,我正悠闲地喝着咖啡,突然收到生产环境告警——用户投诉列表页加载超时。查看日志发现一个简单的分页查询竟然执行了10秒!SQL长这样:

SELECT * FROM user_operation_log ORDER BY create_time DESC LIMIT 10 OFFSET 1000000

这个查询的逻辑很简单:按时间倒序排列,跳过前100万条记录,取10条。但就是这个看似无害的OFFSET,让MySQL服务器CPU直接飙到90%,DBA老张提着机械键盘就冲到了我工位...

关键问题:OFFSET分页在数据量大时性能急剧下降,因为它需要先扫描并丢弃前N条记录

2. 为什么OFFSET会引发性能灾难?

2.1 MySQL分页的底层运作机制

当执行LIMIT 10 OFFSET 1000000时,MySQL实际执行流程是:

  1. 通过索引或全表扫描找到符合条件的所有记录
  2. 对这些记录进行排序(如果无索引)
  3. 依次扫描前1,000,000条记录并丢弃
  4. 返回接下来的10条记录

在千万级数据表中,前三个步骤消耗了绝大部分资源。我通过EXPLAIN看到这个查询扫描了1,000,010行,但只返回10行,效率低得惊人。

2.2 性能测试对比数据

我用测试环境做了组对比实验(500万数据量):

分页方式OFFSET值执行时间扫描行数
传统分页10,0000.12s10,010
传统分页100,0001.3s100,010
传统分页1,000,00012.8s1,000,010
优化方案11,000,0000.15s10
优化方案2任意深度0.02s10

可以看到随着OFFSET增大,性能呈线性下降,这正是DBA最痛恨的"全表扫描"操作。

3. 三大优化方案实战

3.1 方案一:游标分页(推荐)

改用基于索引列的条件查询替代OFFSET:

-- 第一页(常规查询) SELECT * FROM user_operation_log ORDER BY create_time DESC, id DESC LIMIT 10; -- 后续页(记住上一页最后一条记录的create_time和id) SELECT * FROM user_operation_log WHERE create_time < '2023-05-20 14:30:00' OR (create_time = '2023-05-20 14:30:00' AND id < 12345) ORDER BY create_time DESC, id DESC LIMIT 10;

优势:

  • 完全避免OFFSET
  • 每次只扫描需要的10条记录
  • 支持无限深度分页

注意点:

  1. 排序字段必须建立联合索引(如(create_time, id))
  2. 需要前端记录最后一条记录的排序字段值
  3. 不适合需要随机跳页的场景

3.2 方案二:子查询优化

SELECT * FROM user_operation_log WHERE id >= ( SELECT id FROM user_operation_log ORDER BY create_time DESC, id DESC LIMIT 1000000, 1 ) ORDER BY create_time DESC, id DESC LIMIT 10;

原理:

  • 子查询先用索引快速定位到起始位置
  • 外层查询直接范围扫描

适用场景:

  • 主键是自增整数
  • 排序字段有索引
  • 比纯OFFSET快5-10倍

3.3 方案三:延迟关联

SELECT t1.* FROM user_operation_log t1 INNER JOIN ( SELECT id FROM user_operation_log ORDER BY create_time DESC LIMIT 1000000, 10 ) t2 ON t1.id = t2.id ORDER BY t1.create_time DESC;

优势:

  • 内层查询只返回ID,减少数据量
  • 外层通过主键快速关联
  • 适合宽表(字段多)场景

4. MyBatis中的最佳实践

4.1 PageHelper的正确用法

// 错误用法(仍然使用OFFSET) PageHelper.startPage(100001, 10); // 正确用法(游标分页) PageHelper.startPage(1, 10) .setOrderBy("create_time desc, id desc") .setCount(false); // 不执行count查询 List<UserLog> list = mapper.selectAfterTime(lastCreateTime, lastId);

4.2 自定义分页拦截器

实现原理:

  1. 拦截Executor.query方法
  2. 解析分页参数
  3. 改写SQL为游标分页模式
  4. 缓存上次查询的边界值
@Intercepts(@Signature(type=Executor.class, method="query", args={MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})) public class CursorPageInterceptor implements Interceptor { // 实现细节... }

5. 不同场景的选型建议

场景特征推荐方案注意事项
电商订单列表游标分页需要记录浏览位置
后台管理系统子查询优化允许跳页查询
日志分析系统延迟关联配合异步导出
移动端APP游标分页+缓存预加载下一页

6. 避坑指南

  1. 索引陷阱:排序字段必须建立索引,且顺序要与ORDER BY一致。我曾遇到(status, create_time)索引但按create_time排序的情况,导致全表扫描。

  2. MyBatis-Plus分页插件:3.5.x版本默认仍使用OFFSET,需要手动关闭:

    mybatis-plus: configuration: default-page-optimize: false
  3. COUNT查询优化:分页总数统计在大数据量时也很慢,考虑:

    • 使用SQL_CALC_FOUND_ROWS(有性能损耗)
    • 定期异步统计总数并缓存
    • 无限滚动场景可省略总数
  4. 前端配合:游标分页需要前端保存最后一条记录的排序字段值,可以用JSON序列化存储:

    { "lastCreateTime": "2023-05-20T14:30:00", "lastId": 12345 }

那次事故后,我们重构了所有分页查询,平均响应时间从3.2秒降到28毫秒。DBA老张现在看到我都会竖起大拇指——当然,是用握过机械键盘的那只手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询