我记得刚学 Java 那会儿,带我的老大哥让我连个数据库查个用户列表。我吭哧吭哧写了半天 JDBC——Class.forNameDriverManager.getConnectionConnection.prepareStatementResultSet 遍历、finally 里关连接……代码写了 40 行,功能只是查了 3 条记录。

老大哥看完代码,面无表情地说了一句:"小伙子,没用过 MyBatis 吗?"

那一天,我的 Java 世界观被刷新了。

现在 MyBatis 已经成为 Java 开发中司空见惯、不可或缺的持久层框架。但有些人即便是 MyBatis 使用起来熟练到起飞,也从未系统地学习过它,更不能说清楚它到底是怎么工作的。这篇文章将带你从原理到实战,从避坑到面试,一步一步拿下 MyBatis。后续的文章我们再拿下MyBatis-Plus。


一、基础入门:从"手写 JDBC"到"优雅操作数据库"

1.1 先看看我们为什么需要 MyBatis

很多 Java 新人可能没经历过纯 JDBC 时代,我们先来感受一下痛苦。下面这段代码,功能仅仅是"根据 ID 查一个用户":

public User getUserById(int id) {
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    User user = null;
    try {
        Class.forName("com.mysql.cj.jdbc.Driver");
        conn = DriverManager.getConnection(
            "jdbc:mysql://localhost:3306/my_db?useSSL=false&serverTimezone=UTC",
            "root", "password");
        ps = conn.prepareStatement("SELECT id, name, age, email FROM user WHERE id = ?");
        ps.setInt(1, id);
        rs = ps.executeQuery();
        if (rs.next()) {
            user = new User();
            user.setId(rs.getInt("id"));
            user.setName(rs.getString("name"));
            user.setAge(rs.getInt("age"));
            user.setEmail(rs.getString("email"));
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (rs != null) try { rs.close(); } catch (Exception e) {}
        if (ps != null) try { ps.close(); } catch (Exception e) {}
        if (conn != null) try { conn.close(); } catch (Exception e) {}
    }
    return user;
}

这段代码有四个致命问题

痛点 具体表现 带来的后果
🔴 样板代码多 每次操作都要手动管理连接、Statement、ResultSet 40 行代码完成一个查询,80% 是重复代码
🔴 硬编码 SQL SQL 写在 Java 字符串里,拼接参数时极易出错 改一行 SQL 要重新编译部署
🔴 结果集手动映射 字段名和 Java 属性要一个一个手动 set 表字段一变,几十处代码要改
🔴 连接管理复杂 手动开关连接,忘记关就连接泄漏 线上数据库连接池被耗尽

MyBatis 正是为了解决这些痛点而生的。它的核心思想很简单:把 SQL 和 Java 代码解耦,自动完成参数绑定和结果映射

1.2 一句话讲清楚:MyBatis 到底是什么?

MyBatis 是一个半自动的持久层框架,它封装了 JDBC 的复杂操作,让你只需要关注 SQL 本身,其他的事情(参数设置、结果集映射、连接管理)全部帮你搞定。

说它"半自动",是因为它不像 Hibernate 那样完全自动生成 SQL——SQL 仍然由你控制,但执行 SQL 的脏活累活 MyBatis 帮你干了

简单来说,MyBatis 是你和 JDBC 之间的"翻译官"——你告诉它要查什么,它帮你把结果翻译成 Java 对象交给你。

1.3 3 分钟跑通你的第一个 MyBatis 查询

光说不练假把式,先跟着我跑通一个最简示例。我们用 Spring Boot 3.x + MyBatis 来搭建。

第一步:引入依赖

<!-- pom.xml -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

第二步:配置数据源

# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/my_db?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

# MyBatis 配置(可选,有默认值)
mybatis:
  mapper-locations: classpath:mapper/*.xml       # XML 映射文件位置
  type-aliases-package: com.example.entity        # 实体类别名包
  configuration:
    map-underscore-to-camel-case: true            # 下划线转驼峰:user_name → userName
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 控制台打印 SQL

第三步:创建实体类和 Mapper 接口

// User.java — 实体类
@Data
public class User {
    private Long id;
    private String name;
    private Integer age;
    private String email;
}
// UserMapper.java — Mapper 接口(这就是你以后写 SQL 的地方)
@Mapper
public interface UserMapper {
    User selectById(@Param("id") Long id);
}

第四步:写 XML 映射文件

<!-- resources/mapper/UserMapper.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.mapper.UserMapper">

    <select id="selectById" resultType="com.example.entity.User">
        SELECT id, name, age, email
        FROM user
        WHERE id = #{id}
    </select>

</mapper>

第五步:测试一下

@SpringBootTest
class UserMapperTest {
    @Autowired
    private UserMapper userMapper;

    @Test
    void testSelectById() {
        User user = userMapper.selectById(1L);
        System.out.println(user);  // User(id=1, name=张三, age=25, email=zhangsan@test.com)
    }
}

只用 5 行 Java 代码(不算注解),就完成了之前 40 行 JDBC 才能干的事。这就是 MyBatis 的魅力。

新手常见疑惑UserMapper 明明只是一个接口,没有实现类啊,为什么能直接调用?——因为 MyBatis 在启动时用动态代理帮我们生成了接口的实现类。下一节我们会深入聊这个。

1.4 MyBatis 核心组件速览

在深入学习之前,先认识一下 MyBatis 的"核心成员":

组件 职责 生命周期 一句话描述
SqlSessionFactory 创建 SqlSession 的工厂 应用级(全局一个) "整个应用只有一个,它负责生产 SqlSession"
SqlSession 执行 SQL、获取 Mapper、管理事务 请求级(用完即关) "每次数据库操作都要通过它,用完记得关"
Executor 真正执行 SQL 的组件 SqlSession 内部 "SQL 的实际执行者,还管缓存"
StatementHandler 向 JDBC 发送 SQL 每次执行时创建 "负责参数绑定和结果集处理"
Mapper 接口 你定义的数据库操作接口 应用级 "你写 SQL 声明的地方"

这些组件之间的协作关系,可以用一句话概括:SqlSessionFactory 生产 SqlSession,SqlSession 通过 Executor 执行 SQL,Executor 委托 StatementHandler 和 JDBC 交互

好了,基础知识就到这里。接下来,我们进入实战环节——你会接触到 MyBatis 最常用的那些功能。


二、核心进阶:MyBatis 的"十八般武艺"

2.1 XML 映射 vs 注解映射:你该怎么选?

MyBatis 支持两种方式定义 SQL:XML 文件和注解。先看对比:

注解方式——SQL 写在 Java 代码里:

@Mapper
public interface UserMapper {

    @Select("SELECT id, name, age, email FROM user WHERE id = #{id}")
    User selectById(@Param("id") Long id);

    @Insert("INSERT INTO user(name, age, email) VALUES(#{name}, #{age}, #{email})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(User user);
}

XML 方式——SQL 写在 XML 文件中:

<mapper namespace="com.example.mapper.UserMapper">
    <select id="selectById" resultType="User">
        SELECT id, name, age, email FROM user WHERE id = #{id}
    </select>

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO user(name, age, email) VALUES(#{name}, #{age}, #{email})
    </insert>
</mapper>

那么问题来了——到底用哪个?这里给出一个实用的决策指南:

用什么方式写 SQL?
│
├── 简单 CRUD(单表增删改查)
│   └── ✅ 用注解,简洁方便
│
├── 复杂查询(多表 JOIN、嵌套子查询)
│   └── ✅ 用 XML,SQL 和 Java 分离,便于维护
│
├── 动态 SQL(条件搜索、批量操作)
│   └── ✅ 用 XML,注解里写动态 SQL 是噩梦
│
└── 团队规范
    └── ✅ 统一用 XML,保持一致性(推荐)

我的建议:实际项目中,绝大多数团队选择统一用 XML。因为需求一变,简单 SQL 就可能变成复杂 SQL。与其后期迁移,不如一开始就用 XML。

2.2 动态 SQL:MyBatis 最"香"的功能

如果说 MyBatis 有一个功能让我觉得"用了就回不去",那一定是动态 SQL。来看一个经典场景——用户搜索:

// 请求参数:name 和 age 都是可选的
UserSearchReq req = new UserSearchReq();
req.setName("张三");   // 可能为空
req.setAge(25);         // 可能为 null

如果不用动态 SQL,你就得在 Java 代码里手动拼接 SQL 字符串,那简直是一场灾难。而 MyBatis 的动态 SQL 让这件事变得优雅:

<select id="searchUsers" resultType="User">
    SELECT id, name, age, email FROM user
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
    </where>
    ORDER BY id DESC
</select>

<where> 标签很聪明——当内部条件都不满足时,它什么都不输出;当有满足的条件时,它会自动去掉第一个 AND。这就避免了 WHERE AND name = ? 这种 SQL 语法错误。

常用动态 SQL 标签速查

标签 作用 典型场景
<if> 条件判断 "如果传了参数就拼条件"
<where> 自动处理 WHERE 子句的 AND/OR 多条件搜索
<set> 自动处理 UPDATE SET 的逗号 动态更新
<foreach> 遍历集合 IN 查询、批量插入
<choose>/<when>/<otherwise> 多选一(类似 switch) "只按第一个非空条件查"
<trim> 更灵活的字符串裁剪 自定义的前缀/后缀处理
<sql>/<include> SQL 片段复用 多表共用的查询列

来看几个实战例子:

批量插入(foreach)

<insert id="batchInsert">
    INSERT INTO user(name, age, email) VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.age}, #{user.email})
    </foreach>
</insert>

动态更新(set)

<update id="updateUser">
    UPDATE user
    <set>
        <if test="name != null">name = #{name},</if>
        <if test="age != null">age = #{age},</if>
        <if test="email != null">email = #{email},</if>
    </set>
    WHERE id = #{id}
</update>

<set> 会自动去掉最后一个多余的逗号——很贴心吧?

SQL 片段复用(sql/include)

<sql id="userColumns">
    id, name, age, email, create_time
</sql>

<select id="selectById" resultType="User">
    SELECT <include refid="userColumns"/> FROM user WHERE id = #{id}
</select>

<select id="selectAll" resultType="User">
    SELECT <include refid="userColumns"/> FROM user ORDER BY id
</select>

字段变了只需要改一处,这就是复用的价值。

2.3 #{} vs ${}:90% 的人都踩过的坑

这是面试最高频的 MyBatis 问题之一,也是生产事故的高发区。先看结论:

符号 处理方式 效果 用法
#{} 预编译占位符 ? WHERE id = ? 传参数值
${} 直接字符串拼接 WHERE id = 1 传表名、列名、ORDER BY

安全对比——SQL 注入实验

// 用户输入:name = "张三' OR '1'='1"

使用 #{}(安全):

<select id="selectByName" resultType="User">
    SELECT * FROM user WHERE name = #{name}
</select>
<!-- 实际执行:SELECT * FROM user WHERE name = ? -->
<!-- 参数:'张三'' OR ''1''=''1'  →  被当作一个完整的字符串值 -->

使用 ${}(危险!):

<select id="selectByName" resultType="User">
    SELECT * FROM user WHERE name = '${name}'
</select>
<!-- 实际执行:SELECT * FROM user WHERE name = '张三' OR '1'='1' -->
<!-- 条件永远为真,所有用户数据被暴露!-->

${} 的正确使用场景

虽然 ${} 有安全风险,但以下场景必须用它:

<!-- 1. 动态表名 -->
<select id="selectByTable" resultType="map">
    SELECT * FROM ${tableName} WHERE id = #{id}
</select>

<!-- 2. 动态排序字段 -->
<select id="selectAll" resultType="User">
    SELECT * FROM user ORDER BY ${orderColumn} ${orderDirection}
</select>

<!-- 3. 动态分组 -->
<select id="selectByGroup" resultType="map">
    SELECT ${groupColumn}, COUNT(*) as cnt FROM user GROUP BY ${groupColumn}
</select>

安全守则:使用 ${} 时,参数值必须在 Java 代码中做白名单校验,绝不能让用户输入直接传入。

// 白名单校验示例
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "name", "age", "create_time");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List<User> selectByOrder(String orderColumn, String orderDirection) {
    if (orderColumn == null || !ALLOWED_COLUMNS.contains(orderColumn)) {
        throw new IllegalArgumentException("非法的排序字段: " + orderColumn);
    }
    if (orderDirection == null || !ALLOWED_DIRECTIONS.contains(orderDirection.toUpperCase())) {
        throw new IllegalArgumentException("非法的排序方向: " + orderDirection);
    }
    return mapper.selectAll(orderColumn, orderDirection.toUpperCase());
}

2.4 ResultMap:当字段名和属性名不一致时

MyBatis 默认按照字段名和属性名同名匹配来映射。但现实中有三种情况会导致不匹配:

  1. 数据库字段用下划线(user_name),Java 属性用驼峰(userName
  2. 关联查询的嵌套对象
  3. 字段名和属性名完全不同

情况一最简单——开启驼峰转换即可:

mybatis:
  configuration:
    map-underscore-to-camel-case: true  # user_name → userName 自动映射

情况二——关联对象,这才是 ResultMap 的真正威力所在。假设用户表有收货地址:

@Data
public class User {
    private Long id;
    private String name;
    // 关联对象:一个用户有多个地址
    private List<Address> addresses;
}

@Data
public class Address {
    private Long id;
    private Long userId;
    private String province;
    private String city;
    private String detail;
}

用 ResultMap 处理一对多:

<resultMap id="UserWithAddresses" type="User">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <!-- collection:一对多关联 -->
    <collection property="addresses" ofType="Address"
                column="id" select="selectAddressesByUserId"/>
</resultMap>

<select id="selectUserWithAddresses" resultMap="UserWithAddresses">
    SELECT id, name FROM user WHERE id = #{id}
</select>

<select id="selectAddressesByUserId" resultType="Address">
    SELECT id, user_id, province, city, detail
    FROM address WHERE user_id = #{userId}
</select>

一对一关联用 <association>

<resultMap id="UserWithIdCard" type="User">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <!-- association:一对一关联 -->
    <association property="idCard" javaType="IdCard"
                 column="id" select="selectIdCardByUserId"/>
</resultMap>

性能提示<collection> 和 <association> 的分步查询默认会触发 N+1 查询问题。查 1 个用户 → 再查 N 个子记录。如果数据量大,建议改用 JOIN 连表查询。

<!-- 用 JOIN 一次性查出来,避免 N+1 -->
<resultMap id="UserWithAddressesJoin" type="User">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <collection property="addresses" ofType="Address"
                resultMap="addressResultMap"/>
</resultMap>

<select id="selectUserWithAddressesJoin" resultMap="UserWithAddressesJoin">
    SELECT u.id, u.name, a.id as a_id, a.province, a.city, a.detail
    FROM user u
    LEFT JOIN address a ON u.id = a.user_id
    WHERE u.id = #{id}
</select>

2.5 缓存机制:帮你的查询提提速

MyBatis 内置了两级缓存。不过先说一个"反常识"的建议:生产环境通常不推荐开启 MyBatis 的二级缓存

缓存级别 作用范围 默认状态 一句话描述
一级缓存 SqlSession 级别 默认开启 "同一会话中,相同的查询只执行一次 SQL"
二级缓存 Mapper 级别(跨 SqlSession) 默认关闭 "多个会话共享缓存,但要小心脏数据"

一级缓存演示

// 同一个 SqlSession 中
User user1 = userMapper.selectById(1L);  // 查数据库
User user2 = userMapper.selectById(1L);  // 从缓存取,不发 SQL
System.out.println(user1 == user2);      // true,同一个对象引用

一级缓存在以下情况会失效:执行了增删改操作、手动清空缓存、不同的 SqlSession。

为什么不推荐二级缓存?

<!-- 开启二级缓存的方式很简单 -->
<cache/>

但问题是:二级缓存是Mapper 级别的,如果其他 Mapper 修改了同一张表的数据,缓存不会自动失效。这就导致你读到的可能是脏数据。

最佳实践:将缓存控制权交给更专业的组件——Redis。MyBatis 的缓存只适合极少更新的字典表、配置表等场景。需要分布式缓存时,可以集成 mybatis-redis 等扩展。


三、深入理解:钻进 MyBatis 的"发动机舱"

3.1 一条 SQL 的执行全链路

面试时经常被问到:"MyBatis 是怎么工作的?"下面这张流程图就是标准答案:

┌─────────────────────────────────────────────────────┐
│              1. 调用 Mapper 接口方法                  │
│              userMapper.selectById(1L)              │
└───────────────────────┬─────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────┐
│          2. MapperProxy(动态代理)拦截调用            │
│         根据接口全限定名 + 方法名找到对应的 SQL          │
│       → com.example.mapper.UserMapper.selectById    │
└───────────────────────┬─────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────┐
│             3. SqlSession 将请求交给 Executor        │
│             Executor 判断是否命中缓存                 │
└───────────────────────┬─────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────┐
│         4. StatementHandler 处理参数和 SQL           │
│         ParameterHandler:将 Java 参数设置到 ?        │
│         SQL 解析完成,生成可执行的 JDBC Statement      │
└───────────────────────┬─────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────┐
│             5. TypeHandler:类型转换                 │
│             Java 类型 ↔ JDBC 类型 ↔ 数据库类型        │
└───────────────────────┬─────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────┐
│             6. ResultSetHandler:结果映射            │
│             将 JDBC ResultSet → Java 对象            │
└───────────────────────┬─────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────┐
│            7. 返回结果给调用者                         │
└─────────────────────────────────────────────────────┘

对应到源码层面,核心流程大致如下(简化版):

SqlSession.selectOne()
  → Executor.query()
    → 创建 CacheKey(判断缓存)
    → BaseExecutor.query() / CachingExecutor.query()
      → SimpleExecutor.doQuery()
        → StatementHandler.prepare()  → 获取 Connection,预编译 SQL
        → StatementHandler.parameterize()  → ParameterHandler 设置参数
        → StatementHandler.query()  → 执行 SQL,得到 ResultSet
        → ResultSetHandler.handleResultSets()  → 映射为 Java 对象

面试回答模板:MyBatis 通过动态代理拦截 Mapper 接口方法调用,根据方法签名找到对应的 SQL 语句,再通过 Executor → StatementHandler → TypeHandler 的链路,完成参数绑定、SQL 执行和结果集映射。

3.2 动态代理:为什么 Mapper 接口没有实现类也能工作?

这是理解 MyBatis 的关键。看看 MyBatis 是怎么"偷梁换柱"的:

// MyBatis 内部大致做了这样一件事(极度简化版):
public class MapperProxy<T> implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
        // 1. 获取方法全限定名:com.example.mapper.UserMapper.selectById
        String statementId = method.getDeclaringClass().getName() + "." + method.getName();

        // 2. 判断 SQL 类型(SELECT/INSERT/UPDATE/DELETE)
        // 3. 调用 SqlSession 的对应方法(selectOne / selectList / insert / update / delete)
        // 4. 处理参数和返回值
        if (returnsList) {
            return sqlSession.selectList(statementId, args[0]);
        } else {
            return sqlSession.selectOne(statementId, args[0]);
        }
    }
}

// 你拿到的 userMapper 实际上是这个:
UserMapper userMapper = (UserMapper) Proxy.newProxyInstance(
    UserMapper.class.getClassLoader(),
    new Class[]{UserMapper.class},
    new MapperProxy<>(sqlSession)
);

这就是为什么我们只需要写接口——MyBatis 在运行时帮你生成了代理对象,这个代理对象会拦截你的每一次调用,然后去 XML 或注解中找到对应的 SQL 来执行。

3.3 插件机制:MyBatis 的"中间件"系统

MyBatis 的插件(Interceptor)可以在 SQL 执行过程中做手脚,原理是责任链模式 + 动态代理

最著名的插件莫过于 PageHelper——分页插件。它的工作原理就是拦截 Executor.query(),在原始 SQL 外面包一层 LIMIT

// 你写的 SQL
// SELECT * FROM user WHERE age > 18 ORDER BY id
//
// PageHelper 改写成(以 MySQL 为例):
// SELECT * FROM user WHERE age > 18 ORDER BY id LIMIT ?, ?

自己实现一个简单的 SQL 日志插件:

@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 handler = (StatementHandler) invocation.getTarget();
        MetaObject metaObject = SystemMetaObject.forObject(handler);

        // 拿到原始 SQL(有占位符 ? 的)
        String sql = handler.getBoundSql().getSql();
        System.out.println("执行的 SQL:" + sql);

        // 拿到参数
        Object parameterObject = handler.getBoundSql().getParameterObject();
        System.out.println("参数:" + parameterObject);

        // 计算执行时间
        long start = System.currentTimeMillis();
        Object result = invocation.proceed();
        long cost = System.currentTimeMillis() - start;
        System.out.println("耗时:" + cost + "ms");

        return result;
    }
}

MyBatis 允许拦截的四个"关口":

拦截对象 可拦截方法 典型用途
Executor update, query, commit, rollback 分页、缓存、慢 SQL 监控
StatementHandler prepare, parameterize, batch, update, query SQL 改写、加密解密
ParameterHandler setParameters 参数处理
ResultSetHandler handleResultSets 结果集脱敏

3.4 MyBatis vs Hibernate vs JPA:别再纠结了

这个话题在团队技术选型时总能吵上半天。直接上对比:

维度 MyBatis Hibernate/JPA
SQL 控制权 开发者手写 SQL 框架自动生成(HQL/JPQL)
学习曲线 低(会 SQL 就能上手) 高(要学 HQL、Criteria、缓存策略)
复杂查询 直接写 SQL,灵活可控 用 HQL/JPQL,复杂场景费劲
自动映射 须配 ResultMap 自动映射(@Entity 注解即关联)
数据库移植 要改 SQL(不同库语法不同) 理论上无缝切换(实际也有坑)
性能优化 直接优化 SQL 要理解 Hibernate 的 SQL 生成逻辑
适用场景 复杂 SQL 多、报表系统、互联网项目 CRUD 为主、快速开发、标准业务系统

选型建议

你是哪种团队?
│
├── 互联网公司,SQL 需要精细优化,关联查询多
│   └── MyBatis / MyBatis-Plus
│
├── 企业管理系统,CRUD 为主,需要快速开发
│   └── JPA + QueryDSL
│
└── 既要灵活又要效率
    └── MyBatis-Plus(保留了 MyBatis 的灵活性,又提供了便捷的 CRUD)

四、实战与面试:从踩坑到拿 Offer

4.1 常见踩坑指南

坑 1:Mapper 接口方法重载

@Mapper
public interface UserMapper {
    User selectById(Long id);           // ❌
    User selectById(Long id, String name);  // ❌
}

MyBatis 通过 接口名.方法名 定位 SQL,方法重载会导致查找失败。不要重载 Mapper 方法

坑 2:忘记处理 NULL 值

<select id="selectByName" resultType="User">
    SELECT * FROM user WHERE name = #{name}
</select>

如果 name 传入 null,SQL 会变成 WHERE name = null,这永远查不到东西(因为 SQL 中用 IS NULL 而非 = NULL)。正确做法:

<select id="selectByName" resultType="User">
    SELECT * FROM user
    <where>
        <if test="name != null">
            name = #{name}
        </if>
    </where>
</select>

坑 3:大事务导致数据库连接耗尽

// ❌ 反面教材
@Transactional
public void processLargeData() {
    List<User> users = userMapper.selectAll();  // 假设 100 万条
    for (User user : users) {
        // 一顿操作,持有事务 30 分钟
        userMapper.update(user);
    }
}

如果这个接口的 QPS 较高,连接池很快就会被耗尽。正确做法是分批处理

public void processLargeData() {
    int pageSize = 1000;
    Long lastId = 0L;
    while (true) {
        List<User> batch = userMapper.selectByPage(lastId, pageSize);
        if (batch.isEmpty()) break;
        for (User user : batch) {
            // 每批在独立事务中执行
            userService.updateUser(user);
        }
        lastId = batch.get(batch.size() - 1).getId();
    }
}

坑 4:foreach 拼接 IN 查询时参数过多

<select id="selectByIds" resultType="User">
    SELECT * FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" close=")" separator=",">
        #{id}
    </foreach>
</select>

如果 ids 有 5000 个元素,生成的 SQL 会非常长,甚至超出数据库限制。建议分批处理,每批不超过 1000 个 ID。

4.2 性能优化实战

1. 只查需要的字段

<!-- ❌ 差:查出所有字段 -->
<select id="selectUserList" resultType="User">
    SELECT * FROM user
</select>

<!-- ✅ 好:只查需要显示的字段 -->
<select id="selectUserListForDisplay" resultType="User">
    SELECT id, name, age FROM user
</select>

2. 合理使用批量操作

<!-- ✅ 批量插入 -->
<insert id="batchInsert">
    INSERT INTO user(name, age) VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.age})
    </foreach>
</insert>

MyBatis 默认的批量提交需要额外配置(对接 JDBC 的 rewriteBatchedStatements):

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/my_db?rewriteBatchedStatements=true

这个参数能把多条 INSERT 合并成一条,性能提升非常明显。

3. 避免 N+1 查询

N+1 问题是指:先查主表(1 次),再逐条查子表(N 次),总计 N+1 次查询。前面讲 ResultMap 的 <collection> 和 <association> 时就提到了这个问题。解决方案是用 JOIN 一次查出所有数据。

4.3 10 道高频面试题精讲

Q1:MyBatis 中 #{} 和 ${} 的区别是什么?

经典必问。#{} 是预编译占位符,安全防注入;${} 是字符串直接拼接,有注入风险,用于表名/列名等动态场景。

Q2:MyBatis 的一级缓存和二级缓存有什么区别?

一级缓存:SqlSession 级别,默认开启,同一会话相同查询不重复查库。二级缓存:Mapper 级别,跨 SqlSession,默认关闭,需手动开启且要小心脏数据。

Q3:MyBatis 是如何工作的(执行流程)?

MapperProxy 动态代理拦截 → 找 SQL 语句 → Executor 执行 → StatementHandler 参数化 → JDBC 执行 → ResultSetHandler 映射结果。

Q4:MyBatis 的分页是如何实现的?

MyBatis 自身不分页,需要借助插件(如 PageHelper)。插件拦截 Executor.query(),改写 SQL 添加 LIMIT/OFFSET。

Q5:MyBatis 支持哪些执行器(ExecutorType)?

三种:SIMPLE(默认,每次创建新 Statement)、REUSE(复用 Statement)、BATCH(批量执行)。

Q6:当实体类字段名和数据库字段名不一致时怎么办?

三种方案:① 开启 mapUnderscoreToCamelCase(下划线转驼峰);② 用 ResultMap 显式映射;③ SQL 中起别名:SELECT user_name AS userName FROM user

Q7:MyBatis 的延迟加载原理?

通过动态代理,代理关联对象的 getter 方法。当你真正调用关联对象时,才触发第二条 SQL。配置 lazyLoadingEnabled=true 开启。

Q8:如何获取自动生成的主键?

<insert useGeneratedKeys="true" keyProperty="id">,或者 <selectKey> 标签。插入后,主键值会回填到传入的对象中。

Q9:MyBatis 和 Hibernate 的区别及选型?

半自动 vs 全自动。MyBatis 手写 SQL、灵活可控;Hibernate 自动生成 SQL、开发快。互联网项目多用 MyBatis,企业管理系统多用 JPA。

Q10:如何打印 MyBatis 执行的 SQL?

三种方式:① mybatis.configuration.log-impl=StdOutImpl;② 调整日志级别 logging.level.com.example.mapper=DEBUG;③ 自定义插件拦截。


写在最后

回顾一下,我们从 JDBC 的痛苦出发,认识了 MyBatis 的核心价值——把 SQL 和 Java 代码解耦,让我们专注于 SQL 本身。我们学了 XML 和注解两种映射方式,掌握了动态 SQL、ResultMap、参数绑定这些核心技能,还钻进了源码层理解了动态代理和执行链路。

记住一句话:MyBatis 的灵魂是 SQL。框架帮你省掉的是样板代码,但 SQL 质量和数据库设计永远是你自己的功底。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