ResultMap与分页:MyBatis组合踩坑与性能优化全解
发布时间:2026/10/17 12:48:55 锦皓数字建站

先声明这个标题单独拿出来看很短但在实际项目里“ResultMap和分页”永远是连在一起的两个痛点。数据库字段叫user_nameJava属性叫userName查出来全是null数据一多页面卡死不得不分页。这篇文章就围绕这两块把原理、配置、踩坑一次讲透。1. 内容整体设计与思路拆解1.1 为什么要先搞懂ResultMap再谈分页很多新手用MyBatis上来就是resultTypemap或者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_nameJava习惯用驼峰userName不一致就查不出来。解决办法有两个开启驼峰映射在配置文件中加mapUnderscoreToCamelCasetrue简单字段名问题自动解决。使用resultMap手动指定映射关系适合复杂场景。resultType是隐式约定resultMap是显式声明。能用前者就用前者但遇到别名、多表、嵌套只能上后者。2.2 基础映射配置id与result的用法resultMap iduserMap typecom.example.entity.User id propertyid columnuser_id/ result propertyuserName columnuser_name/ result propertyemail columnemail/ result propertycreateTime columncreate_time/ /resultMapid标签对应主键字段result标签对应普通字段。有没有id在基础映射里影响不大但在collection和二级缓存场景下必须写对否则缓存和关联映射可能出错。有一个细节很多人不知道column是数据库返回的列名不一定是数据库里的真实字段名。如果你在SQL里起了别名比如select u.user_id as uid那么column要写别名不写数据库原名。2.3 多表联查时的association与collection这是ResultMap最核心的价值所在。假设一个场景订单表和用户表一个用户有多个订单。查询订单时需要带出用户信息这就是association查询用户时需要列出订单列表这就是collection。resultMap idorderWithUserMap typecom.example.entity.Order id propertyid columnorder_id/ result propertyorderNo columnorder_no/ result propertyuserId columnuser_id/ association propertyuser javaTypecom.example.entity.User id propertyid columnuser_id/ result propertyuserName columnuser_name/ result propertyemail columnemail/ /association /resultMapassociation用于一对一订单对用户collection用于一对多用户对订单。两者核心区别在ofType和javaTypeassociation用javaType指定单个对象类型collection用ofType指定集合元素的类型。很多人会问为什么不直接在SQL里用JOIN查出来再手动封装原因很简单手动封装意味着你要写一堆遍历代码而且一旦字段多、层级深代码会变得极其难维护。ResultMap让你用声明式配置替代命令式代码这是MyBatis存在的根本意义。2.4 嵌套查询的select属性一个容易失控的利器除了JOIN方式ResultMap还支持嵌套查询通过select属性指定另一条SQLresultMap iduserWithOrdersMap typecom.example.entity.User id propertyid columnuser_id/ result propertyuserName columnuser_name/ collection propertyorders ofTypecom.example.entity.Order selectcom.example.mapper.OrderMapper.selectOrdersByUserId columnuser_id/ /resultMap这种做法看起来方便查用户自动带出订单但实际上埋着巨大的坑——N1查询问题。如果你查了10个用户它会额外执行10次订单查询数据库连接压力陡增。注意嵌套查询虽然写法优雅但一定要避免在循环场景下使用。宁可写JOIN让数据库扛也别让应用层发一堆SQL把连接池打死。我在真实项目中见过一个血淋淋的案例同事用嵌套查询展示用户列表一页20条数据前台加载花了8秒数据库连接数直接打满。排查后发现就是N1后来改成JOIN加一次查询搞定。2.5 与resultType的选型标准做项目不能无脑用ResultMap也不能无脑用resultType。我一般按这个标准判断场景推荐方案原因单表查询字段名与属性名一致resultType最简洁无需多余配置单表查询下划线命名resultType 驼峰映射一行配置解决问题多表联查需要对象嵌套resultMap association/collection结构清晰避免手动组装动态查询字段不固定resultTypemapMap结构灵活适合导出等场景需要区分主键、优化缓存resultMapid标签对二级缓存有辅助作用这个表是我多年实践下来最稳妥的选择逻辑。记住简单场景用简单方案复杂场景才上复杂配置过度设计同样是一种技术债。3. 分页实现方案从手写LIMIT到插件原理3.1 分页的本质是什么分页的本质是控制一次查询返回的行数减少数据库和网络传输的压力。SQL层面的核心是LIMIT offset, size。但真实项目里分页通常伴随两个需求当前页的数据列表数据总数用于前端渲染分页组件如果只做列表查询一个LIMIT就搞定要做总数就得额外执行SELECT COUNT(*)。这里就牵扯出两种分页模式手动分页和插件分页。手动分页的麻烦在于每条查询你都要写两条SQL很繁琐。插件分页的核心价值就是帮你自动补全COUNT查询。3.2 手写LIMIT的适用场景与写法如果是小型项目、查询逻辑简单手写LIMIT完全够用。Mapper接口ListUser selectUserPage(Param(offset) int offset, Param(size) int size);XMLselect idselectUserPage resultMapuserMap SELECT user_id, user_name, email, create_time FROM user ORDER BY create_time DESC LIMIT #{offset}, #{size} /selectController层计算offsetint 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查询。具体流程是PageHelper.startPage设置ThreadLocal中的分页参数MyBatis执行查询时拦截器检测到ThreadLocal有分页参数拦截器改写SQL追加LIMIT语句执行COUNT查询获取总数查询完成后清除ThreadLocal中的分页参数看到ThreadLocal你就知道它有一个致命规则startPage之后必须紧跟第一条查询语句。如果你在中间插入其他查询那个查询也会被意外分页如果你在查询前有逻辑判断导致查询没执行ThreadLocal又没有清理下一次查询会被莫名分页。这就是很多人遇到的“怎么这条查询突然多了LIMIT”的原因。使用规范极简单却也极容易被忽视PageHelper.startPage(pageNum, pageSize); ListUser userList userMapper.selectUserList(); // 紧跟 PageInfoUser pageInfo new PageInfo(userList);3.4 为什么有时候PageHelper分页总数不准这是被问得最多的一个问题。总数不准通常有四个原因第一COUNT语句本身被改写了。PageHelper会尝试优化COUNT去掉ORDER BY但遇到复杂SQL时它的优化逻辑可能出错导致总数的条件不对。第二嵌套查询下COUNT重复计算。如果你用collection加select属性做嵌套查询外层LIMIT只限制了外层结果数量但COUNT语句统计的是同一行数据理论上没错可一旦外层SQL里存在一对多的JOINCOUNT会把关联子表的多行也计算进去导致总数虚高。第三DISTINCT或GROUP BY被COUNT误处理。做去重查询时PageHelper的COUNT改写可能没有保留DISTINCT导致总数多算。第四自己处理了结果类型用了resultTypemap总数查询结果无法正确映射。解决这些问题的通用手段是手动指定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条SQL1条分页10条订单数据量上来后性能堪忧。虽然比纯嵌套查询的N1好一些因为订单查询是独立的可以加缓存但还是不值得推荐。4.3 方案二JOIN查询加ResultMap正确示范真正的做法是单条SQL把数据取回来用ResultMap完成映射select idselectUserWithRecentOrders resultMapuserWithOrdersMap 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 iduserWithOrdersMap typecom.example.entity.User id propertyid columnuser_id/ result propertyuserName columnuser_name/ result propertyemail columnemail/ result propertycreateTime columncreate_time/ collection propertyorders ofTypecom.example.entity.Order id propertyid columnorder_id/ result propertyorderNo columnorder_no/ result propertyamount columnamount/ /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同时发起两个查询减少等待时间CompletableFutureListUser pageFuture CompletableFuture.supplyAsync(() - userMapper.selectUserWithRecentOrders(offset, size)); CompletableFutureLong countFuture CompletableFuture.supplyAsync(() - userMapper.countUser()); ListUser userList pageFuture.get(); Long total countFuture.get();如果有筛选条件记住两边的条件必须完全一样。我看到太多开发者在列表查询里加了WHERE状态过滤总数SQL却漏了导致翻页时页数跳动。4.5 如果坚持用PageHelper怎么和ResultMap配合用PageHelper的时候直接把LIMIT交给插件SQL里不要写LIMITPageHelper.startPage(pageNum, pageSize); ListUser userList userMapper.selectUserWithRecentOrders(); PageInfoUser pageInfo new PageInfo(userList);Mapper的SQL去掉子查询写法直接JOIN。但这里有一个细节必须盯紧collection下的订单数据因为用户和订单是一对多PageHelper的分页限制的是主表行数也就是用户数这在大多数情况下是正确的。可如果你JOIN的是多对多关系返回的每行会有重复总数就会偏大。这种场景下要么用子查询先分页再JOIN要么直接放弃一对多的物理分页改为先分页查用户再批量查订单。两种思路都可行取决于你的数据模型。5. 常见问题与排查技巧实录5.1 ResultMap查出来全是null这个问题的原因九成是column写错了。注意两点column对应的是SQL返回的列名不是Java属性名也不是数据库物理字段名如果SQL里用了别名as xxxcolumn必须写别名写原本字段名必为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问题的万能起点。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。