1. 为什么Java后端绕不开MyBatis这个老伙计
1.1 翻出三年前的JDBC代码,我默默删掉了
做Java后端开发,数据库操作永远是绕不开的一环。刚工作那阵子,我写过不少原生JDBC的代码,每次要查一张表,流程都是固定的:加载驱动、获取Connection、拼SQL、创建PreparedStatement、遍历ResultSet、手动关闭资源。如果查询条件稍微变一下,SQL就要重新拼一遍。一个简单的列表查询,代码量轻松突破四五十行,其中真正跟业务相关的SQL就那么一两句,剩下的全是样板代码。
更难受的是结果集的映射。表里有几个字段,代码里就要写几行rs.getXxx("字段名"),字段多了手都抽筋。后来我尝试过把公共部分抽成工具类,稍微好了一点,但只要涉及关联查询、多表分页、动态条件,JDBC的方案依然非常折腾。
所以当我第一次接触MyBatis的时候,最大的感受就是:它把"SQL和Java代码的翻译官"这件事彻底做好了。我再也不用自己写那一堆Connection、Statement、ResultSet的重复操作,只需要把SQL写在XML里,定义一个接口方法,MyBatis就会自动把参数传入SQL,把查询结果映射成对象返回。这个转变对开发效率的提升,几乎可以用"质的飞越"来形容。今天这篇就从零开始,把我自己实际项目里最常用到MyBatis的一整套增删改查流程,按入门顺序完整过一遍,给还没上手或者刚上手的朋友一个可以直接照着跑的参考。
1.2 在Hibernate、JPA盛行的今天,为什么我还选MyBatis
可能有人会问:现在Spring Data JPA、Hibernate这些全自动ORM框架也很流行,为什么还要学MyBatis?这个问题我在实际项目中感受很深。JPA这类框架的理念是"你用对象操作,我帮你自动生成SQL",听起来很美好,但一旦遇到复杂的多表关联、动态条件、批量更新,自动生成的SQL往往不够灵活,要么性能不理想,要么逻辑对不上,最后还得退回写原生SQL。
MyBatis走的是另一条路:它把SQL的控制权完全交还给开发者,你写什么SQL,它就执行什么SQL。这就带来两个实际好处:第一,SQL是自己写的,执行计划、索引命中、查询效率心里有数;第二,动态SQL能力很强,可以在XML里用if、where、foreach等标签拼出不同场景下需要的语句,尤其适合业务逻辑变化频繁的系统。
另外,MyBatis的学习曲线非常平缓。它没有很多复杂的概念,理解了Mapper接口和XML映射文件这两件事,基本就能上手干活。对于团队协作来说,一个新人翻两三天文档就能产出代码,这种低成本上手特性,在中小型团队和创业项目里吸引力很大。这也是很多Java岗位的面试题里,MyBatis总是占着一席之地的原因。
2. 从零搭建一个能跑起来的MyBatis项目
2.1 依赖与目录结构
动手写代码之前,先把项目骨架搭好。我用的是Maven管理依赖,JDK版本1.8以上,数据库以MySQL为例。MyBatis本身只是一个ORM框架,没有绑定Spring,所以入门阶段完全可以直接用原生MyBatis跑通流程,理解底层机制后再接入Spring,这样排查问题的时候思路更清晰。
在pom.xml里引入MyBatis依赖和MySQL驱动:
<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> </dependency>我习惯的目录结构是这样,清晰分区,方便后续扩展:
src/main/java/com/demo/ ├── entity/ # 实体类,和数据库表字段对应 ├── mapper/ # Mapper接口,定义方法 └── util/ # 工具类,比如SqlSessionFactory工厂 src/main/resources/ ├── db.properties # 数据库连接配置 ├── mybatis-config.xml # MyBatis全局配置 └── mapper/ # Mapper XML映射文件这里有个新手容易忽略的点:Maven默认只把src/main/resources下的文件打包进classpath,如果你把XML文件放在src/main/java下面,默认是不被打进去的,运行时会报Invalid bound statement。我把Mapper XML统一放在resources/mapper目录下,既符合Maven规范,也方便管理。
2.2 全局配置文件mybatis-config.xml怎么配
全局配置文件是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> <!-- 引入外部数据库配置 --> <properties resource="db.properties"/> <settings> <!-- 控制台打印SQL,开发期必开 --> <setting name="logImpl" value="STDOUT_LOGGING"/> <!-- 开启驼峰映射:user_name -> userName --> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <!-- 实体类别名:com.demo.entity.User 可以简写为 User --> <typeAliases> <package name="com.demo.entity"/> </typeAliases> <environments default="development"> <environment id="development"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="${driver}"/> <property name="url" value="${url}"/> <property name="username" value="${username}"/> <property name="password" value="${password}"/> </dataSource> </environment> </environments> <!-- 加载Mapper XML,这里直接扫描包下所有接口 --> <mappers> <package name="com.demo.mapper"/> </mappers> </configuration>db.properties文件放在同目录下,内容如下:
driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/mybatis_demo?useSSL=false username=root password=123456从配置里能看到几个关键信息:
transactionManager type="JDBC"表示事务管理交给MyBatis底层的JDBC事务,由我们手动提交或回滚;POOLED数据源是MyBatis内置的连接池,入门阶段完全够用,比Druid这些第三方连接池少一些配置成本;typeAliases配置包别名后,XML映射文件里写resultType="User"就行,不用写全限定名;mappers里的package标签会自动扫描com.demo.mapper包下所有接口,并寻找同路径的XML映射文件。
2.3 实体类与Mapper接口
这里的表结构很简单,我用一张用户表user来做示例,包含id、user_name、age、email四个字段。
对应的实体类User如下:
package com.demo.entity; public class User { private Integer id; private String userName; private Integer age; private String email; // 无参构造 public User() { } // getter/setter 略 // 为了省篇幅,实际代码里一定记得生成 }Mapper接口定义查询方法。MyBatis的Mapper是一个神奇的存在:接口里只声明方法签名,具体的SQL写在同名的XML文件里,运行时MyBatis会生成接口的代理实现类,把方法调用翻译成SQL执行。这也是MyBatis对开发者最友好的一点,写接口的时候完全不需要写实现类。
package com.demo.mapper; import com.demo.entity.User; public interface UserMapper { User selectById(Integer id); }2.4 第一次跑通select查询
XML映射文件放在src/main/resources/mapper/UserMapper.xml:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.demo.mapper.UserMapper"> <select id="selectById" resultType="User"> select id, user_name, age, email from user where id = #{id} </select> </mapper>这里有个核心规则需要特别强调:namespace必须写Mapper接口的全限定名,id必须写接口方法名,这样MyBatis才能把接口方法和XML里的SQL对应起来。没有对应关系,启动时就会报Invalid bound statement (not found)。
接下来写一个工具类,创建SqlSessionFactory全局单例:
package com.demo.util; import org.apache.ibatis.io.Resources; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder; import java.io.IOException; import java.io.InputStream; public class SqlSessionUtil { private static final SqlSessionFactory FACTORY; static { try { InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml"); FACTORY = new SqlSessionFactoryBuilder().build(inputStream); } catch (IOException e) { throw new RuntimeException(e); } } public static SqlSession openSession() { return FACTORY.openSession(); } }然后用一个简单的main方法测试查询:
import com.demo.entity.User; import com.demo.mapper.UserMapper; import com.demo.util.SqlSessionUtil; import org.apache.ibatis.session.SqlSession; public class Main { public static void main(String[] args) { try (SqlSession session = SqlSessionUtil.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1); System.out.println(user); } } }第一次跑通这段代码,把logImpl配置成STDOUT_LOGGING后,控制台会打印出执行的SQL语句、参数和查询结果。看到那行==> Preparing: select id, user_name, age, email from user where id = ?,说明你的第一个MyBatis查询已经成功跑通了。
3. 映射文件里的门道:resultType、resultMap与字段映射
3.1 resultType和resultMap怎么选
刚入门的时候,很多人会在resultType和resultMap之间犹豫。我的建议是:简单查询用resultType,涉及字段名不一致、关联查询、多表映射用resultMap。
resultType其实就是一个"自动映射"的方式。MyBatis会把查询结果的每一列,按照列名和实体属性名的对应关系填充到对象里。如果数据库字段是user_name,实体属性是userName,在开启了mapUnderscoreToCamelCase之后,MyBatis会自动把下划线风格转换为驼峰,你不需要额外写任何映射关系。
当查询结果比较复杂时,比如一个订单关联查询用户信息,返回字段来自多张表,此时再用resultType指向某一个实体就不够用了,需要借助resultMap:
<resultMap id="userMap" type="User"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <result property="age" column="age"/> <result property="email" column="email"/> </resultMap> <select id="selectByMap" resultMap="userMap"> select id, user_name, age, email from user where age > #{age} </select>resultMap还可以处理更高级的场景:一对多、多对一。用collection和association标签可以把关联表的数据嵌套映射到对象里。这一块内容比较深,入门阶段先用好自动映射就够了,等遇到实际需求再加不迟。
3.2 下划线字段的三种处理方案
数据库命名习惯通常是下划线风格,Java命名习惯是驼峰风格,这俩之间的转换是新手最常踩的坑。如果查询结果查询出来的列名是user_name,而实体属性是userName,MyBatis默认不会自动转换,结果就是userName属性为null。
处理这个问题有三种方案,按推荐排序:
第一种,全局开启驼峰映射。在mybatis-config.xml的settings里配置mapUnderscoreToCamelCase为true,这一点我在上文已经提过,一劳永逸。第二种,在SQL里给列起别名,写成user_name as userName,局部解决,适合不想改全局配置的情况。第三种,用resultMap显式映射,适合复杂场景。
我最推荐第一种。它几乎没有副作用,也不影响已有的SQL,改一行配置就能解决90%的字段映射问题。
3.3 别过度依赖全局别名
typeAliases配置了<package name="com.demo.entity"/>之后,XML里可以用User代替com.demo.entity.User。这能少打几个字,但我不建议在resultType里写缩写,因为成员多了之后,IDE自动补全反而会因为重名干扰判断。如果要写resultType,要么用全限定名,要么确保实体类在全局范围内不重名。实际开发里,一个项目可能有好几个User相关的类,如果命名不当,一旦出现了同名的类,全局限定别名会失效,排查起来很麻烦。
我个人的习惯是:resultType写全限定名,虽然长一点,但可读性和可维护性更稳;parameterType反而不建议写,让MyBatis自己推断参数类型,可以减少很多配置错误。
4. 增删改查逐个击破:SQL与事务齐头并进
4.1 查询:单条记录与列表查询
先补全UserMapper接口,把增删改查的方法都定义好:
package com.demo.mapper; import com.demo.entity.User; import java.util.List; public interface UserMapper { User selectById(Integer id); List<User> selectAll(); User selectByUserName(String userName); int insert(User user); int updateById(User user); int deleteById(Integer id); int batchDelete(List<Integer> ids); }对应的XML里,查询相关的SQL如下:
<select id="selectById" resultType="User"> select id, user_name, age, email from user where id = #{id} </select> <select id="selectAll" resultType="User"> select id, user_name, age, email from user </select> <select id="selectByUserName" resultType="User"> select id, user_name, age, email from user where user_name = #{name} </select>selectById返回单个对象,selectAll返回List<User>。MyBatis会根据方法返回值类型自动选择合适的处理逻辑,返回值既可以写成接口的返回类型,也可以写成接口方法返回值类型的元素类型。用的时候注意一个细节:如果你的查询条件实际上可能查出多条记录,但方法返回值是单个对象,MyBatis会抛出TooManyResultsException,这一点和JPA的行为不太一样,要留心。
4.2 新增:useGeneratedKeys拿到自增主键
插入是增删改查里最有门道的一个操作,因为新增之后往往需要拿到自增主键,用于后续业务处理。比如新增一个用户,马上要拿这个用户的id生成订单号,少了主键回填就得再查一次,麻烦且多一次数据库往返。
XML里这样写:
<insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> insert into user (user_name, age, email) values (#{userName}, #{age}, #{email}) </insert>useGeneratedKeys="true"表示使用JDBC生成的自增主键,keyProperty="id"表示把生成的主键值写入传入对象的id属性里。测试代码可以验证:
User user = new User(); user.setUserName("张三"); user.setAge(25); user.setEmail("zhangsan@example.com"); try (SqlSession session = SqlSessionUtil.openSession(true)) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.insert(user); System.out.println("插入后的主键:" + user.getId()); }第二个参数传true表示自动提交事务。如果不传,默认是手动提交,必须调用session.commit(),否则数据不会真正写入数据库。这个细节我见过太多同事踩过,后面第6章会专门细讲。
4.3 更新:动态结合if标签
更新操作最常见的场景是:前端只提交了部分字段,后端只想更新有值的字段,不希望把其他字段覆盖成null。这种需求用if标签配合set标签来实现:
<update id="updateById"> update user <set> <if test="userName != null and userName != ''"> user_name = #{userName}, </if> <if test="age != null"> age = #{age}, </if> <if test="email != null and email != ''"> email = #{email}, </if> </set> where id = #{id} </update>set标签会自动处理几个动态字段之间的逗号问题,并且会在没有任何字段需要更新的时候,避免生成一个语法错误的update user set where ...。你可以打开日志看看生成的SQL,会发现MyBatis在动态拼接这块替我们做了不少细节处理。
4.4 删除:单条删除与批量删除
单条删除比较简单:
<delete id="deleteById"> delete from user where id = #{id} </delete>批量删除则会用到foreach标签:
<delete id="batchDelete"> delete from user where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </delete>这段SQL的最终效果是delete from user where id in (1,2,3)。collection表示传入参数的属性名,item是集合元素的临时变量名,open和close决定前缀后缀,separator决定元素之间的分隔符。调用方式:
List<Integer> ids = List.of(1, 2, 3); try (SqlSession session = SqlSessionUtil.openSession(true)) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.batchDelete(ids); }批量删除这个案例背后的逻辑很简单,但也很典型:MyBatis的动态SQL,本质上是在XML标签的帮助下,用代码逻辑生成最终SQL,理解了这个核心思想,foreach、if这些标签就都好学了。
5. 参数传递的几种姿势:#{}、${}和动态SQL
5.1 参数传递到底有哪几种方式
很多入门教程讲参数传递时会列出一大堆场景,其实归纳起来主要就三种:单参数、多参数、对象参数。
单参数最简单,直接传一个值给SQL里的#{xxx},xxx是什么名字都无所谓,都能取到值:
User user = mapper.selectById(1);多参数就必须借助@Param注解,或者封装成对象/Map。例如接口方法:
User selectByNameAndAge(@Param("name") String name, @Param("age") Integer age);XML里这样引用:
<select id="selectByNameAndAge" resultType="User"> select id, user_name, age, email from user where user_name = #{name} and age = #{age} </select>如果不加@Param注解,MyBatis会按参数位置提供默认的param1、param2这种名字,比如#{param1}。短期看能用,但代码可读性极差,我不建议这么写。
对象参数是最常用的方式,尤其是新增和更新,直接把实体对象传进去,#{}里的名字就是实体属性名:
<insert id="insert"> insert into user (user_name, age, email) values (#{userName}, #{age}, #{email}) </insert>传Map也是同理,#{}里的名字就是Map的key。对象参数同时携带多个字段时阅读性最好,也是我在实际项目里用得最多的方式。
5.2 #{}和${}的本质区别,一句话讲透
这个问题面试被问的概率很高。简单来说:#{}是预编译参数占位符,MyBatis会把它替换成?,再通过PreparedStatement的setXxx方法设置参数值,这个过程对传入值做了安全处理,能有效防止SQL注入。${}则是字符串直接拼接,MyBatis会把它替换成实际的内容,写进SQL里,不做任何转义。
看一个对比就明白了。假设传入了name = "张三' OR '1'='1":
- 用
#{}:最终SQL是where name = ?,参数值被当作纯字符串处理; - 用
${}:最终SQL变成where name = 张三' OR '1'='1,条件被改写,危险。
那么${}完全不能用吗?也不是。它适合那些无法用占位符表达的场景,比如动态排序字段order by ${sortColumn},或者动态表名select * from ${tableName}。这类场景下参数值来自系统内部配置,而不是用户直接输入,风险可控。核心原则是:能写#{}的地方不用${},必须用${}的时候,参数来源要严格校验白名单。
5.3 动态SQL里的where、if、set、foreach怎么配合
动态SQL是MyBatis的精髓。前面更新操作用了set和if,批量删除用了foreach,再看一个组合使用的多条件查询:
<select id="selectByCondition" resultType="User"> select id, user_name, age, email from user <where> <if test="userName != null and userName != ''"> and user_name like concat('%', #{userName}, '%') </if> <if test="age != null"> and age = #{age} </if> <if test="email != null and email != ''"> and email = #{email} </if> </where> order by id </select>where标签的聪明之处在于:如果内部所有if都不成立,它不会生成where关键字;只要有一个条件成立,它会自动去掉第一个条件前面的and。这个设计免去了我们手工判断"要不要拼where、第一个and要不要去掉"的繁琐逻辑。
结合if+where+set+foreach这几个标签,你基本就能应付90%以上的动态SQL业务场景了。至于choose、trim这些更高级的标签,建议入门后遇到具体需求再边查边用,不用一开始就全部背下来。
6. 入门最常踩的坑与排查思路
6.1 Mapper接口和XML绑定失败,报Invalid bound statement
我见过太多次这个报错:代码写得没问题,SQL也正确,一运行就抛出Invalid bound statement (not found): com.demo.mapper.UserMapper.selectById。这个坑的原因通常有以下几种:
第一种,XML映射文件的namespace和Mapper接口全限定名不一致。检查一下namespace是不是写成了类名而不是全限定名,或者接口的包路径和XML里的package配置不对。第二种,XML文件没有被打包到classes目录。确认XML是否放在了src/main/resources下,或者构建工具是否配置了对*.xml资源的过滤排除规则。第三种,接口方法和XML里的id名字对不上。方法名、参数数量可以不同,但id必须精确匹配。
排查方法很直接:打开target/classes目录,看XML有没有被复制进去;没有的话检查Maven配置,在pom.xml里补上资源声明:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> <includes> <include>**/*</include> </includes> </resource> </resources> </build>这是最简单的排查链路,只要按顺序走一遍,大概率能定位问题。
6.2 SqlSession的提交与关闭,很多人栽在这里
MyBatis的SqlSession默认不是自动提交的。执行完insert、update、delete之后,如果不调用session.commit(),事务会一直挂起,直到session关闭时回滚。这也是很多新人最常见的疑惑:"我明明执行了insert,数据库里怎么没有数据?"
正确做法是:写完数据变更操作后记得提交。两个方式,一个是显式调用session.commit(),一个是在openSession时传入true开启自动提交:
SqlSession session = SqlSessionUtil.openSession(true);另外SqlSession使用完应该关闭。MyBatis的SqlSession不是线程安全的,每个线程都应该有自己的实例,用完及时关闭释放连接。我习惯用try-with-resources写法,保证无论正常还是异常都能关闭:
try (SqlSession session = SqlSessionUtil.openSession(true)) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.insert(user); }6.3 一条SQL调试到底:日志输出的正确姿势
入门阶段排错,最有效的工具就是日志。MyBatis的logImpl设置成STDOUT_LOGGING后,SQL、参数、返回结果都会打印到控制台,这是最直观的调试方式。生产环境里则建议接上SLF4J + Logback,把SQL日志单独配置一个logger,按需开启:
<settings> <setting name="logImpl" value="SLF4J"/> </settings>日志里能看到的不仅仅是SQL本身,还包括==> Parameters:这一行,它会打印实际传入的每个参数值。我强烈建议入门阶段每次都盯着这一行看,它往往能帮你快速定位参数类型错误、空值、格式不对之类的问题。
比如说,你本来想传一个Integer类型的id,结果调用时传了个String,日志的Parameters那行通常就会显示类型不匹配。把这个当成日常习惯之后,很多排查工作根本不需要Debug断点,看一眼日志就够了。
做了几年Java开发,MyBatis算是陪我走过了绝大多数项目的一个框架。它没有很复杂的花活,但把"数据库操作"这件最容易写出重复代码的事情,简化成了接口加XML的清爽结构。如果你正在入门,不用急着记口诀,按我上面的顺序把环境搭起来,把增删改查逐个跑一遍,遇到报错就开日志看SQL,再对照第6章的常见坑排查一遍,基本两天就能上手。后面再深入接触Spring整合、动态SQL进阶、缓存机制,就会顺畅很多。