MYBATIS 3.5 · MAPPER · SQLSESSION · EXECUTOR

一行 Java,怎么变成 SQL,又变回对象

你只写了一个接口方法和一段 SQL。中间那些活:找到 SQL、填参数、发给数据库、把一行行结果变成对象,全是 MyBatis 替你干的。

你写的调用orderMapper.selectById(7)
MyBatis 拼出的 SQL
SELECT id, order_no, amount
FROM t_order
WHERE id = ?
? ← 7
数据库回来的行
idorder_noamount
7NO2026110599.00
你拿到的对象Order{id=7, orderNo="NO20261105", amount=99.00}
替身MapperProxy

你注入的 Mapper 接口,背后是它。拿接口全名 + 方法名去找 SQL。

JDK 动态代理
总手册Configuration

启动时把 XML 全读进来,每条 SQL 一页,叫 MappedStatement。

启动时编好,运行时只查
执行器Executor

管缓存:先翻缓存,没有才去数据库。

每个 SqlSession 一个
跑腿三兄弟StatementHandler 等

准备语句、填问号、拆结果。最后干活的都是 JDBC。

Statement · Parameter · ResultSet Handler
配角 会话 SqlSession,一次和数据库打交道的过程 两级缓存 会话内的一级、命名空间的二级 翻译 TypeHandler,Java 类型和 JDBC 类型互转 插件 能插在四个对象上,分页插件就是这么来的

启动时:先把手册编好

MyBatis 启动时把配置和每个 Mapper XML 读一遍,一条 SQL 变成手册里的一页。运行时不再读 XML,只按编号翻页。

OrderMapper.xml
<mapper namespace="com.shop.mapper.OrderMapper">
  <select id="selectById" resultType="Order">
    SELECT id, order_no, amount
    FROM t_order
    WHERE id = #{id}
  </select>
</mapper>
手册里的一页MappedStatement
编号
com.shop.mapper.OrderMapper.selectById
类型
SELECT
SQL
RawSqlSource:没有动态标签,#{id} 启动时就换成了 ?
结果
按 Order 自动映射
语句
PREPARED,也就是 PreparedStatement
缓存
useCache=true,flushCache=false(select 的默认值)
关键编号 = namespace + id。运行时替身拿“接口全名 + 方法名”来翻页,两边必须一字不差。对不上就是那句 Invalid bound statement (not found)。
总手册里还有:所有 MappedStatementResultMapMapper 接口登记表TypeHandler 登记表插件链二级缓存
在 Spring Boot 里谁来编

MybatisAutoConfiguration 读 mybatis.* 配置,造好 SqlSessionFactory 和 SqlSessionTemplate。就是第 03 篇讲的自动配置。

Mapper 怎么变成 Bean

@MapperScan 或 @Mapper 把每个接口登记成 MapperFactoryBean,你注入拿到的就是 MapperProxy。

XML 从哪读

mybatis.mapper-locations 指定的路径;或者放在和接口同一个包路径下,登记接口时会顺手加载。

运行时:一次查询经过的人

从上往下一层层交下去,结果再一层层交回来。

接口层你看得见的
1替身翻手册,找到这一页MapperProxy → MapperMethod
2变成一次会话调用sqlSession.selectOne("…selectById", 7)
执行器拼 SQL、查缓存
3拼出最终 SQL,算缓存 keygetBoundSql · createCacheKey
4查二级缓存CachingExecutor
5查一级缓存BaseExecutor.localCache
JDBC真正干活
6准备语句StatementHandler
7填问号ParameterHandler
8执行ps.execute()
9行变对象ResultSetHandler
打个断点看调用栈,就是这样一层套一层(最上面先调用)
  1. orderMapper.selectById(7)其实是 MapperProxy
  2. MapperProxy.invoke → MapperMethod.execute翻手册
  3. SqlSessionTemplate.selectOneSpring 里才有这层
  4. DefaultSqlSession.selectOne → selectList会话
  5. CachingExecutor.query二级缓存
  6. BaseExecutor.query → queryFromDatabase一级缓存
  7. SimpleExecutor.doQuery默认执行器
  8. PreparedStatementHandler.queryJDBC
  9. PreparedStatement.execute()数据库

