第07篇 · MyBatis核心原理:从接口到SQL的奇妙映射

如果你跟着前几篇一路写过来,用 JdbcTemplate 已经比原生 JDBC 轻松不少了。但你可能也注意到了一些不太对劲的地方:SQL 还是写在 Java 代码里,改个查询条件要重新编译;RowMapper 虽然比手动 rs.getString 好一些,但每个字段还是得写一遍映射。

如果你听说过 MyBatis,大概知道它有个“神奇”的能力——定义一个接口,不需要写实现类,直接就能调用数据库操作。第一次见到这个用法的人,多半会疑惑:接口没有实现,方法体是空的,MyBatis 是怎么把方法调用变成 SQL 执行的?

这一篇,我们把 MyBatis 的核心原理拆开来看。

学习目标

  • 理解 MyBatis 的核心架构:ConfigurationSqlSessionExecutorStatementHandlerResultSetHandler
  • 掌握 @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;
});

这段代码有两个明显的“不舒服”:

  1. SQL 和 Java 代码混在一起。SQL 是字符串,没有语法高亮,没有自动补全。如果要调整查询条件,你得改 Java 代码、重新编译。
  2. 结果映射还是要手写。虽然比原生 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。开发者通过 SqlSessionselectOneinsert 等方法直接操作数据库,或者通过 Mapper 代理的方式间接调用。

数据处理层:负责 SQL 的查找、解析、执行和结果映射。这是 MyBatis 最核心的部分。

基础支撑层:负责连接管理、事务管理、配置加载和缓存等通用功能。

在这三层之上,有几个核心组件需要重点理解:

组件职责
SqlSessionMyBatis 对外暴露的核心 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 是调度中心,协调整个执行过程;StatementHandlerParameterHandlerResultSetHandler 是真正干活的,分别负责 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 的对应方法(如 selectOneinsert)来真正执行数据库操作。

整个过程可以简化为

定义 Mapper 接口 → MyBatis 启动时扫描 → 为每个接口创建 MapperProxyFactory
    ↓
调用 getMapper() → MapperProxyFactory 生成 JDK 动态代理对象
    ↓
调用接口方法 → 代理对象的 invoke() 被触发
    ↓
MapperMethod 解析方法信息 → 调用 SqlSession 执行 SQL
    ↓
返回结果

这就是 Mapper 接口“没有实现类却能工作”的全部秘密。

四、SQL 执行流程:从方法调用到 ResultSet

当 Mapper 代理对象把调用转给 SqlSession 之后,真正的 SQL 执行才开始。整个过程大致如下:

第一步:SqlSession 将操作委托给 Executor

SqlSession 本身不直接执行 SQL,它只是一个门面。真正干活的是 ExecutorExecutor 有三种实现:

  • SimpleExecutor:每次执行都创建新的 Statement
  • ReuseExecutor:复用 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 中配置的 resultTyperesultMap,通过反射创建对象、调用 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注解代码集中,开发快
多表关联查询XMLSQL 复杂,XML 可读性更好
动态 SQL(条件查询、批量操作)XML动态标签(<if><foreach> 等)在 XML 中更自然
需要复用 SQL 片段XML<sql> + <include> 支持片段复用
团队协作、需要 DBA 审核 SQLXMLSQL 独立文件,便于审查和版本管理

在实践中,混合使用是最常见的做法:简单的单表操作用注解,复杂的动态查询和关联查询用 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_iditem_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 但接口找不到方法,调用时报 BindingExceptionnamespace 不是接口的全限定名,或 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 的二级缓存。由于二级缓存涉及跨会话的数据共享,在分布式环境下需要额外考虑缓存一致性问题,实际生产中使用率不高。

思考与延伸

  1. 动手验证:在 Spring Boot 项目中开启 MyBatis 的 SQL 日志(logging.level.com.example.demo.mapper=DEBUG),观察每次调用 Mapper 方法时控制台打印的 SQL。对比使用 #{}${} 时 SQL 的差异。

  2. 思考题:如果一个 Mapper 接口中的方法有多个参数,比如 User findByNameAndAge(String name, int age),如何正确传递参数?提示:@Param 注解。

  3. 延伸阅读: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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值