1. MyBatis-Plus分页机制原理解析
MyBatis-Plus的分页功能本质上是对MyBatis原生分页的增强封装。其核心实现原理是通过ThreadLocal保存分页参数,在执行SQL前动态拦截并重写语句。具体工作流程如下:
- 调用Page构造函数创建分页对象时,会自动将分页参数存入PaginationInnerInterceptor的线程局部变量
- 通过MyBatis的Interceptor接口实现SQL拦截
- 根据数据库类型(MySQL/Oracle等)使用不同的方言处理器重写SQL
- 执行COUNT查询获取总数
- 添加LIMIT/OFFSET等分页语法
// 典型分页查询示例 Page<User> page = new Page<>(1, 10); // 当前页,每页数量 userMapper.selectPage(page, queryWrapper);关键点:分页拦截器会在同一个线程内多次执行SQL,先查总数再查数据,这也是性能瓶颈的主要来源
2. 常见性能问题诊断
2.1 执行计划分析
通过EXPLAIN查看分页查询的执行计划时,常见问题包括:
| 问题类型 | 表现特征 | 影响 |
|---|---|---|
| 全表扫描 | type=ALL | 数据量大时性能急剧下降 |
| 文件排序 | Extra=Using filesort | 高CPU消耗 |
| 临时表 | Extra=Using temporary | 内存占用过高 |
2.2 慢查询日志分析
典型的分页慢查询通常呈现以下特征:
- 深度分页时响应时间非线性增长
- COUNT查询耗时占比超过50%
- 相同分页参数执行时间波动大
3. 深度优化方案
3.1 索引优化策略
对于分页查询的WHERE条件,必须建立复合索引。以用户查询为例:
-- 原始查询 SELECT * FROM user WHERE age > 18 ORDER BY create_time DESC LIMIT 100000, 10 -- 优化索引 ALTER TABLE user ADD INDEX idx_age_create_time (age, create_time DESC)注意:MySQL 8.0+支持DESC索引,低版本需考虑倒序存储技巧
3.2 延迟关联技巧
通过子查询先获取主键,再关联获取完整数据:
QueryWrapper<User> wrapper = new QueryWrapper<User>() .select("id") .eq("status", 1) .orderByDesc("create_time"); Page<User> page = new Page<>(1, 10); page.setSearchCount(false); // 禁用自动COUNT查询 List<Long> ids = userMapper.selectObjs(wrapper, page) .stream().map(o -> (Long)o).collect(Collectors.toList()); if(!ids.isEmpty()) { List<User> users = userMapper.selectBatchIds(ids); }3.3 游标分页实现
基于上次查询结果的标记分页(适合无限滚动场景):
// 首次查询 QueryWrapper<User> wrapper = new QueryWrapper<User>() .gt("id", lastId) .orderByAsc("id") .last("LIMIT 10"); List<User> users = userMapper.selectList(wrapper); Long newLastId = users.get(users.size()-1).getId();4. 高级配置技巧
4.1 拦截器自定义配置
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 自定义分页拦截器 PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setOptimizeJoin(true); // 优化JOIN查询 paginationInterceptor.setMaxLimit(500L); // 单页最大记录数 paginationInterceptor.setOverflow(false); // 溢出总页数后处理 interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; }4.2 多数据源分页支持
需为每个数据源单独配置方言:
@Bean @ConfigurationProperties(prefix = "spring.datasource.ds1") public DataSource ds1() { return DataSourceBuilder.create().build(); } @Bean public PaginationInnerInterceptor ds1PaginationInterceptor() { PaginationInnerInterceptor interceptor = new PaginationInnerInterceptor(DbType.MYSQL); interceptor.setDialect(new MySqlDialect()); return interceptor; }5. 生产环境监控方案
5.1 指标采集配置
通过Micrometer暴露分页指标:
management: metrics: export: prometheus: enabled: true distribution: percentiles: mybatis.page.query.time: 0.5,0.9,0.995.2 告警规则示例
-- Grafana Alert SQL SELECT rate(mybatis_page_query_count_total[5m]) > 100 AND histogram_quantile(0.9, rate(mybatis_page_query_time_seconds_bucket[5m])) > 16. 版本适配指南
针对不同MyBatis-Plus版本的优化差异:
| 版本范围 | 特性支持 | 注意事项 |
|---|---|---|
| 3.0-3.4 | 基础分页 | 不支持JOIN优化 |
| 3.5.0+ | 动态表名 | 需手动设置countSql |
| 3.5.3+ | 性能分析 | 支持拦截器链路追踪 |
对于SpringBoot的版本适配:
<!-- 推荐组合 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> <version>2.7.8</version> </dependency>7. 实战问题排查案例
7.1 COUNT查询异常
现象:分页总数与实际不符 排查步骤:
- 检查是否有逻辑删除字段@TableLogic
- 确认wrapper条件是否包含SQL注入风险字符
- 开启SQL日志对比count语句与实际查询条件
7.2 内存溢出问题
典型堆栈:
java.lang.OutOfMemoryError: Java heap space at com.baomidou.mybatisplus.core.toolkit.CollectionUtils.newHashMap(CollectionUtils.java:67)解决方案:
- 限制maxLimit参数
- 对于导出场景改用流式查询
- 添加JVM参数:-XX:+HeapDumpOnOutOfMemoryError
8. 新型分页模式探索
8.1 分布式分页方案
基于Elasticsearch的搜索后分页:
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .query(QueryBuilders.termQuery("status", 1)) .from((pageNum - 1) * pageSize) .size(pageSize) .sort(SortBuilders.fieldSort("create_time").order(SortOrder.DESC)); SearchRequest request = new SearchRequest("user_index"); request.source(sourceBuilder);8.2 前端分页优化
对于万级以下数据量,考虑全量查询+前端分页:
// Vue实现示例 const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0, allData: [] }) async function loadAllData() { const res = await api.getAllData() pagination.allData = res.data pagination.total = res.data.length } function getPagedData() { const start = (pagination.currentPage - 1) * pagination.pageSize return pagination.allData.slice(start, start + pagination.pageSize) }9. 性能压测对比
使用JMeter对不同方案进行测试(数据量1000万条):
| 方案 | 50并发平均RT | 99线 | 错误率 |
|---|---|---|---|
| 传统分页 | 1200ms | 3500ms | 0.2% |
| 延迟关联 | 280ms | 800ms | 0% |
| 游标分页 | 150ms | 300ms | 0% |
| ES分页 | 80ms | 200ms | 0% |
测试环境配置:
- CPU: 4核
- 内存: 8GB
- MySQL: 5.7.32
- 网络延迟: <1ms
10. 架构级解决方案
对于超大规模数据分页,建议采用以下架构:
- 读写分离:查询走从库
- 结果缓存:使用Redis缓存分页结果
- 预计算:定时任务预先计算热门查询分页
- 异构存储:将历史数据迁移至ClickHouse等分析型数据库
缓存策略示例:
@Cacheable(value = "userPage", key = "#pageNum+'-'+#pageSize+'-'+#wrapper.cacheKey") public Page<User> getCachedPage(Page<User> page, QueryWrapper<User> wrapper) { return userMapper.selectPage(page, wrapper); }缓存Key生成规则:
public QueryWrapper<User> buildWrapper(String name) { QueryWrapper<User> wrapper = new QueryWrapper<User>() .eq(StringUtils.isNotBlank(name), "name", name); wrapper.setEntityClass(User.class); // 支持自动生成cacheKey return wrapper; }