MyBatis
开篇:为什么 MyBatis 在国内比 JPA 更受欢迎?
用一个比喻来理解:JPA(Hibernate)像自动挡汽车,你告诉它"去北京",它自动帮你规划路线、加油、过收费站;MyBatis 像手动挡,你得自己换挡,但路线完全由你掌控。
国内互联网业务复杂度高,SQL 调优需求强烈,多表 JOIN 和复杂查询是常态。MyBatis 让开发者直接写 SQL,既保留了灵活性,又通过 ORM 映射省去了 JDBC 的繁琐操作。看一下对比就知道差距有多大:
// 原生 JDBC:30 行代码做一次查询
Connection conn = DriverManager.getConnection(url, user, pwd);
String sql = "SELECT id, title FROM article WHERE author_id = " + authorId;
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
List<Article> articles = new ArrayList<>();
while (rs.next()) {
Article a = new Article();
a.setId(rs.getInt("id"));
a.setTitle(rs.getString("title"));
articles.add(a);
}
// 还要 close...
// MyBatis:2 行搞定
ArticleMapper mapper = session.getMapper(ArticleMapper.class);
List<Article> articles = mapper.findByAuthor(authorId);MyBatis 帮你管 Connection、Statement、ResultSet,你只负责写 SQL 和定义映射规则。
ORM(Object Relational Mapping):通过描述对象和数据库之间的映射关系,将程序中的对象自动持久化到关系型数据库中。
一、MyBatis 核心概念
MyBatis 的核心就三个东西:配置文件、Mapper、SqlSession。
1.1 三大组件
| 组件 | 作用 | 类比 |
|---|---|---|
SqlSessionFactory | 生产 SqlSession 的工厂,一个数据库对应一个 | 汽车工厂 |
SqlSession | 一次数据库会话,执行 SQL 的入口 | 一辆汽车 |
Mapper | SQL 语句的载体(XML 或注解) | 导航地图 |
获取过程:SqlSessionFactoryBuilder -> 读取配置 -> 创建 SqlSessionFactory -> 打开 SqlSession -> 获取 Mapper。
1.2 XML vs 注解
MyBatis 支持两种方式编写 SQL:
// 注解方式:适合简单 SQL
@Select("SELECT * FROM user WHERE id = #{id}")
User findById(Long id);
// XML 方式:适合复杂 SQL(动态 SQL、多表查询)
// 在 UserMapper.xml 中编写实际项目中,简单的 CRUD 用注解,复杂的用 XML,两者可以混用。
1.3 参数传递的四种方式
// 1. @Param 注解(推荐,最清晰)
User selectUser(@Param("name") String name, @Param("deptId") int deptId);
// XML 中用 #{name}、#{deptId}
// 2. Java Bean
User selectUser(User user);
// XML 中用 #{userName}、#{deptId},对应实体类的属性名(本质是 getter 方法)
// 3. Map
User selectUser(Map<String, Object> params);
// XML 中用 #{userName}、#{deptId},对应 Map 的 key
// 4. 顺序传参(不推荐,可读性差)
User selectUser(String name, int deptId);
// XML 中用 #{param1}、#{param2} 或 #{arg0}、#{arg1}1.4 #{} 和 ${} 的区别
这是面试必问,也是实际开发中必须搞清楚的。
| 对比项 | #{} | ${} |
|---|---|---|
| 底层实现 | PreparedStatement,预编译 | Statement,字符串拼接 |
| SQL 注入 | 安全,参数用 ? 占位 | 不安全,直接拼入 SQL |
| 适用场景 | 绝大多数场景 | 表名、排序字段等不能加引号的场景 |
-- #{} 编译后:安全
SELECT * FROM user WHERE name = ?
-- ${} 编译后:直接替换,有注入风险
SELECT * FROM user WHERE name = 'zhangsan'什么时候必须用 ${}?
-- 1. 动态表名(分表场景)
SELECT * FROM ${tableName} WHERE id = #{id}
-- 2. 动态排序
ORDER BY ${sortField} ${sortOrder}
-- 3. 批量删除(传入的是 "1,2,3" 字符串)
DELETE FROM user WHERE id IN (${ids})
-- 4. 模糊查询(也可以用 CONCAT 函数配合 #{} 替代)
SELECT * FROM user WHERE name LIKE '%${keyword}%'
-- 推荐写法:
SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%')用 ${} 时务必做参数校验(比如白名单、枚举校验),防止 SQL 注入。
二、执行流程
MyBatis 执行一条 SQL 的完整链路:
关键角色说明:
- MapperProxy:通过 JDK 动态代理生成的 Mapper 接口代理类,调用方法时会转发到
MapperMethod,解析注解/XML 中的 SQL 配置 - Executor:执行器,有三种实现——
SimpleExecutor(默认,每次创建新 Statement)、ReuseExecutor(以 SQL 为 key 复用 Statement)、BatchExecutor(批处理,缓存多个 Statement 后统一执行) - StatementHandler:负责创建 Statement、设置参数、执行 SQL
- ParameterHandler:处理输入参数,将 Java 对象转为 SQL 参数
- ResultSetHandler:处理返回结果,将数据库字段映射为 Java 对象
整个过程的本质就三步:组装 SQL -> 执行 SQL -> 映射结果。
三、缓存机制
MyBatis 有两级缓存。直觉上缓存能提升性能,但用不好会引入脏数据。
3.1 一级缓存(SqlSession 级别)
一级缓存默认开启,作用域是 SqlSession。同一个 SqlSession 内,相同的查询只执行一次 SQL,第二次直接返回缓存结果。
一级缓存失效的三种情况:
- 不同的 SqlSession
- 查询条件变了
- 两次查询之间执行了增删改操作(自动清空缓存)
一级缓存本质是一个没有容量限制的 HashMap(PerpetualCache),功能简单但容易出问题——在分布式环境下,一个 SqlSession 缓存了数据,另一个 SqlSession 更新了它,前者拿到的就是脏数据。建议把缓存级别设为 STATEMENT(每次查询都清空缓存)。
3.2 二级缓存(Mapper 级别)
二级缓存的作用域是 Mapper(Namespace),可以跨 SqlSession 共享。默认关闭,需要手动开启。
开启步骤:
<!-- mybatis-config.xml(其实默认就是 true) -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
<!-- XxxMapper.xml 中添加 -->
<cache
eviction="LRU"
flushInterval="60000"
size="1024"
readOnly="false"/>实体类必须实现 Serializable 接口。SqlSession 关闭或提交后,一级缓存的数据才会写入二级缓存。
二级缓存的坑: 因为它是按 Namespace 隔离的,如果在 StudentMapper 中关联查询了 Class 表,而 ClassMapper 中更新了 Class 表,StudentMapper 的二级缓存不会失效,产生脏数据。
建议: 生产环境中不建议使用 MyBatis 自带的二级缓存。如果需要缓存,用 Redis、Caffeine 等专业方案,可控性更强,排查问题也更容易。至少你明确知道哪里有缓存,而不是隐式地被 MyBatis 缓存坑了。
3.3 缓存配置项
| 配置 | 说明 |
|---|---|
eviction | 淘汰策略:LRU(最近最少使用,默认)/ FIFO / SOFT / WEAK |
flushInterval | 刷新间隔(毫秒),不设则不定时刷新,只有增删改才清空 |
size | 最多缓存对象数,默认 1024 |
readOnly | true 返回共享引用(快但多线程不安全),false 返回深拷贝(安全) |
四、动态 SQL
动态 SQL 是 MyBatis 最强大的特性之一。没有它,你就得写一堆字符串拼接或者多个 SQL 方法。
4.1 if + where
<select id="findByCondition" resultType="User">
SELECT * FROM user
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="age != null">
AND age = #{age}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
</select><where> 标签的妙处:所有条件都为空时不生成 WHERE 子句,还能自动去掉开头多余的 AND/OR。
注意:test 中不能用 &&,只能用 and。
4.2 set(动态更新)
<update id="updateSelective">
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> 标签自动去掉末尾多余的逗号。这样只更新非空字段,避免把有值的字段覆盖成 null。
4.3 trim(万能标签)
<where> 和 <set> 其实都是 <trim> 的特例:
<!-- 等价于 <where> -->
<trim prefix="WHERE" prefixOverrides="AND|OR">
...
</trim>
<!-- 等价于 <set> -->
<trim prefix="SET" suffixOverrides=",">
...
</trim>prefix 加前缀,suffix 加后缀,prefixOverrides 去前缀,suffixOverrides 去后缀。
4.4 foreach
用于 IN 查询和批量操作:
<!-- 批量删除 -->
<delete id="deleteByIds">
DELETE FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<!-- 批量插入 -->
<insert id="batchInsert">
INSERT INTO user (name, age) VALUES
<foreach collection="users" item="u" separator=",">
(#{u.name}, #{u.age})
</foreach>
</insert>foreach 属性说明:collection(集合名)、item(元素变量名)、separator(分隔符)、open/close(首尾包裹)。
4.5 choose / when / otherwise
相当于 Java 的 if-else if-else,只有一个分支会被选中:
<select id="findByPriority" resultType="User">
SELECT * FROM user
<where>
<choose>
<when test="id != null">
AND id = #{id}
</when>
<when test="name != null">
AND name = #{name}
</when>
<otherwise>
AND status = 1
</otherwise>
</choose>
</where>
</select>4.6 sql + include(复用片段)
<sql id="userColumns">id, name, age, email, create_time</sql>
<select id="findAll" resultType="User">
SELECT <include refid="userColumns"/> FROM user
</select>
<select id="findById" resultType="User">
SELECT <include refid="userColumns"/> FROM user WHERE id = #{id}
</select>修改字段列表只需要改一处。
五、结果映射
5.1 简单映射
列名和属性名一致时,用 resultType 即可自动映射。不一致时有三种解决方案:
-- 方案一:SQL 别名
SELECT user_name AS userName FROM user
-- 方案二:开启驼峰自动映射(最推荐,一劳永逸)<setting name="mapUnderscoreToCamelCase" value="true"/>-- 方案三:resultMap 手动映射(最灵活)<resultMap id="userMap" type="User">
<id property="id" column="id"/>
<result property="userName" column="user_name"/>
<result property="createTime" column="create_time"/>
</resultMap>5.2 特殊返回值
// 返回 Map(没有实体类时)
Map<String, Object> selectAsMap(Long id);
// 返回 List<Map>
List<Map<String, Object>> selectAllAsMap();
// 返回大 Map(id 作为 key)
@MapKey("id")
Map<Long, Map<String, Object>> selectAllRetMap();5.3 一对一关联(association)
public class Order {
private Long id;
private String orderNo;
private Pay pay; // 一个订单关联一个支付信息
}<resultMap id="orderMap" type="Order">
<id property="id" column="order_id"/>
<result property="orderNo" column="order_no"/>
<association property="pay" javaType="Pay">
<id property="id" column="pay_id"/>
<result property="amount" column="amount"/>
</association>
</resultMap>5.4 一对多关联(collection)
public class Category {
private Long id;
private String name;
private List<Product> products; // 一个分类下有多个商品
}<resultMap id="categoryMap" type="Category">
<id property="id" column="category_id"/>
<result property="name" column="category_name"/>
<collection property="products" ofType="Product">
<id property="id" column="product_id"/>
<result property="name" column="product_name"/>
<result property="price" column="price"/>
</collection>
</resultMap>注意区分:association 用 javaType 指定类型,collection 用 ofType 指定集合元素类型。
5.5 分步查询与延迟加载
关联查询可以拆成两步 SQL,配合延迟加载按需触发第二步:
<resultMap id="studentMap" type="Student">
<id property="id" column="id"/>
<result property="name" column="name"/>
<association property="clazz"
select="com.example.mapper.ClazzMapper.findById"
column="clazz_id"
fetchType="lazy"/>
</resultMap>全局开启延迟加载:
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
</settings>延迟加载的原理:MyBatis 为实体类生成 CGLIB 代理对象,访问关联属性时才触发第二条 SQL。分步查询的优点是代码复用性强,缺点是可能产生 N+1 查询问题,需要根据场景判断是用 JOIN 还是分步。
六、MyBatis-Plus 集成
MyBatis-Plus(简称 MP)是 MyBatis 的增强工具,核心理念是"只做增强不做改变"。它解决了 MyBatis 最大的痛点——简单 CRUD 也要写 SQL。
6.1 快速集成
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.x</version>
</dependency>mybatis-plus:
mapper-locations: classpath:/mapper/**/*.xml
global-config:
db-config:
id-type: auto # 主键自增
logic-delete-value: 1 # 逻辑删除值
logic-not-delete-value: 0启动类加 @MapperScan("com.example.mapper")。
6.2 自动 CRUD
Mapper 继承 BaseMapper 即可拥有十几个内置方法,零 SQL:
public interface UserMapper extends BaseMapper<User> {
// 无需写任何方法,自动拥有 insert/delete/update/select
}
// 使用
userMapper.selectById(1L);
userMapper.insert(user);
userMapper.deleteById(1L);
userMapper.selectList(null); // 查全部
userMapper.updateById(user);6.3 条件构造器(LambdaQueryWrapper)
告别字符串字段名,用 Lambda 表达式构建查询条件,编译期就能发现拼写错误:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, 1)
.like(User::getName, "张")
.gt(User::getAge, 18)
.orderByDesc(User::getCreateTime);
List<User> users = userMapper.selectList(wrapper);6.4 分页插件
MP 的分页是物理分页,自动在 SQL 后拼接 LIMIT:
// 1. 配置分页插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL)
);
return interceptor;
}
// 2. 使用
Page<User> page = new Page<>(1, 10); // 第1页,每页10条
IPage<User> result = userMapper.selectPage(page, wrapper);
// result.getRecords() 数据列表
// result.getTotal() 总记录数
// result.getPages() 总页数分页原理:PaginationInnerInterceptor 拦截 SQL 执行,在 beforeQuery 阶段根据数据库方言(MySQL、Oracle 等)在原 SQL 后拼接分页语句。MySQL 就是 LIMIT ?,?。同时自动执行一条 COUNT(*) 获取总记录数。
6.5 逻辑删除
配置后,调用 deleteById 实际执行的是 UPDATE SET deleted=1,查询时自动追加 WHERE deleted=0:
@TableLogic
private Integer deleted;对业务代码完全透明,不需要在每条 SQL 中手动加 WHERE deleted=0。
6.6 代码生成器
MP 提供了代码生成器,根据数据库表自动生成 Entity、Mapper、Service、Controller:
FastAutoGenerator.create("jdbc:mysql://localhost:3306/test", "root", "123456")
.globalConfig(b -> b.author("fk").outputDir("src/main/java"))
.packageConfig(b -> b.parent("com.example"))
.strategyConfig(b -> b.addInclude("sys_user", "sys_role"))
.execute();生成的代码可能需要根据项目规范微调,但能省去 80% 的样板代码。
七、分页方案对比
MyBatis 生态中有四种分页方式,选择时需要根据场景判断:
| 方案 | 类型 | 原理 | 适用场景 |
|---|---|---|---|
| SQL 中写 LIMIT | 物理分页 | 手动拼接 | 简单场景,最直接 |
| PageHelper 插件 | 物理分页 | 拦截器拼接 LIMIT,参数存 ThreadLocal | 纯 MyBatis 项目 |
| MyBatis-Plus 分页 | 物理分页 | 拦截器拼接 LIMIT | MP 项目(推荐) |
| RowBounds | 逻辑分页 | 全量查询后内存截取 | 小数据量,不推荐 |
生产环境推荐物理分页。逻辑分页(RowBounds)会把全部数据加载到内存,数据量大时直接 OOM。
八、插件机制
MyBatis 的插件机制基于责任链模式,可以拦截四大核心对象的方法:
| 对象 | 可拦截方法 | 典型用途 |
|---|---|---|
Executor | update, query, commit, rollback | 缓存、多数据源 |
StatementHandler | prepare, parameterize, query | SQL 改写、分页 |
ParameterHandler | setParameters | 参数加解密 |
ResultSetHandler | handleResultSets | 结果集处理 |
PageHelper 和 MyBatis-Plus 的分页插件都是基于这个机制实现的。
九、常见面试题精选
Q1:MyBatis 和 Hibernate 有什么区别?
Hibernate 是全自动 ORM,开发者不用写 SQL,它自动生成;MyBatis 是半自动 ORM,SQL 要自己写,但映射自动完成。Hibernate 的优势是开发效率高、数据库移植性好(换数据库不用改 SQL);MyBatis 的优势是 SQL 可控、易调优、多表查询灵活。Hibernate 的缓存系统更完善。国内互联网项目因为对性能和 SQL 调优要求高,普遍选择 MyBatis;管理系统和 ERP 等可以考虑 Hibernate。
Q2:MyBatis 是如何实现字段映射的?
MyBatis 通过 ResultSetHandler 处理查询结果。核心步骤:(1) 创建目标对象实例(延迟加载时创建代理类);(2) 获取 ResultSet 元数据中的列名;(3) 根据 ResultMap 配置或驼峰映射规则找到对应的属性名;(4) 通过 TypeHandler 转换类型后,反射调用 setter 方法赋值。如果默认映射不满足需求,可以自定义 TypeHandler 做类型转换。
Q3:MyBatis 的缓存机制?为什么不建议用二级缓存?
一级缓存是 SqlSession 级别的 HashMap(PerpetualCache),默认开启,同一会话内相同查询直接返回缓存。二级缓存是 Namespace 级别,跨 SqlSession 共享,默认关闭。不建议用二级缓存有三个原因:(1) 按 Namespace 隔离,多表查询会导致脏数据——A 表的 Mapper 缓存了关联查询结果,B 表的 Mapper 更新了数据,A 的缓存不会失效;(2) 不同事务间可能脏读;(3) 隐式缓存出了问题难排查。生产环境推荐用 Redis、Caffeine 等专业缓存方案。
Q4:MyBatis-Plus 的分页原理?
MP 的 PaginationInnerInterceptor 实现了 InnerInterceptor 接口,在 beforeQuery 阶段拦截 SQL。它先执行一条 COUNT(*) 查询总记录数,再根据数据库方言在原 SQL 后拼接分页语句(MySQL 是 LIMIT offset, size)。不同数据库有不同的 IDialect 实现来生成分页 SQL。
Q5:MyBatis 的延迟加载原理?
当开启延迟加载后,MyBatis 会为实体类生成 CGLIB 代理对象。访问关联属性时,代理对象检查该属性是否已加载,如果未加载则触发额外的 SQL 查询,查询结果填充到代理对象中,后续再访问直接返回。全局配置 lazyLoadingEnabled=true 开启,局部可用 fetchType="lazy/eager" 覆盖。注意 aggressiveLazyLoading 设为 false,否则访问任何属性都会触发所有延迟加载。
小结
MyBatis 的核心价值可以用一句话概括:让你专注写 SQL,其他的它来搞定。
- 参数处理:
#{}安全绑定参数,${}用于动态 SQL 片段(注意防注入) - 结果映射:
resultMap+association/collection搞定各种关联查询 - 动态 SQL:
if/where/set/foreach/choose/trim六大标签,告别字符串拼接 - 缓存:了解一级二级缓存原理,但生产环境用 Redis
- MyBatis-Plus:简单 CRUD 零 SQL,LambdaQueryWrapper 告别字符串,分页插件一键集成
- 插件机制:基于拦截器的责任链,分页、数据源切换、审计日志都靠它
掌握以上内容,日常开发和面试中的 MyBatis 问题就能从容应对了。