MyBatis-Plus 分页失效问题全解析:PageHelper vs MyBatis-Plus 分页
一、问题描述
使用 PageHelper.startPage() 配合 MyBatis-Plus 的 BaseMapper.selectList(Wrapper) 进行分页查询时,实际执行的 SQL 没有 LIMIT 子句,返回了全部数据。
java
// 期望生成:SELECT * FROM table WHERE ... LIMIT 0, 50
// 实际生成:SELECT * FROM table WHERE ...(无分页)
PageHelper.startPage(1, 50);
List<Entity> list = mapper.selectList(wrapper);
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、两种分页方案对比
2.1 PageHelper(第三方分页插件)
- 来源:
com.github.pagehelper:pagehelper - 原理:通过 MyBatis Interceptor 拦截 SQL,自动追加
LIMIT - 配合对象:
PageInfo包装分页结果
java
PageHelper.startPage(pageNum, pageSize);
List<User> list = userMapper.selectByCondition(param);
PageInfo<User> pageInfo = new PageInfo<>(list);
2.2 MyBatis-Plus 内置分页
- 来源:
com.baomidou:mybatis-plus内置 - 原理:通过
PaginationInnerInterceptor拦截 SQL - 配合对象:
Page<T>作为参数传入
java
Page<User> page = new Page<>(pageNum, pageSize);
Page<User> result = userMapper.selectPage(page, wrapper);
三、冲突原因深度分析
3.1 PageHelper 的工作原理
① PageHelper.startPage(1, 50)
→ 将分页参数存入 ThreadLocal
② mapper.selectXxx()
→ MyBatis 执行 SQL 前,PageHelper 的 Interceptor 检查 ThreadLocal
→ 发现有分页参数,改写 SQL 追加 LIMIT
→ 执行完毕后,清除 ThreadLocal 中的分页参数
③ new PageInfo<>(list)
→ 从 list(被 PageHelper 包装过的 Page 对象)中取分页信息
关键点 :PageHelper 只会拦截 startPage() 之后的下一条 SQL 执行。
3.2 为什么与 MyBatis-Plus 的 selectList 冲突
MyBatis-Plus 的 BaseMapper.selectList(Wrapper) 不是简单的一次 SQL 调用。其内部流程:
mapper.selectList(wrapper)
↓
MyBatis-Plus 动态构建 SQL(通过 AbstractWrapper 生成 WHERE 条件)
↓
调用 MyBatis 的 SqlSession.selectList()
↓
MyBatis Executor 执行
冲突发生的几种可能原因:
| 原因 | 说明 |
|---|---|
| Interceptor 执行顺序 | PageHelper 的 Interceptor 和 MyBatis-Plus 的 Interceptor 注册顺序不当,导致 PageHelper 没有正确拦截到最终的 SQL |
| ThreadLocal 被提前消费 | 如果在 startPage() 和 selectList() 之间有其他 SQL 执行(如条件构造时触发了查询),分页参数已被消费 |
| SQL 改写时机不匹配 | MyBatis-Plus 构造的 SQL 在 PageHelper 拦截时还未最终确定,导致 LIMIT 没有追加到正确的位置 |
| 版本兼容性 | 不同版本的 PageHelper 和 MyBatis-Plus 对 Interceptor SPI 的实现有差异 |
3.3 PageHelper 的 ThreadLocal 机制详解
java
public class PageHelper {
// 每个线程独立的分页参数
private static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>();
public static <E> Page<E> startPage(int pageNum, int pageSize) {
Page<E> page = new Page<>(pageNum, pageSize);
LOCAL_PAGE.set(page); // 存入当前线程
return page;
}
}
// Interceptor 中:
public class PageInterceptor implements Interceptor {
public Object intercept(Invocation invocation) {
Page page = PageHelper.LOCAL_PAGE.get();
if (page != null) {
// 改写 SQL 追加 LIMIT
PageHelper.LOCAL_PAGE.remove(); // 用完立即清除
// ...执行带 LIMIT 的 SQL
}
return invocation.proceed();
}
}
问题核心 :如果 LOCAL_PAGE.get() 在错误的时机被调用(SQL 还没准备好),或者在正确时机没被调用(Interceptor 顺序靠后),分页就失效。
四、MyBatis-Plus 分页的正确用法
4.1 注册分页插件(必须)
java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(500L); // 单页最大条数限制
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
没有这个配置,selectPage 方法的分页参数会被忽略,等同于 selectList。
4.2 使用 selectPage
java
// 创建分页对象(页码从1开始)
Page<Entity> page = new Page<>(pageNum, pageSize);
// 执行分页查询
Page<Entity> result = mapper.selectPage(page, wrapper);
// 获取结果
List<Entity> records = result.getRecords(); // 当前页数据
long total = result.getTotal(); // 总记录数
long current = result.getCurrent(); // 当前页码
long size = result.getSize(); // 每页条数
long pages = result.getPages(); // 总页数
4.3 生成的 SQL
sql
-- MyBatis-Plus 分页实际执行两条 SQL:
-- 第一条:查总数
SELECT COUNT(*) AS total FROM user WHERE status = 1
-- 第二条:查数据(带 LIMIT)
SELECT id, name, status FROM user WHERE status = 1 LIMIT 0, 50
4.4 优化:不需要总数时跳过 COUNT
java
// 第三个参数 false 表示不查询总数(提升性能)
Page<Entity> page = new Page<>(pageNum, pageSize, false);
五、PageHelper 和 MyBatis-Plus 分页能否共存
可以共存,但需要明确分工:
| 场景 | 推荐方案 |
|---|---|
使用 MyBatis-Plus 的 BaseMapper 方法(selectList/selectPage) |
用 MyBatis-Plus 分页 |
| 使用自定义 XML 中的 SQL(如复杂 JOIN 查询) | 用 PageHelper 或 MyBatis-Plus 分页都可以 |
| 混合使用 | 按方法区分,不要在同一方法中混用 |
原则:一个查询方法中只使用一种分页方式,不要混用。
六、完整示例
6.1 Entity
java
package com.example.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.util.Date;
import lombok.Data;
@Data
@TableName("product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String code;
private String name;
private String category;
private Integer price;
private Integer status;
private Date createTime;
}
6.2 Mapper
java
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.Product;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
}
6.3 分页插件配置
java
package com.example.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(500L); // 防止恶意大分页
pagination.setOverflow(false); // 页码超出总页数时不自动回到第一页
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
6.4 Service
java
package com.example.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.entity.Product;
import com.example.mapper.ProductMapper;
import jakarta.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
/**
* 分页查询商品列表.
*
* @param name 商品名称(模糊搜索)
* @param category 分类(精准搜索)
* @param status 状态
* @param pageNum 页码(从1开始)
* @param pageSize 每页条数
* @return 分页结果
*/
public Map<String, Object> listProducts(
String name, String category, Integer status,
int pageNum, int pageSize) {
// 1. 构造查询条件
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
wrapper.like(name != null && !name.isBlank(), Product::getName, name);
wrapper.eq(category != null && !category.isBlank(), Product::getCategory, category);
wrapper.eq(status != null, Product::getStatus, status);
wrapper.orderByDesc(Product::getCreateTime);
// 2. 创建分页对象
Page<Product> page = new Page<>(pageNum, pageSize);
// 3. 执行分页查询(会自动执行 COUNT + SELECT LIMIT)
Page<Product> pageResult = productMapper.selectPage(page, wrapper);
// 4. 组装返回结果
Map<String, Object> result = new HashMap<>();
result.put("list", pageResult.getRecords()); // 当前页数据
result.put("total", pageResult.getTotal()); // 总记录数
result.put("pageNum", pageResult.getCurrent()); // 当前页码
result.put("pageSize", pageResult.getSize()); // 每页条数
result.put("pages", pageResult.getPages()); // 总页数
return result;
}
}
6.5 Controller
java
package com.example.controller;
import com.example.service.ProductService;
import jakarta.annotation.Resource;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/product")
public class ProductController {
@Resource
private ProductService productService;
@GetMapping("/list")
public Map<String, Object> list(
@RequestParam(required = false) String name,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
return productService.listProducts(name, category, status, pageNum, pageSize);
}
}
6.6 执行效果
请求:
GET /api/product/list?category=电子产品&pageNum=2&pageSize=10
实际执行的 SQL(可在日志中看到):
sql
-- 第一条:查总数
SELECT COUNT(*) AS total FROM product WHERE category = '电子产品'
-- 第二条:查数据
SELECT id, code, name, category, price, status, create_time
FROM product
WHERE category = '电子产品'
ORDER BY create_time DESC
LIMIT 10, 10
返回:
json
{
"list": [...],
"total": 156,
"pageNum": 2,
"pageSize": 10,
"pages": 16
}
七、PaginationInnerInterceptor 配置项
| 配置项 | 默认值 | 说明 |
|---|---|---|
dbType |
必填 | 数据库类型,影响 LIMIT 语法生成 |
maxLimit |
无限制 | 单页最大条数(防止 pageSize=999999 恶意请求) |
overflow |
false | 页码超出时:true=自动回第一页,false=返回空数据 |
optimizeJoin |
true | 优化 COUNT SQL(去掉不必要的 JOIN 和 ORDER BY) |
八、常见问题排查清单
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| SQL 完全没有 LIMIT | 没注册 PaginationInnerInterceptor | 添加 MybatisPlusConfig 配置类 |
| total 始终为 0 | 数据库方言设置错误 | 确认 DbType.MYSQL 与实际数据库匹配 |
| 返回全部数据 | 用了 selectList 而非 selectPage | 改用 selectPage 并传入 Page 对象 |
| PageHelper 时好时坏 | startPage 和查询之间有其他 SQL | 确保 startPage 紧跟查询,或改用 MyBatis-Plus 分页 |
| 分页参数被忽略 | Page 对象没传给 selectPage | 检查方法签名,确认 page 是第一个参数 |
九、总结
选型建议:
使用 MyBatis-Plus 的项目 → 优先用 MyBatis-Plus 内置分页(selectPage + PaginationInnerInterceptor)
✅ 与 MyBatis-Plus 的 Wrapper 体系天然兼容
✅ 不依赖 ThreadLocal,无执行顺序隐患
✅ 支持多数据库方言
使用原生 MyBatis + XML 的项目 → 用 PageHelper
✅ 对已有代码侵入小
✅ 不需要修改 Mapper 方法签名
混合项目 → 不要在同一个方法中混用两种分页