ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis综合练习实战:从Mapper映射到动态SQL的完整指南

MyBatis综合练习实战:从Mapper映射到动态SQL的完整指南

看着PPT上几十行映射文件觉得都懂,自己动手写一个综合练习却频频翻车——这可能是很多人在学MyBatis时的真实状态。JavaWEB06这次综合练习,说白了就是把MyBatis里最常用的那套东西:环境配置、Mapper映射、动态SQL、多表查询、缓存与日志,放在一个贯穿式的业务场景里全部走一遍。这篇文章我按实际带练习的顺序,把从零搭项目到排查问题的完整过程整理出来。

这次综合练习的目标不是教你背MyBatis的API,而是让你在动手过程中真正理解三层结构:哪部分是Java代码负责调用、哪部分是Mapper接口负责定义、哪部分是XML映射文件负责SQL。很多初学者容易卡在“代码写了但查不到数据”、“接口明明定义了却报绑定异常”这种问题上,其实大部分都是对绑定规则和参数传递理解不透造成的。我把练习中常见的关键细节、报错排查思路和设计取舍都放在下文,如果你正在做类似的MyBatis练习项目,这篇可以直接当操作手册来参考。

1. 这个综合练习到底在练什么:从单纯CRUD到业务场景落地

1.1 练习背景:为什么课程里要专门安排一次综合练习

单纯学MyBatis的单点知识点时,你可能觉得每个内容都不难:select标签会写,resultType会配,动态SQL看一眼就懂。但一旦把这些内容组合到同一个项目里,问题就全冒出来了。课程到这里安排一次综合练习,就是要解决“单个知识点会、组合起来不会”的断层问题。

我当时带这个练习时,给学员设定的背景是一个简化的教学管理系统,处理学生信息、课程信息和选课记录的日常维护。需求看起来不算复杂,但涉及的操作正好能把MyBatis的重要功能都串起来:基础CRUD、多条件查询、批量删除、多表关联统计、动态更新等等。练习的最终要求是:不用任何MyBatis生成器,所有Mapper接口和XML映射文件手写,服务层自己控制事务。

这样做的好处很明显。手写一遍XML才能记住namespace必须跟接口全限定名一致,才能踩一遍“改了Java方法名忘了改XML里的id”这种低级错误。我现在觉得,综合练习的最大价值不是做出多少功能,而是让你把“配置——映射——调用”这条链路彻底走通,建立起排查问题的直觉。

1.2 练习需求拆解:我按四个模块来组织代码

为了让练习有层次感,我把需求拆成四个模块,难度依次递增:

基础数据维护模块:对学生和课程两张表做单表CRUD,主要练Mapper接口编写、参数传递、主键回填。这个模块的代码量最大,但技术难度最低。

条件组合查询模块:支持按学生姓名、所属班级、入学年份等多个条件动态组合查询学生列表,主要练动态SQL中的if和where。这是练习中第一个容易出错的点,因为很多初学者习惯用where 1=1拼接,虽然能跑但不够规范。

批量操作模块:批量添加课程记录、按ID批量删除选课记录,主要练foreach以及事务的一致性。这个模块我会要求必须带上事务验证:删除中途如果遇到外键约束,前面的删除操作应该被回滚。

多表统计模块:查询每门课程的选课人数、查询某个学生的完整选课信息,主要练association、collection以及多表关联的结果映射。统计类SQL建议直接在数据库里跑通再搬进XML,能省不少调试时间。

这样拆完之后,每个模块之间还有依赖关系:前面模块建立的表和测试数据,后面模块直接复用。练习者能感受到项目逐步变完整的过程,而不是每个练习都是独立的孤岛。

1.3 练习目标:练完你应该带走哪些能力

我希望每个把这个综合练习做完的人,走出练习之后能回答上几个问题:MyBatis的核心配置里每个标签分别解决什么;Mapper接口和XML映射文件之间靠什么规则绑定;#{}和${}在什么场景下分别使用;多表查询时关联映射怎么设计才不容易出现N+1问题;一级缓存和二级缓存在什么条件下生效。

