拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot整合MyBatis实战:动态SQL、缓存与TypeHandler

SpringBoot整合MyBatis实战:动态SQL、缓存与TypeHandler

我记得第一次在SpringBoot里用MyBatis,是在一个把老SSM项目往SpringBoot迁移的活儿上。当时最大的感受是:XML配置从满屏的SqlMapConfig.xml、applicationContext.xml一下子收敛到了application.yml里的几行,但该踩的坑一个都没少踩。这篇文章不写面试八股,把我这几年在SpringBoot项目里用MyBatis的实践整理一遍,从依赖怎么引、配置怎么调,到动态SQL怎么写、缓存怎么避坑、TypeHandler怎么自定义,全是能直接落到项目里的东西。适合刚接触SpringBoot的同学,也适合用了很久MyBatis但对底层机制还比较模糊的开发者。

1. 为什么SpringBoot项目里依然绕不开MyBatis

1.1 JDBC太原始,Hibernate太“重”,MyBatis正好卡在中间

先聊一个基础问题:Java操作数据库的姿势有好几种,为什么大多数SpringBoot项目最终选择了MyBatis?

如果你写过原生JDBC,大概率记得那段痛苦经历。每次查询都要手动Class.forName加载驱动、DriverManager.getConnection拿连接、PreparedStatement拼接参数、ResultSet逐行取值,然后还要自己关连接。一个简单查询写下来少说二十行,而且看代码的人很难一眼看出这条SQL到底长什么样。更麻烦的是结果集映射,数据库字段是user_name,Java属性是userName,你得手动一个个rs.getString("user_name")再setUserName,字段一多就全是体力活。

后来有了Hibernate这类全自动ORM,它的思路是把Java对象和数据库表完全映射,程序员不写SQL,框架自动生成。听上去很美,但实际用起来有两个问题:第一,框架自动生成的SQL在复杂查询下往往不是你想要的,想优化就得去学HQL、Criteria那一套专用语法,学习成本高;第二,它把SQL细节藏得太深,出了问题很难定位。这种“全自动”对于业务复杂、SQL需要精细控制的场景,反而有点用力过猛。

MyBatis走的是“半自动”路线:SQL你自己写,框架只负责帮你管理连接、解析参数、映射结果。这相当于把“做饭的主动权”留给你,但把“买菜、洗菜、切菜、刷碗”这些杂活全包了。你保留了SQL的可控性,又不用碰JDBC那些重复样板代码。所以MyBatis在业界站稳了脚跟,尤其是在互联网公司那种SQL千变万化的业务场景里,它比全自动ORM更顺手。

1.2 SpringBoot让MyBatis的接入成本又降了一截

SpringBoot出现之前,用MyBatis是有一套固定流程的:先写mybatis-config.xml配置数据源、别名、插件,再写SqlSessionFactoryBuilder去解析配置文件,还要在Spring容器里注册MapperScannerConfigurer扫描Mapper接口,再配合Spring的事务管理器整合。这一套配置对新手来说跟天书一样,稍微写错一个标签项目就起不来。

SpringBoot的自动装配把这个过程大幅简化了。你只需要引入mybatis-spring-boot-starter,Spring Boot就会通过MybatisAutoConfiguration自动完成三件关键事情:帮你创建SqlSessionFactory,注册SqlSessionTemplate,再把所有Mapper接口扫描进去。这个“自动装配”的原理其实不复杂,Spring Boot在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里声明了自动配置类,条件注解检测到classpath里有MyBatis的相关类,就自动执行初始化逻辑。理解这层原理后,遇到“为什么starter没生效”之类的问题,你就知道该去检查依赖是否引入、条件注解是否满足。

说白了,SpringBoot对MyBatis的意义,是把过去二十分钟的初始化配置压缩成了两行依赖加几行YAML,让开发者把精力留到SQL本身。

2. 从零搭建:SpringBoot整合MyBatis的完整步骤

2.1 依赖引入与版本选择

