如果你写过后端接口,大概率经历过这样一个阶段:代码里到处是Connection、PreparedStatement、ResultSet,每次查询都要手动关连接、逐列取值、转VO。特别是结果集映射那段,三个表联查下来,代码被塞得又臭又长。后来换成MyBatis,才发现原来SQL和Java代码可以分得这么干净,自己该控制的SQL一句不少,繁琐的样板代码却被框架吸收得干干净净。
这篇不吹不黑,直接把MyBatis从工程搭建、映射器编写、动态SQL到事务缓存这些核心用法拆开讲。文本默认你有Java基础,懂JDBC,但还没系统用过MyBatis。如果你是想快速在项目里落地,或者面试前想把底层逻辑理顺,这篇应该都能帮上忙。所有示例都基于常规企业级项目的写法,不搞花活,只搞能直接抄的东西。
1. 项目认知与核心痛点:MyBatis到底解决了什么
1.1 从JDBC到MyBatis:省掉的力气和保留下来的控制权
很多人第一次用MyBatis,会有一个直观感受:SQL还是自己写,跟JDBC差不多,只不过少了一堆样板代码。这句话说对了一半。
JDBC开发里最折磨人的不是写SQL,而是以下几件事:
- 每次都得手动
getConnection(),用完还得finally里关连接 PreparedStatement的参数绑定,有多少个?就得按位置set多少次ResultSet逐行取出,再逐列rs.getInt("id")、rs.getString("name"),然后手工塞进对象- 查询结果散落在多个表时,还得自己组装关系结构
MyBatis把这些事全部接管了。你在XMl里写一句select * from user where id = #{id},它负责创建预编译语句、绑定参数、执行SQL、把结果集映射成你指定的对象。SQL是开发者写的,框架不帮你生成SQL,这就跟Hibernate那种全自动ORM有了本质区别。
我的理解是:MyBatis的核心思想是“半自动”。它把人最容易出错的部分简化掉,却把SQL的掌控权原封不动留给开发者。这种取舍让它非常适合那些SQL复杂、需要精细调优的系统。
1.2 半自动框架的分寸把握:为什么不能什么都靠框架生成
全自动ORM确实省事,实体类一映射,增删改查都是框架自动生成的。可一旦业务SQL复杂,要优化连接顺序、调整索引命中、自己控制分页逻辑,生成式SQL反而成了障碍。你对着日志看半天,不知道它生成的是什么鬼东西。
MyBatis的选择恰好相反:框架管映射,不管SQL生成。这种设计带来三个好处:
- 每个SQL都可见、可控、可优化,DBA拿到就能直接review
- 业务逻辑和SQL解耦,改SQL不用动Java代码,改Java代码不影响SQL结构
- sql片段、动态SQL这些机制,让复杂查询条件的拼接变得像搭积木一样灵活
当然,它也不是银弹。如果你的项目就是简单的单表CRUD,几乎没有复杂查询,那MyBatis反而显得麻烦——每个操作都要写接口、写XML、配映射。这种场景下JPA或者Spring Data可能更合适。选型这件事,从来都是看场景,不是看流行度。
2. 工程搭建与基础配置:四步跑通第一个Demo
2.1 依赖引入与配置文件的那些门道
先说Maven依赖。我这边用的是常规组合:
<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency>MySQL驱动版本最好跟数据库版本配套,8.x就用8.x的驱动,省得后面被时区、认证插件的问题搞得头大。
接着是核心配置文件mybatis-config.xml。第一次学的人可能觉得这文件配置项多,其实核心就那几个:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "https://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <properties resource="db.properties"/> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="SLF4J"/> </settings> <typeAliases> <package name="com.example.demo.entity"/> </typeAliases> <environments default="dev"> <environment id="dev"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="${db.driver}"/> <property name="url" value="${db.url}"/> <property name="username" value="${db.username}"/> <property name="password" value="${db.password}"/> </dataSource> </environment> </environments> <mappers> <package name="com.example.demo.mapper"/> </mappers> </configuration>这里有两个容易被忽略的细节。第一,mapUnderscoreToCamelCase打开后,数据库的user_name字段就能自动映射成Java的userName属性,不用为每个字段配resultMap。第二,typeAliases配了实体类的包名,后面在mapper.xml里写parameterType和resultType,就能直接用User简写,不用写全限定名。
2.2 SqlSessionFactory构建:生命周期别搞错
MyBatis的核心对象有三个:SqlSessionFactoryBuilder、SqlSessionFactory、SqlSession。很多人学的时候容易把它们搞混,尤其是生命周期。
SqlSessionFactoryBuilder是用来构建工厂的,用完即丢,它不需要长期存活。SqlSessionFactory是重量级对象,一个应用只需要创建一次,全局共享,由它负责创建SqlSession。SqlSession则是轻量级的,每次数据库操作都应该新开一个,用完了立刻关闭,它不是线程安全的。
创建工厂的代码就一个方法:
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);实际项目中不会裸写这段代码,一般会包一层MyBatisUtil或者直接交给Spring管理。但你自己上手练习时,保持“每个线程/每个操作一个SqlSession,用后就关”的习惯,能避开不少并发问题。
2.3 第一个Mapper:从接口到XML的一次完整走读
先建一张最基础的用户表:
create table user ( id bigint primary key auto_increment, name varchar(64) not null, age int, email varchar(128), create_time datetime );然后是实体类:
public class User { private Long id; private String name; private Integer age; private String email; private LocalDateTime createTime; // getter / setter 省略 }接着是接口:
public interface UserMapper { int insert(User user); User findById(Long id); List<User> findAll(); }最后是映射文件UserMapper.xml,放在mapper包同路径下:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> insert into user(name, age, email, create_time) values(#{name}, #{age}, #{email}, #{createTime}) </insert> <select id="findById" parameterType="long" resultType="User"> select id, name, age, email, create_time from user where id = #{id} </select> <select id="findAll" resultType="User"> select id, name, age, email, create_time from user </select> </mapper>这里有几个新手高频踩坑点。第一,namespace必须是接口的全限定名,不能写错,否则接口方法绑定不上XML里的语句。第二,select标签必须配resultType或resultMap,不然它不知道把结果塞到哪。第三,insert里我特意加了useGeneratedKeys="true" keyProperty="id",这样自增主键会被写回对象的id属性,否则你insert完还得再查一次才能拿到新主键。
使用起来也很直白:
try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = new User(); user.setName("张三"); user.setAge(18); mapper.insert(user); System.out.println("生成的主键:" + user.getId()); session.commit(); }强调一下try-with-resources,SqlSession实现了Closeable,这种写法能保证session被正确关闭,比手写finally省心多了。
3. 深入映射细节:参数、结果与类型转换的里层逻辑
3.1 #{} 与 ${}:一起SQL注入事故留下的教训
MyBatis里传参有#{}和${}两种写法,长得像,本质上差着一条安全红线。我曾经在一个老项目里见过有人把动态表名用${}拼接,结果一个请求把表名替换成“x; drop table user;--”,差点把整个业务库埋了。这事之后团队定了个规矩:能用#{}的地方绝不用${}。
#{}走的是PreparedStatement的预编译机制,参数被当作字符串值绑定,数据库层做了转义,所以能有效防止SQL注入。${}则是直接做字符串替换,本质是拼接SQL,值是什么就拼什么,没有任何保护。
举个最直观的对比:
<!-- 安全写法 --> <select id="findByName" resultType="User"> select * from user where name = #{name} </select> <!-- 危险写法 --> <select id="findByNameBad" resultType="User"> select * from user where name = '${name}' </select>当name传入' or '1'='1时,第一种查不到结果,第二种直接变成where name = '' or '1'='1',全量数据就都查出来了。
那${}是不是一点用处没有?也不是。动态表名、动态排序字段,这俩场景用#{}是做不到的:
<select id="queryBySort" resultType="User"> select * from ${tableName} order by ${sortColumn} ${sortOrder} </select>只不过使用这类写法时,入参必须做白名单校验,比如先判断表名是否在允许列表里,不然等于把数据库大门敞开。
注意:
${}拼接处如果出现括号、引号、分号这类结构字符,整个SQL就可能被改写。哪怕当前没出事,也建议把输入值卡死在可控范围内。
3.2 resultMap的准确场景:从驼峰映射到关联对象组装
resultType适合简单映射,字段名和属性名能自动对上就行。但现实里总会碰到几种情况,resultType搞不定:
- 字段名和属性名对不上,而且不想改SQL别名
- 查询结果需要映射到带有嵌套对象的DTO
- 结果里要有集合属性,比如一个用户对应多张订单
- 字段类型比较特殊,需要自定义转换
踩过一次就会明白,resultMap只要配一次xml,后面所有查询都能复用,比反复改SQL别名划算。
看一个带嵌套对象的结果映射。比如订单里有用户信息:
<resultMap id="orderWithUserMap" type="Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="totalAmount" column="total_amount"/> <association property="user" javaType="User"> <id property="id" column="user_id"/> <result property="name" column="user_name"/> <result property="email" column="user_email"/> </association> </resultMap> <select id="findOrderWithUser" resultMap="orderWithUserMap"> select o.id as order_id, o.order_no, o.total_amount, u.id as user_id, u.name as user_name, u.email as user_email from t_order o left join user u on o.user_id = u.id where o.id = #{id} </select>association用于嵌套的单个对象,collection用于嵌套的列表。这两个标签里都能继续套下一层resultMap,理论上可以无限嵌套,但实际项目中不建议超过三层。越深的嵌套意味着SQL越复杂、映射配置越容易出问题,往往三层以上就该考虑拆查询或者改造表结构了。
3.3 类型处理器:Java类型和数据库类型之间的“翻译官”
Java类型和JDBC类型不是天然对齐的,比如Java的LocalDateTime、枚举、UUID,数据库里可能是datetime、int、varchar。类型处理器就是干这个的。
MyBatis内置的TypeHandler覆盖了大多数基础类型,日常开发基本不用写。真正要自定义的,往往是枚举或一些特殊业务类型。比如数据库存1和0,Java里想映射成枚举:
public enum Gender { MALE(1, "男"), FEMALE(0, "女"); private final int code; private final String desc; Gender(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } }做一个自定义TypeHandler:
@MappedTypes({Gender.class}) public class GenderTypeHandler extends BaseTypeHandler<Gender> { @Override public void setNonNullParameter(PreparedStatement ps, int i, Gender parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } @Override public Gender getNullableResult(ResultSet rs, String columnName) throws SQLException { return intToEnum(rs.getInt(columnName)); } @Override public Gender getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return intToEnum(rs.getInt(columnIndex)); } @Override public Gender getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return intToEnum(cs.getInt(columnIndex)); } private Gender intToEnum(int code) { for (Gender g : Gender.values()) { if (g.getCode() == code) { return g; } } throw new IllegalArgumentException("未知的枚举值: " + code); } }注册方式有两种。一种在XML里用<typeHandlers>标签注册,另一种直接在resultMap或查询参数上声明。一般来说全局注册比较省事,特殊字段才局部指定。
4. 动态SQL实操:条件查询与批量处理的常见套路
4.1 if/where/choose:条件拼接不再靠拼字符串
业务系统里最常遇到的场景,就是查询条件“有就查,没有就跳过”。早期项目用JDBC怎么写?StringBuilder一段段拼SQL,条件前面加and,还得判断首条条件要不要去掉and。代码一多,bug就藏在拼接逻辑里。
MyBatis用if和where两个标签把这个过程变成了声明式的。看这个写法:
<select id="searchUsers" resultType="User"> select id, name, age, email, create_time 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="email != null and email != ''"> and email = #{email} </if> </where> order by id desc </select>where标签会自动处理首条条件前面的and。如果所有条件都不成立,直接生成select ... from user order by id desc,不会多出一个裸露的where。
choose标签则类似Java里的switch,按顺序取第一个匹配的分支:
<select id="searchUserByPriority" resultType="User"> select id, name, age, email from user <where> <choose> <when test="name != null and name != ''"> and name = #{name} </when> <when test="email != null and email != ''"> and email = #{email} </when> <otherwise> and age > 18 </otherwise> </choose> </where> </select>4.2 set与trim:动态更新的正确收尾
写动态update时最怕什么?怕所有if都没生效,最后生成一句残缺的update user set where id = ?;也怕条件生效了一部分,SQL末尾多一个逗号。set标签就是用来兜底的。
<update id="updateUser" parameterType="User"> update user <set> <if test="name != null">name = #{name},</if> <if test="age != null">age = #{age},</if> <if test="email != null">email = #{email},</if> <if test="createTime != null">create_time = #{createTime},</if> </set> where id = #{id} </update>set标签会自动去掉最后一个多余逗号。注意一个隐蔽点:如果传入的对象只有id,所有if都不成立,生成的SQL就是update user where id = ?,这在数据库里会直接报语法错误。所以调用方在业务层要提前校验至少有一个更新字段。
trim标签更灵活,它四个属性分别对应“回填前缀”、“回填后缀”、“去掉前缀”、“去掉后缀”。where和set本质上就是trim的封装版本。上面那个set写法等价于:
<trim prefix="SET" suffixOverrides=","> <if test="name != null">name = #{name},</if> <if test="age != null">age = #{age},</if> </trim>4.3 foreach实战:批量插入和IN查询的边界问题
foreach是动态SQL里实用性最高的标签之一,典型场景是in查询和批量插入。看这个IN查询:
<select id="findByIds" resultType="User"> select id, name, age, email, create_time from user where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>接口方法对应的参数要加@Param("ids"):
List<User> findByIds(@Param("ids") List<Long> ids);批量插入就是拼接多个values:
<insert id="batchInsert"> insert into user(name, age, email, create_time) values <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.email}, #{item.createTime}) </foreach> </insert>这里有个实际经验:批量插入一次别塞太多条。MySQL的max_allowed_packet默认值有上限,SQL太长会被数据库直接拒掉。我之前压过一次,单条SQL拼了三百多条数据就报错,后来改成每100条一批插入,稳定多了。性能也不差,整个批处理几分钟跑完几万条数据毫无压力。
foreach里collection属性容易踩坑。当参数是List时,默认可以用list或collection;当参数是数组时,用array;多参数时必须用@Param指定的名字。如果不加@Param,那几个默认名很容易混淆。
5. 事务与缓存:数据一致性和性能之间的权衡
5.1 事务配置:先说清控制权在谁手里
配置的transactionManager type有两种,日常用JDBC即可。它表示事务控制交给当前连接,自己通过session.commit()、session.rollback()控制。另一种MANAGED则把事务交给外部容器管理,比如Spring。
裸用MyBatis时,事务手动控制很容易忽略:
try (SqlSession session = sqlSessionFactory.openSession(false)) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.insert(user1); mapper.insert(user2); session.commit(); } catch (Exception e) { // 需要在合适的位置 rollback }openSession(false)表示关闭自动提交,所有写操作必须手动commit。这种模式下如果忘了commit,数据不会真正落库;如果commit之前抛异常,应该rollback,否则连接返回连接池时带着未结束的事务状态,下次复用可能读到脏数据。
实际项目里几乎没人裸用MyBatis,基本都会接入Spring,把事务直接交给@Transactional。但理解手动控制的过程,对理解后面框架层面的事务边界有很大帮助。
5.2 一级缓存:默认开启的隐形势力
一级缓存是SqlSession级别的,默认开启,底层就是一个Map。同一个SqlSession里执行两条一模一样的SQL(参数相同),第二次会直接命中缓存,不再查库。
听起来很爽,但一不注意就有坑。我遇到过一例:同一个SqlSession里先按条件查了一次数据,然后另一段代码插入了新记录,再次用相同条件查询时,一级缓存返回的还是旧数据。因为一级缓存的生命周期覆盖整个SqlSession,而当前会话没有任何更新操作触发缓存失效。
还有更隐蔽的:MyBatis的一级缓存只有在一个SqlSession内部才有意义,如果两个线程各自创建了自己的SqlSession,互不影响。但一旦用Spring集成,默认行为下SqlSession跟Spring事务绑定,事务内多次查询共享一个SqlSession,这个缓存坑就会被放大。
处理方式很简单:
- 对数据一致性要求高的查询,在XML语句上设置
flushCache="true" - 或者查询之间做一次
session.clearCache() - 更通用的做法是:别让一个长事务里做多次查询后又做写操作
5.3 二级缓存:提升读多写少场景的性能,但有代价
二级缓存是namespace级别的,也就是一个Mapper对应一份缓存,跨SqlSession共享。配置很轻量:
<mapper namespace="com.example.demo.mapper.UserMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> </mapper>eviction是回收策略,flushInterval是刷新间隔,size是缓存条目数。但我要说的是,二级缓存默认关闭,有原因。它最大的坑在于多表查询:如果查询user和order关联的数据,缓存在UserMapper的namespace里,而OrderMapper里更新了订单,UserMapper的缓存并不知道数据变了。下一次查询就拿到旧数据。
所以我在实际项目里的个人习惯是:只有那些极少变动、只读为主的基础数据表才开二级缓存。对着一堆需要频繁写入的业务表开二级缓存,等于自己给自己挖坑。真遇到查询热点,优先考虑业务层加缓存,比如Redis,比MyBatis的二级缓存可控得多。
6. 实际排错记录与性能底线的经验谈
6.1 常见异常和它们的真正成因
翻看同事的报错记录,发现很多异常翻来覆去就是那几类,这里整理成一张速查表。
| 异常信息 | 真正原因 | 处理办法 |
|---|---|---|
Invalid bound statement (not found) | 接口方法与XML的statement id对不上,或namespace错误,或接口方法没有对应的XML语句 | 检查namespace是否全限定名、接口方法名与XML id是否一致、XML文件是否被扫描到 |
Mapped Statements collection already contains value for ... | 重复的namespace+id | 排查是否有同名MapperXML被多次加载,或同名statement重复定义 |
Could not find resultMap ... | resultMap id写错,或者引用了一个不存在的resultMap | 核对resultMap id以及命名空间前缀 |
TooManyResultsException | 方法返回单个对象,但SQL查出了多行 | 改成返回List,或者SQL加limit 1 |
Parameter 'xxx' not found | 多参数方法没加@Param注解 | 给方法参数加@Param("xxx") |
Cause: java.lang.NumberFormatException | 传参类型不匹配,最常见是字符串传入数字字段 | 检查参数类型,统一类型设计 |
提示:碰到
Invalid bound statement,不要只盯XML文件。很多坑其实是XML没有被maven build进classpath。检查target/classes里有没有对应的xxxMapper.xml,没有就在pom里加上资源目录配置。
6.2 从N+1到慢SQL:优化SQL的基本检查清单
N+1问题在MyBatis里非常典型。比如查订单列表,订单表只查出一条主列表,但每条订单又要查一次用户信息,最后SQL数量变成1+N,数据库连接被拖垮是迟早的事。
解决办法有几种:
- 用一条
join查询替代多次单查,配合前面的resultMap做嵌套映射 - 先查主列表,再去查一批关联ID,用
where in (…)处理 - 开二级缓存,但要注意上面说的脏读风险
分页也是常踩的坑。MyBatis本身没有内置分页能力,手写limit #{offset}, #{size}在数据量上来后性能差别非常大。实际项目普遍用分页插件,它会在执行前自动改写SQL,生成一条count查询和一条带limit的分页查询。注意分页插件要理解它的执行原理,否则在复杂SQL、多表join场景下,count统计结果可能不符合预期。
排查慢SQL的正确姿势是开SQL日志。在配置里加上:
<setting name="logImpl" value="SLF4J"/>然后在日志配置里把mapper包的日志级别调到DEBUG,就能看到MyBatis每次执行的完整SQL、绑定参数和执行耗时。我第一次调一个批量接口的线上问题,就是靠日志发现某个查询没有走索引,全表扫描几百万行,后来在SQL里翻出函数导致索引失效,改掉后接口耗时从2秒降到几十毫秒。
检查清单我常备着,每次写复杂查询前过一遍:
where条件里的字段是否有索引,是否对索引列用了函数- 有没有查出不需要的列,索引覆盖能否命中
- 分页深度太大是否要改游标方式
- 动态SQL里循环拼接的部分是否会产生多余的
or或全表扫描 - 多条查询是否能合并,减少数据库交互次数
6.3 写复杂查询前的一层清单确认
有人问“MyBatis是不是不适合复杂查询?”我觉得恰好相反。MyBatis适合复杂查询,但不代表适合“失控的复杂查询”。页面展示一个列表,联了七八张表、嵌套了三层子查询、动态条件又有十来个分支,这种SQL不管用什么框架都很难维护。正确思路是:查询尽量控制在两三个表的范围内,确实复杂的场景拆成多次查询再在应用层组装。应用层组装听起来多一次交互,但每个SQL都简单、可复用、可单元测试,长远看反而更省心。
写在后面的一些经验
个人体会最深的一点:MyBatis学习曲线其实很平缓,配置照着官方示例抄一遍就能跑通,但这恰恰是很多人止步于“会用”的原因。真正拉开差距的,是对映射机制、缓存边界和SQL执行过程的理解。比如我之前一直没搞懂为什么同样的SQL在两个SqlSession里表现不同,后来把一级缓存、二级缓存的生命周期理清了,很多线上诡异问题瞬间都有了合理解释。
如果你才开始接触MyBatis,我的建议是先别急着上Spring Boot集成,老老实实用原生方式写一个完整的CRUD例子,亲手梳理一下SqlSessionFactory、SqlSession、Mapper这三层的关系。跑明白原生流程后,再跳到Spring Boot的自动配置里,你会看得很清楚。
最后分享一个调试小技巧:刚开始写动态SQL时,容易写错标签导致生成的SQL不对。碰到这种情况,先把logImpl打开,看日志里最终执行的SQL长什么样。或者更粗暴一点,直接从日志里复制这条SQL,丢到数据库客户端里执行,很快能定位到是SQL本身的问题还是映射的问题。这个习惯帮我省下了很多无谓的debug时间。