☰
MyBatis半自动化ORM核心机制与实战踩坑全解析
2026/10/12 6:34:20 网站建设 项目流程

说句实话,我刚开始用MyBatis那阵子特别不以为然:SQL还得自己写在XML里,跟JDBC相比只是少写了一堆连接关闭的样板代码,感觉就是个进阶版工具类。可真在一个业务系统里待了几年,每天跟各种复杂查询、动态条件、批量更新打交道之后,我才慢慢改观——MyBatis的聪明之处,恰恰在于它把SQL的控制权留给了人类,又把参数解析、结果映射、事务边界这些容易出错的脏活接了过去。这篇“MyBatis(上)”不打算把官方文档复述一遍,而是按照我实际项目里摸出来的主线讲:先看它解决什么,再一步步搭建配置、理解Mapper、吃透参数与动态SQL,最后把新手阶段最容易翻车的几个场景连根挖一遍。适合刚接触MyBatis的Java后端读者,也适合写了几个月Mapper但是对报错机理还懵懵懂懂的人。

1. 为什么说MyBatis是“半自动化”ORM:先弄懂它到底解决了什么

1.1 手写JDBC时代每天都在重复什么

先看一段以前每天在写的JDBC代码,感受一下那个酸爽。这是最常见的按ID查用户信息,逻辑本身非常简单,但为了一个查询,连接管理、预编译、结果集遍历、异常处理、资源关闭全都要亲自来。

Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DriverManager.getConnection(url, user, password); ps = conn.prepareStatement("select id, user_name, email from sys_user where id = ?"); ps.setLong(1, userId); rs = ps.executeQuery(); User user = null; if (rs.next()) { user = new User(); user.setId(rs.getLong("id")); user.setUserName(rs.getString("user_name")); user.setEmail(rs.getString("email")); } return user; } catch (SQLException e) { throw new RuntimeException(e); } finally { if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (ps != null) try { ps.close(); } catch (SQLException ignored) {} if (conn != null) try { conn.close(); } catch (SQLException ignored) {} }

这段代码的问题不在于它跑不通,而在于它放大了“重复劳动”。真实项目里大部分查询都是多条件组合、分页、批量更新,而JDBC要求你为每个字段写一遍set和get。今天表里加一个字段,这里少说有三四个地方要跟着改,漏改一处,线上就有一个查询拿不到数据。更麻烦的是,真正频繁出现的多条件查询,一旦用字符串拼接去build SQL,又容易拼出where 1=1 and ...这种丑东西,还要时刻提防SQL注入。

1.2 半自动化的定位:把SQL控制权还给开发人员

我第一次认真读MyBatis官方文档的时候,看到它对自身的定义叫“半自动化持久层框架”,这个“半”字当时没太在意,后来才觉得意味深长。全自动化ORM的理念是让框架帮你生成SQL,把数据库表完整映射成对象模型,开发人员不写SQL,只操作对象。这听起来很美,但实际业务里根本不是那么回事:复杂报表、多表join、数据库特定的JSON函数、某个索引的hint,全自动框架生成的SQL很难精准控制。你想干预的时候,反而要学习它的一套方言和规则,限制比直接写SQL还多。

MyBatis的做法反过来:SQL你自己写,框架负责把参数塞进去、把结果映射成对象、把连接和事务生命周期管好。用人话说,全自动框架像是“自动驾驶”,它替你决定路线;MyBatis像是“手自一体”,方向盘和挡位在你手里,框架帮你处理底盘、变速箱这些复杂机械。这个取舍让它在中小型业务系统里非常舒服——想怎么写SQL就怎么写SQL,数据库有多厉害的特性都能直接用,框架不会跳出来挡你。

1.3 一张对比表看清JDBC与MyBatis的差距

维度手写JDBCMyBatis
连接管理每次手动获取和释放,容易泄漏由SqlSessionFactory统一管理,底层可配连接池
参数绑定手动setObject,还要记住下标#{}按属性名取值,自动走预编译
结果映射逐字段get再set,表结构变更牵连一片resultType/resultMap自动映射,支持驼峰转换
SQL管理散落在Java代码里,难以统一review集中在Mapper XML文件,一个表一个位置
动态SQL字符串拼接加if判断,又丑又险<where><set><foreach>等标签解决
缓存基本没有内置一级缓存、二级缓存,可扩展
调试日志和参数分离,定位难SQL日志带参数占位,几乎能直接还原执行语句