先说依赖。Maven项目里在pom.xml加这一段即可:

<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency>

版本选择是个容易踩坑的地方。如果你是SpringBoot 2.x项目,建议用mybatis-spring-boot-starter的2.x版本,比如2.3.0。如果你的项目已经升级到SpringBoot 3.x,那必须用3.0以上的MyBatis Starter,因为SpringBoot 3基于Jakarta EE规范,老版本的starter里很多包名还是javax.*,直接引会报类找不到。

数据库驱动也得配套。MySQL用com.mysql:mysql-connector-j,PostgreSQL用org.postgresql:postgresql,注意驱动分组和包名在较新版本里有变化,比如MySQL驱动从mysql:mysql-connector-java换成了com.mysql:mysql-connector-j,别在网上抄到过期的坐标。

一个重要建议:不要让Maven帮你随便推导starter的版本。如果你在父工程里已经管理了SpringBoot的BOM,就优先利用BOM里的依赖管理,不要再写version,让版本统一由父工程控制,避免传递依赖冲突。

2.2 application.yml的核心配置项

依赖引好后,配置集中在application.yml里。下面是我个人比较常用的一套基础配置:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true jdbc-type-for-null: 'null' log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

逐项拆解一下为什么这么配。

mapper-locations指定XML文件位置,默认扫描的是classpath:mapper下的所有XML。如果你的XML不在这里,启动就会报Invalid bound statement (not found)错误,这个后面会说。

type-aliases-package是把实体类包名注册成短别名,这样在XML里写resultType="User"就不用写全限定名com.example.demo.entity.User。一旦项目实体类多起来,这个配置可以省掉一堆冗长包名。

map-underscore-to-camel-case: true是开启驼峰映射,数据库字段create_time会自动映射到Java属性createTime,少写大量resultMap。虽然这里用了配置方式,实际上MyBatis也支持直接在XML里定义<setting name="mapUnderscoreToCamelCase" value="true"/>,但在SpringBoot里统一走YAML更干净。

jdbc-type-for-null是个容易被忽略的坑。如果查询参数里有null值,而你的数据库字段有非空约束,插入或更新时可能报“无效的列类型”。设置成'null'可以让MyBatis在参数为null时显式指定JDBC的NULL类型,避免因为驱动差异导致的兼容性问题。

log-impl配成StdOutImpl是开发期最省事的SQL打印方式,但它的缺点是只能输出到控制台,而且是同步输出,性能上不适合生产环境。生产环境一般建议配成Slf4jImpl,走日志框架,方便接入ELK之类的统一日志平台。

2.3 Mapper扫描:@MapperScan和@Mapper怎么选

Mapper接口要让Spring容器管理,离不开一个“扫描”动作。常见两种写法:

第一种,在启动类上添加注解:

@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }

第二种,在Mapper接口上单独加@Mapper:

@Mapper public interface UserMapper { User selectById(@Param("id") Long id); }

这两种方式底层是相通的。@MapperScan会在启动时扫描指定包下所有接口,给每个接口动态生成一个代理实现并注册为Bean;@Mapper则是把这个接口单独标记为Mapper。

我的建议是优先用@MapperScan。原因很简单:如果每个Mapper都要加@Mapper,新写一个接口容易忘记加,启动后注入Mapper时报NoSuchBeanDefinitionException,排查起来先得怀疑一遍自己的注入代码。用@MapperScan集中扫描,新接口只要放到约定包路径下就能自动注册,少一个犯错点。而且它支持同时指定多个包路径,比如@MapperScan({"com.example.mapper", "com.example.other.mapper"}),灵活性更好。

3. Mapper开发的三种姿势:XML、注解、还是混合

3.1 XML映射:复杂SQL的主战场

MyBatis的核心逻辑其实都沉淀在XML里。一个标准的XML映射文件长这样:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <resultMap id="UserResultMap" type="User"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <result property="createTime" column="create_time"/> </resultMap> <select id="selectById" resultMap="UserResultMap"> SELECT id, user_name, create_time FROM user WHERE id = #{id} </select> </mapper>