这些问题的答案如果只靠背,转头就忘。但在综合练习里,它们全部变成了你亲手敲过的代码和踩过的坑。等到后续学习Spring整合MyBatis,你会发现原来这里的SqlSessionFactory对象、Mapper代理对象,在Spring容器里都变成了Bean,理解起来会顺畅很多。

2. 先把地基打牢:依赖清单、核心配置和环境验证

2.1 版本选型:用起来最省心的组合

综合练习不是生产项目,没必要追最新版本,但也不能随便选一个组合结果跑不起来。我建议采用一套验证过比较稳定的版本组合,至少能避免大部分因版本兼容导致的报错。

组件推荐版本说明
JDK8或11绝大多数教学环境默认版本,兼容性最稳
MyBatis3.5.x3.5以上对动态SQL和缓存支持完善
MySQL5.7或8.0练习用5.7更稳,8.0注意驱动类名不同
mysql-connector-java8.0.x(对应MySQL 8.0)用5.1.49连MySQL 8.0会报时区错误
Log4j1.2.17看SQL日志用的,配置简单
JUnit4.13.2单元测试验证每个Mapper方法

如果你用Maven管理依赖,注意mysql驱动坐标在8.0.31之后从mysql:mysql-connector-java改成了com.mysql:mysql-connector-j,这个细节坑过不少人,坐标写错的话依赖根本拉不下来。

2.2 核心配置文件详解:每一个标签都在解决什么问题

MyBatis的核心配置文件(通常叫mybatis-config.xml)是综合练习里第一个门槛。我见过很多学员为了省事把配置全塞在一个文件里,结果改一处参数要到处找。最好是效仿这种职责分离的做法:把数据库连接信息放到单独的db.properties,核心配置文件只声明引用它。

<configuration> <!-- 1. 引入外部数据库配置文件 --> <properties resource="db.properties"/> <!-- 2. 全局设置:开启驼峰映射、设置日志实现 --> <settings> <setting name="logImpl" value="LOG4J"/> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <!-- 3. 类型别名:简化XML中全限定类名的书写 --> <typeAliases> <package name="com.example.pojo"/> </typeAliases> <!-- 4. 环境:默认使用development环境 --> <environments default="development"> <environment id="development"> <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> <!-- 5. 注册映射文件,告诉MyBatis去哪找SQL语句 --> <mappers> <package name="com.example.mapper"/> </mappers> </configuration>

这里每个标签的作用我稍微展开一下。properties解决的是配置分离问题,数据库账号密码不应该硬编码在XML里。settings里的驼峰映射能让你省掉一堆resultMap,比如数据库列create_time可以直接映射到属性createTime。typeAliases配了包扫描之后,XML里写resultType="Student"就行,不用写全限定名,这个练习量不大可能体会不深,等SQL多了以后会觉得真香。

environments里的POOLED数据源是MyBatis内置的连接池实现,练习用它足够。transactionManager选JDBC表示事务由MyBatis自己管理,手动提交或回滚,这样你才能理解事务的边界在哪里。等以后接了Spring,这个配置会被Spring的事务管理器接管,那就是另一套玩法了。

mappers最后注册的是映射文件位置,可以逐个<mapper resource="">声明,也可以直接配包扫描。包扫描有个前提:Mapper接口和XML映射文件必须同名且在同一包下,否则扫不到。我建议练习一开始就用包扫描,强迫自己养成规范命名的习惯。

2.3 连接数据库的环境验证:先跑通再写业务

很多练习者喜欢一上来就写业务代码,结果调了半天发现数据库都连不上。我的习惯是搭好环境之后,先做一个最简单的测试:写一个查询语句,查一行当前时间,确认整条链路是通的,再开始写CRUD。

public class ConnectionTest { @Test public void testConnection() throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream); try (SqlSession session = factory.openSession()) { Integer result = session.selectOne("com.example.mapper.StudentMapper.testQuery"); System.out.println("连接结果:" + result); } } }

这里的selectOne第一个参数是Mapper的Statement完整ID,格式是namespace + "." + id,如果写错了会直接报Invalid bound statement。先做这个测试,可以确认配置文件本身没有问题,后面业务代码出错时,排查范围就能缩小到具体的Mapper或SQL。这一步花几分钟,后续能省几个小时的调试时间。

