MyBatis-Plus 分页失效问题全解析:PageHelper vs MyBatis-Plus 分页

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 方法签名

混合项目 → 不要在同一个方法中混用两种分页