MyBatis 深入原理
Java 持久层框架,核心是将 SQL 和 Java 代码解耦。不是 ORM(对象关系映射),而是 SQL Mapping——你需要手写 SQL,但结果自动映射到对象。
1. 为什么需要 MyBatis
1.1 JDBC 的问题
// JDBC 原生写法
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
conn = dataSource.getConnection();
ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, userId);
rs = ps.executeQuery();
User user = null;
if (rs.next()) {
user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
}
return user;
} finally {
// 关闭资源,漏一处都是bug
}
问题:
- 模板代码巨多:每次都要写 try-catch 和资源关闭
- SQL 硬编码:改 SQL 要改 Java 代码
- 参数映射繁琐:要把数据库字段逐个映射到 Java 属性
- 结果集手工映射:数据库列名和 Java 属性名不匹配时出错
1.2 MyBatis 的改进
<!-- UserMapper.xml -->
<select id="findById" resultType="User">
SELECT id, name, email FROM users WHERE id = #{id}
</select>
// Java 代码
User user = sqlSession.selectOne("UserMapper.findById", userId);
// 或用 Mapper 接口
User user = userMapper.findById(userId);
一行代码完成查询,SQL 在 XML 中管理,参数和结果自动映射。
2. 核心组件与执行流程
2.1 核心组件
| 组件 | 职责 |
|---|---|
SqlSessionFactoryBuilder |
构建 SqlSessionFactory,从 XML 或配置类 |
SqlSessionFactory |
创建 SqlSession,每个 factory 对应一个数据库 |
SqlSession |
MyBatis 核心 API,执行 SQL、管理事务 |
Executor |
SQL 执行器,负责调用 StatementHandler |
StatementHandler |
处理 JDBC Statement,参数设置、SQL 执行 |
ParameterHandler |
参数处理,将 Java 参数转为 JDBC 参数 |
ResultSetHandler |
结果处理,将 JDBC ResultSet 转为 Java 对象 |
MapperProxy |
Mapper 接口的动态代理,拦截方法调用 |
MappedStatement |
SQL 配置的封装,包含 SQL、参数类型、返回类型 |
2.2 执行流程
sqlSession.selectList("UserMapper.findAll")
↓
Executor.query()
↓
StatementHandler.prepare() → 创建 PreparedStatement
↓
ParameterHandler.setParameters() → 绑定参数
↓
StatementHandler.query() → 执行 SQL
↓
ResultSetHandler.handleResultSets() → 映射结果到对象
↓
返回结果
2.3 Mapper 接口的代理机制
// 定义 Mapper 接口
public interface UserMapper {
User findById(Long id);
List<User> findAll();
}
// 动态代理创建实现类
MapperProxy mapperProxy = new MapperProxy(sqlSession);
UserMapper mapper = (UserMapper) Proxy.newProxyInstance(
UserMapper.class.getClassLoader(),
new Class[]{UserMapper.class},
mapperProxy
);
// 调用时
User user = mapper.findById(1L);
// 等价于
User user = sqlSession.selectOne("UserMapper.findById", 1L);
核心:MapperProxy 实现了 InvocationHandler,方法调用时把 namespace + methodName + args 拼成 MyBatis 的 statementId,调用 sqlSession 的对应方法。
3. 动态 SQL
MyBatis 最强大的特性之一,在 XML 中用 OGNL 表达式构建 SQL。
3.1 if 条件
<select id="findByCondition" resultType="User">
SELECT * FROM users
<where>
<if test="name != null">
AND name = #{name}
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
</select>
where 标签会自动处理多余的 AND。
3.2 choose when otherwise
<select id="findByRole" resultType="User">
SELECT * FROM users
<choose>
<when test="role == 'admin'">
WHERE is_admin = 1
</when>
<when test="role == 'vip'">
WHERE is_vip = 1
</when>
<otherwise>
WHERE status = 'active'
</otherwise>
</choose>
</select>
3.3 foreach 循环
<!-- 批量查询 -->
<select id="findByIds" resultType="User">
SELECT * FROM users
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
生成的 SQL:SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5)
3.4 set 和 trim
<!-- 动态更新,只更新非空字段 -->
<update id="updateById">
UPDATE users
<set>
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
</set>
WHERE id = #{id}
</update>
3.5 bind 自定义变量
<!-- 模糊搜索 -->
<select id="search" resultType="User">
<bind name="pattern" value="'%' + name + '%'"/>
SELECT * FROM users WHERE name LIKE #{pattern}
</select>
4. 结果映射
4.1 自动映射
<!-- resultType 自动映射:列名 user_name → userName(驼峰) -->
<select id="findById" resultType="User">
SELECT id, user_name, user_email FROM users WHERE id = #{id}
</select>
需要开启驼峰映射:
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
4.2 ResultMap 手动映射
<!-- 结果映射:解决列名和属性名不一致 -->
<resultMap id="UserResultMap" type="User">
<id property="id" column="user_id"/> <!-- 主键用 id -->
<result property="name" column="user_name"/>
<result property="email" column="user_email"/>
<!-- association:一对一关联 -->
<association property="department" javaType="Department">
<result property="name" column="dept_name"/>
</association>
<!-- collection:一对多关联 -->
<collection property="orders" ofType="Order">
<result property="id" column="order_id"/>
<result property="amount" column="order_amount"/>
</collection>
</resultMap>
4.3 嵌套查询 vs 嵌套结果
嵌套查询(分步查询):查完 User 再查 Orders
<resultMap id="UserResultMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<!-- select 属性:触发另一个 SQL 查询 -->
<collection property="orders"
column="id"
select="com.example.OrderMapper.findByUserId"/>
</resultMap>
优点:懒加载,按需查询 缺点:N+1 问题(查 1 个 User 触发 N 次额外查询)
嵌套结果:一次 JOIN 查询
<resultMap id="UserResultMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="orders" ofType="Order">
<id property="id" column="order_id"/>
<result property="amount" column="order_amount"/>
</collection>
</resultMap>
<select id="findAllWithOrders" resultMap="UserResultMap">
SELECT u.id, u.name, o.id as order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
</select>
优点:无 N+1,一次查询解决 缺点:数据量大时 JOIN 慢
5. 一级缓存 vs 二级缓存
5.1 一级缓存(SqlSession 级)
// 同一个 SqlSession,两次查询同一行数据
User u1 = sqlSession.selectOne("findById", 1L);
User u2 = sqlSession.selectOne("findById", 1L);
// 第二次不查数据库,从一级缓存返回
一级缓存范围是一个 SqlSession:
- 同一个 SqlSession,查询相同的 SQL + 参数,结果被缓存
- SqlSession 关闭/提交事务/执行 DML(insert/update/delete),缓存清空
原理:PerpetualCache,内部是一个 Map,key 是 statementId + params。
5.2 二级缓存(Mapper 级)
<!-- Mapper XML 中开启 -->
<cache/>
<!-- Java 配置开启 -->
@CacheNamespace
public interface UserMapper {}
二级缓存范围是一个 Mapper(一个命名空间),跨 SqlSession:
SqlSession1:第一次查询 → 存入二级缓存
SqlSession2:第二次查询 → 从二级缓存取(不查数据库)
SqlSession3:第三次查询 → 继续从二级缓存取
二级缓存的坑:
- 跨 SqlSession 的数据一致性问题
SqlSession1.update("updateUser", user); // 更新了数据,但 SqlSession1 未提交
SqlSession2.selectOne("findById", 1L); // 查到的是 SqlSession2 自己的缓存(旧数据)
- MyBatis-plus 等框架开启更新时,必须关闭二级缓存
二级缓存对多表查询支持很差,容易产生脏数据。生产环境建议关闭二级缓存,用 Redis 替代。
5.3 缓存清空时机
DML 操作(insert/update/delete):
↓
flushStatements(刷到数据库)
↓
commit(提交事务)
↓
close(关闭会话)
↓
一级缓存:清空
二级缓存:如果配置了 flushCache=true,查询前清空(默认 true)
6. 插件机制(Plugin)
6.1 插件拦截点
MyBatis 四层结构,每层都有可拦截的方法:
| 拦截器 | 拦截方法 | 用途 |
|---|---|---|
| Interceptor | .plugin() | 增强拦截器本身 |
| Executor | update、query | SQL 重写、慢 SQL 日志 |
| StatementHandler | prepare、parameterize、query、update | SQL 拦截、参数处理 |
| ParameterHandler | getPropertyValue | 参数转换 |
| ResultSetHandler | handleResultSets、handleOutputParameters | 结果转换 |
6.2 自定义插件示例
// 1. 实现 Interceptor 接口
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class SqlLogInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler sh = (StatementHandler) invocation.getTarget();
// 取出 SQL 并打印
BoundSql boundSql = sh.getBoundSql();
String sql = boundSql.getSql();
System.out.println("SQL: " + sql);
return invocation.proceed();
}
}
// 2. 配置插件
<plugins>
<plugin interceptor="com.example.SqlLogInterceptor"/>
</plugins>
6.3 分页插件原理
PageHelper 等分页插件的原理:
Executor.query() 方法被拦截
↓
修改 SQL:SELECT * FROM users → SELECT * FROM (SELECT * FROM users) tmp LIMIT 10
↓
额外执行 COUNT SQL 获取总数
↓
把总数存入 Page 对象
↓
返回分页结果
7. 常见问题
7.1 批量插入性能问题
// 低效:循环单条插入(每条都要创建 PreparedStatement)
for (User u : users) {
sqlSession.insert("insert", u);
}
// 高效:批量插入
<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO users(name, email) VALUES
<foreach collection="list" item="u" separator=",">
(#{u.name}, #{u.email})
</foreach>
</insert>
7.2 主键回填
<!-- useGeneratedKeys + keyProperty 主键回填 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users(name, email) VALUES (#{name}, #{email})
</insert>
执行完后 user.getId() 就是数据库生成的主键。
7.3 多数据源如何配置
spring:
datasource:
primary:
jdbc-url: jdbc:mysql://localhost:3306/db1
driver-class-name: com.mysql.cj.jdbc.Driver
secondary:
jdbc-url: jdbc:mysql://localhost:3306/db2
driver-class-name: com.mysql.cj.jdbc.Driver
// 为不同 Mapper 指定不同数据源
@MapperScan(value = "com.example.mapper.db1",SqlSessionFactoryRef = "primaryFactory")
@MapperScan(value = "com.example.mapper.db2",SqlSessionFactoryRef = "secondaryFactory")
public class DataSourceConfig {}
7.4 #{} vs ${}
| 写法 | 原理 | 安全性 | 使用场景 |
|---|---|---|---|
#{value} |
JDBC 的 PreparedStatement,参数绑定 |
✅ 防 SQL 注入 | 参数传递 |
${value} |
字符串拼接,直接替换 | ❌ 可能有 SQL 注入 | 动态表名/列名 |
永远不要用 ${} 接收用户输入:${name} 的值如果是被攻击者控制的,会直接拼入 SQL 导致注入。
8. MyBatis 与 MyBatis-Plus
| 特性 | MyBatis | MyBatis-Plus |
|---|---|---|
| SQL | 手写(XML/注解) | 手写 或 自动生成(Wrapper) |
| CRUD | 手写 | IService<T> + BaseMapper<T> 自动提供 |
| 分页 | 需插件 | 内置(IPage) |
| 主键策略 | useGeneratedKeys | 多种(auto / UUID / 雪花算法) |
| 自动填充 | 无 | @TableField(fill=FieldFill.INSERT) |
| 逻辑删除 | 手写 | @TableLogic 注解 |
| 条件构造器 | 无 | LambdaQueryWrapper |