这张表不是想说明MyBatis比JDBC高级多少,而是它把工程师从大量重复劳动里解放了出来。省下来的时间可以花在真正重要的SQL调优、索引设计、业务边界梳理上,而不是每天跟rs.getInt("id")较劲。

2. 搭起第一个可跑的例子:从全局配置到SqlSessionFactory

2.1 Maven依赖与版本选择

动手阶段,不要一上来就追最新版本。MyBatis的API设计比较稳定,但如果你用的是JDK 8,选3.5.x系列比较贴合。一个当前比较常见、踩坑少的组合如下:

<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.15</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency>

版本选择上有个很容易被忽略的细节:MySQL 8的驱动类从com.mysql.jdbc.Driver换成了com.mysql.cj.jdbc.Driver,同时连接串里通常需要配置时区参数,否则驱动会报时区相关的错误。如果项目还在用老连接串,直接把MySQL驱动升到8.x往往会翻车。另外,mysql-connector-java这个坐标现在逐渐被Maven迁移到了com.mysql:mysql-connector-j,我在新项目里会更倾向用后者,但两种都能跑,以团队内部统一为准。

2.2 mybatis-config.xml里真正重要的几个节点

依赖引好后,需要一个全局配置文件。用Spring Boot之后很多人会把配置写成application.yml,但原生MyBatis的全局配置逻辑还是集中在这里,理解它才能看懂后面所有扩展点。

<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <environments default="dev"> <environment id="dev"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/demo"/> <property name="username" value="root"/> <property name="password" value="root"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>

逐个说明几个关键点。settings里我特别建议把mapUnderscoreToCamelCase设为true,它能让user_name自动映射成userName,省去大量resultMap。还存放了缓存开关、懒加载开关等,后边按需要再开。environments可以配置多个环境,通过default切dev、test、prod,实际项目里连的库不止一套,这个机制非常方便。dataSource type="POOLED"表示使用内置连接池;改成UNPOOLED的话每次请求都会新建物理连接,性能明显差,生产环境一般不用。transactionManager type="JDBC"代表事务边界交给当前连接控制,commit和rollback由代码决定;如果和Spring集成,这里会交给Spring管理,诞生著名的SpringManagedTransaction。

配置文件里的数据库账号密码是演示用的,别真在项目里写root/root。

2.3 SqlSessionFactory与SqlSession的“一生一次”原则

配置文件和Mapper文件就位后,需要构建SqlSessionFactory。我见过不少刚入门的人在每个DAO方法里都new一次构建工厂,这是对生命周期理解不到位。

正确姿势是:整个应用只创建一次SqlSessionFactory,用静态代码块或单例维护。SqlSessionFactory是线程安全的,但SqlSession不是。SqlSession可以理解成一次数据库会话,它不是线程安全的,用的时候开一个,用完立刻关闭。老一点的代码风格会这样封装一个工具类:

public class MyBatisUtil { private static SqlSessionFactory factory; static { try { String resource = "mybatis-config.xml"; InputStream in = Resources.getResourceAsStream(resource); factory = new SqlSessionFactoryBuilder().build(in); } catch (IOException e) { throw new ExceptionInInitializerError(e); } } public static SqlSession getSession() { return factory.openSession(); } public static SqlSessionFactory getFactory() { return factory; } }

注意几个生命周期原则:SqlSessionFactoryBuilder是短命对象,它唯一的作用就是把配置构建成工厂实例,构建完就可以扔;SqlSessionFactory是全局长寿对象,应用启动时创建一次;SqlSession是短命对象,一次请求或一次事务对应一个,执行完在finally里close。openSession(true)能得到自动提交的会话,否则每次执行完需要手动commit(),否则数据不会真正落库。非Spring环境下很多数据没写进去,就是忘了commit导致的。

3. Mapper映射文件:SQL真正生效的地方

3.1 namespace为什么必须等于接口全限定名

全局配置只做了两件事:指定环境、告诉MyBatis去哪里找Mapper XML。真正写SQL的地方是Mapper映射文件。MyBatis有个约定:一个Mapper接口对应一个XML文件,接口的全限定名就是XML的namespace。

public interface UserMapper { User selectById(Long id); }
<mapper namespace="com.example.mapper.UserMapper"> <select id="selectById" resultType="com.example.entity.User"> select id, user_name, email from sys_user where id = #{id} </select> </mapper>

