2284 字
约 7 分钟
22
S08-B06 · MyBatis 映射入门:结果映射、动态 SQL 与缓存

同一条 select * from user where id = 1,在数据库客户端能看到完整一行,塞进 MyBatis 却返回属性全是 null 的对象。问题不在 SQL 执行,而在结果列怎么落到 Java 属性上。MyBatis 不做隐式补齐:列名对不上、映射没声明,它就安静地留空,不报错。

下面沿"一个查询为什么拿到 null"往下走:三层结构 → 映射规则 → 动态条件 → 缓存,把链路跑通一次。


1. 三层结构:接口为什么不需要实现类

层 谁在干活 你写的部分
SqlSession 一次会话,持有连接、执行器、一级缓存 由 SqlSessionFactory 打开,通常交给 Spring 管
Mapper 接口 只声明方法签名,没有实现 User findById(Long id)
XML / 注解 真正的 SQL 与映射规则 <select id="findById" ...>

接口没有实现类却能调用,靠动态代理 + 全限定名绑定:MapperRegistry 解析时把 namespace 与接口全名对上,getMapper() 生成 JDK 代理,代理按方法名找到 MappedStatement,再把参数与结果搬进搬出。

接口本身只写签名,如 User findById(Long id);,方法名就是 XML 里的 id。

<mapper namespace="com.example.mapper.UserMapper">
  <select id="findById" resultType="com.example.domain.User">
    select id, user_name, created_at from t_user where id = #{id}
  </select>
</mapper>

三条绑定规则:namespace 等于接口全限定名;id 等于方法名;参数名靠 @Param 或编译时 -parameters,否则只能写 #{arg0}。注解 @Select 与 XML 走同一套 MappedStatement。


2. #{} 与 ${}:预编译与字符串拼接

where user_name = ?          -- #{} 生成占位符,值走绑定参数
where user_name = 'alice'    -- ${} 直接拼成文本
写法 处理方式 防注入 适用
#{} PreparedStatement 占位符,值随参数发送 能,值不参与语法解析 所有值位置
${} 字面量拼接,值成为 SQL 文本 不能 表名、列名、排序方向

${} 的合法用途只有"结构部分无法参数化":动态表名、order by ${sortColumn}。要用又不想被注入,就做白名单:

private static final Set<String> ALLOWED = Set.of("created_at", "score");
if (!ALLOWED.contains(sort)) throw new IllegalArgumentException("bad sort");

3. resultType 与 resultMap 的分工

resultType 靠列名与属性名自动匹配;resultMap 显式声明每一列的落点。前者省事,后者是唯一能处理"名字不一致 + 嵌套"的手段。

场景 用什么 说明
列名与属性一致(或开了驼峰) resultType 一行一个对象
user_name → userName resultType + 驼峰开关 见下
列名完全不同(u_name → name) resultMap 必须显式 column/property
对象含集合或子对象 resultMap + collection/association resultType 表达不了
聚合查询的临时结果 resultType 指向 DTO 别名对齐 DTO 字段

驼峰开关在 3.5.x 默认 false,开了才把 user_name 映射到 userName:

mybatis:
  configuration:
    map-underscore-to-camel-case: true
<resultMap id="UserMap" type="com.example.domain.User">
  <id column="id" property="id"/>
  <result column="u_name" property="name"/>
</resultMap>

字段全 null 的核对顺序:① resultType 写成接口/Map;② 列名没别名也没开驼峰;③ select 没查这列;④ 下划线或大小写写错。


4. 一对多与多对一:collection 与 association

<!-- 嵌套结果 -->
<resultMap id="OrderMap" type="com.example.domain.Order">
  <id column="order_id" property="id"/>
  <association property="user" javaType="com.example.domain.User">
    <id column="user_id" property="id"/>
    <result column="user_name" property="name"/>
  </association>
  <collection property="items" ofType="com.example.domain.OrderItem">
    <id column="item_id" property="id"/>
  </collection>
</resultMap>
<!-- 嵌套查询 -->
<resultMap id="OrderMapLazy" type="com.example.domain.Order">
  <id column="id" property="id"/>
  <collection property="items" column="id"
              select="com.example.mapper.ItemMapper.findByOrderId"/>
</resultMap>
方式 SQL 条数 数据放大 N+1
嵌套结果(join) 1 子集合笛卡尔放大 否
嵌套查询(select 属性) 1 + N 每行独立 是,N 为主表行数

嵌套查询取 10 个订单就是 11 条 SQL;主表 2,000 行就是 2,001 条。要么改嵌套结果,要么开 lazyLoadingEnabled 分批加载,要么业务层二次聚合。join 用于一对多时主表字段每行重复,collection 会按 id 归并。


5. 动态 SQL:多出来的那个 and