3. Mapper接口与XML映射文件:绑定关系、入参与结果集的三道关

3.1 接口绑定原理:namespace和statementId怎么对上的

MyBatis最核心的机制之一就是Mapper接口和XML映射文件的绑定。规则就两条:XML的namespace必须等于接口的全限定名;XML里每个语句的id必须等于接口里的方法名。这两条不满足,运行时就会报绑定异常。

// 接口定义 public interface StudentMapper { Student findById(Integer id); }
<mapper namespace="com.example.mapper.StudentMapper"> <select id="findById" resultType="Student"> SELECT id, name, class_name, create_time FROM student WHERE id = #{id} </select> </mapper>

这里有个容易忽略的细节:为什么StudentMapper接口没有实现类,session.getMapper(StudentMapper.class)却能直接拿到对象?因为MyBatis在运行时根据接口的方法签名和XML语句生成了一个代理对象。当调用studentMapper.findById(1)时,代理对象会用findById拼上接口的namespace去XML里找对应的select语句,然后执行并封装结果。

理解了这一点,很多报错就很好解释了。方法重载在Mapper接口里是大忌,因为XML里的id不能重复;接口里新增了方法但XML没加对应语句,运行时就报Invalid bound statement (not found)。我在练习前会让大家刻意看一眼这个代理机制,知道原理后遇到这个常见报错,第一反应应该是检查绑定是否一致,而不是怀疑数据库坏了。

3.2 参数传递:#{}与${}的区别,以及多参数如何传

参数传递这关,练习里几乎每个人都会踩一次坑。先说最核心的:#{}是预编译占位符,MyBatis会把它替换成?,然后通过PreparedStatement设置参数,天然防SQL注入。${}是字符串拼接,直接把变量内容拼到SQL语句里,大多数场景下应该避免使用。

<select id="findByCondition" resultType="Student"> SELECT * FROM student WHERE name = #{name} </select>

哪类场景必须用${}?动态排序、动态表名这种结构无法预编译的情况。比如按某列排序,ORDER BY #{column}是无效的,因为排序字段是结构不是值。这时可以这样写:

<select id="findAllOrderBy" resultType="Student"> SELECT * FROM student ORDER BY ${column} DESC </select>

但调用方必须对column做白名单校验,切不能直接把用户输入传进来。我把这个原则总结成一句话:能用#{}的地方绝不用${},不得不用${}时先校验再拼接。

再来看多参数传递。综合练习里经常出现“按姓名和班级查询”这样的方法,如果不做任何处理,直接写两个参数,XML里取参就会很痛苦,只能靠arg0、arg1或param1、param2这种隐含名称去取,可读性差还容易取错。我的建议是优先使用@Param注解显式命名:

List<Student> findByNameAndClass(@Param("name") String name, @Param("className") String className);
<select id="findByNameAndClass" resultType="Student"> SELECT * FROM student WHERE name = #{name} AND class_name = #{className} </select>

如果参数超过三个,或者参数本身就是一个对象的属性集合,直接传POJO或Map也行。传POJO的时候XML里直接写属性名,比如#{name}对应student.getName()。但要注意:如果传的是Map,键名必须和XML里的#{}里的名字完全一致,这个键名没有编译期检查,写错了只会运行时报错,只能细心一点。

3.3 结果集映射:resultType不够用的时候,用resultMap兜底

resultType用起来最省事,但有个限制:它要求数据库列名和Java属性名能够对应上。因为数据库规范命名通常是下划线风格class_name,Java属性是驼峰className,所以我在配置里开了mapUnderscoreToCamelCase=true,能解决大部分字段映射问题。

但总有一部分场景需要手动控制映射。比如多表联查时,查询结果的某一列要填到关联对象的属性里;或者表里某个字段叫desc,跟Java关键字冲突了,要起个别名。这时候就该用resultMap:

<resultMap id="StudentMap" type="Student"> <id property="id" column="id"/> <result property="name" column="name"/> <result property="className" column="class_name"/> <result property="createTime" column="create_time"/> </resultMap> <select id="findById" resultMap="StudentMap"> SELECT id, name, class_name, create_time FROM student WHERE id = #{id} </select>

