☰
ResultMap与分页:MyBatis组合踩坑与性能优化全解
2026/10/17 12:48:45 网站建设 项目流程

先声明:这个标题单独拿出来看很短,但在实际项目里,“ResultMap和分页”永远是连在一起的两个痛点。数据库字段叫user_name,Java属性叫userName,查出来全是null;数据一多,页面卡死,不得不分页。这篇文章就围绕这两块,把原理、配置、踩坑一次讲透。

1. 内容整体设计与思路拆解

1.1 为什么要先搞懂ResultMap再谈分页

很多新手用MyBatis,上来就是resultType="map"或者resultType="实体类",能跑通就完事。但一旦遇到数据库字段和Java属性对不上、多表联查、嵌套查询,resultType立刻失效。此时ResultMap是唯一解。分页则是性能问题,两者看似独立,实际在复杂查询里经常同时出现:你一边要用ResultMap处理多表映射,一边又要对结果分页,如果对映射机制不理解,分页查出来的数据很可能出现字段丢失、映射错乱,甚至总数都对不上。

所以这篇文章的思路是:先拆解ResultMap的完整映射机制,再讲分页的三种主流实现,最后用一个组合案例把两者串起来。这样你既知道每个配置项的含义,也知道在真实项目里怎么搭配使用。

1.2 我选择从这个角度切入的原因

网上讲ResultMap的文章很多,但大多是复制文档,告诉你有哪些标签,不讲为什么。分页更是重灾区——有人直接说用PageHelper就完事,但PageHelper的拦截器原理是什么?为什么有时候总数查不准?为什么嵌套查询下分页会出问题?这些才是项目里真正会绊倒你的地方。

我自己在一个后台管理系统里被坑过不止一次:用resultType查关联表,乱七八糟的数据结构让人想摔键盘;后来换ResultMap,又因为column大小写问题查出来全是null;分页用了PageHelper,结果在collection嵌套查询里总数翻倍。这些经验我会全部写出来,你照着操作可以少走两三天弯路。

2. ResultMap核心机制拆解

2.1 从resultType到resultMap:到底差在哪

先明确一个概念:resultType是MyBatis自动完成映射,要求数据库字段名和Java属性名完全一致。实际开发中,数据库习惯用下划线命名(user_name),Java习惯用驼峰(userName),不一致就查不出来。解决办法有两个:

  • 开启驼峰映射:在配置文件中加mapUnderscoreToCamelCase=true,简单字段名问题自动解决。
  • 使用resultMap:手动指定映射关系,适合复杂场景。

resultType是隐式约定,resultMap是显式声明。能用前者就用前者,但遇到别名、多表、嵌套,只能上后者。

2.2 基础映射配置:id与result的用法

<resultMap id="userMap" type="com.example.entity.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> <result property="createTime" column="create_time"/> </resultMap>

id标签对应主键字段,result标签对应普通字段。有没有id在基础映射里影响不大,但在collection和二级缓存场景下必须写对,否则缓存和关联映射可能出错。

有一个细节很多人不知道:column是数据库返回的列名,不一定是数据库里的真实字段名。如果你在SQL里起了别名(比如select u.user_id as uid),那么column要写别名,不写数据库原名。

2.3 多表联查时的association与collection

这是ResultMap最核心的价值所在。

假设一个场景:订单表和用户表,一个用户有多个订单。查询订单时需要带出用户信息,这就是association;查询用户时需要列出订单列表,这就是collection。

<resultMap id="orderWithUserMap" type="com.example.entity.Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="userId" column="user_id"/> <association property="user" javaType="com.example.entity.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> </association> </resultMap>

association用于一对一(订单对用户),collection用于一对多(用户对订单)。两者核心区别在ofType和javaType:association用javaType指定单个对象类型,collection用ofType指定集合元素的类型。

很多人会问:为什么不直接在SQL里用JOIN查出来再手动封装?原因很简单:手动封装意味着你要写一堆遍历代码,而且一旦字段多、层级深,代码会变得极其难维护。ResultMap让你用声明式配置替代命令式代码,这是MyBatis存在的根本意义。

2.4 嵌套查询的select属性:一个容易失控的利器

除了JOIN方式,ResultMap还支持嵌套查询,通过select属性指定另一条SQL:

<resultMap id="userWithOrdersMap" type="com.example.entity.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> <collection property="orders" ofType="com.example.entity.Order" select="com.example.mapper.OrderMapper.selectOrdersByUserId" column="user_id"/> </resultMap>

