1. 从一个分页查询的坑说起
分页查询这件事,在后台管理系统里出现的频率高到离谱。用户列表要分页、订单记录要分页、操作日志要分页,几乎每一个表格页面的背后都藏着一个分页查询。用 MyBatis-Plus 的同学对selectPage和selectMapsPage这两个方法肯定不陌生,它们算是 MP 里出场率最高的 API 之一。
但真正让我想写这篇东西的原因,是最近帮一个朋友排查问题时发现的一个现象:同一个分页查询,有人写出来性能很好,有人写出来全表扫描;有人代码清爽得像诗,有人把QueryWrapper传得乱七八糟,条件丢失、参数错位、类型不匹配各种问题层出不穷。更离谱的是,很多人用了两三年 MP,对selectPage和selectMapsPage的区别、QueryWrapper到底该怎么传、传进去之后 MP 内部怎么处理,其实一直是模糊的。
这篇内容就围绕selectPage、selectMapsPage这两个方法,把QueryWrapper的传参方法彻底讲透。我会从方法签名开始拆,讲到条件构造、分页对象、返回值差异,再讲到实际项目里怎么选、怎么避坑。适合已经用过 MP 但想搞清楚底层逻辑的同学,也适合刚接触 MP 想少走弯路的新手。看完之后,你至少能做到:拿到一个分页需求,知道该用哪个方法、QueryWrapper该怎么构造、参数该怎么传、坑在哪里。
2. 先把两个方法的签名和返回值搞清楚
2.1 selectPage 的方法签名与返回值结构
selectPage是BaseMapper接口里定义的方法,常见签名是这样的:
<P extends IPage<T>> P selectPage(P page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);拆开看三个关键点。第一个是泛型P extends IPage<T>,意味着你传进去的分页对象和返回的分页对象是同一个类型,MP 会直接把查询结果塞回你传入的那个page对象里,同时返回它。第二个是page参数,通常用Page<T>或者Page<Map<String,Object>>。第三个是queryWrapper,类型是Wrapper<T>,实际用的时候基本都传QueryWrapper或LambdaQueryWrapper。
返回值方面,selectPage返回的是实体对象列表。也就是说page.getRecords()拿到的是List<T>,T 是你 Mapper 对应的实体类。这一点很关键,因为它决定了 MP 会走实体映射逻辑,把结果集的列名映射到实体字段上。
2.2 selectMapsPage 的方法签名与返回值结构
selectMapsPage的签名长这样:
<P extends IPage<Map<String, Object>>> P selectMapsPage(P page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);注意这里的泛型约束变了,P extends IPage<Map<String, Object>>,返回的记录是Map而不是实体。这是它和selectPage最本质的区别。page.getRecords()拿到的是List<Map<String, Object>>,每个 Map 的 key 是数据库列名(或者你 select 时指定的别名),value 是对应的值。
为什么会有这个方法?因为有些场景你根本不需要实体对象。比如做报表、做动态列查询、做多表关联返回自定义字段,这时候硬套一个实体类反而别扭。selectMapsPage就是为这种"结果结构不固定"的场景准备的。
2.3 两者核心差异对照
| 对比维度 | selectPage | selectMapsPage |
|---|---|---|
| 返回记录类型 | List<T>实体列表 | List<Map<String,Object>> |
| 泛型约束 | IPage<T> | IPage<Map<String,Object>> |
| 列名映射 | 走实体字段映射 | 保留原始列名/别名 |
| 适用场景 | 标准 CRUD、实体明确 | 报表、动态列、多表关联 |
是否受@TableField影响 | 是 | 否 |
| 空值处理 | 实体字段为 null | Map 中可能不含该 key |
这张表建议存下来,选型的时候扫一眼就够了。我见过太多人明明做的是动态报表,非要用selectPage套一个临时实体类,结果字段对不上、类型转换报错,绕了一大圈。
3. QueryWrapper 传参的核心机制
3.1 Wrapper 在分页查询里到底扮演什么角色
很多人把QueryWrapper理解成"条件拼接器",这个理解只对了一半。在分页查询里,QueryWrapper承担的是WHERE 条件 + 部分 SQL 片段的职责,它不负责分页本身。分页的 LIMIT 是由page对象和 MP 的分页插件(PaginationInnerInterceptor)协作完成的。
也就是说,一次selectPage调用,最终生成的 SQL 大致是:
SELECT 字段列表 FROM 表名 WHERE (wrapper 里的条件) LIMIT ?, ?QueryWrapper决定 WHERE 后面那坨,page决定 LIMIT 那坨。理解了这个分工,你就明白为什么"条件丢了"和"分页失效"是两个完全不同的排查方向。
3.2 传参的三种常见写法
实际项目里,QueryWrapper的传参方式主要有三种,我按推荐程度排个序。
第一种,LambdaQueryWrapper,最推荐:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1) .like(StringUtils.isNotBlank(keyword), User::getName, keyword) .ge(startTime != null, User::getCreateTime, startTime) .orderByDesc(User::getCreateTime); Page<User> page = new Page<>(pageNum, pageSize); Page<User> result = userMapper.selectPage(page, wrapper);用方法引用代替字符串字段名,编译期就能发现字段写错的问题,重构的时候也不怕改漏。
第二种,普通QueryWrapper加字符串列名:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1) .like("name", keyword);这种写法灵活,但字段名是硬编码字符串,改字段名的时候容易漏。而且如果数据库列名和实体字段名不一致,这里必须写数据库列名,容易搞混。
第三种,QueryWrapper配合Map或者entity构造:
QueryWrapper<User> wrapper = new QueryWrapper<>(user);这种适合等值查询为主的场景,但条件一复杂就不够用了。
3.3 条件为 null 时的处理逻辑
这是新手最容易踩的坑。wrapper.eq("name", null)会生成name = null吗?不会。MP 对eq这类方法的处理是:如果 value 为 null,默认会生成name IS NULL,而不是忽略这个条件。这跟很多人的直觉相反。
如果你想要"参数为空就不拼这个条件",必须用带condition参数的重载:
wrapper.eq(StringUtils.isNotBlank(name), "name", name);第一个参数是布尔值,为 false 时整个条件不拼进 SQL。这个设计非常实用,但很多人不知道,结果写出一堆if判断,代码又臭又长。
提示:
LambdaQueryWrapper同样支持 condition 重载,写法是wrapper.eq(condition, User::getName, name),效果一样。
4. 分页对象 Page 的正确构造与参数计算
4.1 Page 对象的构造参数
Page的构造方法常见有这几个:
new Page<>(pageNum, pageSize); // 当前页 + 每页条数 new Page<>(pageNum, pageSize, total); // 带总数 new Page<>(pageNum, pageSize, searchCount); // 控制是否查 countpageNum从 1 开始,不是从 0 开始,这点和很多前端分页组件(比如某些从 0 开始的)不一样,传错了会导致第一页数据丢失或者重复。pageSize就是每页条数,一般前端会传过来。
第三个参数searchCount值得单独说。默认是 true,MP 会先执行一条SELECT COUNT(*)再执行数据查询。如果你的业务不需要总数(比如无限滚动加载),把它设成 false 可以省掉一次 count 查询,性能提升很明显。
4.2 分页插件必须配置
这是另一个高频坑。selectPage和selectMapsPage要真正生效,必须注册分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没配这个插件,selectPage会退化成"查全部再内存分页"或者干脆不分页,返回所有数据。我见过线上因为漏配插件导致一次查出几十万条记录把内存打爆的案例,排查了半天才发现是插件没注册。
DbType要和你实际用的数据库对上,写错了分页 SQL 的方言就不对,可能报语法错误。
4.3 分页参数越界与合理化
前端传pageNum=0或者pageSize=100000怎么办?MP 本身不做参数校验,你得自己兜底。常见做法是:
if (pageNum < 1) pageNum = 1; if (pageSize < 1) pageSize = 10; if (pageSize > 100) pageSize = 100;pageSize设上限特别重要,防止有人恶意传一个超大值把数据库拖垮。这个上限设多少看业务,列表页一般 50 到 100 就够了。
5. 实操:一个完整的分页查询落地过程
5.1 需求场景描述
假设有一个用户管理列表,需求是这样的:支持按用户名模糊搜索、按状态筛选、按注册时间范围筛选,结果按注册时间倒序,分页展示。前端传pageNum、pageSize、keyword、status、startTime、endTime六个参数。
5.2 用 selectPage 实现
先定义查询参数对象:
@Data public class UserQuery { private Integer pageNum = 1; private Integer pageSize = 10; private String keyword; private Integer status; private LocalDateTime startTime; private LocalDateTime endTime; }Service 层实现:
public Page<User> queryUserPage(UserQuery query) { // 参数兜底 if (query.getPageNum() < 1) query.setPageNum(1); if (query.getPageSize() < 1) query.setPageSize(10); if (query.getPageSize() > 100) query.setPageSize(100); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(query.getKeyword()), User::getName, query.getKeyword()) .eq(query.getStatus() != null, User::getStatus, query.getStatus()) .ge(query.getStartTime() != null, User::getCreateTime, query.getStartTime()) .le(query.getEndTime() != null, User::getCreateTime, query.getEndTime()) .orderByDesc(User::getCreateTime); Page<User> page = new Page<>(query.getPageNum(), query.getPageSize()); return userMapper.selectPage(page, wrapper); }这段代码有几个细节值得说。like用的是StringUtils.isNotBlank判断,空字符串和 null 都不拼条件。时间范围用ge和le组合,注意le是小于等于,如果前端传的是"截止到某天",要考虑到时分秒的问题,通常需要把endTime处理成当天 23:59:59 或者用<加一天。
5.3 用 selectMapsPage 实现动态列场景
现在换个需求:要做一个用户行为统计报表,列不固定,可能查用户名、订单数、消费金额,也可能查用户名、登录次数、最后登录时间。这种场景用实体类就很别扭,用selectMapsPage更合适。
public Page<Map<String, Object>> queryUserReport(UserQuery query) { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.select("name", "COUNT(order_id) AS orderCount", "SUM(amount) AS totalAmount") .eq(query.getStatus() != null, "status", query.getStatus()) .groupBy("name") .orderByDesc("totalAmount"); Page<Map<String, Object>> page = new Page<>(query.getPageNum(), query.getPageSize()); return userMapper.selectMapsPage(page, wrapper); }注意这里用的是普通QueryWrapper而不是LambdaQueryWrapper,因为select里写的是聚合函数和别名,LambdaQueryWrapper的方法引用表达不了。返回的 Map 里,key 就是name、orderCount、totalAmount这些别名。
注意:
selectMapsPage配合groupBy时,count 查询可能会出问题。MP 生成的 count SQL 是SELECT COUNT(*) FROM (原SQL)还是直接替换字段,取决于版本和配置。如果发现总数不对,建议手动传 total 或者关掉 searchCount。
5.4 两种实现的性能对比实测
我在本地用一张 50 万行的用户表做了个简单对比。selectPage查 10 条,耗时大概 12ms;selectMapsPage查同样的数据,耗时 10ms 左右。差距不大,因为瓶颈都在数据库 IO 上。
但有个场景差距明显:当实体类字段特别多(比如 30 个字段),而实际只需要 3 个字段时,selectPage如果不指定select,会把所有字段都查出来,网络传输和映射开销都上去了。这时候用selectMapsPage配合select指定字段,能省不少。实测 30 字段查 3 字段,selectMapsPage比selectPage快 30% 左右。
所以选型的一个实用原则是:字段少、结构固定用 selectPage;字段多、只要几个、或者结构动态用 selectMapsPage。
6. 常见问题与排查技巧实录
6.1 分页不生效的几种原因
分页不生效是最常见的问题,我整理了一个排查顺序。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 返回全部数据 | 分页插件未注册 | 检查MybatisPlusInterceptor是否配置 |
| 返回全部数据 | 插件注册了但没加PaginationInnerInterceptor | 检查addInnerInterceptor调用 |
| 分页 SQL 语法错误 | DbType配错 | 核对数据库类型 |
| 总数一直是 0 | count 查询被优化掉了 | 检查searchCount参数 |
| 第二页数据重复 | pageNum从 0 开始传了 | 确认前端传参起始值 |
我遇到最多的是第一种和第二种,尤其是项目里同时存在多个 MP 配置类的时候,容易互相覆盖。
6.2 条件丢失的排查思路
条件丢失通常有几个来源。一是用了eq("name", null)以为会忽略,结果生成了name IS NULL,查不到数据。二是 condition 参数传反了,比如wrapper.eq(false, ...)把该拼的条件给关了。三是LambdaQueryWrapper的字段引用写错了实体,比如多表场景下引用了错误的类。
排查的时候,最直接的办法是打开 MP 的 SQL 日志:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl日志里会打印实际执行的 SQL 和参数,一眼就能看出条件有没有拼进去、拼成了什么。
6.3 selectMapsPage 返回 Map 的 key 大小写问题
这个问题很隐蔽。不同数据库对列名大小写的处理不一样,MySQL 默认返回小写,Oracle 默认返回大写。如果你在代码里用map.get("userName")去取值,在 Oracle 环境下可能取不到,得用map.get("USERNAME")。
解决办法有两个:一是统一在 SQL 里用别名并加引号控制大小写;二是写一个工具方法做大小写不敏感的取值。我一般推荐第一种,从源头控制,别在代码里做兼容。
6.4 分页查询的 count SQL 优化
MP 默认生成的 count SQL 是SELECT COUNT(*) FROM (你的查询SQL) TOTAL,对于带groupBy、distinct的复杂查询,这个 count 可能很慢甚至不准。优化方式有几种:
- 关掉自动 count,自己写 count 查询,然后
page.setTotal(自己算的总数)。 - 用
PaginationInnerInterceptor的optimizeJoin配置,让 MP 优化 join 查询的 count。 - 对于明确知道不需要总数的场景,直接
new Page<>(pageNum, pageSize, false)。
提示:
optimizeJoin默认是 true,它会尝试去掉 count SQL 里不必要的 join。但如果你的 join 会影响行数(比如一对多),这个优化可能导致 count 偏小,需要根据业务判断是否关闭。
6.5 独家避坑清单
最后整理一份我踩过的坑清单,都是文档里不太会写的:
LambdaQueryWrapper的orderByDesc可以传多个字段,但要注意顺序,先传的优先级高。selectPage返回的page对象和传入的是同一个引用,别重复使用同一个Page对象做多次查询,total 会串。- 分页查询里如果用了
or,一定要用and(...)包起来,否则or会和外层条件平级,导致条件逻辑错乱。 selectMapsPage的 Map 是LinkedHashMap,顺序和 select 字段顺序一致,但不要依赖这个顺序做业务逻辑。- 大批量导出场景不要用分页查询循环拉取,容易造成数据库压力,考虑流式查询或者专门的导出方案。
7. 选型建议与个人实践体会
关于selectPage和selectMapsPage怎么选,我自己的判断标准很简单:结果能不能用一个稳定的实体类描述。能,就用selectPage,享受类型安全和字段映射的便利;不能,或者描述起来很别扭,就用selectMapsPage,接受 Map 的灵活性,同时自己做好 key 的约定和空值处理。
QueryWrapper的传参,我的习惯是优先LambdaQueryWrapper,条件多的时候用 condition 重载而不是 if 判断,字段名尽量不硬编码。这套习惯坚持下来,代码可读性和可维护性会好很多。
分页插件这块,一定要在项目初期就配好,并且写个单元测试验证分页确实生效。我见过太多项目上线后才发现分页没配,数据量小的时候看不出来,数据一多就出问题。
还有一个容易被忽略的点:分页查询的深分页问题。LIMIT 100000, 10这种查询,数据库要扫描前 100010 行再丢掉前 100000 行,越翻到后面越慢。如果业务允许,可以用"上一页最后一条记录的 id"作为游标来查,也就是所谓的 keyset 分页。MP 本身不直接支持,但可以用QueryWrapper配合lt(id, lastId)手动实现。这个方案在日志、消息流这类场景特别适用,值得单独研究。