这里有三个重要约定说三遍都不嫌多。

第一,namespace必须和Mapper接口的全限定名完全一致。MyBatis是通过namespace + 语句id的组合来定位一个SQL语句的,如果你把namespace写错,启动时虽然可能不报错,但应用调用Mapper方法时会报BindingException。第二,select标签的id必须和接口方法名一致。第三,resultType或resultMap二者至少给一个,别指望MyBatis能猜出你要返回什么,猜测的代价就是映射出来的对象全是null。

为什么XML是复杂SQL的主战场?因为它天然支持多语句、子查询、动态SQL、结果映射等复杂结构,代码提示配合IDE插件(比如MyBatisX)做得比注解方式好得多。而且SQL是独立文件,DBA或者后端同事review的时候不用在一堆Java注解里翻找SQL,方便维护。我见过很多团队甚至规定“查询超过三行SQL一律写XML”,这个规范虽然粗暴,但确实能防止注解SQL堆到不可维护的程度。

3.2 注解SQL:简单操作的轻量选择

MyBatis也支持直接在Mapper接口方法上加SQL注解,比如:

@Mapper public interface UserMapper { @Select("SELECT id, user_name, create_time FROM user WHERE id = #{id}") User selectById(@Param("id") Long id); @Insert("INSERT INTO user(user_name, create_time) VALUES(#{userName}, #{createTime})") int insert(User user); }

注解方式的优点很直观:Mapper接口和SQL在同一个文件里,对于只有一两行的简单查询,看代码时不用跳来跳去,特别符合“围在一起就能看懂”的心态。

但注解方式有明显的天花板:第一,动态SQL写起来非常痛苦,虽然也有<script>标签可以在注解里写动态SQL,但那个可读性比XML差远了,转义还容易出错;第二,复杂的resultMap在注解里要用@Results一层层套注解,MultiResultMap的嵌套一多就眼花缭乱;第三,MySQL、PostgreSQL的方言差异在注解SQL里更难管理,不好做多数据库兼容。

所以我的建议是:单表简单增删改查,尤其是插入和根据主键查询这类语句,用注解完全够用;一旦涉及多表联查、动态条件、批量操作,就老实迁到XML里。

3.3 混合模式与团队规范沉淀

实际项目里我更推荐混合模式:简单操作走注解,复杂查询走XML,但前提是团队要定好规矩。比如一个典型规范是:Mapper接口方法上没有语句定义的,去XML里找同名id;XML里有的语句,接口里必须有对应方法。这两个地方很容易出现“接口方法存在,但XML没写,启动不报错,一调用就报Invalid bound statement”的情况。

还有个容易被忽略的规范问题:XML文件要放到src/main/resources/mapper目录下,不要跟Java源码混在一起。如果放到Java包里,Maven默认打包时不把XML打进target,运行时报找不到XML。

另外补充一点,如果你在用IDEA,强烈建议装一个MyBatisX插件。它不仅提供XML和接口间的跳转,还能在XML里给SQL语句做高亮和自动补全,查错效率高很多。

4. 动态SQL与参数传递:MyBatis的灵魂所在

4.1 动态SQL的几个核心标签

动态SQL是MyBatis区别于普通ORM的一个重要能力,业务里“根据前端传的条件拼查询语句”这类需求全靠它。核心标签就那几个:if、choose、when、otherwise、where、set、trim、foreach。

最典型的场景是条件查询:

<select id="selectByCondition" resultType="User"> SELECT id, user_name, create_time FROM user <where> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> <if test="createTime != null"> AND create_time = #{createTime} </if> </where> ORDER BY create_time DESC </select>

