一、核心概念

  1. IPage<T> 接口: MyBatis-Plus 分页的核心接口,代表一个分页结果。它包含了当前页码、每页大小、总记录数、总页数以及当前页的数据列表等信息。
  2. Page<T>: IPage<T> 的常用实现类。在查询前,我们创建一个 Page 对象,设置 current (当前页) 和 size (每页大小)。查询后,MyBatis-Plus 会自动填充 total, pages, records 等字段。
  3. PaginationInnerInterceptor: MyBatis-Plus 的分页插件(在新版本中取代了旧的 PaginationInterceptor)。它是一个 MyBatis 的 Interceptor,负责拦截 SQL 执行,在 SQL 语句前后自动添加 COUNT 查询和 LIMIT/OFFSET (或数据库特定的分页语法,如 ROW_NUMBER())。
  4. 自定义分页方法: 指在 Mapper 接口中定义一个返回类型为 IPage<T>Page<T> 的方法,并在对应的 XML 文件或使用 @Select 注解中编写自定义的复杂 SQL。当调用此方法时,PaginationInnerInterceptor 会自动生效,实现分页。
  5. @Select, @Results, @ResultMap: 用于在 Mapper 接口上直接编写 SQL 注解。@Results@ResultMap 用于处理复杂的结果映射(如多表关联字段映射到 DTO)。
  6. XML 映射文件: 更常用和灵活的方式,将复杂的 SQL 写在 XML 文件中,便于维护和调试。

二、详细操作步骤

以下是创建和使用自定义分页查询方法的完整、详细步骤:

步骤 1: 引入依赖并配置分页插件

确保 mybatis-plus-boot-starter 依赖已引入。最关键的是配置 PaginationInnerInterceptor

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>

