MyBatis半自动化ORM核心机制与实战踩坑全解析
说句实话,我刚开始用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 11 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判断,又丑又险wheresetforeach等标签解决缓存基本没有内置一级缓存、二级缓存,可扩展调试日志和参数分离,定位难SQL日志带参数占位,几乎能直接还原执行语句这张表不是想说明MyBatis比JDBC高级多少,而是它把工程师从大量重复劳动里解放了出来。省下来的时间可以花在真正重要的SQL调优、索引设计、业务边界梳理上,而不是每天跟rs.getInt(id)较劲。2. 搭起第一个可跑的例子从全局配置到SqlSessionFactory2.1 Maven依赖与版本选择动手阶段,不要一上来就追最新版本。MyBatis的API设计比较稳定,但如果你用的是JDK 8,选3.5.x系列比较贴合。一个当前比较常见、踩坑少的组合如下:dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.15/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.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 version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration settings setting namemapUnderscoreToCamelCase valuetrue/ /settings environments defaultdev environment iddev transactionManager typeJDBC/ dataSource typePOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/demo/ property nameusername valueroot/ property namepassword valueroot/ /dataSource /environment /environments mappers mapper resourcemapper/UserMapper.xml/ /mappers /configuration逐个说明几个关键点。settings里我特别建议把mapUnderscoreToCamelCase设为true,它能让user_name自动映射成userName,省去大量resultMap。还存放了缓存开关、懒加载开关等,后边按需要再开。environments可以配置多个环境,通过default切dev、test、prod,实际项目里连的库不止一套,这个机制非常方便。dataSource typePOOLED表示使用内置连接池;改成UNPOOLED的话每次请求都会新建物理连接,性能明显差,生产环境一般不用。transactionManager typeJDBC代表事务边界交给当前连接控制,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 namespacecom.example.mapper.UserMapper select idselectById resultTypecom.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 idselectById resultTypecom.example.entity.User select id, user_name, email from sys_user where id #{id} /select插入,重点是自增主键回填:insert idinsertUser useGeneratedKeystrue keyPropertyid insert into sys_user(user_name, email) values(#{userName}, #{email}) /insert更新:update idupdateEmail update sys_user set email #{email} where id #{id} /update删除:delete iddeleteById delete from sys_user where id #{id} /deleteuseGeneratedKeys配合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 iduserMap typecom.example.entity.User id propertyid columnid/ result propertyuserName columnuser_name/ result propertyemail columnemail/ /resultMap我的选择经验很简单:单表查询、字段命名规范、没有复杂嵌套的,直接用resultType,开着mapUnderscoreToCamelCase就够;一旦查询涉及一个表对应多个业务字段、或者多表关联要返回一个聚合对象的,老老实实写resultMap,这样未来加字段的时候不用在全链路找映射问题。4. 参数传递的底层#{} 与 ${} 的差别远不止预编译4.1 单参数、多参数、POJO参数的约定Mapper方法里的参数怎么传到XML,这是新手问得最多的问题之一。先说最简单的单参数情况。接口这样写:User selectById(Long id);XML里#{id}可以写成#{任意名字}吗?单参数时确实可以#{abc}都行,MyBatis拿到唯一参数后,不管叫什么都取这个参数值。但从可读性考虑,接口参数名和#{}里保持一致才是好习惯,不然过一个月自己都看不懂。多参数就必须讲究了。比如:ListUser selectByNameAndStatus(Param(name) String name, Param(status) Integer status);XML里用#{name}和#{status}。如果没有Param,MyBatis也允许用#{arg0}#{arg1}或者#{param1}#{param2}几种索引形式,但这写法一长就模糊,而且不同MyBatis版本对arg0和param1的支持还有些微妙差异,统一加Param是最稳妥的。参数是POJO时最舒服,不需要任何注解:ListUser selectByCondition(UserQuery query);XML里直接#{userName}#{status}去取对象的属性。我习惯把多个查询条件封装成一个Query对象,传参清晰,扩展也方便。4.2 #{} 的预编译保护与 ${} 的字符串拼接风险#{}和${}是MyBatis里最重要的两条规则。很多人知道“一个防注入一个不防”,但不清楚底层到底为什么。#{}最终会被解析成JDBC的?占位符,由PreparedStatement执行,值通过setObject传进去。数据库收到的是一个“参数化后的SQL模板 一堆独立参数”,不会把参数内容当成SQL片段解析。${}则是在SQL解析阶段直接把字符串拼进语句里,再交给JDBC执行。给一个反面例子:select iddanger resultTypecom.example.entity.User select * from sys_user where user_name ${userName} /select此时如果有人传入一段带有OR条件的字符串,整个where条件就会变成恒真表达式。所以默认情况下,凡是用户输入的值,一律用#{}。那${}有没有合法用途?有,而且很实用:排序字段、表名、动态列名这些不能作为预编译占位符出现的地方。但使用时必须加白名单校验,比如排序字段只允许映射到一组预先定义好的列名,表名同理。没有白名单的${}就是给攻击者递刀子。4.3 命名规范和TypeHandler的简单提醒关于参数处理,再补两个容易踩的细节。第一个是参数和jdbcType的关系。插入数据时如果一个字段允许为空,某些数据库驱动在没有显式指定类型时会报JDBC type相关的错,这时候可以在#{}里写明类型:#{email, jdbcTypeVARCHAR}。第二个是TypeHandler机制:MyBatis拿到Java属性后,需要知道把Java类型转成JDBC类型,内置的TypeHandler覆盖了常见类型,但当你自定义枚举、JSON字段时,就需要自己实现TypeHandler并注册。这个属于进阶内容,但了解存在这个机制,遇到诡异“类型转换”报错时不会一头雾水。5. 动态SQL怎么把条件判断写进XML5.1 if where 组合拳多条件查询不再拼接字符串真实项目里没有几个查询是固定条件的,用户搜用户列表时可能只给用户名,也可能只给状态,还可能全都不给。用字符串拼接写这种SQL,除了丑,还得处理where 11和and的位置。MyBatis动态SQL用标签解决了这个问题。select idselectByCondition resultTypecom.example.entity.User select id, user_name, email from sys_user where if testuserName ! null and userName ! and user_name like concat(%, #{userName}, %) /if if teststatus ! null and status #{status} /if /where /selectwhere标签会自动去掉跟在它后面的第一个AND,避免生成where and status ?这种错SQL。if的test属性不是SQL,而是OGNL表达式,判断的是传入对象的属性。这里最容易犯的错是把写成,或者把数据库列的判断写进去,记住test里面只能看到Java属性,看不到SQL列名。5.2 choose/when/otherwise类似switch的分支逻辑有时候多条件之间不是叠加关系,而是“只选其中一种”。比如前端传一个查询类型,可能按邮箱查,也可能按用户名查,二选一。if集合做不到这种互斥,要用choose。select idselectByQueryType resultTypecom.example.entity.User select id, user_name, email from sys_user where choose when testqueryType email email #{keyword} /when when testqueryType name user_name #{keyword} /when otherwise id #{keyword} /otherwise /choose /where /selectchoose的语义和Java的switch几乎一样,从上到下匹配第一个成立的when,没有一个成立就走otherwise。注意test里的字符串比较用单引号,不是双引号,写成queryType email在某些OGNL版本下会得到奇怪结果。5.3 set 标签处理UPDATE的动态字段更新用户信息的时候,如果只改了邮箱,不该把user_name也一起更新成null。最土的办法是先查出来再整行更新,浪费一次查询;用动态SQL可以让XML自动组装非空字段。update idupdateById update sys_user set if testuserName ! nulluser_name #{userName},/if if testemail ! nullemail #{email},/if /set where id #{id} /updateset标签会把生成的字段列表里最后一个逗号去掉。这个看起来不起眼,但没它的时候,你得自己在每个if里纠结到底加不加逗号,一多就出错。这也是动态SQL里最有“用了就回不去”感受的一个标签。5.4 foreach 遍历集合in查询和批量插入foreach解决两类问题:一类是where id in (...);一类是批量插入。select idselectByIds resultTypecom.example.entity.User select id, user_name, email from sys_user where id in foreach collectionids itemid open( separator, close) #{id} /foreach /select对应的Mapper方法:ListUser selectByIds(Param(ids) ListLong 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 idinsertUser useGeneratedKeystrue keyPropertyid insert into sys_user(user_name, email) values(#{userName}, #{email}) /insert注意keyProperty填的是Java实体的属性名id,不是数据库列名id——这里通常一致所以容易忽略,但如果你实体的主键属性叫userId,数据库列也叫id,那必须写keyPropertyuserId,否则回填不进去。这是大家最常犯的错。还有一类场景是批量插入,同样要在insert标签上配置useGeneratedKeys,MyBatis会把生成的主键依次回填到传入List中每个对象的id属性。不同版本对批量回填的支持有细节差异,实测时注意验证,别假设它一定自动处理。6.4 我的实操体会与“上篇”的学习顺序建议每次带新同事熟悉MyBatis,我都会让他把三组概念各做一次小实验:第一组,改错namespace,观察启动报错和日志输出;第二组,把${}用在一个查询条件上,试着输入特殊字符看日志里SQL变成了什么;第三组,把resultType误写成List,看控制台怎样提示。这三件事都做完,对MyBatis的绑定机制、预编译机制、结果映射机制的理解,会比照文档抄配置要扎实得多。这篇是上篇,解决的是“怎么搭、怎么写、为什么会报错”的问题。按我现在的习惯,后面再聊会直接深入到Mapper动态代理的生成过程、拦截器对SQL执行链路的扩展、一级二级缓存的数据一致性陷阱、以及和Spring整合后SqlSession到底是怎么被管理的。先把基础的几个核心机制吃透,后面看源码、改框架、写插件都不会心虚。