这里<id>标签用于标识主键列,虽然平时没写也能正常封装,但在做关联映射和二级缓存时,主键标识会影响MyBatis对对象的唯一性判断,所以建议每个resultMap都认真标上主键。resultMap一旦指定,MyBatis就会完全按照你定义的映射关系来封装结果,不再看列名是否和属性名一致。这个特性既是自由也是约束——偷懒少写一行映射,那列数据就静默变成null,而MyBatis不会给你任何提示。

4. 动态SQL实战:条件查询、批量操作和更新场景的SQL写法

4.1 多条件组合查询:if + where解决“查询条件不确定”问题

综合练习里最常遇到的需求是:查询条件可能有,也可能没有,而且条件数量不定。没学动态SQL的时候,很多人会写多个方法分别处理,或者用Java代码拼接SQL字符串,这两种方式要么导致方法爆炸,要么引入SQL注入风险。MyBatis的if和where就是为这种场景设计的。

先看一个反面写法,很多初学者喜欢这样拼:

<select id="search" resultType="Student"> SELECT * FROM student WHERE 1 = 1 <if test="name != null and name != ''"> AND name LIKE #{name} </if> <if test="className != null and className != ''"> AND class_name = #{className} </if> </select>

WHERE 1 = 1确实能跑,但属于一种取巧的做法。MyBatis提供了更优雅的方案:用<where>标签包裹动态条件,它会自动处理掉第一个条件前面的AND或OR:

<select id="search" resultType="Student"> SELECT * FROM student <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="className != null and className != ''"> AND class_name = #{className} </if> </where> </select>

