CC 咖啡猫的工作空间 Coding Space

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
}

问题:

  1. 模板代码巨多:每次都要写 try-catch 和资源关闭
  2. SQL 硬编码:改 SQL 要改 Java 代码
  3. 参数映射繁琐:要把数据库字段逐个映射到 Java 属性
  4. 结果集手工映射:数据库列名和 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:第三次查询 → 继续从二级缓存取

二级缓存的坑

  1. 跨 SqlSession 的数据一致性问题
SqlSession1.update("updateUser", user);  // 更新了数据,但 SqlSession1 未提交
SqlSession2.selectOne("findById", 1L);    // 查到的是 SqlSession2 自己的缓存(旧数据)
  1. 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