如果你跟着前几篇一路写过来,用 JdbcTemplate 已经比原生 JDBC 轻松不少了。但你可能也注意到了一些不太对劲的地方:SQL 还是写在 Java 代码里,改个查询条件要重新编译;RowMapper 虽然比手动 rs.getString 好一些,但每个字段还是得写一遍映射。
如果你听说过 MyBatis,大概知道它有个“神奇”的能力——定义一个接口,不需要写实现类,直接就能调用数据库操作。第一次见到这个用法的人,多半会疑惑:接口没有实现,方法体是空的,MyBatis 是怎么把方法调用变成 SQL 执行的?
这一篇,我们把 MyBatis 的核心原理拆开来看。
学习目标
- 理解 MyBatis 的核心架构:
Configuration→SqlSession→Executor→StatementHandler→ResultSetHandler - 掌握
@Mapper接口的 “动态代理”原理——为什么只写接口不写实现类也能工作 - 理解 MyBatis 的 SQL 执行流程(从 Mapper 接口方法到 JDBC Statement)
- 掌握注解方式和 XML 方式的适用场景选择
正文
一、从 JdbcTemplate 到 MyBatis:解决了什么问题?
先回顾一下 JdbcTemplate 的用法:
String sql = "SELECT id, username, email FROM users WHERE id = ?";
User user = jdbcTemplate.queryForObject(sql, new Object[]{id}, (rs, rowNum) -> {
User u = new User();
u.setId(rs.getInt("id"));
u.setUsername(rs.getString("username"));
u.setEmail(rs.getString("email"));
return u;
});
这段代码有两个明显的“不舒服”:
- SQL 和 Java 代码混在一起。SQL 是字符串,没有语法高亮,没有自动补全。如果要调整查询条件,你得改 Java 代码、重新编译。
- 结果映射还是要手写。虽然比原生 JDBC 简洁了,但每个字段还是得
rs.getString一次。
MyBatis 解决这两个问题的方式很直接:
- SQL 和 Java 代码分离:SQL 写在 XML 文件或注解里,Java 代码只负责调用
- 自动结果映射:查询结果自动转换成 Java 对象,不需要手写
RowMapper
MyBatis 的官方文档对自己的定位是:“MyBatis 是一个一流的持久层框架,支持定制化 SQL、存储过程以及高级映射。MyBatis 几乎消除了所有的 JDBC 代码和手动设置参数以及检索结果。”
换句话说,MyBatis 是 JDBC 的“高级封装”——它帮你处理了参数设置和结果映射,但 SQL 还是由你来写。这也是它和 Hibernate 这类“全自动 ORM”最大的区别:MyBatis 把 SQL 的控制权完全交给你,它只负责“怎么把参数传进去”和“怎么把结果拿出来”。
二、MyBatis 核心组件架构:从配置到执行
MyBatis 的整体架构可以分为三层:
API 接口层:提供给开发者使用的接口,主要是 SqlSession。开发者通过 SqlSession 的 selectOne、insert 等方法直接操作数据库,或者通过 Mapper 代理的方式间接调用。
数据处理层:负责 SQL 的查找、解析、执行和结果映射。这是 MyBatis 最核心的部分。
基础支撑层:负责连接管理、事务管理、配置加载和缓存等通用功能。
在这三层之上,有几个核心组件需要重点理解:
| 组件 | 职责 |
|---|---|
SqlSession | MyBatis 对外暴露的核心 API,代表一次数据库会话,提供了 CRUD 接口 |
Configuration | 所有配置的集中管理对象,包含 Mapper 映射、数据源、事务管理等全部配置信息 |
Executor | 执行器,由 SqlSession 调用,负责 SQL 语句的执行和一级缓存的维护 |
StatementHandler | 封装了 JDBC Statement 操作,负责参数设置和 SQL 执行 |
ParameterHandler | 负责将用户传入的参数转换成 JDBC Statement 所需的参数 |
ResultSetHandler | 负责将 JDBC 返回的 ResultSet 转换成 Java 对象集合 |
MappedStatement | 对 Mapper 中一条 SQL 语句的封装(<select>、<insert> 等标签的解析结果) |
TypeHandler | 负责 Java 类型和 JDBC 类型之间的转换 |
这些组件之间的关系,可以这样理解:SqlSession 是门面,开发者只和它打交道;Executor 是调度中心,协调整个执行过程;StatementHandler、ParameterHandler、ResultSetHandler 是真正干活的,分别负责 Statement 的创建与执行、参数设置、结果映射。
MyBatis 的启动过程,本质上就是把 XML 配置文件和 Mapper 文件解析成 Configuration 对象和 MappedStatement 对象的过程。这些对象在应用启动时加载完成,之后每次请求直接使用,不需要重复解析。
三、Mapper 接口的“魔法”:动态代理
这是 MyBatis 最让人困惑的地方:为什么定义一个接口,不写任何实现类,就能直接调用?
答案就是 JDK 动态代理。
MyBatis 在启动时,会扫描所有标注了 @Mapper 的接口(或者通过 Mapper 扫描配置的接口),为每个接口创建一个代理工厂 MapperProxyFactory。当你通过 SqlSession.getMapper(UserMapper.class) 获取 Mapper 实例时,MapperProxyFactory 会利用 JDK 动态代理生成一个代理对象。
这个代理对象实现了 UserMapper 接口,但它并没有真正实现接口中定义的方法。当你在代码中调用 userMapper.findById(1) 时,实际执行的是代理对象的 invoke 方法:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 如果调用的是 Object 的方法(如 toString),直接执行
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
// 获取或创建 MapperMethod——封装了 SQL 执行逻辑
final MapperMethod mapperMethod = cachedMapperMethod(method);
// 执行 SQL
return mapperMethod.execute(sqlSession, args);
}
MapperMethod 是另一个关键角色。它封装了一条 SQL 语句的全部信息:SQL 类型(SELECT/INSERT/UPDATE/DELETE)、对应的 MappedStatement、参数映射方式、返回值处理方式等。当 invoke 被调用时,MapperMethod 会根据这些信息,调用 SqlSession 的对应方法(如 selectOne、insert)来真正执行数据库操作。
整个过程可以简化为:
定义 Mapper 接口 → MyBatis 启动时扫描 → 为每个接口创建 MapperProxyFactory
↓
调用 getMapper() → MapperProxyFactory 生成 JDK 动态代理对象
↓
调用接口方法 → 代理对象的 invoke() 被触发
↓
MapperMethod 解析方法信息 → 调用 SqlSession 执行 SQL
↓
返回结果
这就是 Mapper 接口“没有实现类却能工作”的全部秘密。
四、SQL 执行流程:从方法调用到 ResultSet
当 Mapper 代理对象把调用转给 SqlSession 之后,真正的 SQL 执行才开始。整个过程大致如下:
第一步:SqlSession 将操作委托给 Executor
SqlSession 本身不直接执行 SQL,它只是一个门面。真正干活的是 Executor。Executor 有三种实现:
SimpleExecutor:每次执行都创建新的 StatementReuseExecutor:复用 Statement(相同 SQL 的 Statement 会被缓存)BatchExecutor:支持批量操作
第二步:Executor 创建 StatementHandler
Executor 会根据 SQL 类型选择合适的 StatementHandler:
PreparedStatementHandler:处理带参数的 SQL(使用PreparedStatement),这是最常用的SimpleStatementHandler:处理不带参数的 SQL(使用Statement)CallableStatementHandler:处理存储过程
第三步:ParameterHandler 设置参数
StatementHandler 内部有一个 ParameterHandler,负责把 Java 对象参数转换成 JDBC 类型并设置到 PreparedStatement 中。#{} 占位符就是在这一步被替换成 ? 并完成参数绑定的。
第四步:执行 SQL,获取 ResultSet
StatementHandler 执行 SQL,数据库返回 ResultSet。
第五步:ResultSetHandler 处理结果集
ResultSetHandler 负责将 ResultSet 转换成 Java 对象。它根据 MappedStatement 中配置的 resultType 或 resultMap,通过反射创建对象、调用 setter 方法完成赋值。
整个流程可以用一句话概括:SqlSession 牵头,Executor 调度,StatementHandler 执行,ParameterHandler 传参,ResultSetHandler 收尾。
五、两种开发方式:注解 vs XML
MyBatis 支持两种方式定义 SQL:注解和 XML。
注解方式:
@Select("SELECT * FROM users WHERE id = #{id}")
User findById(int id);
@Insert("INSERT INTO users(username, email) VALUES(#{username}, #{email})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
优点:代码集中,一个文件里能看到 SQL 和 Java 方法,适合简单场景。缺点:SQL 和 Java 代码耦合,复杂 SQL(尤其是多表关联、动态条件)在注解里写起来很别扭。
XML 方式:
<select id="findById" resultType="com.example.User">
SELECT * FROM users WHERE id = #{id}
</select>
优点:SQL 和 Java 代码分离,复杂 SQL 可读性更好,支持动态 SQL 的全部功能。缺点:多了一个 XML 文件,项目结构稍微复杂一些。
如何选择?
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 单表简单 CRUD | 注解 | 代码集中,开发快 |
| 多表关联查询 | XML | SQL 复杂,XML 可读性更好 |
| 动态 SQL(条件查询、批量操作) | XML | 动态标签(<if>、<foreach> 等)在 XML 中更自然 |
| 需要复用 SQL 片段 | XML | <sql> + <include> 支持片段复用 |
| 团队协作、需要 DBA 审核 SQL | XML | SQL 独立文件,便于审查和版本管理 |
在实践中,混合使用是最常见的做法:简单的单表操作用注解,复杂的动态查询和关联查询用 XML。MyBatis 对两种方式一视同仁,最终都会被解析成 MappedStatement,执行路径完全一样。
六、#{} 和 ${} 的区别
在 MyBatis 的 SQL 中,有两种方式引用参数:#{} 和 ${}。
#{}:预编译参数
<select id="findById" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
MyBatis 会把 #{id} 替换成 ?,然后通过 PreparedStatement 的参数绑定机制传入值。这个方式和前面讲到的 PreparedStatement 完全一样——预编译,防 SQL 注入。
${}:字符串替换
<select id="findByColumn" resultType="User">
SELECT * FROM users ORDER BY ${column}
</select>
MyBatis 会直接把 ${column} 替换成参数的值,不做任何转义或预编译。这意味着如果 ${} 里的内容来自用户输入,存在 SQL 注入风险。
什么时候用 ${}?
${} 的适用场景非常有限:
- 动态表名:
SELECT * FROM ${tableName} - 动态列名:
ORDER BY ${columnName} - 动态数据库名:有些分库场景需要
这些场景下,列名、表名是 SQL 的“结构”部分,不能用占位符 ? 替代(PreparedStatement 不允许参数绑定到表名或列名)。所以只能用 ${}。但正因为如此,${} 的值绝对不能来自用户输入——如果必须来自用户,一定要做白名单校验。
代码示例
示例一:注解方式完成 CRUD
以下是一个完整的 Spring Boot 3.x + MyBatis 3.5.x 的示例。
依赖(pom.xml) :
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
实体类:
package com.example.demo.entity;
import java.time.LocalDateTime;
public class User {
private Integer id;
private String username;
private String email;
private Integer age;
private LocalDateTime createTime;
// getter / setter 省略
}
Mapper 接口(注解方式) :
package com.example.demo.mapper;
import com.example.demo.entity.User;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface UserMapper {
/**
* 查询所有用户
*/
@Select("SELECT id, username, email, age, create_time FROM users")
List<User> findAll();
/**
* 根据 ID 查询用户
*/
@Select("SELECT id, username, email, age, create_time FROM users WHERE id = #{id}")
User findById(int id);
/**
* 插入用户 —— 使用 @Options 获取自动生成的主键
*/
@Insert("INSERT INTO users(username, email, age) VALUES(#{username}, #{email}, #{age})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
/**
* 更新用户
*/
@Update("UPDATE users SET username = #{username}, email = #{email}, age = #{age} WHERE id = #{id}")
int update(User user);
/**
* 删除用户
*/
@Delete("DELETE FROM users WHERE id = #{id}")
int deleteById(int id);
}
@Options(useGeneratedKeys = true, keyProperty = "id") 的作用:告诉 MyBatis 使用数据库自动生成的主键,并把生成的主键值赋值给 user 对象的 id 属性。如果不加这个注解,插入后 user.getId() 返回的是 null。
Service 层调用:
package com.example.demo.service;
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserMapper userMapper;
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
public User findById(int id) {
return userMapper.findById(id);
}
public void save(User user) {
userMapper.insert(user);
System.out.println("插入后生成的主键: " + user.getId());
}
}
示例二:XML 方式实现复杂查询
当查询涉及多表关联或复杂映射时,XML 方式更合适。
Mapper 接口:
package com.example.demo.mapper;
import com.example.demo.entity.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface OrderMapper {
/**
* 查询用户的所有订单(包含订单明细)
* XML 方式实现
*/
List<Order> findOrdersWithItems(@Param("userId") int userId);
}
XML 映射文件(src/main/resources/mapper/OrderMapper.xml) :
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.OrderMapper">
<!-- 定义 Order 的结果映射 -->
<resultMap id="orderWithItemsMap" type="com.example.demo.entity.Order">
<id property="id" column="order_id"/>
<result property="userId" column="user_id"/>
<result property="orderNumber" column="order_number"/>
<result property="totalAmount" column="total_amount"/>
<result property="createTime" column="create_time"/>
<!-- 一对多关联:一个 Order 包含多个 OrderItem -->
<collection property="items" ofType="com.example.demo.entity.OrderItem">
<id property="id" column="item_id"/>
<result property="orderId" column="item_order_id"/>
<result property="productName" column="product_name"/>
<result property="quantity" column="quantity"/>
<result property="price" column="price"/>
</collection>
</resultMap>
<!-- 多表关联查询 -->
<select id="findOrdersWithItems" resultMap="orderWithItemsMap">
SELECT
o.id AS order_id,
o.user_id,
o.order_number,
o.total_amount,
o.create_time,
oi.id AS item_id,
oi.order_id AS item_order_id,
oi.product_name,
oi.quantity,
oi.price
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = #{userId}
ORDER BY o.create_time DESC
</select>
</mapper>
resultMap 的关键点:
<id>标签标记主键列,MyBatis 在构建对象时会用主键值来判断是否是同一条记录<collection>标签处理一对多关联,ofType指定集合中元素的类型- 用
AS别名来区分不同表的同名字段(如order_id和item_id)
在 application.yml 中配置 Mapper XML 的位置:
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.demo.entity
configuration:
map-underscore-to-camel-case: true
map-underscore-to-camel-case: true 开启后,数据库的 create_time 字段会自动映射到 Java 的 createTime 属性,不需要在每个 resultMap 中单独配置。
新手错误 vs 正确姿势
| 错误表象 | 根本原因 | 正确姿势 |
|---|---|---|
在 XML 中写了 SQL 但接口找不到方法,调用时报 BindingException | namespace 不是接口的全限定名,或 id 与方法名不匹配 | 检查 namespace 是否为 接口的包名.接口名,id 是否与方法名完全一致 |
查询结果部分字段为 null,但数据库中有值 | 数据库字段名(下划线风格)和 Java 属性名(驼峰风格)不一致 | 开启 map-underscore-to-camel-case: true,或使用 resultMap 显式映射 |
| 插入数据后获取不到自增主键 | 未配置 @Options(useGeneratedKeys = true) | 在 @Insert 上添加 @Options(useGeneratedKeys = true, keyProperty = "id") |
动态 SQL 中 if 标签的条件永远不成立 | 用 ${} 取参数值,但条件判断中直接用参数名写错了 | #{} 用于取值,<if test="username != null"> 中直接用参数名,不需要 #{} |
疑难深度追问
Q1:MyBatis 的 Mapper 接口为什么可以没有实现类?
因为 MyBatis 在运行时通过 JDK 动态代理 生成了代理对象。MapperProxyFactory 为每个 Mapper 接口创建代理,MapperProxy 实现了 InvocationHandler,拦截所有接口方法的调用,将其转换为 SQL 执行。这个代理对象在 getMapper() 时返回,所以开发者拿到的“接口实例”实际上是一个代理对象。
Q2:#{} 和 ${} 在底层处理上有什么本质不同?
#{} 使用 PreparedStatement 的占位符机制。MyBatis 在生成 SQL 时把 #{id} 替换成 ?,参数通过 ParameterHandler 单独设置。SQL 的结构在预编译时已经固定,参数值不参与 SQL 语法解析,所以能防 SQL 注入。
${} 是纯字符串替换。MyBatis 直接把 ${} 的内容拼接到 SQL 中,然后才发送给数据库编译执行。因为拼接发生在编译之前,用户输入可能改变 SQL 的结构,存在注入风险。
Q3:MyBatis 的 SqlSession 是线程安全的吗?
不是。 SqlSession 不是线程安全的,每个线程应该使用自己独立的 SqlSession 实例。在 Spring 整合 MyBatis 的场景中,SqlSessionTemplate 解决了这个问题——它通过 ThreadLocal 为每个线程绑定独立的 SqlSession,并且是线程安全的。所以在 Spring 项目中,你不需要手动管理 SqlSession 的创建和关闭,SqlSessionTemplate 会帮你做好。
Q4:MyBatis 的一级缓存和二级缓存分别是什么?
一级缓存是 SqlSession 级别的缓存,默认开启。同一个 SqlSession 中执行相同的查询,第二次会直接从缓存中取结果,不会真的查询数据库。但当 SqlSession 执行了 INSERT/UPDATE/DELETE 操作时,缓存会被清空。
二级缓存是 Mapper 级别的缓存,默认关闭,需要配置开启。多个 SqlSession 可以共享同一个 Mapper 的二级缓存。由于二级缓存涉及跨会话的数据共享,在分布式环境下需要额外考虑缓存一致性问题,实际生产中使用率不高。
思考与延伸
-
动手验证:在 Spring Boot 项目中开启 MyBatis 的 SQL 日志(
logging.level.com.example.demo.mapper=DEBUG),观察每次调用 Mapper 方法时控制台打印的 SQL。对比使用#{}和${}时 SQL 的差异。 -
思考题:如果一个 Mapper 接口中的方法有多个参数,比如
User findByNameAndAge(String name, int age),如何正确传递参数?提示:@Param注解。 -
延伸阅读:MyBatis 的官方文档对 Mapper XML 文件的讲解非常详细,建议花时间通读一遍,尤其是
resultMap的复杂映射配置。另外,MyBatis-Spring 的整合原理也值得了解——SqlSessionTemplate如何做到线程安全并与 Spring 事务协同。
参考与延伸阅读
- MyBatis. MyBatis 3 | Getting started. mybatis.org, 2025-01-02
- MyBatis. MyBatis 3 | Mapper XML Files. mybatis.org, 2025-01-02
- MyBatis. MyBatis 3 | Java API. mybatis.org, 2025-01-02
- 百度百科. MyBatis. 百度百科, 2026-03-30
- 阿里云开发者社区. MyBatis中#{}和${}的区别. 阿里云开发者社区
- MyBatis-Spring. SqlSessionTemplate. mybatis.org

1005

被折叠的 条评论
为什么被折叠?



