01-MyBatis 基础:从配置到动态 SQL 与两级缓存
📝 笔记摘要:MyBatis 把 JDBC 的哪一段收走了
用裸 JDBC 查一次数据,代码里要出现建连接、拼 SQL、逐个设参数、执行、遍历结果集、关连接这一整套样板;换一张表,这套样板要重抄一遍。MyBatis 收走的正是这一段,代价是多了一层 XML 配置和一层运行时装配。
这篇按 2026 年的现状写:MyBatis 3.5.19,Connector/J 的坐标已经换成 com.mysql:mysql-connector-j,驱动类名不再是 com.mysql.jdbc.Driver。配置文件的元素顺序是 DTD 硬约束,顺序错了报错信息会直接把合法顺序打出来;参数绑定里 #{} 和 ${} 只差一个字符,一个是预编译占位符,一个是字符串拼接。
这篇的结构
1. 一条查询在 MyBatis 里怎么走
2. 引入与版本
3. 配置文件:元素顺序是硬约束
4. 数据源和连接串
5. SqlSessionFactory 与 SqlSession
6. 映射:resultMap 与驼峰规则
7. 参数绑定:#{} 与 ${}
8. 动态 SQL 的七个标签
9. 两级缓存:挂在哪儿、什么时候失效
10. 注解开发与 Lombok
11. 常见报错的根因
1. 一条查询在 MyBatis 里怎么走
裸 JDBC 查一张 user 表,写出来大致是这样:
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement(
"SELECT id, user_name FROM user WHERE id = ?");
ps.setLong(1, 1L);
ResultSet rs = ps.executeQuery();
User u = new User();
while (rs.next()) {
u.setId(rs.getLong("id"));
u.setUserName(rs.getString("user_name"));
}
rs.close();
ps.close();
conn.close();
这里面真正和业务有关的只有两行:WHERE id = ? 和取值的两个 rs.getXxx。剩下六行是每次都要重抄一遍的连接管理,两行是每次都要重抄一遍的字段搬运。
MyBatis 收走的第一段 连接管理交给
DataSource,应用代码不再出现DriverManager、conn.close()
收走的第二段 字段搬运交给ResultMap,列名到属性的映射写一次就够
没有收走的 SQL 本身还是你写的,MyBatis 不是 ORM 黑盒,复杂 SQL 照样要手写
运行时这条链路大致是这样:

链路上几个对象的职责值得记住,因为排查问题时得知道该往哪看:
SqlSessionFactory 重量级、全局一个。内部装的是
Configuration,解析完的 XML 常驻在这里
SqlSession 轻量级、一次业务一个。非线程安全,不能当成员变量共享
Executor 真正发 SQL 的地方,一级缓存也挂在这里
StatementHandler 把参数塞进PreparedStatement,执行
ResultSetHandler 按ResultMap把结果集搬成对象
Executor 有三种模式,defaultExecutorType 决定默认哪种:
SIMPLE 默认值,每次都新建 Statement
REUSE 复用 PreparedStatement,省掉重复预编译
BATCH 攒一批更新再一次性提交,配合addBatch()用
2. 引入与版本
核心坐标就一个:
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.19</version>
</dependency>
3.5.19 是目前 org.mybatis:mybatis 的最新版本,也是 3.5.x 的收尾版本。老教程里的 3.4.6、3.5.7 都已经过时,其中 3.5.9 是个分水岭:它把 LOG4J 日志实现标成了 deprecated。
提示:
3.5.9之前用<setting name="logImpl" value="LOG4J"/>还能跑,之后请改用SLF4J或LOG4J2。STDOUT_LOGGING不受影响,它不依赖任何日志框架。
如果项目是 Spring Boot,直接用 starter 更省事,它把 SqlSessionFactory 和 SqlSession 都交给容器管理:
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
依赖坐标、版本号管理和私服配置那套东西,Maven 入门与私有仓库搭建 里讲得比这里细:怎么配 settings.xml 的镜像、怎么把内部包推上去,这里不重复。
3. 配置文件:元素顺序是硬约束
mybatis-config.xml 看着像随便写,其实元素的先后顺序是 DTD 规定的,写错了启动就炸。报错长这样:
The content of element type "configuration" must match
"(properties?,settings?,typeAliases?,typeHandlers?,objectFactory?,
objectWrapperFactory?,reflectorFactory?,plugins?,environments?,
databaseIdProvider?,mappers?)".
这条报错把合法顺序完整列了出来,照着抄就能过。翻译成人话,顺序是这样:
1. properties 外部配置文件,用来把连接信息抽出去
2. settings 全局行为开关,驼峰映射、二级缓存、日志实现都在这
3. typeAliases 类型别名,把长类名缩成短名字
4. typeHandlers 自定义类型处理器
5. objectFactory 6. objectWrapperFactory 7. reflectorFactory 三个工厂,默认就行
8. plugins 插件注册,分页、乐观锁这类靠它挂进来
9. environments 环境与数据源
10. databaseIdProvider 多数据库适配
11. mappers Mapper XML 的位置
一个能跑的最小配置长这样:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<properties resource="db.properties"/>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="cacheEnabled" value="true"/>
</settings>
<typeAliases>
<package name="com.example.demo.model"/>
</typeAliases>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${driver}"/>
<property name="url" value="${url}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mapper/UserMapper.xml"/>
</mappers>
</configuration>
提示:XML 里不能写
//注释,那是 Java 的写法。XML 注释只有<!-- -->一种,写成//会直接报解析错误。
<properties> 把连接信息抽到 db.properties,好处是换环境只改一个文件,不动 XML。这里的 ${} 是配置解析阶段的占位符,和 SQL 里的 ${} 不是一回事。
typeAliases 写上 <package name="..."/> 之后,包里的类可以直接用首字母小写的别名引用,resultType="user" 就够了。也可以在实体类上加 @Alias("u") 单独指定。
<mappers> 里不写 Mapper 的路径,运行时就会报:
Type interface com.example.demo.dao.UserMapper is not known to the MapperRegistry.
这个错的意思是「接口没被注册到 MapperRegistry 里」,不是「找不到类」。修法两条:要么在 <mappers> 里补上 <mapper resource="..."/>,要么用 <package name="com.example.demo.dao"/> 整包扫。注意 Mapper XML 的路径是相对 classpath 的,写成 src/main/resources/mapper/UserMapper.xml 在 IDE 里能跑,打成 jar 就找不到。
4. 数据源和连接串
type 有三个值:
UNPOOLED 每次操作开一条、用完就关。慢,但连接可用性要求不高的场景够用
POOLED 连接池,初始化和认证的开销摊掉,Web 应用的默认选择
JNDI 从容器里拿数据源,应用服务器场景才用得上
连接串是这篇里最容易踩坑的地方,下面几个参数逐个说清楚:
jdbc:mysql://localhost:3306/demo
?characterEncoding=UTF-8
&serverTimezone=Asia/Shanghai
&sslMode=DISABLED
&allowPublicKeyRetrieval=true
driverClassName 8.x 是
com.mysql.cj.jdbc.Driver。老教程里的com.mysql.jdbc.Driver在 8.x 驱动里已经没有了,照抄会报ClassNotFoundException
characterEncoding 字符集,正确写法是UTF-8。characterUnicode不是 Connector/J 的属性名,写了不报错也不生效,属于典型的静默失效
serverTimezone 8.x 必填。不写的话常见报错是The server time zone value '...' is unrecognized or represents more than one time zone
sslMode 8.0.13 引入,默认PREFERRED,可选REQUIRED、VERIFY_CA、VERIFY_IDENTITY。老写法useSSL=true只在连 5.6.25 / 5.7.5 之前的服务器时才需要
allowPublicKeyRetrieval 默认 false。MySQL 8 默认的caching_sha2_password认证要向服务端取 RSA 公钥,非 SSL 连接下不打开这个开关会连不上
驱动坐标也换了,旧的 mysql:mysql-connector-java 停在 8.0.33,新的坐标是:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>26.10.0</version>
</dependency>
如果生产环境用的是 MySQL 8,26.10.0 是当前最新;9.7.0 是 LTS 分支,长期跑生产的系统更适合锁它。
5. SqlSessionFactory 与 SqlSession
这是 MyBatis 里生命周期最容易搞混的一对对象:
SqlSessionFactory 应用启动时建一次,解析完的 XML 常驻其中,全局单例,线程安全
SqlSession 每业务开一个,非线程安全,用完必须关
不配 Spring 的话,工具类这么写:
public class MyBatisUtils {
private static final SqlSessionFactory FACTORY;
static {
try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")) {
FACTORY = new SqlSessionFactoryBuilder().build(in);
} catch (IOException e) {
throw new UncheckedIOException("mybatis-config.xml 读取失败", e);
}
}
private MyBatisUtils() {
}
public static SqlSession openSession() {
return FACTORY.openSession();
}
}
这里有个细节值得单独提:资源路径写的是 mybatis-config.xml,不是 src/main/resources/mybatis-config.xml。后者是文件系统路径,IDE 里能跑,打成 jar 之后 src/ 这个目录根本不存在,于是报 FileNotFoundException。正确写法是从 classpath 拿:
InputStream in = Resources.getResourceAsStream("mybatis-config.xml");
拿 Session 的地方用 try-with-resources,关不掉的问题就不存在了:
try (SqlSession session = MyBatisUtils.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
User user = mapper.selectById(1L);
session.commit();
}
提示:
openSession()开的 Session 默认不自动提交,写操作要手动commit()。要自动提交用openSession(true)。不关 Session 的话连接不归还池,池子很快就空。
事务边界就是 Session:一个 Session 一个事务,跨 Session 的写操作不在同一个事务里,Spring 环境下事务边界由@Transactional决定。
在 Spring Boot 里这些都不用自己管:mybatis-spring-boot-starter 提供的 SqlSessionTemplate 会在每个方法调用时开一个 Session、用完关掉,并且把 Session 绑定到当前线程的事务上。手动 openSession() 在 Spring 项目里反而会脱离事务管理。
6. 映射:resultMap 与驼峰规则
数据库列名和 Java 属性名对不上时,有两条路。
一条是开驼峰映射,user_name 自动对上 userName:
<setting name="mapUnderscoreToCamelCase" value="true"/>
这一项 MyBatis 原生默认是 false,很多「查出来全是 null」的锅就在这。不开的话,SELECT * 查出来的 user_name 列对不上 userName 属性,结果全 null。
另一条是显式写 resultMap,适合列名和属性名完全对不上、或者有额外字段要映射的情况:
<resultMap id="userResultMap" type="User">
<id property="id" column="user_id"/>
<result property="username" column="user_name"/>
<result property="password" column="hashed_password"/>
</resultMap>
<select id="selectUsers" resultMap="userResultMap">
SELECT user_id, user_name, hashed_password
FROM some_table
WHERE id = #{id}
</select>
<id> 和 <result> 的区别
<id>标主键,<id>有值时 MyBatis 会把整行对象的缓存键升级为它,一级缓存的粒度更准;<result>只是普通字段
开驼峰映射之后,resultType 就够用了,resultMap 留给有特殊映射的场景。
7. 参数绑定:#{} 与 ${}
这是 MyBatis 里最要紧的一个区别,两个写法只差一个字符,含义完全相反:
#{} 预编译占位符。MyBatis 把它换成
?,走PreparedStatement的setXxx赋值。值被当成数据,类型由 JDBC 驱动处理,不可能拼进 SQL 结构里,天然防注入
${} 字符串拼接。值原样拼进 SQL 文本,当成代码 执行,注入风险全在这
所以:
<!-- 对:走预编译 -->
WHERE name LIKE CONCAT('%', #{name}, '%')
<!-- 错:拼进 SQL -->
WHERE name LIKE '%${name}%'
拼出来的结果长这样,第二个明显是在执行一段代码:
-- #{} 交给驱动的是参数值
SELECT * FROM user WHERE name = ? -- 参数: 'x' OR '1'='1'
-- ${} 拼进 SQL 的是字符串
SELECT * FROM user WHERE name = 'x' OR '1'='1'
什么时候不得不写 ${}?只有表名、列名、排序方向这类SQL 结构本身没法用占位符的地方:
<select id="listByOrder" resultType="User">
SELECT * FROM ${tableName} ORDER BY ${sortColumn} ${sortDirection}
</select>
这时候安全责任转移到了调用方身上。正确做法是白名单,而不是「前端传什么拼什么」:
private static final Set<String> SORTABLE = Set.of("id", "created_at", "name");
private static final Set<String> DIRECTIONS = Set.of("asc", "desc");
if (!SORTABLE.contains(sortColumn) || !DIRECTIONS.contains(sortDirection)) {
throw new IllegalArgumentException("不支持的排序字段");
}
预编译为什么能防注入,索引为什么还能用上,MySQL 优化与版本细节 从数据库那一侧讲得更细:预编译计划复用、隐式类型转换让索引失效、like '%x' 前置通配符走不了索引。
8. 动态 SQL 的七个标签
SQL 里那些「参数为空就不拼这个条件」的逻辑,MyBatis 用一组标签在 XML 里直接写出来。
<if> 条件成立才把这段 SQL 拼进去
<where> 自动去掉开头的AND/OR,还会补WHERE
<set> 自动处理更新语句里多出来的逗号
<trim> 上面两个的通用版,能自定义前缀后缀
<choose> 多选一,类似 Java 的switch
<sql> + <include> 把公共字段列表抽出来复用,像前端的插槽
<foreach> 遍历集合拼IN条件
拼装结果对照着看最清楚:

<where> 存在的意义就是消灭 where 1=1 这种写法。
<!-- 反模式:where 1=1 让优化器多一次判断 -->
WHERE 1=1
<if test="state != null"> AND state = #{state} </if>
<if test="title != null"> AND title like #{title} </if>
<!-- 正确写法 -->
<where>
<if test="state != null"> AND state = #{state} </if>
<if test="title != null"> AND title like #{title} </if>
</where>
两种写法生成的 SQL 结果一样,但 where 1=1 那个版本在部分数据库上会让优化器放弃部分索引优化。差异不在语法,在计划。
<set> 处理的是更新语句的另一个经典问题:
<update id="updateAuthorIfNecessary">
UPDATE author
<set>
<if test="username != null">username = #{username},</if>
<if test="nickname != null">nickname = #{nickname},</if>
<if test="email != null">email = #{email},</if>
<if test="bio != null">bio = #{bio}</if>
</set>
WHERE id = #{id}
</update>
手写逗号的话,最后一个字段后面会多出一个逗号,SQL 直接语法错误。用 <set> 它会自己把末尾的逗号去掉。注意里面每个条件后面的逗号是必须写的,这是 <set> 唯一能省的部分。
<foreach> 展开集合:
<select id="selectByIds" resultType="User">
SELECT * FROM post
WHERE id IN
<foreach item="item" collection="list"
open="(" separator="," close=")">
#{item}
</foreach>
</select>
open、separator、close 三个属性拼出括号和分隔符,collection 写集合名,item 是循环变量。集合为空时整个 IN 条件要跳过,否则会生成 IN () 这样的非法 SQL:
<choose>
<when test="ids != null and ids.size() > 0">
AND id IN
<foreach item="item" collection="ids" open="(" separator="," close=")">
#{item}
</foreach>
</when>
<otherwise>
AND 1 = 0
</otherwise>
</choose>
<choose> 的语义是只走一个分支,<when> 从上往下第一个成立的生效,<otherwise> 兜底。这和 <if> 可以叠加多个完全不同,写的时候别混。
9. 两级缓存:挂在哪儿、什么时候失效
MyBatis 有两层缓存,作用域不一样,这是最容易搞混的地方。

一级缓存 默认开着,作用域是
SqlSession。同一个 Session 里查两次同一条 SQL,第二次不访问数据库
二级缓存 默认关,作用在 Mapper 的 namespace 上,也就是按<mapper>里的id分组,跨 Session 共享
一级缓存的失效规则:
执行写操作 增删改一执行,同一 Session 里的缓存全清。不是只清被改的那条,是全清
Session 关闭 Session 一关,一级缓存就没了,下次查重新打数据库
手动清session.clearCache()
改作用域localCacheScope设成STATEMENT,一级缓存就只管当前这条语句,等于关掉
二级缓存的开启方式和粒度:
<!-- 全局开关,默认 true;MyBatis-Plus 里建议显式关掉 -->
<setting name="cacheEnabled" value="true"/>
<!-- 在需要二级缓存的 Mapper XML 里开 -->
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
eviction 淘汰策略,默认
LRU,可选LRU、FIFO、SOFT、WEAK
flushInterval 刷新间隔(毫秒),默认不按时间刷新,只靠写操作触发
size 缓存条目上限,默认1024
readOnly 默认false,即读改写:取出的是副本,调用方随便改。设成true则所有调用方共享同一个实例,速度快但谁改都串
开二级缓存有两个硬前提,缺一个就会在运行时炸:
实体类要实现
Serializable读改写模式要把对象序列化后存进缓存,反序列化时要求实体可序列化,缺了会报NotSerializableException
缓存里的数据是副本 二级缓存跨 Session 共享,Session 提交事务后,缓存里的对象状态可能和数据库不一致
二级缓存什么时候会被清掉:同一个 namespace 下任何写操作执行,整个 namespace 的缓存全清;flushInterval 到期;缓存超过 size 上限按淘汰策略清。
提示:单机的应用开二级缓存意义不大——应用重启缓存就没了,还多了一份序列化开销。真正需要分布式缓存的场景,MyBatis 这一层解决不了,得用 Redis 之类的外部缓存,那套东西 Redis 缓存实战 里讲得更完整。
10. 注解开发与 Lombok
简单 SQL 用注解写在接口上也能跑:
public interface UserMapper {
@Select("SELECT id, user_name FROM user WHERE id = #{id}")
User selectById(@Param("id") Long id);
}
但注解不适合复杂 SQL——多行 SQL 在注解里没法格式化,改一行 SQL 要整个方法重写。实践上的分界线很清楚:
用注解 单表增删改查、一两行的简单查询
用 XML 多表连接、动态 SQL、复杂映射
实体类上 Lombok 是个常见选择,@Data 一步生成 getter、setter、toString、equals、hashCode:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.48</version>
<scope>provided</scope>
</dependency>
提示:Lombok 是编译期改字节码的,IDE 第一次导入要装插件才能不报红;另外它的版本对 JDK 敏感,跑新 JDK 要用新版本 Lombok,
1.18.12那种老版本在 JDK 17 以上会直接编译失败。
11. 常见报错的根因
配置解析报错,提示 must match
mybatis-config.xml元素顺序不对,照报错里列的顺序改
XML 解析到一半失败 XML 里写了//注释,改成<!-- -->
is not known to the MapperRegistry Mapper XML 没在<mappers>里注册,或者路径写成了src/main/resources/...
查出来字段全是 nullmapUnderscoreToCamelCase没开,或者列名和属性名对不上得写resultMap
ClassNotFoundException: com.mysql.jdbc.Driver 8.x 驱动里这个类没了,改com.mysql.cj.jdbc.Driver
server time zone value ... is unrecognized 连接串少了serverTimezone
Public Key Retrieval is not allowed MySQL 8 的caching_sha2_password认证,连接串加allowPublicKeyRetrieval=true
NotSerializableException 开了二级缓存但实体类没实现Serializable
连接池很快耗尽SqlSession没关,每次用完必须关
💡 补充:这套配置里最值得回头检查的是三处——mapUnderscoreToCamelCase 开没开、#{} 有没有写成 ${}、SqlSession 有没有关。前两个决定结果对不对,后一个决定应用跑久了会不会连不上数据库。
🔥 一句话总结:MyBatis 把 JDBC 的连接管理和字段搬运收进了配置和映射,配置文件按 DTD 顺序写、参数一律用 #{}、Session 用完就关,剩下的就是 SQL 本身和一级缓存的会话边界。
