记得我刚带第一个实习生的时候,他问我:为什么项目里查列表都在用 Mybatis-plus 的分页插件,就不能自己写个 LIMIT 加个 COUNT(*) 吗?我说你先试试看,结果他写了不到三十行代码,就遇到了 offset 计算错误、总数查不出来、SQL 拼接漏条件、代码里每一处查询都要重复写一遍分页逻辑等等一堆问题。那一刻我就明白,SpringBoot 加 Mybatis-plus 的分页查询方案之所以能普及,真不是因为大家图省事,而是它在"少写代码"和"保证正确性"之间找到了一个非常舒服的平衡点。
这篇文章不绕弯子,直接和你讲清楚 SpringBoot 与 Mybatis-plus 集成时,分页查询到底是怎么实现的。内容从依赖配置讲到插件原理,再走一遍完整的前后端联调,最后把我这些年踩过的坑一并交代。适合刚接触 SpringBoot 的后端初学者,也适合那些目前还在手写分页、想替换成更省心方案的同学。
1. 为什么最终选 Mybatis-plus 分页:从手写 LIMIT 到插件方案的演进
1.1 我最初手写分页时踩过的那些坑
很多人在数据库第一课就学了 LIMIT offset, size,所以刚进项目时最容易产生一个想法:分页这么简单的基础功能,写几个工具方法不就搞定了?我当年也是这么干的,接第一个有真实并发的项目时,手写分页的方案看起来也跑得挺好,直到需求开始变复杂。
第一类问题是分页参数的传递。前端会传 currentPage 和 pageSize,后端要做校验:页码不能小于 1,每页条数不能大于一个上限。然后你要在业务层手动算 offset:offset = (currentPage - 1) * pageSize。这个公式本身不难,但每个查询都要算一遍,一旦有一处忘记减一,页面就会永远少显示一条数据,而且这种 bug 很难被发现。
第二类问题是总数查询。一个列表页除了当前页数据,还得知道总记录数和总页数,否则前端没法渲染分页组件。这意味着每写一个分页查询,就要配套写一条 SELECT COUNT(*) FROM ...,条件也得原样拷贝一份。如果你用的是 MyBatis 的 XML,你会看到两段几乎一模一样的 SQL 片段,只是 select 字段不同。后续改条件时,漏改任何一边,总数和列表就对不上了。
第三类问题在多表关联时最明显。联表查询的分页,如果直接套 LIMIT,先 join 再 limit 是常规操作,但一旦涉及 LEFT JOIN 之后一对多的数据,一个主表记录对应多条子表记录,分页就会出现总数虚高和数据重复。要正确处理,得用子查询或 DISTINCT 优化 SQL。手写方案在这种场景下,基本就是给生产环境埋雷。
1.2 Mybatis-plus 的解决方案好在哪
Mybatis-plus 分页插件做的事情,本质上比你想象中要多得多。它不是一个简单帮你拼 LIMIT 的小工具,而是一个 SQL 拦截器,会在你的 SQL 真正执行之前,先做一次解析和改写。
你调用一个 selectPage 方法,传入一个 Page 对象,插件会做两件事:第一,自动在原来的查询 SQL 后面追加 LIMIT 参数,并把当前页和每页条数填进去;第二,自动把原来的 SQL 包装成一条 count 查询语句,计算出总记录数。最关键的改动是它会帮你动态优化 count SQL:比如你的查询里没有 GROUP BY、DISTINCT、UNION 等复杂结构时,它会自动把 select 的字段列表替换成 COUNT(*),减少数据库的统计开销。
这样,你业务层就只需要关注查询条件本身,分页的细节完全被收口到插件层。你的 Mapper 接口里写一个方法,传入 Page 和条件构造器,插件自动完成列表和总数的双重查询。代码量减少得非常明显,而且每一处用法的行为一致,不再有人手算 offset 或者漏写 count。
1.3 版本选择与适用边界
Mybatis-plus 从 3.4.x 到当前的 3.5.x,分页插件的使用方式基本一致,都是通过 MybatisPlusInterceptor 注册 PaginationInnerInterceptor。区别主要在于适配的 MyBatis 版本和 SpringBoot 版本。如果你的项目是 SpringBoot 2.x,用 mybatis-plus-boot-starter 3.5.x 比较顺手;SpringBoot 3.x 需要引入 mybatis-plus-spring-boot3-starter,这个要注意,名字都不一样。
这里要先声明一下边界:Mybatis-plus 分页插件的强项是单表查询和简单联表查询,它设计上就是帮你省掉重复劳动,不是用来处理特别复杂的 SQL 性能调优。如果你的查询本身是一个五分钟都跑不完的慢 SQL,不要幻想加了分页插件就能解决性能问题。插件只会帮你正确分页,不会帮你优化索引和 join 结构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:依赖、配置类和数据库初始化的细节
2.1 引入依赖时的版本匹配问题
在 SpringBoot 项目里引入 Mybatis-plus,最常见的错误是复制一段旧版依赖就直接启动,结果项目直接在启动阶段报错。依赖的写法很关键,我直接贴一份我常用的配置。
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
这里插一句:3.5.5 版本的 mybatis-plus-boot-starter 依赖的 MyBatis 版本是 3.5.x,如果你项目里同时还有其他依赖也引入了 MyBatis,可能出现版本冲突。最常见的现象是启动日志里出现 Invalid bound statement (not found),或者 mapper 方法运行时直接报错。这种问题优先在 IDEA 的 Maven 依赖树里看一下 mybatis 的版本归属,把不该出现的旧版本排除掉。
SpringBoot 3.x 场景下,请使用 mybatis-plus-spring-boot3-starter,artifact 名称不同,内容适配了 Jakarta EE 的命名空间。我也见过有人把 SpringBoot 3 的项目硬塞 mybatis-plus-boot-starter,结果启动时直接报 servlet API 相关的 NoClassDefFoundError,这个坑在选依赖时就要避开。
2.2 分页插件配置类:不配置等于白引入
很多人以为引入依赖就完事了,结果调用 selectPage 之后发现返回的数据是全部数据,根本没分页。原因很简单:分页插件是一个拦截器,如果你没有把这个拦截器注册到 MyBatis 的拦截器链里,它压根不会执行。Mybatis-plus 官方文档写得很清楚,插件不会因为依赖引入了就自动生效,必须手动配置 MybatisPlusInterceptor。
下面是一个标准的分页插件配置类:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(500L);
paginationInterceptor.setOverflow(true);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
这个配置类里有几个值得一提的参数。
DbType.MYSQL 是指定数据库类型,分页插件要根据数据库类型生成不同的分页 SQL。MySQL 是 LIMIT offset, size,PostgreSQL 是 LIMIT ... OFFSET ...,SQL Server 是 TOP 配合 OFFSET FETCH。选错数据库类型,生成的 SQL 语法不对,分页就会直接报错。
setMaxLimit(500L) 是单页最大条数限制,超过 500 时插件会强制改回 500。这是一个生产环境必须设置的防护参数,不然有人传一个 pageSize=1000000,整个数据库都可能被拖垮。
setOverflow(true) 表示当前页超过总页数时,自动回退到最后一页。设置成 true 之后,用户手动把页码改成 9999,接口不会返回空数据,而是返回最后一页。这能避免很多前端页码越界导致的空列表问题。
2.3 准备一张表和对应的实体类
为了演示完整流程,我用一个最典型的用户表来跑通链路。建表 SQL 如下:
sql复制CREATE TABLE `user_info` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(50) NOT NULL,
`age` INT DEFAULT 0,
`email` VARCHAR(100) DEFAULT '',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
);
实体类直接标注 Mybatis-plus 的注解,字段名和表字段名之间的下划线转驼峰由插件自动完成,这个特性默认开启:
java复制@Data
@TableName("user_info")
public class UserInfo {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Integer age;
private String email;
private LocalDateTime createTime;
}
特别注意 @TableId(type = IdType.AUTO)。如果你创建表时用的自增主键,这个注解不加或者类型不对,插入时主键策略就可能错乱。分页本身不受影响,但它属于一个完整的 CRUD 项目里早晚会踩到的点,顺手就规范了。
3. 分页插件的工作原理:SQL 拦截器到底改了什么
3.1 用生活化类比理解拦截器机制
你可以把 Mybatis-plus 的分页插件想象成快递分拣中心的一道自动扫描仪。你手里的快递是写好的业务 SQL,本来应该直接装车送走,但中途过了一道扫描仪。扫描仪一看这个快递是"分页件",就自动拆包、核对地址、贴上正确的区域标签,然后再放行。这个扫描仪就是 PaginationInnerInterceptor。
在 MyBatis 执行一个查询之前,插件会拦截到这条 SQL,判断它是不是分页查询——判断依据是 Mapper 方法的第一个参数是否是 Page 类型。如果是,插件就执行改写逻辑。它不是简单拼接字符串,而是先通过 JSqlParser 解析 SQL 的 AST 结构,找到正确的插入位置,再生成新的 SQL。
3.2 一条普通 select 被改写成两条 SQL
假设你执行了下面这段代码:
java复制Page<UserInfo> page = new Page<>(2, 10);
IPage<UserInfo> result = userInfoMapper.selectPage(page, null);
插件实际会对数据库执行两条 SQL。第一条是 count 查询,大概长这样:
sql复制SELECT COUNT(*) FROM user_info
第二条是当前页的数据查询:
sql复制SELECT id,name,age,email,create_time FROM user_info LIMIT 10,10
注意这里 LIMIT 10,10 对应的是第二页。如果你传的是 new Page<>(1, 10),生成的就会是 LIMIT 0,10。也就是说,你完全不需要自己算 offset,插件内部自动完成。
这也解释了为什么 Mapper 方法签名里必须有 Page 参数,而且它的位置有讲究:Mybatis-plus 的 BaseMapper 内置的 selectPage 方法,签名就是 IPage<T> selectPage(IPage<T> page, Wrapper<T> queryWrapper),Page 参数必须在第一位。如果你在自己的自定义 Mapper 方法里写分页,同样要注意,Page 作为参数时,MyBatis 才能通过参数类型识别这是分页方法。一旦把这个参数放在第二位或者没有传 Page,分页插件就不会介入。
3.3 count 查询的自动优化逻辑
分页插件在执行 count 时并不是无脑地把 select 列表替换成 COUNT(*) 就完事。它会在解析 SQL 时做几项判断:
第一,如果原 SQL 里没有 GROUP BY、HAVING、DISTINCT、UNION,它就放心地把 select 后面的字段列表替换成一个 COUNT(*)。这样数据库引擎做 count 时就只需要扫描统计,不需要把每一行的大字段全部捞出来。
第二,如果原 SQL 里有 DISTINCT,插件会在 count 时生成 COUNT(DISTINCT 字段) 的形式,避免去重后统计错乱。
第三,如果原 SQL 里有 GROUP BY,插件就不能用简单的 COUNT(*) 替代了,因为它统计的是分组后的结果数。插件会直接把原 SQL 包一层子查询,变成 SELECT COUNT(*) FROM ( 原SQL ),保证分组语义正确。
这些逻辑你不需要记,但理解了之后,你就明白为什么联表查询时分页有时候会慢,原因多半是 count 的那条 SQL 依然扫描了大量数据。实际排查时,你可以打开 MyBatis 的 SQL 日志,看看插件生成的两条 SQL 长什么样,一旦发现 count SQL 不够优化,可以考虑手工写 count 方法,绕过插件的自动 count。
3.4 Page 对象里的数据流向
Page 对象在查询完成后,会被插件填充几个关键字段。records 存放当前页的数据列表,total 是总记录数,pages 是总页数,current 是当前页码,size 是每页条数。调用方从 selectPage 的返回值里,拿到的是同一个 Page 对象,也就是说查询之前你传入的是一个只有 current 和 size 的空壳,查询之后里面已经装好了全部数据。这个设计很巧妙,你不用再构造一个专门的结果对象,直接把 Page 返回给前端就行。
4. 核心代码实现:实体、Mapper、Service 到 Controller 的一路串联
4.1 定义自定义分页查询方法
BaseMapper 自带 selectPage,但实际项目里几乎都会用到带条件的筛选。这时你可以直接在 Mapper 接口里写一个方法,用 Mybatis-plus 的 Wrapper 传条件,或者用注解 SQL 写自定义查询。我通常这么写:
java复制public interface UserInfoMapper extends BaseMapper<UserInfo> {
IPage<UserInfo> selectUserPage(Page<?> page, @Param("name") String name, @Param("minAge") Integer minAge);
}
对应的 XML 文件:
xml复制<select id="selectUserPage" resultType="com.example.entity.UserInfo">
SELECT * FROM user_info
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
</where>
ORDER BY create_time DESC
</select>
这里有个细节值得说:Page 参数我在 XML 里没用到,但定义方法时必须有这个参数,它的作用是让插件识别这是一个分页查询。你不需要在 SQL 里写 LIMIT,插件会帮你做。同时注意 Page<?> 泛型用了通配符,在自定义方法时这比写死 Page<UserInfo> 更灵活,因为有时你需要返回的泛型类型不是实体本身。
4.2 Service 层逻辑:条件构造器的使用
Service 层的核心逻辑是把前端传入的查询条件处理成 Mybatis-plus 能识别的 Wrapper,或者直接传给自定义方法。我用 Mybatis-plus 提供的 ServiceImpl 和 IService 接口来减少重复代码,但分页方法我一般会单独写:
java复制@Service
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> implements UserInfoService {
@Override
public IPage<UserInfo> pageUser(int current, int size, String name, Integer minAge) {
Page<UserInfo> page = new Page<>(current, size);
LambdaQueryWrapper<UserInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.hasText(name), UserInfo::getName, name)
.ge(minAge != null, UserInfo::getAge, minAge)
.orderByDesc(UserInfo::getCreateTime);
return this.page(page, wrapper);
}
}
LambdaQueryWrapper 的好处是类型安全,字段通过方法引用来指定,不会出现手写字符串列名时打错字的问题。like 和 ge 方法的第一个参数是布尔条件,条件为 false 时整个条件不加入 SQL,天然做了参数判空,这比用 if 手动拼接优雅太多。
有人问 this.page(page, wrapper) 和 userInfoMapper.selectPage(page, wrapper) 有什么区别。其实 IService 接口的 page 方法内部调用的也是 baseMapper.selectPage,二者走到的是同一个逻辑,只是 ServiceImpl 帮你省掉了注入 Mapper 的代码。个人建议项目里以 Service 层作为统一入口,Controller 不直接触碰 Mapper,方便后续加缓存、加权限校验。
4.3 Controller 层敏感参数处理
Controller 接收分页参数时,我建议不要直接使用 int current, int size 这种裸类型,而是用一个单独的查询对象。这样既方便扩展筛选条件,也方便做参数校验:
java复制@Data
public class UserQuery {
@Min(value = 1, message = "页码不能小于1")
private int current = 1;
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 100, message = "每页条数不能超过100")
private int size = 10;
private String name;
private Integer minAge;
}
Controller 里的写法:
java复制@RestController
@RequestMapping("/user")
public class UserController {
private final UserInfoService userInfoService;
public UserController(UserInfoService userInfoService) {
this.userInfoService = userInfoService;
}
@GetMapping("/page")
public Result<IPage<UserInfo>> page(@Valid UserQuery query) {
IPage<UserInfo> pageResult = userInfoService.pageUser(
query.getCurrent(), query.getSize(), query.getName(), query.getMinAge());
return Result.success(pageResult);
}
}
这里的 Result 是统一返回类型,我只贴了示意,你项目里大概率已经有了类似的封装。重点要说的是:分页参数必须校验,特别是 size。一个 @Max(value = 100) 就能保证接口不会因为前端传了一个超大分页参数而被拖垮。哪怕插件层已经设置了 maxLimit,Controller 这层仍然值得再做一次。两道防线,总比一道防线稳妥。
4.4 条件分页的完整返回结构
最终接口返回的 JSON 结构大概是这个形状:
json复制{
"code": 200,
"message": "success",
"data": {
"records": [
{ "id": 1, "name": "张三", "age": 25, "email": "zhangsan@example.com", "createTime": "2025-01-01 12:00:00" }
],
"total": 100,
"size": 10,
"current": 1,
"pages": 10
}
}
别小看这个结构,我见过联调时前端拿不到数据,就是因为后端把 IPage 再包了一层自定义对象,导致序列化之后字段路径变了。如果你直接返回 IPage,Jackson 会序列化 records、total、size、current、pages 这些字段。如果你们公司有统一返回体 Result<T>,就把 IPage 作为泛型参数塞进去,别自己在 Service 里重新组装一个 Map,那样反而破坏了结构约定。
5. 前端联调与常见对接问题
5.1 前端如何正确消费分页数据
大部分 Vue 或 React 项目的分页组件,都需要 total 和 records,一个用来渲染总页数,一个用来渲染表格数据。接口返回的 data 里的 records 就是当前页列表,total 是总记录数,current 和 size 一般会在请求参数里带过去,前端可以直接拿返回值里的值回填,防止用户操作期间有其他请求改变了页码。
有一个容易错的地方:很多前端同学会把 data.list 当作列表字段,但 Mybatis-plus 默认的字段名是 records。要么后端加 @JsonProperty("list") 做映射,要么前端按 records 取。这个属于约定问题,最好在接口文档里写清楚,不然每次联调都要花十分钟排查"为什么我用 list 取不到数据"。
如果前端框架用的是类似 el-table 的组件,它通常要求数据是纯数组。这时你在前端代码里取 response.data.data.records 就行,这个层级看着绕,但其实是:最外层 Result 的 data 字段,里面才是 IPage 对象,IPage 对象里才是 records 数组。
5.2 排序与字段映射的坑
默认情况下 Mybatis-plus 开启驼峰转换,数据库的 create_time 会自动映射到实体类的 createTime。这个功能依赖 MyBatis 的 mapUnderscoreToCamelCase 配置,Mybatis-plus 默认开启,所以通常不用管。但如果你调整过 MyBatis 配置,把这个选项关掉了,那么查询结果里 createTime 会一直为 null,而 create_time 又取不到值,表现就是分页接口返回的数据每个时间字段都是空的。排查问题的时候,先检查配置项,再检查实体类字段名。
还有一种情况:前端传排序字段 createTime,你直接拼到 SQL 的 ORDER BY 后面,结果发现数据库报错,因为数据库里的列名是 create_time。这种问题用 Mybatis-plus 的 LambdaQueryWrapper 时不会出现,因为你用的是 orderByDesc(UserInfo::getCreateTime),插件自动把属性名转成了列名。有同学图方便,在 XML 里用 ${} 拼接排序字段,这种写法不建议使用,因为除了字段名映射之外,还存在 SQL 注入风险。
5.3 大列表前端的性能体验
分页查询做出来之后,你还需要考虑一个细节:前端用户操作时,频繁翻页会不断请求后端,有时候一个列表页连续触发七八个请求。如果查询本身稍慢,用户体验会很差。后端可以在 Service 层给列表数据加一层本地缓存或 Redis 缓存,但要注意缓存 key 的设计。分页数据的缓存 key 至少要包含 current、size 和所有筛选条件,通常的做法是算一个 hash。另外,修改数据之后要记得删除相关分页缓存,否则用户会看到旧数据,这个坑比不加缓存还难受。
6. 实战中的疑难问题排查:不生效、总数不对、性能变慢
6.1 分页不生效:最常见的三个原因
如果你运行 selectPage 之后发现查出来的结果是全量数据,不要急着怀疑框架,按顺序排查下面三点。
第一,分页拦截器是否配置。回到我之前写的 MybatisPlusConfig,检查 MybatisPlusInterceptor 这个 Bean 有没有被 Spring 容器加载。项目里有时会出现多个配置类互相覆盖的问题:你在 A 配置类里注册了拦截器,B 配置类里又定义了一个同名的 @Bean,结果 B 把 A 覆盖了。这种问题看启动日志不容易发现,建议在配置类里加一行初始化日志,确认拦截器确实被加载。
第二,方法参数里是否真的传了 Page。如果 Mapper 方法签名第一个参数不是 Page 类型,或者你调用时传了 null,插件不会工作。注意 selectPage 的 Page 参数不能是 null,否则插件的拦截入口都进不去。
第三,当前执行的是不是自定义的 XML SQL。分页插件对自定义 XML 里的 SQL 也能生效,但前提是 Mapper 方法参数包含 Page。如果方法签名没问题,看 XML 里是不是用了 bind 标签或者动态 SQL 导致 SQL 结构太复杂,插件解析 AST 时理论上能处理,但极端情况下可能解析异常。这时你可以把 SQL 日志打开,看插件有没有真的改写,如果生成的原 SQL 没有 LIMIT,多半是参数类型问题。
6.2 total 等于 0 或记录数对不上
一类典型的 bug 是:列表数据能查出来,但 total 恒为 0。出现这个问题的原因通常是实体类上缺少 @TableName 注解,或者表名解析错误,导致生成的 count SQL 查的是一张不存在的表,实际执行时抛出异常却被吞掉了。先检查控制台有没有红色报错,再检查实体类的表名注解。
另一类情况是 total 比实际数据量大,常见于联表查询:左连接子表产生了一对多的行,导致 count 出来的总数是子表的行数,不是主表的行数。这是业务上最常见的分页陷阱。解决方式有三种:一是把 COUNT 改成基于主表 ID 的 COUNT(DISTINCT 主表.id);二是在 XML 里手动写 count 查询,覆盖插件默认生成的 count 逻辑;三是调整 SQL 结构,用子查询先聚合子表数据,再和主表关联。Mybatis-plus 的 PaginationInnerInterceptor 是支持手动指定 count SQL 的,你可以写一个专门的 count 方法,在 XML 里用 select 标签定义 count 语句。
6.3 分页查询变慢的排查思路
很多项目上线后,分页接口在数据量小的时候一切正常,等单表数据过了百万级,越往后翻页越慢。这背后的核心原因是 LIMIT 100000, 20 这种写法,数据库需要扫描前面 100000 行才能定位到目标数据。分页插件本身不会优化这种深层分页,它只负责正确执行。
我在项目中常用的优化手段是"游标分页"替代"页码分页"。页码分页适合前端交互需要跳页的场景,游标分页适合"加载更多"和滚动加载的场景。深分页优化可以借助覆盖索引:先查出当前页的 ID 集合,再用 WHERE id IN (...) 或 JOIN (SELECT id FROM ... LIMIT offset, size) temp 来取出完整行。这样数据库在扫描偏移量时可以通过覆盖索引完成,不需要回表,速度会有几十倍的提升。
Mybatis-plus 同样可以配合这种思路,但你如果真想对深分页做极端优化,最终还是要回到自己写 SQL 的层面。分页插件是基础工具,不是万能银弹,理解这个边界,在实际场景中才能做出合理的技术决策。
6.4 分页参数丢失的隐蔽问题
最后提一个比较隐蔽的点:如果你在 Service 里先对 Page 做了一次赋值或转换,然后传给 Mapper,很容易出现分页参数丢失。最常见的是把 Page<UserInfo> 转成了 Page<SomeVo>,再用这个新 Page 去查询,但实际上 Mybatis-plus 的 Page 是 final 类,泛型转换不会改变分页参数本身。问题是有人会把 Page 的字段拷贝到一个普通对象里,导致分页插件识别失败。
如果确实需要在返回前端之前做实体到 VO 的转换,建议查询阶段始终使用同一个 Page<UserInfo>,查询完成后遍历 records 转成 VO,再用 Page 的构造函数或者 setter 重新组装一个 VO 分页对象。不要在中途"改造"Page。
写在最后的小建议
分页查询在 SpringBoot 项目里算是最常见不过的需求,但"会用"和"用得明白"之间隔着的那些坑,我基本都在上面交代了。实际工作中,我从最开始手写分页件套,到现在几乎所有项目都用 Mybatis-plus 插件,最大的体会是:好工具确实能把人从重复劳动里解放出来,省下来的精力应该花在真正值得关注的问题上,比如接口的性能、数据的一致性,以及前端联调时那些约定俗成的字段格式。你把这篇文章里提到的原理、代码和坑都理解透,分页这件事基本就不会再给你添乱了。