跟着一次调用走一遍

选一个剧情,一步一步看:SQL 拼成了什么样,缓存里有什么,数据库被查了几次,最后返回了什么。

orderMapper.selectById(7)
  1. 1替身翻手册MapperProxy → MapperMethod
  2. 2会话SqlSessionTemplate → DefaultSqlSession
  3. Executor 里面
  4. 3拼 SQLgetBoundSql + CacheKey
  5. 4二级缓存CachingExecutor
  6. 5一级缓存BaseExecutor.localCache
  7. StatementHandler 里面(JDBC)
  8. 6准备语句prepareStatement
  9. 7填问号ParameterHandler + TypeHandler
  10. 8执行ps.execute()
  11. 9处理结果ResultSetHandler · KeyGenerator
  12. 10回程放缓存,返回

剧情里 OrderMapper.xml 没写 <cache/>,也就是没开二级缓存,这是最常见的配置。

SQL · BoundSql

会话和缓存

返回给你的

1/10
MapperProxy.invoke

拼 SQL:动态标签,#{} 和 ${}

第 3 步“拼 SQL”有两件事:先按参数把动态标签算一遍,再把 #{} 换成问号。

selectByCond
<select id="selectByCond" resultType="Order">
  SELECT id, order_no, amount FROM t_order
  <where>
    <if test="status != null">
      AND status = #{status}
    </if>
    <if test="keyword != null and keyword != ''">
      AND order_no LIKE CONCAT('%', #{keyword}, '%')
    </if>
  </where>
</select>
statuskeyword拼出来的 WHERE
1nullWHERE status = ?
null"1105"WHERE order_no LIKE CONCAT('%', ?, '%')
开头的 AND 被 <where> 去掉了
1"1105"WHERE status = ? AND order_no LIKE …
nullnull没有 WHERE,整张表都查出来
<if>条件成立才留下这段
<where>有内容才加 WHERE,去掉开头的 AND / OR
<set>UPDATE 用,去掉末尾多余的逗号
<foreach>把集合展开成 IN (?, ?, ?) 或批量 VALUES
<choose>多选一,相当于 if / else if / else
<sql> + <include>抽出公共片段,比如列名清单

WHERE name = #{name}


        

输入原样当成一个字符串值,查不到人,也改不了 SQL。

WHERE name = '${name}'


        

#{} 变成问号,用 PreparedStatement 传值;${} 是直接把字符串拼进 SQL。表名、ORDER BY 的列名这类不能用问号的地方才用 ${},而且只接受白名单里的值。

两级缓存

查询顺序是先二级、再一级、最后数据库。一级默认开着,二级要自己在 XML 里写 <cache/>。

二级缓存OrderMapper 命名空间写了 <cache/> 才有。所有 SqlSession 共用,会话提交或关闭后,结果才放进来。
SqlSession #1一级缓存,只有 #1 自己能看到
selectById · 7
SqlSession #2一级缓存,只有 #2 自己能看到
selectByCond · 1
两级都没命中,才查数据库
查询顺序:二级缓存一级缓存数据库
一级缓存二级缓存
默认开着关着,要在 Mapper XML 里写 <cache/>
范围一个 SqlSession一个 namespace,跨 SqlSession
什么时候放进去查完马上放SqlSession 提交或关闭之后
什么时候清空本会话任何增删改、commit、rollback、close本 namespace 的增删改
在 Spring 里没开事务时每次调用都是新会话,基本用不上多台机器各存各的;别的 namespace 改了同一张表不会清,容易读到脏数据
要求无实体要实现 Serializable,默认返回的是序列化出来的副本

实际项目里,二级缓存很少开。要缓存,一般在 Service 层用 Redis 自己管。

插件:能插在哪四个地方

MyBatis 只允许拦这四个对象。插件就是给它们套一层 JDK 动态代理,方法被调用时先经过你。

Executor
query · update · commit · rollback

例:PageHelper 分页,在这里改写 SQL、先查总数。

StatementHandler
prepare · parameterize · query · update

例:慢 SQL 统计、多租户自动加条件。

ParameterHandler
setParameters

例:入库前给手机号加密。

ResultSetHandler
handleResultSets

例:查出来后给身份证号打码。

一个统计慢 SQL 的插件
@Intercepts(@Signature(type = StatementHandler.class, method = "query",
        args = {Statement.class, ResultHandler.class}))
public class SlowSqlPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation inv) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return inv.proceed();                // 继续往下走
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > 500) log.warn("慢 SQL {} ms", cost);
        }
    }
}
  • 在 Spring Boot 里声明成 @Bean 就行,自动配置会把所有 Interceptor 装进去。
  • 多个插件一层套一层:后登记的在最外层,最先执行。
  • 插件拿到的是真实对象的方法调用,改错了会影响每一条 SQL。写之前先想清楚要拦哪个方法。