为什么用<where>而不是直接在WHERE后面写<if>?因为当所有条件都为空时,<where>会帮你去掉SQL里多余的WHERE关键词;当第一个条件成立,但userName为空、createTime不为空时,SQL会变成WHERE AND create_time = ?这种语法错误,<where>会自动把第一个多余的AND去掉。类似地,<set>用于UPDATE语句,会自动处理SET关键词和末尾多余的逗号。这两个标签底层都是<trim>的封装,理解trim的prefix、prefixOverrides、suffixOverrides属性,你对动态SQL的理解就是降维打击。

foreach是做批量操作的关键标签。比如批量插入:

<insert id="batchInsert"> INSERT INTO user(user_name, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.userName}, #{item.createTime}) </foreach> </insert>

collection属性写list还是array,取决于你传入的参数是什么。如果是List集合,用list;如果是数组,直接用array;如果是一个用了@Param命名的参数,用参数名。这一块特别容易踩坑,下面单独展开。

4.2 @Param与参数索引的微妙区别

很多初学者被#{param1}、#{param2}以及#{arg0}、#{arg1}搞迷糊过。说实话,这两种写法是MyBatis不同版本留的“历史遗产”。较老的版本用param1、param2,新版本里方法参数名如果不带@Param,也可以用arg0、arg1来引用。但不管哪个版本,我都建议一个规矩:多个参数方法里,每个参数都加上@Param。

有人可能问,单参数方法也需要加@Param吗?如果是普通POJO对象,不需要,MyBatis会直接以对象的属性名作为key。但如果是单个简单类型,比如User selectById(Long id),XML里写#{id}在很多版本下能通过,因为MyBatis会自动把单个参数包装成Map并给一个默认key。可这种“能通过”依赖版本和configuration的useActualParamName设置,一旦项目升级,可能就崩了。所以我的个人习惯是:Mapper方法里凡是简单类型参数,一律显式加@Param注解,POJO类型的可以不加,但动态SQL里如果要用到POJO里的属性,得保证属性名和#{属性名}对得上,否则反射取不到值。

@Param还有一个实际影响:当Mapper方法被MyBatis解析时,带@Param的参数会被放入一个参数Map,Map的key就是你注解里写的名字。这样在XML里面的test表达式、foreach的collection、#{}占位符,都能用这个key稳定引用。不写注解的版本,MyBatis靠反射拿参数名,如果你的代码编译时没开-parameters编译参数(IDEA和Maven默认不一定开),参数名就会变成arg0、arg1,引用的名字对不上,报错就来了。

4.3 批量操作的效率与代价

业务上经常遇到批量插入、批量更新的需求。最粗暴的写法是在Java里循环调用单条insert,这样不仅发起的SQL请求多,每一条都走一次网络往返,性能差得明显。

用<foreach>拼批量SQL是常规解法,比如上面那段批量插入,一条SQL插入几百条数据。这种方式在数据量不大(几百条内)时效率提升非常明显,因为它把多次网络往返压缩成了一次。但要注意SQL长度限制,MySQL默认max_allowed_packet是4MB(不同版本有差异),如果一次插入一万条,可能直接把SQL撑爆。解决办法是分片提交,在Service层按500条一批切分,循环提交。

批量更新也是类似场景。有人喜欢用<foreach>拼多条UPDATE ...; UPDATE ...;的语句,这种写法有隐患:一是MySQL JDBC默认不支持多语句执行,需要连接串里加allowMultiQueries=true,二是一旦中间某条失败,事务回滚的语义会变得很模糊。如果确实要批量更新,我更推荐在Java里取出要更新的数据,用JDBC的batch机制来执行,也就是SqlSessionTemplate里的ExecutorType.BATCH模式,它会缓存多条SQL直到手动flush,从而减少网络往返。MyBatis-Plus里也有批量操作的封装,但底层依然跑的是这套逻辑。

5. 缓存机制:一级缓存二级缓存到底怎么用

5.1 一级缓存:默认开着却最容易忽略

MyBatis的一级缓存是默认开启的,作用域是SqlSession。同一个SqlSession内执行相同的查询,第二次会直接命中缓存,不再查数据库。听起来很美,但在SpringBoot项目里,一级缓存的实际作用比想象中小很多,因为它依赖SqlSession的生命周期。

