同一条 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 >= #{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。三个要点:
- Spring 下没有事务时每次调用都是新
SqlSession,一级缓存等于不存在。 - 二级缓存按 namespace 隔离:
OrderMapper更新 A 表不清UserMapper的缓存——"改了库还查到旧值"最常见的原因。 - 二级缓存是进程内的,多实例互不可见。
排查顺序:换 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 到命中缓存
- 建表插一行:
create table t_user(id bigint primary key auto_increment, user_name varchar(32), score int); resultType指向User先不开驼峰查一次:id有值,userName为null;打开map-underscore-to-camel-case: true重启后有值。这就是第 3 节第二个原因。- 加第 5 节的动态查询,传
name=null, minScore=1,看日志里and是否被剥掉。 - 开
<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. 自测
- Mapper 接口没有实现类,调用时是谁在执行 SQL?
#{}与${}差在哪一步,为什么后者有注入风险?- 开了驼峰仍然全
null,按什么顺序排查? collection用嵌套查询取 10 个订单,会发几条 SQL?<where>剥掉的是哪个位置的and?手写where为什么做不到?- 同一事务里改了数据、再查仍是旧值,最可能命中了哪一级缓存?