一篇文章掌握一个技能:MyBatis 从入门到精通与高频面试题精讲
我记得刚学 Java 那会儿,带我的老大哥让我连个数据库查个用户列表。我吭哧吭哧写了半天 JDBC——
Class.forName、DriverManager.getConnection、Connection.prepareStatement、ResultSet遍历、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 默认按照字段名和属性名同名匹配来映射。但现实中有三种情况会导致不匹配:
- 数据库字段用下划线(
user_name),Java 属性用驼峰(userName) - 关联查询的嵌套对象
- 字段名和属性名完全不同
情况一最简单——开启驼峰转换即可:
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 质量和数据库设计永远是你自己的功底。
更多推荐



所有评论(0)