<select id="search" resultType="com.example.domain.User">
  select id, user_name from t_user
  <where>
    <if test="name != null and name != ''">
      and user_name like concat('%', #{name}, '%')
    </if>
    <if test="minScore != null">and score &gt;= #{minScore}</if>
    <choose>
      <when test="status != null">and status = #{status}</when>
      <otherwise>and status != 'DELETED'</otherwise>
    </choose>
    <if test="ids != null and ids.size() > 0">
      and id in
      <foreach collection="ids" item="it" open="(" separator="," close=")">#{it}</foreach>
    </if>
  </where>
</select>

<where> 只做两件事:子句非空时补 where,并剥掉最前面的 and/or。所以"多一个 and 就语法错"的成因不是多写:

现象 成因 改法
第一个条件生效就报错 手写 where,没让 <where> 接管 改用 <where>
只有第二个条件生效仍报错 <trim> 漏了 prefixOverrides 的 and/or 补该属性
条件全不成立少了 where <trim> 只配了 suffixOverrides 同时配 prefix="where"
and 跑到 order by 前 动态条件写在排序之后 调整 XML 顺序

<set> 是 <trim prefix="set" suffixOverrides=","> 的语法糖,专治 update 尾逗号。<foreach> 的 collection 写接口参数名:有 @Param 按注解,无注解是 arg0/list,写错报 Parameter 'ids' not found。

#{} 不能直接放进 in:in (#{ids}) 只绑定一个值,永远不展开成列表,必须用 <foreach> 生成 ?,?,?。集合为 null 要先判空,否则 <if test="ids.size() > 0"> 直接 NPE——OGNL 对 null 调 .size() 不返回 false。


6. 一级缓存与二级缓存:为什么改了库还查到旧值

缓存 作用域 默认 失效条件
一级 同一个 SqlSession 开 commit/rollback/close、执行 update、flushCache
二级 同一个 namespace 关 同 namespace 的写操作、flushInterval;跨 namespace 写入不清
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>

readOnly="false" 要求返回对象可序列化,否则抛 NotSerializableException。三个要点:

  1. Spring 下没有事务时每次调用都是新 SqlSession,一级缓存等于不存在。
  2. 二级缓存按 namespace 隔离:OrderMapper 更新 A 表不清 UserMapper 的缓存——"改了库还查到旧值"最常见的原因。
  3. 二级缓存是进程内的,多实例互不可见。

排查顺序:换 namespace 查一次,变新就是二级缓存 → 再看有无跨 namespace 写入 → 确认更新已提交。要一致就关掉二级缓存,用 Redis 显式失效。


7. 批量插入与分页:limit 和插件的差别

<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
  insert into t_user (user_name, score) values
  <foreach collection="list" item="u" separator=",">(#{u.name}, #{u.score})</foreach>
</insert>
try (SqlSession s = factory.openSession(ExecutorType.BATCH)) {
    for (User u : users) s.getMapper(UserMapper.class).insertOne(u);  // 攒批
    s.commit();                                                      // 才真正发出
}

useGeneratedKeys 只能回填自增主键,多行 values 的回填条数按驱动版本核对。BATCH 执行器只把 Statement 攒起来,MySQL 还要 JDBC URL 加 rewriteBatchedStatements=true 才合并成多值 INSERT,否则仍是逐条往返。

分页方式 机制 代价
手写 limit #{offset}, #{size} SQL 自带分页 自己算 offset,还得单独写 count
分页插件(PageHelper 一类) 拦截器改写 SQL 并自动 count 多一次 count;order by 不稳会重复/漏行

两者最终都落到 limit,插件省的是样板代码。


8. 最小闭环:从 null 到命中缓存

  1. 建表插一行:create table t_user(id bigint primary key auto_increment, user_name varchar(32), score int);
  2. resultType 指向 User 先不开驼峰查一次:id 有值,userName 为 null;打开 map-underscore-to-camel-case: true 重启后有值。这就是第 3 节第二个原因。
  3. 加第 5 节的动态查询,传 name=null, minScore=1,看日志里 and 是否被剥掉。
  4. 开 <cache/>,同事务内查两次同 id 只有一条 SQL;update + commit 后再查 SQL 重新出现。换另一个 Mapper 更新同一行再查仍是旧值——跨 namespace 不清缓存。

logging.level.com.example.mapper 调到 debug 即可看到每条 SQL 与参数。


9. 排错

现象 原因 处理
属性全 null,SQL 有结果 列名不匹配且未开驼峰 / 未写 resultMap 开驼峰或补 resultMap
Parameter 'xxx' not found 多参数未加 @Param,collection 名写错 加注解或改用 arg0
改了库仍查到旧值 二级缓存跨 namespace 未清 / 未提交 关缓存或显式失效
in 查询语法错 #{} 直接放在 in (...) 内 改 <foreach>

10. 边界

场景 更合适的选择 理由
复杂动态条件 + 多表 MyBatis-Plus 或手写 XML 注解里拼条件不可维护

MyBatis 的价值在"SQL 可控 + 映射可声明",它不负责 SQL 写得好不好。


11. 自测

  1. Mapper 接口没有实现类,调用时是谁在执行 SQL?
  2. #{} 与 ${} 差在哪一步,为什么后者有注入风险?
  3. 开了驼峰仍然全 null,按什么顺序排查?
  4. collection 用嵌套查询取 10 个订单,会发几条 SQL?
  5. <where> 剥掉的是哪个位置的 and?手写 where 为什么做不到?
  6. 同一事务里改了数据、再查仍是旧值,最可能命中了哪一级缓存?
S08-B06 · MyBatis 映射入门:结果映射、动态 SQL 与缓存
https://www.clxhxhhr.top/posts/6144/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。