Spring整合MyBatis后,SqlSessionTemplate里的SqlSession是根据SQL执行动态创建的,执行完自动关闭。也就是说,在同一个Service方法里调用两次同一个Mapper方法,看起来像“同一个事务里”,但多数情况下这两个调用会使用不同的SqlSession,一级缓存根本不会命中,所以你会看到SQL执行了两次,这是正常现象,不是配置坏了。

那什么时候一级缓存能生效?在配置了Spring事务的同一个事务方法里,SqlSession会被事务管理器绑定到当前线程,事务内的多次查询会复用同一个SqlSession,此时一级缓存才真正有用。比如一个事务方法里先查用户信息,再根据这个用户的id查订单,相同查询命中缓存,能节约一次数据库访问。

一级缓存带来的一个经典问题是脏数据:如果事务A里第一次查询后,事务B修改了这条数据并提交,事务A再次查询时命中了一级缓存,拿到的还是旧数据。这个问题通常不需要特别处理,因为一级缓存生命周期太短,影响有限,但如果你测试时看到“数据明明改了,查询结果没变”,先想想是不是同一个事务内的缓存问题。

5.2 二级缓存:跨SqlSession的缓存

二级缓存是跨SqlSession级别的,粒度是Mapper的namespace。开启方式是在XML里加一行:

<cache/>

这样MyBatis会把该namespace下select的查询结果缓存起来,后续相同查询直接从缓存取;对该namespace的增删改操作会刷新缓存。听起来很香,但实际生产环境里我非常不建议直接开二级缓存,原因有三个。

第一,缓存是JVM级别的,单机缓存无法支持多实例部署。你的服务一上K8s多副本,每个Pod的缓存是独立的,数据一致性立刻出问题。第二,当SQL涉及多表join时,如果只缓存了某一个表的namespace,另一个表的数据被更新,不会触发这个namespace的缓存刷新,你用到的就是脏数据。第三,二级缓存的序列化、反序列化有额外开销,加上命中率如果不高,性能可能不升反降。

如果你真的需要给查询结果做缓存,我更推荐用Redis这类集中式缓存,在Service层自己控制缓存逻辑。好处是缓存存储集中、过期策略统一、多实例一致性好,还能把缓存和业务逻辑揉在一个地方,容易排查。

5.3 一个真实的缓存踩坑案例

我之前在一个后台管理系统里排查过一个诡异问题:管理员在后台把某条配置改成了“关闭”,前端却还是显示“开启”。查代码发现查询走的是MyBatis二级缓存,而更新操作虽然走的是同一个namespace,但因为缓存配置里写的是<cache eviction="LRU" flushInterval="60000" readOnly="true"/>,flushInterval是60秒,更新后缓存并没有立即清空,导致旧值在缓存里存活了一分钟。

这种“更新后缓存不刷新”的坑,排查时看执行日志特别明显:改了数据但没有任何delete缓存日志。然后才发现是团队里有人图方便开了二级缓存,又没理解flushInterval的语义。从那以后我对MyBatis二级缓存的看法很明确:宁可在应用层自己控制缓存,也不要把并发一致性的希望放在一个框架自带的JVM缓存上。

6. TypeHandler与自定义配置的进阶玩法

6.1 TypeHandler到底干了什么

TypeHandler这个名字看起来唬人,其实作用很纯粹:负责Java类型和JDBC类型之间的转换。数据库驱动把ResultSet里的值读出来时是JDBC类型,MyBatis要把它变成Java对象返回给你;反过来,执行SQL时,你要把Java对象的值通过PreparedStatement设进SQL占位符,这中间也需要转换。

MyBatis内置了很多TypeHandler,常见的String、Integer、Date都有默认转换,所以大部分情况下你感知不到它的存在。但遇到一些特殊类型,比如枚举、JSON字符串、List<String>、Map,默认转不了,就需要自定义TypeHandler。