这里我要强调两个细节。第一个是test表达式里判空一定要写完整:name != null and name != ''缺一不可。只写name != null,前端传个空字符串过来条件依然会拼接,查出来就是个空列表。第二个是模糊匹配推荐用CONCAT('%', #{name}, '%'),而不是直接在Java代码里拼好%再传进来,这样能让SQL语句的参数占位保持清晰。

if不光是查询能用,很多业务规则都能通过它实现。比如“按年份过滤,没传年份就查全部”,这种需求在综合练习的统计模块里很常见,用if就能轻松覆盖,不用为每个分支单独写SQL。

4.2 批量操作:foreach的三种常见用法

foreach是动态SQL里花样最多的标签,综合练习里一般会出现三种场景。

第一种是批量删除,例如一次删除多个选课记录:

<delete id="deleteBatchByIds"> DELETE FROM course_selection WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </delete>

这里collection在不同参数类型下写法不同:传的是数组时写array,传的是List时写list,用@Param注解时写注解指定的名字。我建议练习里统一用@Param注解,彻底避开这个混乱的隐含规则。

第二种是批量插入,例如一次性给课程添加多个学生选课记录:

<insert id="batchInsert"> INSERT INTO course_selection (student_id, course_id, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.studentId}, #{item.courseId}, NOW()) </foreach> </insert>

执行批量插入时要注意SQL长度限制。如果一次性插入几千条,拼接出来的SQL字符串会非常大,数据库的max_allowed_packet参数可能要调大,或者分片插入。练习阶段数据量不大,但思路要建立起来。

第三种是动态更新,用foreach配合<set>标签只更新传入的非空字段,这个我在下一节单独说。foreach还有一个容易踩的坑:separator只负责生成分隔符,不会帮你处理集合为空的场景。如果传入的集合是空列表,拼出来的SQL就会变成WHERE id IN (),这是非法语法。所以调用批量方法前,一定要先判断集合不为空,我习惯在Service层做这个校验。

4.3 动态更新:set标签与“只更新非空字段”

更新操作有一个常见需求:用户可能只修改了部分字段,未修改的字段保持原值不动。如果用一条写死的UPDATE语句,所有字段都会被覆盖,原来有值的字段被更新成空字符串或null,这在真实业务里是有风险的。

MyBatis提供的解法是<set>标签结合<if>:

<update id="updateSelective"> UPDATE student <set> <if test="name != null and name != ''"> name = #{name}, </if> <if test="className != null and className != ''"> class_name = #{className}, </if> </set> WHERE id = #{id} </update>

<set>会自动去除最后一个条件后面的逗号,这是手写SQL时很容易忽略的问题。如果忘了用<set>而是自己拼SET语句,多一个逗号就会导致SQL语法错误。

这里还有个更深层的判断要注意:空字符串和null是不同的语义。null表示字段未设置,空字符串表示用户明确清空了文本输入框。业务上到底该把空字符串当成“不更新”还是“更新为空”,需要根据需求来定。我在练习中统一按“空字符串不更新”来处理,因为交互上用户没填的内容通常不应该覆盖数据库已有数据。但如果你做的是强制清空功能,那判断条件就要去掉空字符串检测,改成只判断null。

5. 多表关系与事务处理:一对多、多对一怎么映射更安全

5.1 多对一与一对多的结果映射:association和collection

综合练习做多表查询时,很多人直接改SQL语句把多张表查出来,然后用resultType接,发现封装结果和自己想的不一样。原因很简单:多表查询的结果行数和结构跟单表完全不同,比如查询学生和他的选课列表时,一个学生对应多行结果,resultType只能把每行映射成一个学生对象,无法自动把多行归并成一个学生对象里的课程集合。

正确做法是用resultMap里的association和collection。association用于处理“多对一”关系,比如多条选课记录对应一门课程:

<resultMap id="SelectionMap" type="CourseSelection"> <id property="id" column="id"/> <result property="studentId" column="student_id"/> <association property="course" javaType="Course"> <id property="id" column="cid"/> <result property="name" column="course_name"/> </association> </resultMap> <select id="findSelectionsWithCourse" resultMap="SelectionMap"> SELECT cs.id, cs.student_id, c.id AS cid, c.name AS course_name FROM course_selection cs LEFT JOIN course c ON cs.course_id = c.id </select>

collection用于处理“一对多”关系,比如一个学生有多门选课:

<resultMap id="StudentCoursesMap" type="Student"> <id property="id" column="id"/> <result property="name" column="name"/> <collection property="courses" ofType="Course"> <id property="id" column="cid"/> <result property="name" column="course_name"/> </collection> </resultMap>

使用collection时必须注意列的别名冲突。比如学生表有id,课程表也有id,如果SQL里直接写c.id而不起别名,MyBatis拿到ResultSet后按列名找值时,两个id列就会互相覆盖,导致课程对象的主键变成学生的id。所以关联查询的SQL里,关联表的主键列一定要起别名,我习惯统一加c_前缀。

另外要理解resultMap的嵌套映射是“按行处理、按主键合并”的。<id>标签在这里很关键,MyBatis判断同一学生的多行数据是否属于同一个学生对象,靠的就是<id>列的相同性。如果你忘了在resultMap里定义<id>,它就会把每一行都当成新对象,一对多就变成了一对一,课程列表永远只有一条数据。

5.2 嵌套查询与嵌套结果的选择:性能与代码可读性权衡

多表关联映射有两种实现路径。上面写的是“嵌套结果映射”:一条SQL用JOIN把所有数据查出来,再按映射规则组装成对象图。另一种是“嵌套查询”:在association或collection里写另一个查询语句,按需去数据库取关联数据。

嵌套查询的XML大致长这样:

<resultMap id="SelectionMap" type="CourseSelection"> <id property="id" column="id"/> <association property="course" javaType="Course" column="course_id" select="com.example.mapper.CourseMapper.findById"/> </resultMap>

这种方式的优点是SQL清晰、不用写一大堆别名;缺点是会产生N+1查询问题——查询一次主表数据,每一条记录都要再执行一次关联查询,数据量大时性能会很差。所以MyBatis提供了懒加载开关:lazyLoadingEnabled=true和aggressiveLazyLoading=false,让关联查询在真正访问属性时才执行。但懒加载并不是银弹,如果你在Service层直接遍历列表并逐个访问关联属性,经典N+1问题依然会出现。

我在综合练习里对学员的建议是:数据量小的关联查询直接用嵌套结果映射,性能可控且一次SQL到位;如果确实需要嵌套查询,先确认关联数据不会频繁访问,再开启懒加载。不要一上来就为了“代码清晰”选择嵌套查询,等数据量上来了再回头优化,成本会很高。

5.3 事务提交与SqlSession生命周期:不commit到底会不会写入数据库

这个问题综合练习必考:执行了insert之后,数据到底写进数据库没有?答案是:如果SqlSession不是自动提交模式,你执行完insert后数据只存在于当前事务的缓存里,必须调用sqlSession.commit()才会真正持久化。

SqlSession session = factory.openSession(); // 默认不自动提交 try { StudentMapper mapper = session.getMapper(StudentMapper.class); mapper.insert(student); session.commit(); // 手动提交 } catch (Exception e) { session.rollback(); // 异常回滚,保证数据一致 throw e; } finally { session.close(); // 释放连接 }

如果用factory.openSession(true),MyBatis会开启自动提交模式,每次执行SQL后自动提交。练习阶段我不建议用这种模式,因为它会掩盖事务边界的感知。综合练习里的批量删除场景,最理想的验证方式是:故意让批量删除执行到一半时抛异常(比如外键约束),然后不调用commit,检查数据库数据是否保持原状。做过这个实验之后,你对事务的理解会非常直观。

还有一个经常被忽略的点:SqlSession不是线程安全的,而且代表一个数据库连接。每得到一个SqlSession,底层就从连接池拿一个连接,长时间不关闭会耗尽连接池。所以养成finally里close()的习惯,比什么都重要。等后续整合Spring,你会发现Spring通过SqlSessionTemplate帮你管理了生命周期,但在纯MyBatis练习阶段,手动管理是必须掌握的底层能力。

6. 性能与调试:缓存、SQL日志和常见异常的排查思路

6.1 一级缓存与二级缓存:什么时候生效,什么时候坑你

MyBatis的缓存机制在综合练习里不算重点,但理解它能解决不少“玄学问题”。一级缓存是SqlSession级别的,默认开启。同一个SqlSession里执行两次完全相同的查询,第二次不会真正查数据库,直接返回缓存结果。

但这个特性有时候会坑人。比如同一个SqlSession里先查询学生,然后修改了这个学生的姓名,再查询同一个学生,你会发现查出来的还是旧名字。原因是MyBatis在SqlSession执行了增删改操作后,会清空一级缓存。这是正确行为,但如果你因为业务逻辑复杂而让一个SqlSession活得很久,中间数据被别人改了,你再查会发现缓存还是旧的。所以一级缓存的正确使用方式是短生命周期,用完即关。

二级缓存是Mapper级别的,跨SqlSession共享,默认不开启。需要先在XML里配置:

<mapper namespace="com.example.mapper.StudentMapper"> <cache/> </mapper>

配置后,同一个namespace下的查询结果会进入二级缓存。这里有个大坑:二级缓存缓存的是对象,如果某个查询依赖了多表关联结果,而关联表的数据被另一个Mapper改了,当前Mapper的二级缓存不会自动失效,查出来的就是脏数据。所以在多表关联较多的综合练习里,我会建议大家先不要开二级缓存,等理解了缓存失效机制再加。这跟生产环境中“缓存只用于低并发读取、高一致性场景谨慎开启”的原则是一致的。

6.2 把SQL日志打印出来:排查一切问题的第一步

不管是练习还是生产,SQL日志都是MyBatis调试的第一利器。配置里logImpl设成LOG4J,然后在log4j.properties里设置输出级别:

log4j.rootLogger=DEBUG, stdout log4j.logger.com.example.mapper=DEBUG log4j.appender.stdout=org.apache.log4j.ConsoleAppender log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern=%d{HH:mm:ss} [%t] %-5p %c - %m%n

启动测试后,控制台会打印两部分关键信息:Preparing:后面是MyBatis生成的预编译SQL,参数位显示成?;Parameters:后面是实际传入的参数值。排查问题时,第一件事永远是看这两行,而不是盯着Java代码猜。

我从这个习惯里受益很多。有一次学员的查询返回空列表,我让他看日志,发现Preparing里的SQL条件比预期多了一个,原因是XML里某个if判断写成了test="name != null and name != ''",但Java代码传了一个空格字符串,既不等于null也不等于空字符串,条件照样拼接。这个问题不看SQL日志很难定位。

顺便说一句,生产环境一般不会开DEBUG级别的SQL日志,因为打印量太大,但练习阶段请务必开着。等MyBatis语句写熟了,再考虑按需关闭。

6.3 高频异常排查链路:从报错信息倒推问题根因

我把综合练习里出现频率最高的几类异常整理成了一张表,每类都附上我的排查顺序:

异常信息常见原因排查顺序
Invalid bound statement (not found)XML的namespace或id与接口不匹配先查namespace,再查id,最后查包扫描是否覆盖了XML
Type interface ... is not known to the MapperRegistryMapper接口没有在核心配置里注册检查<mappers>配置是否扫描到了对应包
Cause: java.sql.SQLException: Access denied for user数据库账号密码错误或权限不足先用数据库客户端手工连接验证db.properties
Unknown column 'xxx' in 'field list'SQL列名写错或表结构不一致打开数据库客户端确认表字段,再对照SQL
ClassNotFoundException: com.mysql.jdbc.DriverMySQL驱动版本或坐标错误确认依赖坐标,8.0以上用com.mysql.cj.jdbc.Driver
Could not set property 'xxx'查询列名与Java属性对应不上开启驼峰映射或补resultMap
SQL语句拼接后报语法错误动态SQL标签使用不当打印SQL日志,看最终生成的SQL长什么样

排查链路的关键是不要慌。大部分MyBatis异常都能通过日志和报错信息直接定位,只有一小部分需要反复试。我见过学员在一个绑定异常上报错半天,最后发现是XML文件名和接口名不一致,少写了一个字母。这类问题靠眼睛很难发现,我建议直接对比接口名和XML文件名,或者把两个文件放到同一个包下,利用IDE的关联提示快速检查。

另外还有一个高频坑踩得比较隐蔽:mybatis-config.xml里的<mappers>如果用<package>扫描,XML文件必须放在resources目录下且包路径与Java接口一致。很多练习项目里Java文件在src/main/java下,XML却放在src/main/java同一个包下但目录结构错了,Maven打包时没有把XML作为资源拷贝过去,运行时就报绑定异常。如果确定代码没问题,试试在pom.xml里加资源过滤配置,或者直接把XML放到src/main/resources的对应目录下。

7. 综合练习做完之后:一次自查清单与收尾建议

整个练习做完,建议按下面的清单自查一遍,确认不是“跑通了”而是“真正理解了”。

配置层面:能不能不看笔记说出properties、settings、typeAliases、environments、mappers各自的作用?如果把数据库从MySQL换成别的数据库,需要改哪几个地方?

映射层面:Mapper接口方法参数在什么情况下需要加@Param?resultType和resultMap分别适合什么场景?开启驼峰映射后哪些情况还需要resultMap?

动态SQL层面:if的判空条件为什么必须同时判断null和空字符串?where和set标签分别帮你去掉了什么?foreach传数组、集合、@Param命名别名的写法分别是什么?

关联查询层面:association和collection在resultMap里有什么区别?为什么关联查询要给主键列起别名?嵌套查询和嵌套结果映射各自有哪些优缺点?

工程习惯层面:SqlSession是否每次用完都关闭了?事务的提交和回滚边界在哪里?日志输出级别是不是开着?如果代码挪到新环境,数据库连接参数需要改哪些地方?

这七类问题如果能不假思索答上来,这次综合练习才算真正过关。很多学员做练习时只关心“能不能跑通”,一旦跑通就赶紧交作业,结果两周后问起动态SQL的where标签怎么用,已经完全没有印象了。我自己带练习的时候,反而会要求大家故意制造几个错误:把#{id}改成${id}看看有什么后果、把resultMap里的<id>删掉看看一对多映射会不会出问题、不调用commit就结束程序看看数据是否入库。这些“反向验证”做完,比多写十个CRUD方法都管用。

最后说一个我个人的实操习惯:我会把综合练习里所有写过的XML映射文件按业务模块分类,同时给每个Mapper接口配一个同名的测试类,每写一个方法就立刻测试一个。这个方法看起来慢,但能让错误在最早期暴露,而不是攒到最后一口气调试,那时候几十个报错混在一起,光定位就要花掉半天。保持这个节奏,综合练习大概两天左右就能高质量完成,而且做完之后对MyBatis的掌握程度,比光看教程一个月扎实得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进