JDK 8 和 JDK 17:流程一样,依赖版本不同

MyBatis 本体两边都是 3.5.x,上面讲的流程完全一样。变的是和 Spring Boot 对接的那几个包。

Spring Boot 2

JDK 8+
  • mybatis-spring-boot-starter 2.3.x(配 Boot 2.7),里面是 mybatis-spring 2.1.x
  • MyBatis-Plus 用 mybatis-plus-boot-starter
  • 注解里写长 SQL,只能用字符串拼接:
@Select("SELECT id, order_no, amount " +
        "FROM t_order " +
        "WHERE status = #{status}")
List<Order> selectByStatus(int status);

Spring Boot 3

JDK 17+
  • mybatis-spring-boot-starter 3.0.x,里面是 mybatis-spring 3.0.x,最低要 JDK 17
  • MyBatis-Plus 要换成 mybatis-plus-spring-boot3-starter,用老的会启动失败
  • 自动配置登记在 AutoConfiguration.imports 里(第 03 篇)
  • 注解里的 SQL 可以用文本块,好读多了:
@Select("""
        SELECT id, order_no, amount
        FROM t_order
        WHERE status = #{status}
        """)
List<Order> selectByStatus(int status);
没变的:启动时编手册MapperProxy → SqlSession → Executor → StatementHandler两级缓存四大对象和插件

常踩的坑

先想清楚问题出在哪一步:启动时编手册、拼 SQL、查缓存,还是把行变成对象。

Invalid bound statement (not found)

手册里没有这一页。查 mybatis.mapper-locations 的路径;XML 放在 src/main/java 下的话,Maven 默认不打包;再核对 namespace 和 id 跟接口全名、方法名是否一致。

Parameter 'status' not found. Available parameters are [arg1, arg0, param1, param2]

方法有多个参数,没写 @Param,编译时又没带 -parameters,MyBatis 拿不到参数名。给每个参数加上 @Param("status")。

用了 ${},被 SQL 注入

值一律用 #{}。排序列名这类只能用 ${} 的,先和白名单比对。

查出来 orderNo 是 null

列名 order_no 和属性名 orderNo 对不上。配 mybatis.configuration.map-underscore-to-camel-case=true,或者写 resultMap。

TooManyResultsException: Expected one result (or null) … but found: 2

返回单个对象的方法查出了多行。检查 WHERE 条件,或者把返回类型改成 List。

一级缓存:不开事务不生效,开了事务又读到旧数据

没事务时每次调用一个新 SqlSession;有事务时,同一会话里第二次查直接拿缓存,看不到别人刚改的数据。不想要它,配 local-cache-scope=statement。

<if test="status != null and status != ''">,status 传 0,条件没了

OGNL 把 '' 当成 0 来比,0 != '' 不成立。数字参数只判断 != null。

<foreach> 遇到空集合,SQL 报语法错

拼出了 IN ()。调用前先判空,空集合直接返回空结果。

insert 之后拿不到 id

方法返回的是影响行数。配 useGeneratedKeys="true" keyProperty="id",id 会写回你传进去的那个对象。

查一个列表,日志里刷出一堆小 SQL

N+1 问题:<association> / <collection> 用了 select 嵌套查询,每一行再查一次。改成 JOIN 加 resultMap,或者打开懒加载。