这种做法看起来方便,查用户自动带出订单,但实际上埋着巨大的坑——N+1查询问题。如果你查了10个用户,它会额外执行10次订单查询,数据库连接压力陡增。

注意:嵌套查询虽然写法优雅,但一定要避免在循环场景下使用。宁可写JOIN让数据库扛,也别让应用层发一堆SQL把连接池打死。

我在真实项目中见过一个血淋淋的案例:同事用嵌套查询展示用户列表,一页20条数据,前台加载花了8秒,数据库连接数直接打满。排查后发现就是N+1,后来改成JOIN加一次查询搞定。

2.5 与resultType的选型标准

做项目不能无脑用ResultMap,也不能无脑用resultType。我一般按这个标准判断:

场景推荐方案原因
单表查询,字段名与属性名一致resultType最简洁,无需多余配置
单表查询,下划线命名resultType + 驼峰映射一行配置解决问题
多表联查,需要对象嵌套resultMap + association/collection结构清晰,避免手动组装
动态查询,字段不固定resultType="map"Map结构灵活,适合导出等场景
需要区分主键、优化缓存resultMapid标签对二级缓存有辅助作用

这个表是我多年实践下来最稳妥的选择逻辑。记住:简单场景用简单方案,复杂场景才上复杂配置,过度设计同样是一种技术债。

3. 分页实现方案:从手写LIMIT到插件原理

3.1 分页的本质是什么

分页的本质是控制一次查询返回的行数,减少数据库和网络传输的压力。SQL层面的核心是LIMIT offset, size。但真实项目里,分页通常伴随两个需求:

  • 当前页的数据列表
  • 数据总数(用于前端渲染分页组件)

如果只做列表查询,一个LIMIT就搞定;要做总数,就得额外执行SELECT COUNT(*)。这里就牵扯出两种分页模式:手动分页和插件分页。手动分页的麻烦在于每条查询你都要写两条SQL,很繁琐。插件分页的核心价值就是帮你自动补全COUNT查询。

3.2 手写LIMIT的适用场景与写法

如果是小型项目、查询逻辑简单,手写LIMIT完全够用。Mapper接口:

List<User> selectUserPage(@Param("offset") int offset, @Param("size") int size);

XML:

<select id="selectUserPage" resultMap="userMap"> SELECT user_id, user_name, email, create_time FROM user ORDER BY create_time DESC LIMIT #{offset}, #{size} </select>

Controller层计算offset:

int pageNum = 1; // 前端传入 int pageSize = 10; // 前端传入 int offset = (pageNum - 1) * pageSize;

这种方式的优点是直观、可控、不依赖第三方库,SQL自己写,性能可控。缺点也很明显:每个查询都要手写count语句;排序和过滤条件要在两条SQL中重复维护;一旦SQL复杂,维护成本直线上升。

如果你在分页查询里用了动态SQL(比如<where>标签拼条件),手写LIMIT意味着COUNT语句里也要拼一遍同样的条件,但凡漏一个条件,总数就是错的。这是手写分页最坑人的地方。

3.3 PageHelper的原理与你必须知道的坑

PageHelper是目前最流行的分页插件,我承认它确实好用,但很多人只会在Service里调一行PageHelper.startPage(pageNum, pageSize),完全不理解它内部做了什么。一旦出了问题,只能干瞪眼。

它的核心原理是拦截器:在你执行的查询SQL上动态拼接LIMIT,并自动生成一条COUNT查询。具体流程是:

  1. PageHelper.startPage设置ThreadLocal中的分页参数
  2. MyBatis执行查询时,拦截器检测到ThreadLocal有分页参数
  3. 拦截器改写SQL,追加LIMIT语句
  4. 执行COUNT查询获取总数
  5. 查询完成后清除ThreadLocal中的分页参数

看到ThreadLocal,你就知道它有一个致命规则:startPage之后必须紧跟第一条查询语句。如果你在中间插入其他查询,那个查询也会被意外分页;如果你在查询前有逻辑判断导致查询没执行,ThreadLocal又没有清理,下一次查询会被莫名分页。这就是很多人遇到的“怎么这条查询突然多了LIMIT”的原因。

使用规范极简单却也极容易被忽视:

PageHelper.startPage(pageNum, pageSize); List<User> userList = userMapper.selectUserList(); // 紧跟 PageInfo<User> pageInfo = new PageInfo<>(userList);

3.4 为什么有时候PageHelper分页总数不准

这是被问得最多的一个问题。总数不准通常有四个原因:

第一,COUNT语句本身被改写了。PageHelper会尝试优化COUNT,去掉ORDER BY,但遇到复杂SQL时它的优化逻辑可能出错,导致总数的条件不对。