Java 配置类 (推荐):

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 {

    /**
     * 配置 MyBatis-Plus 插件
     * @return MybatisPlusInterceptor
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); // 指定数据库类型
        // paginationInnerInterceptor.setOverflow(false); // 溢出处理,默认false,即查询页数超过总页数时,查询最后一页。true则进行处理(如返回空或第一页)
        // paginationInnerInterceptor.setMaxLimit(100L); // 单页分页条数限制,-1 不受限制
        interceptor.addInnerInterceptor(paginationInnerInterceptor);
        return interceptor;
    }
}

注意: 旧版本使用 PaginationInterceptor,新版本(3.4.0+)推荐使用 MybatisPlusInterceptor + PaginationInnerInterceptor

步骤 2: 创建实体类 (Entity) 和数据传输对象 (DTO)

假设我们有 UserOrder 表。

User 实体类:

import com.baomidou.mybatisplus.annotation.*;
import java.io.Serializable;

@TableName("user")
public class User implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @TableField("name")
    private String name;

    @TableField("email")
    private String email;

    // 省略 Getter、Setter
    public User() {}
    // ... (Getter and Setter)
}

Order 实体类:

import com.baomidou.mybatisplus.annotation.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@TableName("`order`")
public class Order implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @TableField("user_id")
    private Long userId;

    @TableField("order_no")
    private String orderNo;

    @TableField("amount")
    private BigDecimal amount;

    @TableField("create_time")
    private LocalDateTime createTime;

    @TableField("status")
    private Integer status;

    // 省略 Getter、Setter
    public Order() {}
    // ... (Getter and Setter)
}

自定义分页查询结果 DTO (UserOrderDTO):

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 用于接收用户及其订单信息的分页查询结果
 */
public class UserOrderDTO implements Serializable {
    private static final long serialVersionUID = 1L;

    private Long userId;
    private String userName;
    private String userEmail;
    private Long orderId;
    private String orderNo;
    private BigDecimal orderAmount;
    private LocalDateTime orderCreateTime;
    private Integer orderStatus;

    // 省略 Getter、Setter、构造函数
    public UserOrderDTO() {}
    // ... (Getter and Setter)
}

步骤 3: 创建 Mapper 接口

创建一个 Mapper 接口,定义自定义分页查询方法。该方法的返回类型必须是 IPage<T>Page<T>,并且第一个参数必须是 IPage<T> 类型的分页对象

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface UserOrderMapper extends BaseMapper<User> {

    /**
     * 自定义分页查询:获取用户及其最新订单信息
     * @param page 分页对象 (必须是第一个参数)
     * @param status 订单状态 (可选筛选条件)
     * @return 分页结果 IPage<UserOrderDTO>
     */
    IPage<UserOrderDTO> selectUserOrderPage(IPage<UserOrderDTO> page, @Param("status") Integer status);

    /**
     * 另一种写法:返回 Page<UserOrderDTO>
     * @param page 分页对象
     * @param minAmount 订单金额下限
     * @return 分页结果 Page<UserOrderDTO>
     */
    // Page<UserOrderDTO> selectUserOrderPageByAmount(Page<UserOrderDTO> page, @Param("minAmount") BigDecimal minAmount);
}

关键点:

  • 返回类型: IPage<UserOrderDTO>Page<UserOrderDTO>
  • 第一个参数: 必须是 IPage<UserOrderDTO>Page<UserOrderDTO> 类型的分页对象。这是 MyBatis-Plus 分页插件能够识别并拦截此方法的关键。
  • 其他参数: 使用 @Param("paramName") 注解,以便在 SQL 中引用。@Param 注解对于非 IPage 参数是必须的,否则 MyBatis 无法正确绑定参数。

步骤 4: 编写自定义 SQL

方式一:使用 XML 映射文件 (推荐)

创建 UserOrderMapper.xml 文件,通常放在 resources/mapper/ 目录下。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserOrderMapper">

    <!-- 定义结果映射 ResultMap -->
    <resultMap id="UserOrderResultMap" type="com.example.dto.UserOrderDTO">
        <id property="userId" column="user_id"/>
        <result property="userName" column="user_name"/>
        <result property="userEmail" column="user_email"/>
        <result property="orderId" column="order_id"/>
        <result property="orderNo" column="order_no"/>
        <result property="orderAmount" column="order_amount"/>
        <result property="orderCreateTime" column="order_create_time"/>
        <result property="orderStatus" column="order_status"/>
    </resultMap>

    <!-- 自定义分页查询 SQL -->
    <select id="selectUserOrderPage" resultMap="UserOrderResultMap">
        SELECT
            u.id AS user_id,
            u.name AS user_name,
            u.email AS user_email,
            o.id AS order_id,
            o.order_no,
            o.amount AS order_amount,
            o.create_time AS order_create_time,
            o.status AS order_status
        FROM user u
        LEFT JOIN `order` o ON u.id = o.user_id
        <where>
            <if test="status != null">
                AND o.status = #{status}
            </if>
            <!-- 可以添加更多动态条件 -->
        </where>
        ORDER BY o.create_time DESC, u.id ASC
        <!-- 注意:这里不需要写 LIMIT/OFFSET,分页插件会自动添加 -->
    </select>

</mapper>

关键点:

  • namespace: 必须与 Mapper 接口的全限定名一致。
  • id: 必须与 Mapper 接口中方法名一致。
  • resultMap: 强烈推荐使用 resultMap 来处理复杂的字段映射,避免 SELECT * 和列名冲突。
  • ORDER BY: 建议指定排序规则,确保分页结果的稳定性。
  • LIMIT/OFFSET: 绝对不要手动写PaginationInnerInterceptor 会根据 IPage 对象的 currentsize 自动计算 OFFSETLIMIT 并添加到 SQL 末尾。
  • 动态 SQL: 可以使用 <where>, <if>, <choose>, <foreach> 等 MyBatis 动态标签。

方式二:使用注解 @Select (适用于较简单 SQL)

如果 SQL 相对简单,可以直接在 Mapper 接口上使用 @Select 注解。

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.*;

@Mapper
public interface UserOrderMapper extends BaseMapper<User> {

    @Select("SELECT u.id AS user_id, u.name AS user_name, u.email AS user_email, " +
            "o.id AS order_id, o.order_no, o.amount AS order_amount, " +
            "o.create_time AS order_create_time, o.status AS order_status " +
            "FROM user u " +
            "LEFT JOIN `order` o ON u.id = o.user_id " +
            "WHERE (#{status} IS NULL OR o.status = #{status}) " +
            "ORDER BY o.create_time DESC, u.id ASC")
    @Results(id = "userOrderMap", value = {
        @Result(property = "userId", column = "user_id", id = true),
        @Result(property = "userName", column = "user_name"),
        @Result(property = "userEmail", column = "user_email"),
        @Result(property = "orderId", column = "order_id"),
        @Result(property = "orderNo", column = "order_no"),
        @Result(property = "orderAmount", column = "order_amount"),
        @Result(property = "orderCreateTime", column = "order_create_time"),
        @Result(property = "orderStatus", column = "order_status")
    })
    IPage<UserOrderDTO> selectUserOrderPage(IPage<UserOrderDTO> page, @Param("status") Integer status);
}

注意: 注解方式处理复杂动态 SQL 不如 XML 方便,且 SQL 可读性较差。

步骤 5: 在 Service 中调用自定义分页方法

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserOrderService {

    @Autowired
    private UserOrderMapper userOrderMapper;

    /**
     * 获取用户订单分页列表
     * @param current 当前页码 (从1开始)
     * @param size 每页大小
     * @param status 订单状态筛选条件
     * @return 分页结果 IPage<UserOrderDTO>
     */
    public IPage<UserOrderDTO> getUserOrderPage(int current, int size, Integer status) {
        // 1. 创建 Page 对象
        Page<UserOrderDTO> page = new Page<>(current, size); // current: 页码, size: 每页数量

        // 2. 调用自定义 Mapper 方法
        // 注意:传入的是 page 对象,方法返回的也是 IPage<UserOrderDTO>
        IPage<UserOrderDTO> resultPage = userOrderMapper.selectUserOrderPage(page, status);

        // 3. 返回结果
        // resultPage 已经包含了 total, pages, current, size, records 等所有分页信息
        return resultPage;
    }

    // 调用示例:
    // IPage<UserOrderDTO> pageResult = userOrderService.getUserOrderPage(1, 10, 1);
    // System.out.println("总记录数: " + pageResult.getTotal());
    // System.out.println("总页数: " + pageResult.getPages());
    // System.out.println("当前页数据: " + pageResult.getRecords());
}

关键点:

  • 创建 Page 对象时,传入 current (页码) 和 size (每页大小)。
  • Page 对象作为第一个参数传递给 Mapper 方法。
  • 方法返回的 IPage 对象包含了完整的分页信息,可以直接使用。

三、常见错误

  1. 分页插件未配置:

    • 错误: 忘记在配置类中注册 MybatisPlusInterceptorPaginationInnerInterceptor
    • 后果: 自定义分页方法不会分页,会返回所有数据(或只返回 LIMIT 但没有 COUNT,导致 total 为 0)。
    • 解决: 确保正确配置了分页插件。
  2. Mapper 方法参数顺序错误:

    • 错误: IPage 参数不在第一个位置,例如 IPage<UserOrderDTO> selectUserOrderPage(@Param("status") Integer status, IPage<UserOrderDTO> page)
    • 后果: 分页插件无法识别此方法需要分页,导致不分页。
    • 解决: 必须IPage 参数作为方法的第一个参数。
  3. 缺少 @Param 注解:

    • 错误: 在 Mapper 方法中,非 IPage 参数缺少 @Param 注解,如 IPage<UserOrderDTO> selectUserOrderPage(IPage<UserOrderDTO> page, Integer status)
    • 后果: MyBatis 无法将参数值绑定到 SQL 中的 #{status},导致 SQL 执行错误(参数未找到)。
    • 解决: 为所有非 IPage 参数添加 @Param("paramName") 注解。
  4. SQL 中手动写了 LIMIT:

    • 错误: 在 XML 或注解的 SQL 中写了 LIMIT #{page.offset}, #{page.size} 或类似语句。
    • 后果: 会导致分页混乱,可能执行两次 LIMIT,或者分页插件无法正确工作。
    • 解决: 绝对不要在自定义 SQL 中写 LIMITOFFSET,让分页插件自动处理。
  5. XML 文件未被扫描:

    • 错误: UserOrderMapper.xml 文件路径错误或未在 application.yml 中配置 mybatis-plus.mapper-locations
    • 后果: 报错 Invalid bound statement (not found)
    • 解决: 确保 XML 文件在正确的目录下(如 resources/mapper/),并在 application.yml 中配置:
      mybatis-plus:
        mapper-locations: classpath:mapper/*.xml
      
  6. resultMap 定义错误或未引用:

    • 错误: resultMappropertycolumn 映射错误,或在 <select> 标签中写了 resultType 而不是 resultMap
    • 后果: DTO 字段无法正确赋值,为 null
    • 解决: 仔细检查 resultMap 定义,并确保 <select> 标签使用 resultMap="UserOrderResultMap"

四、注意事项

  1. IPage 参数位置: 这是硬性要求,必须是第一个参数。
  2. @Param 注解: 对于非 IPage 参数,@Param 是必需的。
  3. 不要手动分页: SQL 中禁止出现 LIMIT, OFFSET, ROWNUM 等分页关键字。
  4. 返回类型: 方法返回类型必须是 IPage<T>Page<T>
  5. 分页插件: 确保 PaginationInnerInterceptor 已正确配置并生效。
  6. 性能: 自定义 SQL 的性能取决于 SQL 本身。确保关联字段有索引,避免全表扫描。复杂的 JOINORDER BY 可能影响性能。
  7. COUNT 查询: 分页插件会自动执行一个 COUNT 查询来获取总记录数。这个 COUNT 查询是基于你的主查询 SQL 生成的(通常是 SELECT COUNT(*) FROM (...) 的形式)。确保这个 COUNT 查询也能高效执行。
  8. ORDER BY 稳定性: 为了保证分页结果的稳定(即同一页的数据不会因为排序微小变化而跳动),ORDER BY 子句应包含一个或多个能唯一确定行顺序的字段(如主键)。

五、使用技巧

  1. 使用 resultMap: 对于复杂映射,resultMapresultType 更强大、更清晰、更少出错。
  2. 动态 SQL: 充分利用 MyBatis 的动态 SQL 标签(<if>, <choose>, <where>, <set>, <foreach>)构建灵活的查询条件。
  3. @Results 注解: 如果使用注解方式,可以用 @Results 定义结果映射,并用 @ResultMap 引用。
  4. 复用 resultMap: 可以在多个 <select> 语句中复用同一个 resultMap
  5. <sql> 片段: 将常用的 SQL 片段(如公共的 SELECT 列表)提取到 <sql> 标签中复用。
    <sql id="userOrderColumns">
        u.id AS user_id, u.name AS user_name, u.email AS user_email,
        o.id AS order_id, o.order_no, o.amount AS order_amount,
        o.create_time AS order_create_time, o.status AS order_status
    </sql>
    
    <select id="selectUserOrderPage" resultMap="UserOrderResultMap">
        SELECT
            <include refid="userOrderColumns"/>
        FROM user u
        LEFT JOIN `order` o ON u.id = o.user_id
        ...
    </select>
    
  6. @Param 命名: 使用有意义的参数名,便于 SQL 阅读。
  7. 调试: 开启 MyBatis SQL 日志,观察生成的 SQL(包括自动添加的 LIMIT 和执行的 COUNT 查询),验证分页逻辑。

六、最佳实践与性能优化

  1. 优先使用 XML: 对于复杂的自定义分页查询,优先使用 XML 映射文件,代码更清晰,易于维护和调试。
  2. 索引优化:
    • 确保 JOIN 条件(如 u.id = o.user_id)中的字段有索引。
    • 确保 WHERE 子句中的过滤字段(如 o.status)有索引。
    • 确保 ORDER BY 子句中的字段有索引,特别是当排序字段选择性不高时。
    • 考虑创建复合索引以覆盖查询(Covering Index)。
  3. 优化 COUNT 查询: 如果主查询非常复杂,其衍生的 COUNT 查询也可能很慢。可以考虑:
    • 优化主 SQL: 从根本上优化主查询。
    • 使用近似值: 在某些场景下,可以接受不精确的总数,使用 SQL_CALC_FOUND_ROWS (MySQL 旧版) 或其他方式,但这在 MyBatis-Plus 分页插件中不易直接实现。
    • 业务妥协: 如果总数计算成本过高,考虑只提供“下一页”功能,而不显示总页数(但这会影响用户体验)。
  4. **避免 `SELECT ***: 明确指定需要的列,减少网络传输和内存消耗。
  5. 合理设置分页大小: 避免设置过大的 size,防止一次性加载过多数据导致内存溢出或响应缓慢。设置合理的上限(可通过 PaginationInnerInterceptor.setMaxLimit())。
  6. 深分页优化: 对于 current 非常大的深分页(如第 10000 页),OFFSET 会很大,性能很差。考虑使用游标分页 (Cursor-based Pagination)键集分页 (Keyset Pagination),例如 WHERE id > last_seen_id ORDER BY id LIMIT size。这需要在业务逻辑中维护上一次查询的最后一个 ID。
  7. 缓存: 对于不经常变化的分页数据,考虑使用 Redis 等缓存 IPage 结果,但要注意缓存失效策略。
  8. 异步查询: 对于耗时较长的分页查询,考虑在 Service 层使用 @Async 注解进行异步处理,避免阻塞主线程。
  9. 监控: 监控包含自定义分页查询的 SQL 执行时间,及时发现慢查询并优化。

通过本教程,您应该已经掌握了 MyBatis-Plus 中自定义分页查询方法的完整流程。核心在于:配置分页插件 -> Mapper 方法第一个参数为 IPage -> 使用 @Param -> 编写自定义 SQL (不写 LIMIT) -> Service 中创建 Page 对象并调用。遵循最佳实践,特别是索引优化和避免深分页,可以确保您的分页查询高效稳定。