MyBatis核心:resultMap、关联查询与缓存避坑指南 项目里用到 Spring Boot MyBatis 的时间一长你会发现一个规律线上绝大部分数据错误的“根因”其实不在 SQL 本身而在 SQL 结果集和 Java 对象之间的那层 ORM 映射。我做过多商户跨境商城类项目订单表、用户表、商户表、订单明细表、商品表之间几乎全是关联关系。单表随便 SELECT 不算难难的是让 MyBatis 把一个 JOIN 出来的扁平结果“折叠”成 Order 对象里嵌套的 User 对象和 List 集合。搞不定这层接口就会频繁出现属性为空、对象对不上、一页三条数据却被分页器算成六条等怪事。所以这篇就围绕 MyBatis 最核心的 ORM 映射与关联查询把 resultMap、association、collection、懒加载以及和缓存叠加时的坑一次讲透。想系统梳理 MyBatis 的开发者或者正准备面试被问到“一级缓存二级缓存”的朋友这篇都可以直接参考。1. MyBatis 的半自动 ORM映射链路在三个位置“出活”很多刚接触 MyBatis 的人以为它和 Hibernate 一样是“全自动 ORM”。实际上 MyBatis 是典型的半自动 ORM表结构不会自动生成实体关联关系也不会自动帮你维护它只提供一套映射规则让你明确告诉它“数据库这一列去哪个属性、Java 这个类型怎么转成 JDBC 类型”。理解这一点是搞清楚所有关联查询问题的基础。1.1 入参映射不只是 #{} 和 ${} 的区别入参映射是最容易被忽略的一层。当我们写where user_id #{userId}时MyBatis 做的事不只是“替换成 ?”它还要通过 ParameterHandler 把 Java 参数的类型转换成 JDBC 驱动认识的类型。这里藏了第一个坑团队里如果有人在 XML 里把 #{userId} 写成了字符串拼接 ${userId}不仅可能注入还会让查询计划无法复用但一旦遇到“明明参数传了值却查不到数据”很多人第一反应是 SQL 写错了实际却是 Java 参数类型和数据库列类型没对上。比如数据库字段是BIGINT UNSIGNEDJava 传了一个负数或超长值那么在 JDBC 驱动层就会直接抛异常或者被静默转换掉。排查这种问题时我的习惯是先确认#{}里参数的 getter 是否正常再看数据库列的类型最后才怀疑 SQL。1.2 结果映射resultType 与 resultMap 的分工结果映射是 ORM 映射里最重要的部分。MyBatis 拿到 ResultSet 之后通过 ResultSetHandler 逐行处理数据如果用resultTypeMyBatis 默认按列名和属性名做“同名字段”自动映射配合mapUnderscoreToCamelCase可以把下划线转驼峰如果用resultMap则完全由你定义每一列对应哪个属性包括嵌套对象、集合、类型转换等复杂逻辑。很多人踩过的坑是resultType指望它自动把user_name映射到userName但项目里没开驼峰配置结果属性全部为 null。另一类坑是把resultType写成resultMapuserMap却又没有定义 id运行时直接报错。严格来说只要涉及多表或嵌套对象就不要用resultType它只适合最简单的单表查询。1.3 先学会“把 SQL 打出来”再谈映射排查映射问题最实用的一招是先把 MyBatis 实际执行的 SQL 和参数输出到控制台。Spring Boot 项目里我基本都会在application.yml中加mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplStdOutImpl会把 SQL、参数和返回值行数直接打印出来。你肉眼对照一下数据库执行结果和 Java 收到的对象通常十秒就能定位是 SQL 的问题还是映射的问题。这里有一个经验永远不要凭直觉猜映射结果先看 SQL 输出再下结论。线上环境如果不想用 StdOutImpl可以用 Slf4jImpl然后通过 logback 配置单独控制 mapper 包日志级别同样能拿到 SQL。2. resultMap 单表映射id、typeHandler 和那些容易忽略的小开关关联查询的 resultMap 看上去很复杂但它的基础还是单表映射。如果单表映射都没写对association 和 collection 一定跟着错。我建议先把 resultMap 的每个元素吃透再去看嵌套结构。2.1 id 和 result为什么第一个主键映射那么特殊resultMap 里最常用的是id和resultresultMap iduserMap typeUser id columnuser_id propertyid / result columnuser_name propertyuserName / result columncreated_at propertycreatedAt javaTypejava.time.LocalDateTime / /resultMap很多人以为id只是“主键标记”作用不大。其实它的意义在于MyBatis 在解析结果时会用id判断对象是否相同尤其是在嵌套关联映射中。比如一条订单 JOIN 出两条明细时如果 order 的 id 映射写成了result而不是idMyBatis 可能无法正确合并同一个订单对象在某些特殊结果集中还会影响缓存键的生成。所以只要是主键列尽量用id而不是result。2.2 javaType、jdbcType 与 typeHandler 的配合当 Java 类型和 JDBC 类型不一致时就需要 typeHandler 介入。比如订单状态在数据库里是TINYINTJava 里是枚举OrderStatus商品附加信息在数据库里是 JSON 字符串Java 里是ListSkuAttr。这种场景resultMap 必须显式指定 typeHandler。result columnstatus propertystatus typeHandlercom.demo.handler.OrderStatusTypeHandler/自定义 TypeHandler 通常继承BaseTypeHandlerT重点是setNonNullParameter和getNullableResult两个方向public class OrderStatusTypeHandler extends BaseTypeHandlerOrderStatus { Override public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } Override public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { Integer code rs.getObject(columnName, Integer.class); return code null ? null : OrderStatus.fromCode(code); } }写 typeHandler 最容易漏的是 null 处理。数据库里很多列允许为空getNullableResult里的rs.getObject可能返回 null如果不判空行映射时一旦遇到 null 值就直接 NPE 或转换成默认枚举线上排查时特别误导人。这也是我建议团队里枚举字段统一走 typeHandler而不是在 Service 层手动转换的原因映射层语义归映射层业务层不该背着这种脏活。2.3 用驼峰自动映射省掉一半 resultMap如果你的表设计规范列名都是user_name、created_at这类下划线风格并且实体类属性是标准的驼峰命名那么打开驼峰自动映射后单表 resultMap 可以大大瘦身mybatis: configuration: map-underscore-to-camel-case: true但我要提醒一句驼峰自动映射只对“同名转换”有效。如果查询 SQL 里写了别名比如SELECT u.name AS user_name别名user_name依然能正确映射到userName反过来如果 SQL 里写了SELECT u.name而没有别名即使开了驼峰也只会找name属性跟userName毫无关系。很多人在这里栽跟头以为是驼峰开关没用其实是 SQL 别名根本没写。3. association 与 collection一对多关联查询的两种写法和选型关联查询的映射核心就两个标签association表示“一个对象里嵌套另一个对象”collection表示“一个对象里嵌套一个集合”。它们的用法相似但语义完全不同。我以订单模块为例把两种典型写法都列出来。3.1 association一对一对象嵌套订单查用户典型的一对一。Java 类大概是public class Order { private Integer id; private String orderNo; private User user; }用 JOIN 一次查出结果通过嵌套结果映射resultMap idorderWithUserMap typeOrder id columnorder_id propertyid/ result columnorder_no propertyorderNo/ association propertyuser javaTypeUser id columnuser_id propertyid/ result columnuser_name propertyuserName/ /association /resultMap对应 SQLSELECT o.order_id, o.order_no, u.user_id, u.user_name FROM t_order o LEFT JOIN t_user u ON o.user_id u.user_id WHERE o.order_id #{id}这里有两个关键点。第一association里的id很重要决定了 MyBatis 能否正确区分两个不同的 User 对象第二如果user_id在结果集里为 NULL也就是订单没有用户MyBatis 会把user属性置为 null不会报错但也不会帮你创建一个空对象。所以业务代码里别默认order.getUser().getUserName()一定非空。3.2 collection一对多集合嵌套订单带明细典型的一对多。Java 类public class Order { private Integer id; private String orderNo; private ListOrderItem items; }resultMap 这样写resultMap idorderWithItemMap typeOrder id columnorder_id propertyid/ result columnorder_no propertyorderNo/ collection propertyitems ofTypeOrderItem id columnitem_id propertyid/ result columnitem_name propertyitemName/ result columnitem_price propertyitemPrice/ /collection /resultMapSQL 通常是订单表 LEFT JOIN 明细表。注意collection必须要写ofType它表示集合元素的类型如果没有ofTypeMyBatis 不知道ListOrderItem里装的是哪种对象。3.3 嵌套 select 与懒加载N1 的起点除了 JOIN 一次性查出所有数据MyBatis 还支持另一种写法嵌套 Select。也就是主查询先查出 Order再根据user_id单独查一次 UserresultMap idorderWithUserMap typeOrder id columnorder_id propertyid/ association propertyuser columnuser_id selectcom.demo.mapper.UserMapper.selectById fetchTypelazy/ /resultMap这种写法很灵活但它是 N1 查询的经典来源主表查出 N 条订单每条订单又额外执行一次用户查询总共执行 N1 次 SQL。数据量一上来数据库连接池和慢查询报表很容易就被打爆。正因为有这个问题MyBatis 提供了懒加载。开启方式mybatis: configuration: lazy-loading-enabled: true aggressive-lazy-loading: false当aggressive-lazy-loading为 false 时只有真正访问order.getUser()才会触发嵌套查询。这个设计听起来优雅但实战里非常容易踩坑如果你在 Service 层把 Order 对象直接返回给前端Jackson 序列化时会调用getUser()此时 SqlSession 已经关闭就会抛LazyInitializationException。我遇到过多次这种问题最后的结论是复杂业务查询优先用 JOIN嵌套 select 只适合少数明确不会并发量很大的场景。4. 关联查询与分页的实战死角重复行、字段冲突和 N1如果说上面的内容还算“教科书基础”那这一节基本就是实战项目里最容易炸的部分。尤其是订单列表这种一对多关联再叠加分页各种诡异问题都会冒出来。4.1 一对多 JOIN 之后分页总数为什么不再可信最常见的场景是订单主表 LEFT JOIN 订单明细表然后通过 PageHelper 分页。表面上 SQL 没问题COUNT 却经常算错。原因其实很简单一条订单有三条明细JOIN 之后结果集里就会产生三行。物理分页把这三行当作三条记录导致 PageHelper 统计出来的 total 比真实订单数多。此时你看到的列表可能长这样同一个订单 ID 重复出现、每页数据量明明写的是 10 却只显示了 3 个不同订单。解决办法不是去改 PageHelper 的配置而是调整查询思路。要么把明细放在子查询中聚合要么对主表先分页再关联。最直接的做法SELECT o.*, ... FROM t_order o LEFT JOIN t_order_item oi ON o.order_id oi.order_id WHERE o.order_id IN ( SELECT order_id FROM t_order WHERE 条件 ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} )把分页条件限制在主表上JOIN 出来的明细再多也只是把单页的订单行数放大而不会污染总数统计。4.2 主表分页 子集合批量回填的替代方案如果列表聚合的字段特别多JOIN 会让 SQL 非常臃肿。我在实际项目里更常用的是一套“分段查询”方案尤其适合订单列表、商品 SKU 列表这类典型一对多场景先按条件分页查出主表数据收集主表记录的 ID 列表用WHERE order_id IN (...)一次性查明细表在内存里按 orderId 分组回填到对应对象的集合属性中。伪代码大概长这样PageOrder page orderMapper.selectPage(query); ListInteger orderIds page.getRecords().stream() .map(Order::getId) .toList(); ListOrderItem items orderItemMapper.selectByOrderIds(orderIds); MapInteger, ListOrderItem group items.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); page.getRecords().forEach(order - order.setItems(group.getOrDefault(order.getId(), Collections.emptyList())));这样主查询和子查询各自只执行一次从 N1 变成了稳定的 11而且分页总数完全可控。表面上写了三层代码但性能通常比一个大 JOIN 要好尤其是在明细表数据量大的时候。这就是为什么我一直劝团队MyBatis 的嵌套 collection 很强大但不代表每个列表都要用它。4.3 多表 JOIN 的字段别名和结果映射冲突多表关联时字段重名是最坑的。比如用户表有name商户表也有name如果 SQL 直接SELECT u.name, m.nameMyBatis 映射完可能两个属性都拿到同一个值。因为 MyBatis 映射时按列名取name后一个列覆盖了前一个。解决方案只有一个在 SQL 里给重名列起不同的别名。我通常在关联查询里强制约定所有列名都要带业务前缀比如user_name、merchant_name然后 resultMap 里对应propertyuserName和propertymerchantName。不要嫌啰嗦这个习惯能避免掉线上 80% 的属性错乱问题。还有一个容易被忽略的是聚合函数列比如COUNT(*)MyBatis 结果集里列名是COUNT(*)Java 属性是totalCount如果不加别名永远映射不上。5. 一级缓存、二级缓存和关联查询叠加时的几个深坑缓存是 MyBatis 面试题里高频出现的点也是 ORM 映射完成后最容易连锁出错的地方。关联查询一旦沾上缓存问题就不仅是怎么映射而是“缓存里的对象到底是不是你想要的”。5.1 一级缓存Spring 管理下的“有等于无”MyBatis 一级缓存基于 SqlSession 生命周期。同一个 SqlSession 内同样的查询第一次会查库第二次直接返回缓存结果。但在 Spring Boot MyBatis 项目中Mapper 接口的每次调用都通过 SqlSessionTemplateSQL 执行完 SqlSession 就关闭了一级缓存几乎形同虚设。真正能感受到一级缓存存在的是两种情况一是在同一个Transactional事务方法里连续查同一 Mapper二是有手动 SqlSession 的场景。事务模式下一级缓存能帮你合并掉 N1 里重复子查询的数据库压力。所以你如果问“一级缓存会不会查脏数据”在 Spring 非事务环境里很难碰到因为它压根没存多久。5.2 二级缓存跨 SqlSession 复用带来的脏数据风险二级缓存是 Mapper namespace 级别的。开启方式是在 mapper XML 里加一行cache/默认基于 PerpetualCache加装饰器 LRU 淘汰、定时刷新等配置mapper namespacecom.demo.mapper.OrderMapper cache evictionLRU flushInterval60000 size512 readOnlyfalse/ /mapper关联查询里最常见的坑是二级缓存按 namespace 划分而 JOIN 查询却跨越了多个表。比如OrderMapper里缓存了一个“订单明细用户”的联合结果之后单独更新了OrderItemMapper对应的明细表OrderItemMapper的缓存会被清空但OrderMapper的缓存不会联动失效。于是用户会看到旧数据。这种脏数据很难定位因为它不是必现而是在“订单查询先发生、明细更新后发生”的时间窗口里出现。要规避一般有三招涉及频繁更新的表关联查询干脆不开启二级缓存让相关 Mapper 共享同一个缓存区域用cache-ref指向同一个 namespace这样任何一个 Mapper 的更新都会清理共享缓存更新别的表后手动调用需要清理缓存的那个 Mapper 对应的 SqlSession.clearCache()或者通过flushCache强制刷新。mapper namespacecom.demo.mapper.OrderItemMapper cache-ref namespacecom.demo.mapper.OrderMapper/ /mapper第二招在技术上是通的但它会牺牲命中率因为明细表一更新就全清。实际项目里我更倾向于对“多表 JOIN 频繁写”的组合直接关掉二级缓存把性能优化放在 SQL 和索引上。还要注意readOnly属性。readOnlyfalse时 MyBatis 会对缓存对象做序列化复制所以缓存对象必须实现Serializable否则会抛NotSerializableException。而readOnlytrue虽然不要求序列化但多个线程拿到的是同一个对象引用任何线程修改了对象属性都会污染缓存。很多团队一开始把订单对象加缓存结果订单状态一变列表里所有订单的状态都跟着变就是因为这个。5.3 懒加载代理对象与序列化的相爱相杀二级缓存和懒加载叠加是另一个雷区。当关联查询使用嵌套 select 并开启懒加载时映射出来的对象可能是一个 CGLIB 代理对象。这时候如果二级缓存配置成readOnlyfalse需要对象可序列化而代理对象往往不能被正常序列化轻则缓存写入失败重则直接抛异常。我的经验是一个查询如果既要用懒加载又想进二级缓存基本就是给自己找麻烦。要么把懒加载关掉让对象在进入缓存前是完整状态要么让这个查询不进二级缓存只靠一级缓存撑住单次会话内的重复查询。提示凡是resultMap里带association或collection且开启了二级缓存的查询上线前一定提前压测。别等到生产环境出现莫名其妙的序列化异常再回头查那就是一场灾难。最后说一点筛选映射层的个人经验如果你想听我的建议在复杂业务系统里不要迷信“全自动”映射也不要什么都堆在一个超大 resultMap 里。单表查询用自动映射简单关联用 resultMap 的 association/collection复杂的列表聚合用“主表分页 子表批量回填”。这样分类出来整个 Mapper 的可维护性会高很多。我在大促流量下还吃过一次亏某个运营报表查询在开发环境数据量小看不出来一上生产就慢到几秒。后来定位是因为它的 resultMap 嵌了三层 collectionJOIN 出来的临时结果行数爆炸MyBatis 要逐行做对象合并。改成先分页主表、再批量查子表、最后内存分组后接口从 3.2 秒降到了 300 毫秒。这是我印象比较深的一次实践也是我把这个方法当作压箱底经验的原因。MyBatis 的映射思路本质上是“告诉框架怎么把列变成对象”真正性能提升的关键还是在 SQL 结构和数据访问方式的设计上。