第二,嵌套查询下COUNT重复计算。如果你用collection加select属性做嵌套查询,外层LIMIT只限制了外层结果数量,但COUNT语句统计的是同一行数据,理论上没错,可一旦外层SQL里存在一对多的JOIN,COUNT会把关联子表的多行也计算进去,导致总数虚高。

第三,DISTINCT或GROUP BY被COUNT误处理。做去重查询时,PageHelper的COUNT改写可能没有保留DISTINCT,导致总数多算。

第四,自己处理了结果类型,用了resultType="map",总数查询结果无法正确映射。

解决这些问题的通用手段是手动指定COUNT查询:

PageHelper.startPage(pageNum, pageSize).setCountSql("SELECT COUNT(*) FROM user WHERE status = 1");

或者干脆放弃PageHelper,对这种复杂查询手写单独的COUNT语句,用Map封装结果,条件更可控。

3.5 物理分页与逻辑分页的取舍

顺便把分页的底层概念说清楚。物理分页是数据库层面加LIMIT,只查询当前页需要的数据;逻辑分页是把全量数据查出来放在内存里,再截取当前页。

逻辑分页听起来没什么用,但在某些场景还真是唯一选择:比如数据源是接口返回的JSON,没有数据库,只能全量拉取后在内存分页。或者跨数据库统计数据时,无法直接用SQL分页。但这种操作只适用于数据量小于几千条的轻量场景,大数据量下内存爆炸是必然。

我个人建议:能用物理分页绝不用逻辑分页。哪怕只差几千条数据,逻辑分页对内存的浪费也完全没必要。你在写代码时就应该优先考虑SQL层分页。

4. 组合案例实操:用户订单查询的分页实现

4.1 场景设定与需求拆解

用一个实际的业务场景来演示。假设有一个用户订单查询页面,需求是:

  • 展示用户列表,每条记录包含用户基本信息
  • 每个用户名下带出最近5笔订单
  • 分页显示,每页10个用户

这个需求里,同时涉及ResultMap的collection嵌套映射和分页。我分别用两种方案实现,并对比差异。

4.2 方案一:单表分页加循环查询(简单但低效)

第一步,先查用户分页:

SELECT user_id, user_name, email FROM user ORDER BY create_time DESC LIMIT #{offset}, #{size}

第二步,遍历用户的ID列表,逐个查询订单:

SELECT order_id, order_no, amount, user_id FROM orders WHERE user_id = #{userId} ORDER BY create_time DESC LIMIT 5

这个方案逻辑清晰,代码写起来也容易:先分页查用户,再对每个用户查订单。10个用户就有11条SQL(1条分页+10条订单),数据量上来后性能堪忧。虽然比纯嵌套查询的N+1好一些,因为订单查询是独立的,可以加缓存,但还是不值得推荐。

4.3 方案二:JOIN查询加ResultMap(正确示范)

真正的做法是单条SQL把数据取回来,用ResultMap完成映射:

<select id="selectUserWithRecentOrders" resultMap="userWithOrdersMap"> SELECT u.user_id, u.user_name, u.email, u.create_time, o.order_id, o.order_no, o.amount FROM user u LEFT JOIN orders o ON u.user_id = o.user_id WHERE u.user_id IN ( SELECT user_id FROM user ORDER BY create_time DESC LIMIT #{offset}, #{size} ) ORDER BY u.create_time DESC, o.create_time DESC </select>

子查询先把当前页的用户ID取出来,再JOIN订单表,不会出现分页数据混乱的问题。映射配置:

<resultMap id="userWithOrdersMap" type="com.example.entity.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> <result property="createTime" column="create_time"/> <collection property="orders" ofType="com.example.entity.Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="amount" column="amount"/> </collection> </resultMap>

注意collection里的result标签的column,对应的必须是SQL查询结果里的列名,也就是u.order_id、o.order_no这些。如果SQL里有两个表都有同名字段(比如都叫create_time),映射时容易串,必须起别名,比如u.create_time as user_create_time、o.create_time as order_create_time。

4.4 总数查询的正确姿势

分页必须有总数。手写方案里,总数查询单独执行:

SELECT COUNT(*) FROM user WHERE status = 1;

这个COUNT可以和分页主查询并行执行,互不干扰。在Spring中,可以用CompletableFuture同时发起两个查询,减少等待时间:

CompletableFuture<List<User>> pageFuture = CompletableFuture.supplyAsync(() -> userMapper.selectUserWithRecentOrders(offset, size)); CompletableFuture<Long> countFuture = CompletableFuture.supplyAsync(() -> userMapper.countUser()); List<User> userList = pageFuture.get(); Long total = countFuture.get();

如果有筛选条件,记住两边的条件必须完全一样。我看到太多开发者在列表查询里加了WHERE状态过滤,总数SQL却漏了,导致翻页时页数跳动。

4.5 如果坚持用PageHelper,怎么和ResultMap配合

用PageHelper的时候,直接把LIMIT交给插件,SQL里不要写LIMIT:

PageHelper.startPage(pageNum, pageSize); List<User> userList = userMapper.selectUserWithRecentOrders(); PageInfo<User> pageInfo = new PageInfo<>(userList);

Mapper的SQL去掉子查询写法,直接JOIN。但这里有一个细节必须盯紧:collection下的订单数据因为用户和订单是一对多,PageHelper的分页限制的是主表行数,也就是用户数,这在大多数情况下是正确的。可如果你JOIN的是多对多关系,返回的每行会有重复,总数就会偏大。这种场景下,要么用子查询先分页再JOIN,要么直接放弃一对多的物理分页,改为先分页查用户再批量查订单。两种思路都可行,取决于你的数据模型。

5. 常见问题与排查技巧实录

5.1 ResultMap查出来全是null

这个问题的原因九成是column写错了。注意两点:

  • column对应的是SQL返回的列名,不是Java属性名,也不是数据库物理字段名
  • 如果SQL里用了别名(as xxx),column必须写别名,写原本字段名必为null

解决方案是在SQL里加上别名,或者调整column名称。

5.2 嵌套查询返回多条重复数据

典型错误是collection里没有写id标签,或者id标签的column没有指向主键。MyBatis在组装集合时,依赖id的column去重。没有id时,每行都作为一个独立对象塞进集合,导致重复数据出现。

正确做法:在collection对应的子对象映射中,给主键字段配上id标签。

5.3 PageHelper分页失效或影响其他查询

分页失效的常见原因:

  • startPage后没有紧跟执行查询,中间被其他逻辑打断
  • 方法内提前返回,查询没执行,但ThreadLocal里的参数没清掉
  • 多数据源下,拦截器没有正确配置到对应数据源分页插件

排查方法:打印执行SQL,看看是否真的追加了LIMIT;没有LIMIT说明拦截器没有生效。被误分页的排查方法:检查是不是上一次startPage之后没有查询语句,导致参数残留,被下一次查询消费。代码里要养成习惯,startPage和查询语句之间不要有任何非查询逻辑。

5.4 总数查询慢导致接口超时

COUNT查询慢通常是因为表数据量大且没有合适的索引,或者COUNT语句里带了复杂JOIN。优化建议:

  • 给WHERE条件涉及的字段建立索引
  • 用COUNT(1)替代COUNT(*)(虽然MySQL 8.0两者性能基本一致,但习惯上更明确)
  • 如果总数不需要实时精确,可以用缓存计数近似值

5.5 大型分页深翻页的性能优化

分页越往后翻越慢,原因是LIMIT 100000, 20需要先扫描前100020行再丢弃前100000行。优化方案:

  • 延迟关联:先用子查询查出主键范围,再JOIN明细数据
  • 游标分页:基于唯一排序键的记录位置翻页,适合App端无限滚动场景
  • 限制最大翻页深度:后台业务数据量巨大时,限制用户只能翻前100页

延迟关联示例:

SELECT u.*, o.* FROM user u INNER JOIN ( SELECT user_id FROM user ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON u.user_id = tmp.user_id LEFT JOIN orders o ON u.user_id = o.user_id

这样扫描的依然是分页范围,但JOIN之前已经缩小了数据量,速度提升明显。

6. 写在最后

用了这么多年MyBatis,我的切身体会是:ResultMap和分页这两块,单独拎出来看都不难,难的是它们一组合,问题就成倍放大。嵌套映射下分页总数可能虚高,collection里忘记加id可能导致重复,startPage位置不对可能污染其他查询——这些坑,光靠看文档是躲不过去的,非得亲手踩一遍才记得住。

而你如果能把本文这套思路吃透——用ResultMap的时候盯紧column和id,分页的时候物理分页优先、复杂场景手写COUNT,深翻页用延迟关联——大部分项目里的相关问题都能在这个框架内解决。真遇到了文章里没覆盖的怪现象,也不用慌,打开MyBatis的SQL日志,把实际执行的SQL打印出来看一下,八成问题就暴露了。这是排查一切MyBatis问题的万能起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询