
先问一个扎心的问题你的MappedStatement真的“看得懂”数据库里的列吗我见过太多刚接触MyBatis的朋友在XML里写了一句select idfindUserByName resultTypecom.example.model.User select * from user where name #{name} /select然后信心满满地启动Spring Boot结果控制台没有报错查询出来User对象却是满屏的null。去翻数据库表里明明数据都在列名也没拼错。折腾半天才发现问题出在数据库列名和Java属性名对不上——user_name映射不到userName上。这就是MyBatis映射最基础、也最容易踩的第一个坑。这篇文章我就围绕“MyBatis映射”这件事把从基础映射到resultMap高级用法、从缓存坑点到源码层面的东西全部过一遍。不管你是刚接触MyBatis的新手还是被各种映射坑搞到血压升高的老手看完这篇应该都能把映射这块的脉络理清楚。1. 映射的整体设计与基本玩法1.1 resultType和resultMap先用哪个很多人在刚学MyBatis时都会纠结一个问题写映射的时候到底用resultType还是resultMap我的建议很简单粗暴单表查询、字段名能对得上用resultType字段对不上、有多表关联、有特殊嵌套结构用resultMap。resultType的本质是“让MyBatis自动帮你映射”。你指定一个类型MyBatis拿到SQL执行结果后会把每一列的值按列名和属性名的对应关系塞进对象里。这个对应关系默认是“列名等于属性名”。如果数据库表设计得规范、列名和Java属性一一对应那真的是开箱即用。resultMap则是一张“人工映射表”。你告诉MyBatis哪一列对应哪个属性、主键怎么处理、一对一关联怎么组装、一对多集合怎么填充。它的表达能力强得多但代价是配置量大、写起来繁琐。我还在实际项目里见过一种混用方案查询关联表时只查单表要用的字段拼接多个表的字段然后用一个自定义的DTO类接收全部走resultType。这种做法在团队小、表结构简单时效率极高但一旦关联多了、字段名字对不齐就会变成维护噩梦。所以“优先resultType必要时换resultMap”是对大多数项目最务实的选择。1.2 自动映射MyBatis是怎么把列名变成对象属性的自动映射看着神奇实际原理并不复杂。MyBatis在执行查询后会拿到ResultSet的元数据把每一列的列名取出来然后遍历配置的resultType对应的类检查每个属性名能不能和列名对上。能对上就赋值对不上就跳过最终生成一个缺字段的对象。所以默认情况下你写的SQL是select user_id, user_name, email from t_userJava类是public class User { private Integer userId; private String userName; private String email; }那恭喜你三个字段全是null。因为列名user_id和属性名userId对不上。解决方式有几种。最省事的是在SQL里写别名select user_id as userId, user_name as userName, email from t_user这个方法非常直接我早期在项目里就是这么做的简单好用。但写多了你会发现每个查询都要手工加别名重复劳动严重。第二种方式就是MyBatis官方配置里的mapUnderscoreToCamelCasemybatis: configuration: map-underscore-to-camel-case: true这个配置打开后MyBatis会把列名里的下划线去掉然后把下一个字母转成大写自动和属性名匹配。user_name会匹配userName。这是目前项目里最主流的做法也算是我最推荐的做法。这里要提醒一点mapUnderscoreToCamelCase对resultType和resultMap都有影响但影响机制略有不同。对resultMap来说如果column属性明确写了列名property还是会按你的配置匹配不会自动把下划线转驼峰。换句话说resultMap里你写了columnuser_name propertyuserName这个配置是明确的一对一关系不会受全局开关影响。但如果你用了autoMapping或者resultMap里没指定column的部分全局开关还是会生效。1.3 typeAliases让映射配置不再啰嗦映射配置写多了你会发现最烦的不是坚持写XML而是resultType里的全限定类名太长了select idfindUser resultTypecom.example.module.user.model.entity.User写这么一串手稍微一抖就容易写错而且IDE也不一定能帮你自动补全到XML里。MyBatis提供了别名机制可以在配置里给类起短名mybatis: type-aliases-package: com.example.module.user.model.entity这种情况下你只需要写select idfindUser resultTypeUserMyBatis会自动扫描这个包下所有的类把类名转换成别名。默认规则很直接User类的别名就是user首字母小写不区分大小写。也就是说User和user都能匹配上。我遇到过一些团队会用Alias注解自定义别名Alias(userInfo) public class User { }这么写的好处是可以避开不同包下同名类的冲突。比如订单模块有个User用户模块也有个User全限定类名是不同的但短别名都是user这就会在启动时直接报错提示别名冲突。用Alias给每个类起不同别名能从根源上避免这种问题。不过提醒一句别过度依赖别名。项目维护到后期如果你在XML里看到一个User还要反查是哪个包下的User那就是给自己埋坑。我个人的做法是简单类直接扫描必要的时候用Alias做区分代码评审时重点查这种地方。2. resultMap高级映射一对一和一对多实战2.1 resultMap的构造套路先看一个典型的resultMap长什么样resultMap idUserOrderMap typecom.example.model.UserOrder id propertyid columnid/ result propertyuserName columnuser_name/ result propertyemail columnemail/ association propertyorder javaTypecom.example.model.Order id propertyorderId columnorder_id/ result propertyorderNo columnorder_no/ /association /resultMap构造逻辑非常直白id子元素标记主键列result子元素标记普通列association处理“有一个”的关系collection处理“有多个”的关系。这里有个细节很多人会忽略id不光是用来声明主键的它还有一个重要的去重功能。尤其是在处理一对多、多层嵌套映射时MyBatis会拿id列的值来判断当前记录是不是同一个对象。如果主键配置错了查询结果会出现对象重复、数据覆盖的问题。我举一个真实踩过的例子。当时做订单列表一个订单对应多个商品我用collection映射商品列表resultMap里正好忘记写主键id的映射只写了result。结果两条订单记录本来是不同的单子因为商品数量不同、结果集里主键信息没有参与去重MyBatis把两条订单当成了同一条后面的记录直接把前面的覆盖了。调了半天才发现是缺了id映射。2.2 一对一映射嵌套结果映射和嵌套查询怎么选一对一场景最常见的就是主表关联详情表比如用户关联用户详情订单关联订单扩展信息。MyBatis里association有两种实现方式嵌套结果映射和嵌套查询。嵌套结果映射的意思是一句SQL把两张表的数据都查出来然后在resultMap里声明怎么把列拆出来、怎么组装成对象select idgetUserWithDetail resultMapUserDetailMap select u.id as user_id, u.user_name, d.address, d.phone from t_user u left join t_user_detail d on u.id d.user_id where u.id #{id} /select resultMap idUserDetailMap typecom.example.model.User id propertyid columnuser_id/ result propertyuserName columnuser_name/ association propertydetail javaTypecom.example.model.UserDetail result propertyaddress columnaddress/ result propertyphone columnphone/ /association /resultMap嵌套查询则是先查主表然后根据主表返回的结果再执行一条查询去拿关联数据select idgetUserWithDetail resultMapUserDetailMap select * from t_user where id #{id} /select resultMap idUserDetailMap typecom.example.model.User id propertyid columnid/ result propertyuserName columnuser_name/ association propertydetail javaTypecom.example.model.UserDetail selectselectUserDetailByUserId columnid /association /resultMap select idselectUserDetailByUserId resultTypecom.example.model.UserDetail select * from t_user_detail where user_id #{id} /select两种方式的取舍我用一张表总结一下维度嵌套结果映射嵌套查询SQL数量一条多表join两条及以上按主表结果逐条触发查询性能一次查完但join可能产生大量中间数据小数据量时方便大数据量容易触发N1配置复杂度resultMap里嵌套结构多但还好一条select属性搞定直观简单适合场景关联层级多、数据量大、追求性能关联数据简单、不追求极致性能、写起来省事我的经验是关联层级不超过两层、能容忍一条SQL的join都优先用嵌套结果映射。因为数据库的join在大多数场景下比应用层多次查询更可控尤其配合分页时嵌套查询的分页经常出问题——你以为是主表分页实际子查询可能把整个结果集都带出来了。这块踩过坑的人应该懂我在说什么。2.3 一对多映射collection的正确姿势一对多场景比如上一篇博客对应多条评论或者一个订单对应多个订单商品MyBatis里的主力就是collection。最常见的写法是两表join用一条SQL查出主表和子表的数据然后通过resultMap把子表记录收集到集合里resultMap idOrderWithItemsMap typecom.example.model.Order id propertyid columnorder_id/ result propertyorderNo columnorder_no/ collection propertyitems ofTypecom.example.model.OrderItem id propertyid columnitem_id/ result propertyorderId columnorder_id/ result propertyproductName columnproduct_name/ result propertyprice columnprice/ /collection /resultMap select idgetOrderWithItems resultMapOrderWithItemsMap select o.id as order_id, o.order_no, i.id as item_id, i.product_name, i.price from t_order o left join t_order_item i on o.id i.order_id where o.id #{id} /select这里有两个心得。第一ofType和javaType别写混。collection的集合属性本身是ListOrderItemjavaType其实可以缺省但ofType必须写它表示集合里面元素的类型。有些人拿javaType指元素类型结果启动直接报错报错信息还不一定一眼能看懂。第二resultMap里id的配置在一对多场景下尤其重要。如果主表id配置不完整MyBatis就无法正确判断两条结果是不是属于同一条主子记录可能导致子表数据乱套甚至主表对象被重复创建。我一直建议主表结果的id必须显式配出来哪怕你觉得“MyBatis应该能判断出来”也千万别省这一步。还有一个非常实用的小技巧如果你想在collection里过滤子表数据不要依赖SQL的where条件去过滤。比如只查订单里价格大于100的商品如果你在SQL末尾写and i.price 100结果就是订单主表信息也会跟着被过滤——因为SQL层面它已经不是一条订单记录了而是订单和商品组合出来的中间结果。正确做法是子查询只查符合条件的数据或者先把结果集查出来在应用层再过滤或使用collection配合columnPrefix和子查询的方式。总之不要在join的查询条件里直接限制子表的字段除非你确认这个订单没有其他商品了。2.4 N1查询嵌套查询最大的坑N1问题在MyBatis的嵌套查询里很容易出现。拿上面的一对一举例用select属性做关联查询时每查出主表一条记录就会触发一次子查询。如果主表查出100条那就会有100次子查询加上主查询本身1次一共101次数据库交互。这个性能问题在小数据量时感受不到但一旦涉及列表页、批量查询数据库连接池的负荷会肉眼可见地暴涨。我真实遇到过的情况是一个管理后台的分页列表接口本来只该查几百条结果因为嵌套查询的触发日志里打印了上千条SQL整个页面从200ms直接飙到3秒多。解决N1问题的思路主要有三种改成嵌套结果映射一条join SQL搞定这是最推荐的方案。使用association的多行结果映射能力配合MyBatis的columnPrefix做字段前缀区分一次查询减少多次查询。分页查询时在业务层手动拆分查询比如先查主表再根据主表id批量查子表用内存合并关系。第三种方案虽然代码写起来多但可控性最强尤其适合那种关联关系复杂、子表数据需要单独做处理的业务。3. 注解映射与缓存简单场景的正确姿势3.1 注解映射什么时候不用写XMLMyBatis的注解映射提供了另一条路不写XML直接在Mapper接口方法上标注SQL。举个最简单的例子Select(select * from t_user where id #{id}) User findById(Integer id);还可以配合Results做映射Select(select user_name, email from t_user where id #{id}) Results(id userMap, value { Result(column user_name, property userName), Result(column email, property email) }) User findByIdWithAnnotation(Integer id);注解映射的好处是直观方法就在一眼能看见的地方SQL也紧挨着。但坏处也很明显复杂动态SQL在Java注解里写起来极其痛苦你需要在script标签里用XML语法或者用Provider去写Provider类绕来绕去反而不如直接写XML清晰。我的判断标准单表简单查询、SQL固定、没有复杂动态条件用注解完全没问题一旦涉及到动态SQL哪怕只是一个if判断我都会老老实实改回XML。注解不是不能用而是要用对场景。另一个现实问题是代码评审时XML文件能直接搜SQL、能全局排查注解散落在接口方法上排查起来效率会低不少。3.2 二级缓存到底该不该开二级缓存在MyBatis面试里是个高频话题但在真实项目里我却很少开。原因不是它不好而是它的坑在复杂业务里让人防不胜防。先理清概念。一级缓存是SqlSession级别的默认开启同一个SqlSession里执行相同的SQL第二次直接从缓存拿。二级缓存是namespace级别的跨SqlSession共享需要配置才会生效。二级缓存最大的坑是脏数据。MyBatis的二级缓存不是分布式缓存数据存在应用本地内存里。如果你有多个应用实例或者多个Mapper操作同一张表一个Mapper改了数据另一个Mapper的缓存并不知道就会读到旧数据。我举个实际发生的例子。用户模块用UserMapper查询用户信息并开启了二级缓存权限模块直接改了用户表里的状态字段没走UserMapper结果UserMapper的缓存里还是老状态用户被禁用了但前端调接口还是返回正常。排查到缓存时大家都不信是缓存问题直到手动清缓存后才恢复。所以我的建议是单机部署、数据基本只读的场景可以开启二级缓存能带来一定性能提升。多实例部署、多人协作、多Mapper操作同一表别开二级缓存。就算要开一定要设置合理的flushInterval而且修改数据的操作得走同一个Mapper。这里还有个容易被忽略的细节二级缓存默认是内置的本地缓存实现如果你用了Spring Boot MyBatis在多数据源事务、分布式部署场景下缓存失效的情况会比你想的更多。很多时候你省下的那点数据库压力还不够填排查脏数据的坑。3.3 把SQL打出来排查映射问题的第一手段排查映射问题第一步不是看代码而是看执行的SQL到底是什么、参数绑定成什么样了、返回结果集长什么样。MyBatis打印SQL很简单在配置文件里加上mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者用logback配置把com.example.mapper包的日志级别设成debug。这样执行查询时MyBatis会打印Preparing、Parameters、Total等关键信息一眼就能确认SQL拼接和参数是否正常。我再推荐一个更彻底的办法直接看查询结果的话可以先改成resultTypemap跑一下看看未经映射的原始列长什么样。这个办法在排查字段对不上的时候特别好使。有一次同事说“明明查出来了为什么对象是空的”我第一反应就是让他把resultType临时改成map结果发现数据库里的列名和代码里拼的字段根本不是一回事。用map一探真相立刻浮出水面。顺带提一句开启日志打印会有一定性能损耗线上环境注意别把全包都打成debug。我的习惯是只对出问题的Mapper包临时打开排查完马上关掉。4. 映射源码与常见坑点入门到面试必备4.1 一条select是怎么变成PreparedStatement的很多人把MyBatis的映射理解成“配置写好就完事了”但面试官问起来就开始含糊。这里我把它内部的流程串一遍。你调用Mapper接口方法时MyBatis会通过动态代理把调用转交给SqlSessionSqlSession根据Mapper方法的全限定名找到对应的MappedStatement。这个MappedStatement是XML或注解配置解析后的产物里面封装了SQL语句、resultMap、parameterMap、缓存配置等信息。接着MappedStatement会获取BoundSql对象。BoundSql是“绑定后的SQL”也就是把SQL里的#{}占位符处理后的结果。处理过程由SqlSource完成。XML里的select会被解析成DynamicSqlSource如果SQL里包含动态标签if、where等执行时还需要经过SqlSourceBuilder生成完整的SQL。拿到BoundSql后MyBatis会用JDBC的PreparedStatement执行查询。最后ResultSetHandler会根据resultType或resultMap配置逐行把结果集转换成JAVA对象。整个链路里和映射关系最紧密的就是最后一步。resultMap的解析结果会被存储在MappedStatement里执行到结果集处理阶段MyBatis就根据这张“映射表”把列塞进对象。4.2 resultMap在启动时经历了什么MyBatis启动阶段XMLConfigBuilder会加载所有的XML配置文件其中Mapper文件由XMLMapperBuilder解析。每解析到一个resultMap就会构建一个ResultMap对象再把它的详细信息存到Configuration里。每个ResultMap包含多个ResultMapping。每个ResultMapping对应一个id或result标签也对应association和collection里嵌套的映射。ResultMapping里的关键字段是column和property这是我们写映射时最关心的两个属性。这里有一个有趣的知识点MyBatis在构建ResultMap时会做一次“嵌套映射预处理”。如果ResultMap的type属性对应类里缺少某个属性的setter启动阶段可能不会立即报错但执行阶段会抛出反射异常。所以我在项目里经常强调写resultMap时property名字一定要反复核对很多人精力都放在SQL上结果栽在一个错别字上。还有一个经常被忽略的东西叫resultMap继承。你可以写resultMap idBaseResultMap typeUser id propertyid columnid/ result propertyuserName columnuser_name/ /resultMap resultMap idExtResultMap extendsBaseResultMap typeUser result propertyemail columnemail/ /resultMap这样ExtResultMap自动包含BaseResultMap的全部映射。这个特性很适合做统一字段扩展比如所有表都有create_time、update_time写一个基础resultMap其他resultMap都继承它维护成本能降不少。4.3 映射报错排查实录直接上一份我在实际项目中遇到过的映射报错排查清单。报错一ResultMap id xxx not found这个十有八九是resultMap的id拼写了或者Mapper XML根本没被MyBatis扫描到。检查mapper-locations配置是否指向了正确的路径。Spring Boot项目中最常见的坑是XML文件放在了src/main/java目录下却没有在pom.xml或build配置里把XML资源打进去结果启动时没加载到。报错二TypeException: Could not set properties of type X这是resultMap里property写错Java对象里根本没有这个属性。通常在日志里能直接看到具体是哪个字段出问题顺着改就行。我自己写resultMap时养成了一个习惯写完立刻打开对应实体类对照一遍属性名宁可多看两眼也不想到线上再暴露。报错三查出来很多重复数据这是resultMap里id没配或者配错导致的结果去重失败。把主表对应的id配好基本能解决。还有一种情况是join造成笛卡尔积这时候要回头检查SQL的关联条件有没有写全。报错四java.lang.UnsupportedOperationException或结果集类型不匹配这种通常发生在你用了resultTypemap然后又期望它自动转成某个DTO。MyBatis对map的处理逻辑和POJO不同map不会帮你做驼峰转换除非开启了驼峰配置。如果业务里需要把查询结果转成特定DTO直接指定DTO类型别偷懒用map再去手转否则代码维护起来很痛苦。报错五嵌套查询触发时报错“Cannot determine value type from string”这个经常出现在用select属性做嵌套查询时主查询返回了不止一个参数列。比如association selectselectOrderItem columnid,name/这种情况下子查询接收的参数不匹配MyBatis会尝试把列值解析成对象然后报类型错误。解决办法是确认column里的每个列都是子查询需要的参数顺序和名称都要对应好。4.4 面试高频考点映射相关问题整理结合热词里频繁出现的“mybatis面试题”我把映射相关的必考问题整理了一遍。resultType和resultMap的本质区别是什么resultType是约定式自动映射MyBatis根据列名和属性名的匹配规则自动赋值resultMap是显式声明映射关系能处理嵌套对象、集合、复杂别名等场景。两者的背后都是ResultSetHandler在做结果集到对象的转换区别只在“映射规则谁来定义”。自动映射的原理是什么MyBatis读取ResultSetMetaData里的列名和实体类属性名做匹配。默认情况下要求列名等于属性名。开启mapUnderscoreToCamelCase后会把下划线风格自动转换为驼峰风格再和属性名匹配。需要注意resultMap里显式配置的column、property不受这个开关影响。MyBatis缓存层级和映射的关系一级缓存默认开启SqlSession内部共享二级缓存是namespace级别需要配置开启。和映射关系最紧密的是当实体的update、delete等操作发生时MyBatis会清空对应namespace的缓存但如果你通过其他Mapper改了同一张表二级缓存不会感知。这个点在缓存问题上被问到的概率很高。association和collection在不同场景下应该如何选择association对应“has one”关系collection对应“has many”关系。两者都支持嵌套结果映射和嵌套查询两种实现。选择时要考虑性能、复杂度、数据量一般情况下优先嵌套结果映射。如果使用嵌套查询要注意N1问题必要时做批量查询优化。为什么有时候resultType指定了实体类查询返回的字段还是null优先检查两者的列名和属性名是否能映射上。数据库使用下划线命名而Java使用驼峰命名时最常见。另外一个隐蔽原因是实体类里字段有static或final修饰MyBatis会跳过这种字段的赋值。还有一种情况是SQL里用了聚合函数或表达式比如count(*)默认列名是count(*)属性名肯定对不上你需要给它起别名。写在最后的一点点经验映射这块内容说浅了就是“列对属性”四个字说深了能一直聊到源码层面。我在项目里踩过最多的坑不是不会写resultMap而是写完之后没有认真核对。后来我给自己定了三条规矩写完XML先看实体类再复查resultMap的property、开启驼峰转换并统一数据库命名规范、任何嵌套查询上线前都检查一遍SQL执行次数。这三条规矩帮我挡掉了不少线上问题。如果你现在正被某个映射问题卡住我建议你先别急着翻代码把SQL日志拉出来看一遍或者把resultType临时改成map看看原始结果。很多时候答案就藏在你看似熟悉却从没认真看过的结果集里。