理解TypeHandler的入口是两个方法:setNonNullParameter负责把Java值写进PreparedStatement,getNullableResult负责从ResultSet里读出值并转成Java类型。看名字就知道,一个管“写出去”,一个管“读进来”。

SpringBoot的mybatis.type-handlers-package配置项可以指定TypeHandler所在的包,启动时自动扫描注册。也可以单独在configuration里通过typeHandlers注册,但用包扫描更省事。

6.2 写一个自定义TypeHandler:JSON字段映射

举例一个非常常见的需求:数据库里有一个TEXT类型字段,存的是JSON数组,Java里对应的是List<String>标签列表。默认MyBatis不知道要怎么把List转成String存进数据库,也不知道怎么把数据库里的JSON字符串读出来变成List。

自定义一个TypeHandler可以这样写:

@MappedJdbcTypes(JdbcType.VARCHAR) public class StringListTypeHandler extends BaseTypeHandler<List<String>> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException("List转JSON失败", e); } } @Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { return parseJson(rs.getString(columnName)); } @Override public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseJson(rs.getString(columnIndex)); } @Override public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseJson(cs.getString(columnIndex)); } private List<String> parseJson(String json) { if (json == null || json.isEmpty()) { return Collections.emptyList(); } try { return MAPPER.readValue(json, new TypeReference<List<String>>() {}); } catch (JsonProcessingException e) { throw new RuntimeException("JSON转List失败", e); } } }

然后在实体类字段上加注解:

public class Article { private Long id; @TableName("tag_list") private List<String> tags; }

注意@TableName这里只是示意,实际MyBatis是在XML的resultMap里通过typeHandler属性指定:

<resultMap id="ArticleResultMap" type="Article"> <result property="tags" column="tag_list" typeHandler="com.example.handler.StringListTypeHandler"/> </resultMap>

自定义TypeHandler写一次,所有涉及这个字段的查询和写入都会自动走转换逻辑,避免了在Service层到处写JSON.toJSONString和JSON.parseObject。

6.3 自定义Configuration和MyBatis插件

如果你的项目对MyBatis的默认行为有特殊要求,可以自定义Configuration类。做法是继承org.apache.ibatis.session.Configuration,重写你关心的方法,然后在SpringBoot里用@Bean返回一个ConfigurationCustomizer(注意是org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer,不同版本包名略有差异)。

我自己用过的一个场景是统一开启下划线转驼峰,虽然YAML里也能配,但某些统一平台化的项目希望把这类硬编码配置集中在Java代码里,方便代码评审时一眼看到。这时候自定义Configuration就很有价值。

比自定义Configuration更常用的是插件(Interceptor)。MyBatis的插件机制本质上是JDK动态代理,通过@Intercepts注解拦截四大核心对象的方法:Executor、StatementHandler、ParameterHandler、ResultSetHandler。分页插件就是这么做的,拦截Executor#query方法,改写原始SQL,然后执行。

写插件有几个注意点:第一,@Intercepts注解里的@Signature必须写准确,签名错了拦截器不会生效;第二,插件的intercept方法里别忘了调用invocation.proceed(),否则SQL不会执行;第三,插件的顺序是有意义的,多个插件拦截同一方法时按注册顺序叠加,顺序不对可能导致SQL改写逻辑互相冲突。用别人写的分页插件之前,先搞清楚它拦截的是哪一层,避免和你自己的拦截器打架。

7. 高频问题与排查经验实录

7.1 让SQL日志现原形

排查MyBatis问题时,第一件事是把SQL日志打出来。看打印的SQL是不是自己预期的那条,参数绑定对不对,执行时间多长,问题基本能缩小一大半。

开发期最简单的方式,就是把log-impl配成StdOutImpl,上面已经给过配置,控制台会直接打印Preparing、Parameters、Total时间。如果你用的是logback或log4j2,还可以只对Mapper接口所在包开启DEBUG级别,这样打印更可控:

logging: level: com.example.demo.mapper: debug

