
SpringBoot 项目做后台管理系统分页查询是躲不开的一关。用户列表、订单列表、日志列表几乎每个列表页都要加一个分页接口而且通常刚接手的新人都会被安排去做这一块。这篇围绕 SpringBoot Mybatis-plus 实现分页查询展开把从零搭一个分页接口的完整过程、底层原理和我在真实项目里踩过的坑一起写出来。适合正在做 SpringBoot 毕设的同学、刚进公司要写列表功能的新人以及被原生手写分页折腾过的老手照着做很快就能跑通少走很多弯路。1. 手写分页的痛点为什么 MyBatis-Plus 要内置分页插件1.1 没有插件之前一个分页接口要写多少东西在很多老项目里分页查询通常长这样先写一个查询当前页数据的 SQL里面拼上LIMIT #{offset}, #{pageSize}然后又要单独写一个 count SQL 去统计总条数。我用最简单的用户表举例Mapper 的 XML 里会有这么两段东西select idselectUserPage resultTypecom.example.demo.User SELECT * FROM sys_user ORDER BY id LIMIT #{offset}, #{pageSize} /select select idcountUser resultTypelong SELECT COUNT(*) FROM sys_user /select接着在 Service 层要自己算 offset公式是(current - 1) * pageSize还要把两个查询结果包装成一个“包含列表和总数”的返回对象。如果列表需要支持“用户名模糊搜索、状态筛选、部门筛选”那 selectUserPage 和 countUser 两个 SQL 里必须各写一份一样的 where 条件。第一次写的时候可能没问题后面需求一变改漏了一个地方页面就会出现当前页有数据但总数不对、或者条件筛了但分页总数没筛的诡异问题。这种问题排查起来很浪费时间因为两段 SQL 单独看都没错最后才发现是两个查询条件不一致。1.2 MyBatis-Plus 分页插件到底帮你做了什么MyBatis-Plus 内置了 MybatisPlusInterceptor 和 PaginationInnerInterceptor 这套拦截器机制。它的核心作用是在 SQL 执行前做一次拦截和改写如果你的方法参数里带了 Page 或 IPage 对象它会自动分析你的 SQL然后拼接上对应的 limit 语句并且额外生成一条 count 查询去拿总数。这样业务代码里就不用维护两套 SQL 了也不需要关心 offset 的计算。这个机制还顺带解决了跨数据库的问题。MySQL 的写法是LIMIT offset, pageSizePostgreSQL 是LIMIT pageSize OFFSET offsetOracle 则要用 ROWNUM 或 FETCH FIRST 子句。只要你配置分页插件时指定了 DbType它会按当前数据库方言自动生成对应的分页语句。同一套代码换数据库不用大面积改 SQL这一点在分库分表和异构数据库迁移时特别值钱。1.3 分页插件不是万能的我自己在使用时有一个直觉判断分页插件只是一个 SQL 拦截器单表查询和常规多表 join 的自动分页支持已经非常成熟但遇到存储过程、动态 SQL 里手动写了 limit 的情况插件不会像你想的那样完美处理。有些场景下插件生成的 count 语句可能不精确或者和业务预期不一致。后面第 5 节我会专门讲那些“看着分页了、其实没分页”的场景以及深分页超过 1 万条时的优化思路。2. 环境准备依赖、数据源与分页插件注册2.1 先选对依赖版本如果你的项目是 SpringBoot 2.7.x最稳的方案是引入dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency如果项目已经升到了 SpringBoot 3.x注意 starter 的包名发生了变化要用另一个dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency这个区别踩过的人不少。直接把老项目里的 mybatis-plus-boot-starter 搬进 SpringBoot 3启动时会报一堆 jar 包冲突或者自动配置类加载失败。还有一个容易忽视的点mybatis-plus 版本不是越高越好有些新版本依赖的 mybatis 版本和项目里其他框架冲突明显实践里我更推荐用 3.5.x 中相对稳定的版本比如 3.5.3.1 或 3.5.5。2.2 数据源配置不要漏了时区开发时最常用的数据库连接配置是连 MySQL。我习惯在 application.yml 里这样写spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456serverTimezoneAsia/Shanghai 这个参数很重要不写的话 MySQL 8 的驱动很容易报时区相关的错误。过几年再看几乎所有新手都会在这里卡一次。还有一点如果你的 SQL 里经常用user这种关键字做表名或字段名要么加反引号要么直接改表名否则写 SQL 时会莫名其妙报语法错误。分页插件生成 SQL 时不会帮你自动包反引号遇到关键字类型的问题只能自己处理。2.3 注册分页拦截器这是分页生效的前提在 SpringBoot 里新建一个配置类把 Mapper 扫描路径和分页插件注册进去Configuration MapperScan(com.example.demo.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里需要注意几点。第一DbType.MYSQL 要和你的数据库匹配如果是 PostgreSQL就换成 DbType.POSTGRE_SQL。第二这个拦截器必须作为 bean 交给 Spring 管理否则 MyBatis 拿不到它selectPage 方法执行时就只会查全表。第三如果项目中已经存在自定义的 MyBatis 拦截器要留意执行顺序分页拦截器通常在 SQL 真正发送到数据库之前完成即可太晚拦截可能就错过了可改写的时机。2.4 分页到底有没有生效用日志验证刚搭建完分页环境时我建议先打开 SQL 日志验证一次。在 application.yml 里加一段配置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl启动项目后调用一次分页接口如果控制台打印出来的 SQL 末尾带有LIMIT 10并且又执行了一条SELECT COUNT(*)开头的查询说明分页插件已经生效。如果只看到一条不带 limit 的 SQL那基本可以断定拦截器没注册成功。这里有个小细节分页插件生成的 count 语句是自动包了一层 select count(*) from (...)如果想让日志少刷一点可以设置page.setSearchCount(false)但功能测试阶段别关先确认全链路是通的。3. 从实体到接口一个真正能跑的用户分页接口3.1 实体类与表设计为了把流程走通我建了一张简单的用户表 sys_user。实体类写得很常规Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private String email; private Integer status; private LocalDateTime createTime; }TableName注解用来指定表名避免实体类名和表名不一致时找错表。TableId标记主键字段并且用 IdType.AUTO 告诉 MyBatis-Plus 主键是数据库自增的。这个设置虽然基础但影响后续分页排序的稳定。如果主键不设置默认空值策略可能会让 MyBatis-Plus 在插入时把主键当成 null 处理反而干扰分页结果。3.2 Mapper 接口继承 BaseMapper 就有通用方法Mapper 接口不用自己实现任何方法只要继承 BaseMapper 就自动拥有了很多通用能力public interface UserMapper extends BaseMapperUser { }这里常见的操作是新人会在 UserMapper 里额外写一些与分页无关的 selectByUsername 方法。没问题自定义方法和继承的 selectPage 方法互不冲突。需要注意如果自定义方法返回的是ListUser即使参数里带了 PageMyBatis-Plus 也认不出这是分页方法真正能触发分页插件的方法返回值必须是 IPage或者参数必须包含一个 Page 对象。这个坑我在第 5 节还会再提。3.3 Service 层用 IService 还是直接用 Mapper两种方式都常见。项目里如果用了 MyBatis-Plus 的 IService 体系可以这样组织public interface UserService extends IServiceUser { } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }如果项目逻辑比较简单直接在 Controller 里注入 UserMapper 也可以。我的习惯是业务逻辑稍微多一点就上 Service 层让 Controller 保持轻量。分页查询我经常不直接调用userMapper.selectPage而是用userService.page(new Page(current, size), wrapper)。ServiceImpl 里内置的 page 方法底层一样会走 mapper 的 selectPage但调用点更统一后续加业务逻辑时不需要在 Controller 改来改去。3.4 Controller 层接收 current 和 size 参数最精简的接口写法如下RestController RequestMapping(/user) public class UserController { Resource private UserMapper userMapper; GetMapping(/page) public IPageUser page(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size) { PageUser page new Page(current, size); return userMapper.selectPage(page, new QueryWrapper()); } }我用 RequestParam(defaultValue 1) 和 defaultValue 10 来处理默认值这样前端不传参数也不会报错。前端传 current1size10 时执行效果等价于SELECT * FROM sys_user LIMIT 10传 current2size10 时等价于SELECT * FROM sys_user LIMIT 10 OFFSET 10。注意 Page 构造函数里 current 是从 1 开始的如果前端哪天把 current0 传过来你最好在 Controller 或 Service 里做一次兜底if (current 1) { current 1; }不然查出来的第一页数据会是空的。3.5 返回结果里到底有哪些字段selectPage 返回的 IPage 在序列化成 JSON 后主要字段是字段含义records当前页的数据列表total总记录数size每页条数current当前页码pages总页数前端拿到 total 和 pages 之后就可以渲染分页组件了。实际项目里我很少直接把 IPage 返回给前端原因在第 4 节会说。但作为思路梳理先把这一版跑通再说毕竟分页的核心是数据正确而不是返回结构多优雅。4. 进阶玩法条件分页、排序与自定义响应结构4.1 给分页加上筛选条件列表接口一般都要支持“按名称模糊查询”“按状态筛选”。这一步用 LambdaQueryWrapper 很顺手GetMapping(/page) public IPageUser page(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size, RequestParam(required false) String keyword, RequestParam(required false) Integer status) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StrUtil.isNotBlank(keyword), User::getUsername, keyword) .eq(status ! null, User::getStatus, status) .orderByDesc(User::getCreateTime); PageUser page new Page(current, size); return userMapper.selectPage(page, wrapper); }这里的技巧是 MyBatis-Plus 的方法重载里有个 condition 参数。like 和 eq 方法的第一个参数如果传 false这个条件就直接不拼接。这样做的好处是你不需要在代码里写一堆 if 判断代码短很多。但第一次用要注意如果你把status写成status ! null status 1其实没必要MyBatis-Plus 只关心条件是否为 true拼接的 SQL 还是status ?。在实际项目里keyword 为空字符串时 StrUtil.isNotBlank 会返回 false比单纯判空更稳。4.2 排序应该写在 wrapper 里还是 Page 对象里两种都行。我用 wrapper.orderByDesc(...) 比较多因为排序字段往往跟着业务条件走。但如果业务要求前端把排序字段传过来就需要额外小心。Page 对象也可以拼排序PageUser page new Page(current, size); page.addOrder(new OrderItem(create_time, false));这种方法如果字段名直接来自外部而且服务端没有做字段白名单校验是存在注入风险的。前端传了一个看起来正常的字段名拼到底层 SQL 后可能会被改写成危险语句。我的做法是建立一个允许排序的字段集合比如只允许create_time和id其他值一律忽略或使用默认排序。这样既保持了接口灵活性又能防止动态排序字段把 SQL 带偏。4.3 自定义 SQL 怎么分页多表联查场景分页插件不仅支持 BaseMapper 里的 selectPage还支持我们自己写的 Mapper 方法前提是方法签名里带上 Page 或 IPage 参数。比如用户表和部门表联查先在 Mapper 里声明方法IPageUserVO selectUserPage(Page? page, Param(keyword) String keyword);对应的 XML 写成select idselectUserPage resultTypecom.example.demo.vo.UserVO SELECT u.id, u.username, u.status, d.dept_name FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id d.id where if testkeyword ! null and keyword ! AND u.username LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY u.id /select调用方式就是IPageUserVO result userMapper.selectUserPage(new Page(current, size), keyword);插件识别到第一个参数是 Page 后会自动给这条 SELECT 生成 count 查询并拼接 limit。这里有个小坑如果 SQL 里有 GROUP BY插件生成的 count 会使用子查询来包一层。这样做结果是准的但性能会稍微变差因为子查询要先把分组结果跑出来。如果分组数据量很大建议手动优化这条 SQL或者考虑用 COUNT(DISTINCT ...) 的方式减少临时表开销。4.4 别把 IPage 直接丢给前端服务端返回结构直接使用 IPage有两个问题。第一IPage 实现类里有一些框架内部字段比如 orders、optimizeCountSql、searchCount 等这些字段对前端没有意义只会增加接口返回体积。第二将来如果要把分页对象从 MyBatis-Plus 换成别的实现接口返回值一变前端就要跟着改。我常用的方式是定义一个通用的 PageResult在 Service 层做一次转换public class PageResultT { private ListT records; private long total; private long current; private long size; private long pages; }转换代码大概是这样public PageResultUser toPageResult(IPageUser source) { PageResultUser result new PageResult(); result.setRecords(source.getRecords()); result.setTotal(source.getTotal()); result.setCurrent(source.getCurrent()); result.setSize(source.getSize()); result.setPages(source.getPages()); return result; }代码多了一点但接口更干净也方便做字段裁剪。实际项目里我还会在 PageResult 里加上一个泛型字段用来放一些额外的统计信息比如筛选结果的合计金额、平均耗时。这些信息如果硬塞进 records 里会很别扭单独放在 PageResult 里就自然多了。5. 高频踩坑分页失效、深分页超过 1 万条与性能调优5.1 分页没生效先按这个清单排查分页“假分页”是新手遇到最多的问题表现是 total 正确但 records 里被塞入了全表数据或者压根没有 LIMIT 语句。我在排查时按下面这些步骤走现象原因处理办法分页插件没生效查出来的是全表没注册 MybatisPlusInterceptor或注册了但没加 PaginationInnerInterceptor在配置类中把拦截器 bean 加上自定义方法没走分页方法签名里没有 Page/IPage 参数或返回类型是 List 而不是 IPage把 Page 作为方法第一个参数返回类型改成 IPage项目是 SpringBoot 3用了老 starter依赖不对导致分页类加载失败换 mybatis-plus-spring-boot3-starter分页正常但 total 是 0wrapper 里的条件没生效或 count 查询走了另一个数据源检查 condition 参数是否误传 false并确认多数据源都注册了插件还有一种情况容易忽略多数据源项目里分页插件如果只注册在其中一个 SqlSessionFactory 上另一个数据源的分页就会失效。现代微服务架构里为了读写分离经常配两个数据源你在排查时不要把目光只放在一个数据源上得逐个确认每个数据源是否都加载了拦截器。5.2 深分页超过 1 万条LIMIT offset 为什么慢以及怎么解决当接口的页码翻到很深比如 current1000、size10底层 SQL 会变成LIMIT 10000, 10。MySQL 在处理这种语句时会把前 10010 行全部扫描出来再丢弃前 10000 行只把最后 10 行返回给客户端。数据量小的时候感觉不出来一旦表里有几十万甚至上百万条记录用户的每一次翻页都可能导致数据库 CPU 飙升、响应时间暴涨。解决思路主要有三种。第一种最简单粗暴限制单页条数和最大页码数。在业务层面约定 size 最大只能传 100页面最多翻到 500 页超出就提示“请使用筛选条件缩小范围”。这种策略在后台管理系统里非常常见因为管理员翻到几千页去看数据的概率极低。第二种是使用“基于游标”的方式让前端每次带上当前列表最后一条记录的 idSQL 改成WHERE id ? ORDER BY id LIMIT 20数据库只需要扫描 20 行。第三种是在排序字段和筛选字段上建立联合索引。深分页优化的核心是减少数据库需要扫描的行数而不是优化那个分页参数本身。比如ORDER BY id时如果 where 条件里带 create_time纯加一个单列索引效果有限。更好的做法是建一个(create_time, id)联合索引让 where 和 order by 能同时命中同一个索引避免产生很大的临时表。5.3 count 查询慢或不准怎么处理MyBatis-Plus 自动生成的 count 语句在简单场景下很好用但遇到带 GROUP BY、DISTINCT 或者特别复杂的多表关联时自动包装出来的 count 性能会差一些。分页插件里有个optimizeJoin参数默认会自动优化 join 的 count 语句大部分情况不需要手动调整。如果 count 那条 SQL 特别慢建议先把慢查询日志拉出来看看是不是连表条件没用索引。另外有些需求并不需要精确总数。比如日志中心翻页时只要列表本身能显示就行total 显示一个“超过 1 万条”就够。这种情况可以不让插件执行 count直接通过page.setSearchCount(false)关掉 count 查询或者设置page.total -1前端看到 total 为负数就不显示具体数字。省掉一次 count 查询接口响应速度通常会快一半以上这个优化在数据量大时效果尤其明显。5.4 内存溢出和返回类型踩坑有同学在 Service 层写分页时喜欢先 selectList 把全表捞到内存再用 subList 切出当前页。数据量几百条时没问题但到了上万甚至十万条一次接口调用就可能把内存打爆。分页一定要在数据库层面完成这也是 MyBatis-Plus 要注册拦截器的原因。可以打开 SQL 日志确认每次分页请求真正带着 LIMIT。另外自定义 Mapper 方法的返回类型特别容易踩坑。如果方法返回值写成了ListUser即使参数里带了 PageMyBatis-Plus 也无法帮你做分页因为插件识别不到 IPage它会认为你只是想普通查询。正确写法必须是IPageUser方法返回 IPage同时参数里放一个Page?或IPage?拦截器才会自动改写 SQL。5.5 一个真实的大分页故障案例前两年我维护过一个报表导出系统里面有张明细表积累了上百万条记录。某个导出列表接口用了current5000, size20这样的大页码查询结果每次接近页面尾部时数据库 CPU 直接打满最后接口超时。排查过程发现 SQL 是ORDER BY create_time LIMIT 100000, 20这个 offset 已经过十万了。后来改成限制最大页码 默认按 id 排序 用游标查询的方式才把接口从 4 秒多降到 150 毫秒以内。所以做分页查询时千万不要只看每页条数小还要盯着页码别被前端传得过大。6. 一些我的分页查询习惯与团队约定最后再说几个我在真实项目里养成的个人习惯也当作给团队定的约定。第一列表接口不允许无限翻页。我会给每个分页接口加一个最大 size 限制比如 20 或 50前端需要更多数据时走导出或筛选。这个限制看起来不近人情但在深分页问题面前它是最有效的一层防线。第二接前端参数时不使用“自动包装”的写法而是显式接收 current 和 size自己做参数校验和默认值处理。这么做一方面避免出现 current0 的异常页另一方面也方便以后做参数格式变更。比如前端改成传 pageNum、pageSize 时你只需要在 Controller 层转换不用去改 Service 核心逻辑。第三在 Service 层统一组装返回对象不让 IPage 流到 Controller 外面。我见过不少项目IPage 被一层层传出去最后前端拿到一串带orders空数组和optimizeCountSql的复杂 JSON。整理一个 PageResult字段就那五个前后端都轻松。第四排序字段不给外部传除非做白名单。这一点前面讲过但我还是愿意再强调一次动态排序字段如果不对齐数据库真实列名很容易产生 SQL 注入风险。白名单可以是一个 Set 里面放允许排序的字段名查不到就 fallback 到 id 排序。分页查询看起来是 CRUD 里最简单的一个点可一旦深入到底层 SQL 和业务场景能展开的问题远比想象中多。把这几个基础做法掌握住SpringBoot Mybatis-plus 的分页用法基本就稳了后面再遇到分页性能问题也知道该往哪个方向排查。