这背后是MapperRegistry机制:启动时MyBatis会扫描XML,把namespace对应的接口注册到knownMappers中,同时把每个select标签的id和接口方法名对应起来。调用userMapper.selectById(1L)时,实际是通过JDK动态代理生成了一个代理对象,再根据方法名去MappedStatements里找到对应SQL执行。理解这个流程之后,很多报错就能一眼看出根源。

3.2 一组最基础的CRUD XML模板

新手其实不需要把每个标签的文档都背下来,只要有一组能跑通的模板,再照着改就够了。下面是我项目里最基本的四种写法。

查询:

<select id="selectById" resultType="com.example.entity.User"> select id, user_name, email from sys_user where id = #{id} </select>

插入,重点是自增主键回填:

<insert id="insertUser" useGeneratedKeys="true" keyProperty="id"> insert into sys_user(user_name, email) values(#{userName}, #{email}) </insert>

更新:

<update id="updateEmail"> update sys_user set email = #{email} where id = #{id} </update>

删除:

<delete id="deleteById"> delete from sys_user where id = #{id} </delete>

useGeneratedKeys配合keyProperty用于把数据库生成的自增ID回填到传入对象的id属性,很多人写了很多次还拿不到id,多半是keyProperty写错了,这个我在第六章单独展开。值得多说一句的是,select标签里的parameterType在最基础的场景下可以不写,MyBatis会根据Mapper接口方法的参数类型自动判断,少写一个反而减少一处出错机会。

3.3 resultType与resultMap怎么选

resultType是最省事的映射方式,它指定的是返回的单行对象类型。比如查询返回一个User对象,resultType就写com.example.entity.User。如果查询返回的是List ,resultType依然写User,不要写java.util.List。这个错误我见过太多次了,一旦写成List,启动阶段就会报错,因为MyBatis需要知道每一行映射成什么类型,而不是集合本身是什么类型。

resultMap则适合字段名和属性名对不齐、多表join、嵌套对象这些场景。它相当于你手动告诉MyBatis:哪一列对应哪个属性。

<resultMap id="userMap" type="com.example.entity.User"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> </resultMap>

我的选择经验很简单:单表查询、字段命名规范、没有复杂嵌套的,直接用resultType,开着mapUnderscoreToCamelCase就够;一旦查询涉及一个表对应多个业务字段、或者多表关联要返回一个聚合对象的,老老实实写resultMap,这样未来加字段的时候不用在全链路找映射问题。

4. 参数传递的底层:#{} 与 ${} 的差别远不止预编译

4.1 单参数、多参数、POJO参数的约定

Mapper方法里的参数怎么传到XML,这是新手问得最多的问题之一。先说最简单的单参数情况。接口这样写:

User selectById(Long id);

XML里#{id}可以写成#{任意名字}吗?单参数时确实可以#{abc}都行,MyBatis拿到唯一参数后,不管叫什么都取这个参数值。但从可读性考虑,接口参数名和#{}里保持一致才是好习惯,不然过一个月自己都看不懂。

多参数就必须讲究了。比如:

List<User> selectByNameAndStatus(@Param("name") String name, @Param("status") Integer status);

XML里用#{name}和#{status}。如果没有@Param,MyBatis也允许用#{arg0}#{arg1}或者#{param1}#{param2}几种索引形式,但这写法一长就模糊,而且不同MyBatis版本对arg0和param1的支持还有些微妙差异,统一加@Param是最稳妥的。

参数是POJO时最舒服,不需要任何注解:

List<User> selectByCondition(UserQuery query);

XML里直接#{userName}#{status}去取对象的属性。我习惯把多个查询条件封装成一个Query对象,传参清晰,扩展也方便。

4.2 #{} 的预编译保护与 ${} 的字符串拼接风险

#{}和${}是MyBatis里最重要的两条规则。很多人知道“一个防注入一个不防”,但不清楚底层到底为什么。

#{}最终会被解析成JDBC的?占位符,由PreparedStatement执行,值通过setObject传进去。数据库收到的是一个“参数化后的SQL模板 + 一堆独立参数”,不会把参数内容当成SQL片段解析。${}则是在SQL解析阶段直接把字符串拼进语句里,再交给JDBC执行。

给一个反面例子:

<select id="danger" resultType="com.example.entity.User"> select * from sys_user where user_name = '${userName}' </select>

此时如果有人传入一段带有OR条件的字符串,整个where条件就会变成恒真表达式。所以默认情况下,凡是用户输入的值,一律用#{}。那${}有没有合法用途?有,而且很实用:排序字段、表名、动态列名这些不能作为预编译占位符出现的地方。但使用时必须加白名单校验,比如排序字段只允许映射到一组预先定义好的列名,表名同理。没有白名单的${}就是给攻击者递刀子。

4.3 命名规范和TypeHandler的简单提醒

关于参数处理,再补两个容易踩的细节。第一个是参数和jdbcType的关系。插入数据时如果一个字段允许为空,某些数据库驱动在没有显式指定类型时会报JDBC type相关的错,这时候可以在#{}里写明类型:#{email, jdbcType=VARCHAR}。第二个是TypeHandler机制:MyBatis拿到Java属性后,需要知道把Java类型转成JDBC类型,内置的TypeHandler覆盖了常见类型,但当你自定义枚举、JSON字段时,就需要自己实现TypeHandler并注册。这个属于进阶内容,但了解存在这个机制,遇到诡异“类型转换”报错时不会一头雾水。

5. 动态SQL怎么把条件判断写进XML

5.1 if + where 组合拳:多条件查询不再拼接字符串

真实项目里没有几个查询是固定条件的,用户搜用户列表时可能只给用户名,也可能只给状态,还可能全都不给。用字符串拼接写这种SQL,除了丑,还得处理where 1=1和and的位置。MyBatis动态SQL用标签解决了这个问题。

<select id="selectByCondition" resultType="com.example.entity.User"> select id, user_name, email from sys_user <where> <if test="userName != null and userName != ''"> and user_name like concat('%', #{userName}, '%') </if> <if test="status != null"> and status = #{status} </if> </where> </select>

<where>标签会自动去掉跟在它后面的第一个AND,避免生成where and status = ?这种错SQL。<if>的test属性不是SQL,而是OGNL表达式,判断的是传入对象的属性。这里最容易犯的错是把==写成=,或者把数据库列的判断写进去,记住test里面只能看到Java属性,看不到SQL列名。

5.2 choose/when/otherwise:类似switch的分支逻辑

有时候多条件之间不是叠加关系,而是“只选其中一种”。比如前端传一个查询类型,可能按邮箱查,也可能按用户名查,二选一。<if>集合做不到这种互斥,要用choose。

<select id="selectByQueryType" resultType="com.example.entity.User"> select id, user_name, email from sys_user <where> <choose> <when test="queryType == 'email'"> email = #{keyword} </when> <when test="queryType == 'name'"> user_name = #{keyword} </when> <otherwise> id = #{keyword} </otherwise> </choose> </where> </select>

<choose>的语义和Java的switch几乎一样,从上到下匹配第一个成立的<when>,没有一个成立就走<otherwise>。注意test里的字符串比较用单引号,不是双引号,写成queryType == "email"在某些OGNL版本下会得到奇怪结果。

5.3 set 标签处理UPDATE的动态字段

更新用户信息的时候,如果只改了邮箱,不该把user_name也一起更新成null。最土的办法是先查出来再整行更新,浪费一次查询;用动态SQL可以让XML自动组装非空字段。

<update id="updateById"> update sys_user <set> <if test="userName != null">user_name = #{userName},</if> <if test="email != null">email = #{email},</if> </set> where id = #{id} </update>

<set>标签会把生成的字段列表里最后一个逗号去掉。这个看起来不起眼,但没它的时候,你得自己在每个<if>里纠结到底加不加逗号,一多就出错。这也是动态SQL里最有“用了就回不去”感受的一个标签。

5.4 foreach 遍历集合:in查询和批量插入

<foreach>解决两类问题:一类是where id in (...);一类是批量插入。

<select id="selectByIds" resultType="com.example.entity.User"> select id, user_name, email from sys_user where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>

对应的Mapper方法:

List<User> selectByIds(@Param("ids") List<Long> ids);

collection属性必须和@Param里的名字一致,如果方法不带@Param,list类型参数的默认集合名是list或者collection,很多人写成了ids,于是报“There is no getter for property named 'ids'”。这个报错信息其实已经把线索给得很清楚了。批量插入同理,用foreach包裹一组values。批量插入时还建议控制每次插入的条数,一次塞几千条可能超过数据库SQL长度限制或参数占位符上限,按几百条一批拆分比较稳。

6. 我实际用MyBatis时踩过的坑与排查思路

6.1 “Invalid bound statement (not found)”的完整排查链路

这个报错几乎每个MyBatis用户都遇到过,写出来就一行:

Invalid bound statement (not found): com.example.mapper.UserMapper.selectById

它翻译成人话是:MyBatis知道你有这个接口方法,但在已注册的SQL映射里没找到对应id。很多新手第一反应是查XML语法,但实际原因往往在前面几步。

我的排查顺序固定四条。第一步,看namespace是否等于接口全限定名,以及select标签的id是否等于方法名,这两个对不上是零分错误。第二步,看XML文件有没有被复制到classpath。Maven项目如果把XML放在了src/main/java目录下,默认构建时不会把XML复制进target/classes,于是资源加载时压根找不到文件。解决方案是把XML放到src/main/resources的对应目录,或者在pom里配置resources过滤。第三步,看全局配置里的<mappers>是否引到了这个XML,resource路径写错、class路径写错都会导致Mapper从未被加载。第四步,开启MyBatis日志,启动时观察每个Mapper的注册记录,确认MappedStatement是否真的存在。

这一步一个脚印排查下来,基本没有解决不了的not found。最怕的是直接改一行代码碰运气,改错方向反而把问题弄复杂。

6.2 下划线字段映射不上:从现象到修复

有个非常典型的现场:数据库字段是user_name,实体属性是userName,查询SQL没写别名,返回对象的userName就是null,其他字段都正常。原因在于resultType自动映射只做“列名和属性名同名匹配”,默认情况下user_name和userName不是同名,所以映射不上。

修复方式有三种。最简单的是全局开启mapUnderscoreToCamelCase;第二种是在SQL里给字段起和属性一样的别名,例如select user_name as userName;第三种是定义resultMap精确映射。

我实际更推荐第一种别嫌它“全局影响大”,它影响的只是“下划线转驼峰”这一件事,基本是正向收益。如果用了第一种还是不行,大概率是SQL里已经起了别名,列名变成了另外的名字,此时别名没对齐属性名,照样映射不上。这种时候别继续在resultType里纠结,直接上resultMap。

还有一个小坑:查询结果用Map接收时,MyBatis默认把列名作为Map key,通常是小写的user_name,不是驼峰userName。如果调用方指望从map里get("userName"),就会拿到null。

6.3 自增主键回填失败:useGeneratedKeys的正确打开方式

插入记录之后,业务上经常需要立刻用到新记录的主键,比如新建用户后要自动创建一条关联配置。很多人写完insert发现实体的id还是null,第一反应是“MyBatis不返回自增id”。其实MyBatis不仅会返回,还能直接帮你把id回填到传入的实体对象上,关键看你有没有写对。

正确写法前面出现过:

<insert id="insertUser" useGeneratedKeys="true" keyProperty="id"> insert into sys_user(user_name, email) values(#{userName}, #{email}) </insert>

注意keyProperty填的是Java实体的属性名id,不是数据库列名id——这里通常一致所以容易忽略,但如果你实体的主键属性叫userId,数据库列也叫id,那必须写keyProperty="userId",否则回填不进去。这是大家最常犯的错。

还有一类场景是批量插入,同样要在insert标签上配置useGeneratedKeys,MyBatis会把生成的主键依次回填到传入List中每个对象的id属性。不同版本对批量回填的支持有细节差异,实测时注意验证,别假设它一定自动处理。

6.4 我的实操体会与“上篇”的学习顺序建议

每次带新同事熟悉MyBatis,我都会让他把三组概念各做一次小实验:第一组,改错namespace,观察启动报错和日志输出;第二组,把${}用在一个查询条件上,试着输入特殊字符看日志里SQL变成了什么;第三组,把resultType误写成List,看控制台怎样提示。这三件事都做完,对MyBatis的绑定机制、预编译机制、结果映射机制的理解,会比照文档抄配置要扎实得多。

这篇是上篇,解决的是“怎么搭、怎么写、为什么会报错”的问题。按我现在的习惯,后面再聊会直接深入到Mapper动态代理的生成过程、拦截器对SQL执行链路的扩展、一级二级缓存的数据一致性陷阱、以及和Spring整合后SqlSession到底是怎么被管理的。先把基础的几个核心机制吃透,后面看源码、改框架、写插件都不会心虚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询