这种方式走的是MyBatis的Slf4jImpl,输出会进入你的日志文件,生产环境排查问题时也能用,只要把那个包的日志级别动态调成DEBUG即可。注意日志只会打印SQL和执行参数,不会打印结果集,所以排查“返回值不对”的问题,还得靠断点或临时把结果打印出来。

7.2 SpringBoot版本与MyBatis的兼容性

项目一老,版本兼容性问题就冒头了。常见的一个场景是:SpringBoot升级到3.x,MyBatis还是2.x的starter,结果应用启动直接报错,原因是javax和jakarta命名空间不兼容。解决办法只有两个方向,要么把SpringBoot降回2.7,要么把MyBatis starter升级到3.x并确保JDK版本跟上。SpringBoot 3.x要求JDK 17以上,如果你的团队还在用JDK 8,那基本只能停留在SpringBoot 2.x,最近这两个大版本上的选择其实是个技术栈的硬约束,不是想升就能升的。

还有个反向的坑:新项目直接用了SpringBoot 3.2,然后随便网上搜到一篇老教程,照抄了老的starter坐标和配置项,启动后有一个配置项根本不存在,但Spring Boot对于mybatis.configuration下的未知key是静默忽略的,不报错,让你误以为配置生效了。排查这类问题的方法是把应用的配置输出看一眼,或者直接进MyBatis官方文档核对当前版本支持的配置项。

7.3 零碎的实战心得

最后分享几条我在项目里摸爬滚打攒下来的心得。

第一,数据库方言问题。MyBatis本身不做SQL方言转换,你写的SQL就是给数据库直接执行的。项目如果要从MySQL迁到PostgreSQL、GaussDB或人大金仓这类数据库,SQL里特殊语法要自己注意,比如MySQL的LIMIT、IFNULL、双反引号,换到别家可能要改。MyBatis的动态SQL能做的是把这类差异收敛到XML里,比如用<choose>按数据库类型选择SQL片段,但别指望框架替你彻底解决。

第二,时间字段映射问题。Oracle的DATE类型用MyBatis查出来默认可能是Timestamp,你Java里定义成LocalDateTime倒还好,如果定义的是Date,DBA答印一个java.sql.Date、java.util.Date的差异就出现了。遇到这类问题先看一眼字段的JDBC类型和Java类型是否符合预期,必要时给resultMap字段显式指定jdbcType和typeHandler。

第三,关于XSS这类安全问题。有同学问SpringBoot项目里全局过滤器处理上传PDF文件时的XSS攻击,这其实不是MyBatis的问题,而是过滤器和SQL注入之间的分层责任。MyBatis使用#{}预编译参数能防掉绝大部分SQL注入,条件是所有用户输入都走#{},不要图方便在SQL里拼${}。至于文件上传的XSS防护,应该在网关或过滤器层面对文件内容、文件名做检查,和ORM无关,别混在一起。

第四,关于idea新建SpringBoot项目时生成的初始工程,基本自带了一个空的mapper目录和application.properties。如果翻到一些老教程用的是application.properties而不是application.yml,两者只是格式不同,mybatis.mapper-locations这类配置写法完全一致,不用纠结。

第五,调试SpringBoot应用时,如果你想直接看MyBatis的执行过程,除了日志,还可以在IDEA里给BaseExecutor#query打条件断点,条件断点命中后能看到缓存的key、BoundSql、参数列表,比翻日志直白得多。这个技巧我用了很久,算是排查MyBatis问题的隐藏福利。

我个人在把MyBatis用熟的路上,最大的体会是:框架给的默认配置能解决七八成问题,但剩下两三成的复杂场景,比如缓存、动态SQL、类型转换,恰恰都是面试题里反复问的东西。你别把它当成八股背,真正写完一个TypeHandler、排查过一次缓存脏数据后,再去看那些面试题,会觉得它们讲的就是你手边正在发生的事。希望这篇文章里的经验,能帮你少踩几个我踩过的坑。

返回列表