身边搞Java的朋友,十有八九都跟MyBatis打过交道。不管你是刚入行还在纠结JDBC模板代码,还是已经在Spring Boot项目里把MyBatis用得飞起,这个框架几乎成了国内Java后端绕不开的标配。但要真说“懂”MyBatis,很多人其实是处于一种“会用但说不清”的状态:知道写Mapper接口、写XML,知道有缓存,但一级缓存和二级缓存到底怎么工作、什么时候失效、Spring Boot里面为什么加个注解就能扫描到Mapper,这些问题一问就卡壳。
这篇内容我打算把MyBatis从核心机制到缓存实现、从SQL日志配置到源码阅读路线,完整梳理一遍。不是教科书式的概念罗列,更多是我自己这些年实际使用、排查问题、面试别人和被面试时积累下来的理解。无论你是准备面试,还是想在项目里把缓存和日志配置用得更明白,都能从里面找到能直接拿去用的东西。
1. 先说清楚MyBatis到底解决了什么问题
1.1 从JDBC的痛说起
Java后端最早操作数据库,用的是JDBC原生API。第一眼看上去还挺正规:注册驱动、获取连接、创建PreparedStatement、执行查询、遍历ResultSet、手动关闭连接。但写多了就发现全是重复劳动。尤其是查询逻辑复杂一点,ResultSet里字段要一个一个手动取出来往实体类里塞,写一百行代码可能九十五行都在做机械赋值。
// 典型的JDBC查询代码,我早期项目里到处都是 Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = dataSource.getConnection(); ps = conn.prepareStatement("select id, name, age from user where id = ?"); ps.setLong(1, userId); rs = ps.executeQuery(); User user = new User(); while (rs.next()) { user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); } return user; } finally { if (rs != null) rs.close(); if (ps != null) ps.close(); if (conn != null) conn.close(); }这段代码看着不长,但一个项目里可能有几十上百个这样的方法。每次加一张表、加一个查询,就得把这个流程重新写一遍。更麻烦的是,如果表结构字段改了,所有对应的实体类赋值代码都得跟着改,漏一个就是线上问题。
MyBatis做的事情,本质上就是把“SQL执行”和“结果映射”这两件事做了一个框架级的封装。你只需要定义好Mapper接口、SQL语句以及实体类的映射关系,框架来负责创建连接、执行语句、把结果集自动映射成对象。表现在代码上就是我们非常熟悉的写法:
public interface UserMapper { User selectById(@Param("id") Long id); }XML里写一条SQL,接口方法直接调用。程序员从重复的模板代码里解脱出来,关注点可以全部放在SQL本身和业务逻辑上。
1.2 MyBatis和Hibernate的路线之争
聊MyBatis难免提到Hibernate。很多刚接触的人会问,既然Hibernate能完全自动映射,为什么还要用MyBatis这种半自动的框架?
我的理解是:MyBatis把SQL的掌控权明明白白交回到开发者手里。我们团队做过好几个报表类项目,SQL要关联四五张表,还涉及各种条件分支、函数嵌套。Hibernate的HQL或者Criteria写起来非常别扭,生成的SQL有时候连DBA都看不懂,性能一有问题根本没法快速定位。换成MyBatis就直白多了,SQL是我们自己写的,执行计划有问题直接在SQL层面调优。
代价就是你要自己维护SQL,不能像Hibernate那样完全小于等于“对象操作数据库”的舒适区。但对追求可控性和查询性能的团队来说,这个取舍完全值得。MyBatis在国内普及度这么高,恰恰是因为很多业务系统的查询复杂度远超CRUD,手写SQL反而是一种优势。
2. MyBatis核心机制拆解:SqlSession与Mapper动态代理
2.1 SqlSession的生命周期,很多人从一开始就搞错了
MyBatis最核心的入口就是SqlSession。你可以把它理解成一次数据库会话的抽象,类似于JDBC里一个Connection的“升级版”。它负责执行SQL、获取Mapper代理、管理事务、控制一级缓存。
我在代码里看到过不少直接把SqlSession当成全局对象用的写法,在工具类里定义一个静态SqlSession,所有Mapper都从它身上拿。这种写法通常能跑,但隐患非常大。SqlSession不是线程安全的,内部还绑定了一级缓存和事务状态,如果多个线程共享同一个SqlSession,轻则缓存数据错乱,重则连接池被污染导致连接迟迟不归还。
正确做法是短生命周期使用。一次请求需要操作数据库,就创建一次SqlSession,用完立刻关闭:
try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1L); // 业务处理 }在Spring集成环境中,这个生命周期由框架来管理,一般不手动操作。但面试的时候我经常会问候选人一个问题:Spring整合MyBatis之后,SqlSession是每次都新建还是一次请求共用?这个问题能看出你是否理解MapperProxy的动态代理机制,以及SqlSessionTemplate的存在意义。
2.2 Mapper接口为什么不需要写实现类
这是MyBatis里最让人好奇的设计之一。你定义了一个接口,没写任何实现类,却能在Service里直接注入进来用。关键就在于动态代理。
MyBatis启动时会扫描Mapper接口,针对每个接口生成一个代理对象放进Spring容器。当你调用接口里的方法时,实际上调用的是MapperProxy的invoke方法。它会根据你调用的方法名,在配置里找到对应的MappedStatement——也就是你写的SQL语句的封装对象,然后交给SqlSession执行。
// 从SQLSession getMapper的调用链来看 public <T> T getMapper(Class<T> type, SqlSession sqlSession) { MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type); return mapperProxyFactory.newInstance(sqlSession); }这也就解释了为什么Mapper接口的方法名必须和XML里SQL的id保持一致。id不是随便写的,它是MappedStatement的唯一标识。方法名对应id,参数对应SQL里的#{}占位符,返回值对应resultType或resultMap。理解了这条链路,你就能明白为什么Mapper接口不能重载——因为方法名直接映射到SQL id,重载会导致无法区分到底该绑定哪条SQL。
2.3 参数映射与结果映射的细节
MyBatis参数映射里最有用也最容易被忽视的就是#{}和${}的区别。很多人知道#{}是预编译占位符,${}是字符串拼接,但再往深问一句,何时必须用${},反而答不上来。
-- 表名、排序字段这类结构信息只能用 ${} select * from ${tableName} order by ${orderColumn} -- 传值参数应该老老实实用 #{} select * from user where name = #{name}核心原因在于,JDBC的预编译占位符只能绑定值,不能绑定表名、列名、排序方向这类SQL结构。所以凡是涉及动态表名、动态排序字段的SQL,只能用${}。这也是SQL注入的高发点。使用${}时,所有输入必须自己做白名单校验,绝不直接信任前端传来的字符串。我见过不止一次因为排序字段用了${}且未校验,被人在请求参数里塞了恶意SQL导致数据库数据被删的案例。
结果映射方面,MyBatis默认按列名和属性名做映射,开启mapUnderscoreToCamelCase之后,数据库的user_name列就能自动映射到userName属性。但遇到复杂嵌套结果,比如一对多、多对多,就要靠resultMap里的association和collection了。这里的性能陷阱很明显,N+1查询通常是不合理嵌套查询导致的。
3. 缓存机制:一级缓存与二级缓存实现
3.1 一级缓存:默认开启但容易踩坑
MyBatis的一级缓存默认是开启的,作用域是SqlSession。同一个SqlSession里执行两次完全相同的查询,第二次不会真正访问数据库,而是直接从本地缓存里拿结果。
这听起来很美好,但坑也在这。先看一个经典问题:在同一个SqlSession里,第一次查询用户返回对象A,然后执行了一个update操作修改了同一条记录,接着再去查询同一个用户,拿到的还是第一次查询的缓存对象。因为MyBatis的规则是,执行了增删改操作后,会清空一级缓存。所以如果你在两次查询之间做过update,第三次查询确实会重新查库。但如果期间没有增删改,缓存就一直存在。
很多会出问题的地方在于Spring整合MyBatis之后,不同方法通常是不同SqlSession的。如果你在一个事务里通过同一个SqlSession查询两次,第二次走了缓存,但你手动修改了第一次查询返回的对象,接着第二次查询拿到了同一个对象引用,数据已经被你污染了。这种隐性问题排查起来非常费劲。
我一般建议:明确一级缓存只作为会话级别的优化手段,不要依赖它来实现业务上的数据一致性。需要强一致性的场景,直接忽略缓存,每次查询都走数据库。
// 同一SqlSession下的两次查询,结果完全一致 SqlSession sqlSession = sqlSessionFactory.openSession(); UserMapper mapper = sqlSession.getMapper(UserMapper.class); User user1 = mapper.selectById(1L); User user2 = mapper.selectById(1L); // user1 == user2 是true,第二次走的是缓存看到这里有人会问,那怎么强制不走缓存呢?可以用sqlSession.clearCache()手动清空,或者把查询设置到不同SqlSession里执行。实际项目中,正常使用Spring管理的SqlSessionTemplate时,每个Mapper方法默认拿到的SqlSession可能不同,所以一级缓存的问题频率并不高。但如果手写代码时没注意,就很容易出现。
3.2 二级缓存实现:配置细节、序列化问题与脏读风险
二级缓存的作用域是Mapper级别的,也就是同一个namespace下的所有查询可以共享缓存。它跨SqlSession生效,默认是关闭的,需要手动开启。
配置步骤很简单:
第一步,在主配置里设置cacheEnabled为true,这个默认就是true。第二步,在Mapper的XML里加一行:
<mapper namespace="com.example.mapper.UserMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> </mapper>几个关键参数我解释一下。eviction是缓存回收策略,默认是LRU,即最近最少使用淘汰。flushInterval是刷新间隔,单位毫秒,到了指定时间自动清空缓存,这里设置60000就是60秒刷新一次。size是缓存最多缓存多少个对象引用。readOnly设为true时,缓存返回的是同一个对象实例,性能好但存在数据安全隐患;设为false时,MyBatis会序列化再反序列化出副本返回,安全性高。
这里必须提醒两件事。
第一件是序列化问题。如果readOnly设为false,那么缓存对象对应实体类必须实现Serializable接口,否则反序列化报错。很多人在配置二级缓存后,一执行查询就抛异常,查了半天发现是实体类没实现序列化接口。
第二件是脏读风险,这是更严重的坑。MyBatis的二级缓存是namespace级别的,如果你用多表联查,比如UserMapper里查了订单表的数据,同时订单表又被OrderMapper做了更新操作,那UserMapper的二级缓存并不会因为OrderMapper的更新而失效。结果就是UserMapper里缓存了旧的数据,拿到客户端去展示,用户看到的就是脏数据。
解决这个问题的方案有几种。我常用的做法是:对涉及多表联查的Mapper,干脆不开启二级缓存,或者把所有关联查询都放到同一个namespace下统一管理。还有一个更彻底的选择是引入外部缓存中间件,比如Redis,直接放弃MyBatis自带的二级缓存。实际上我接触的几个线上高并发项目,基本都是这个思路。本地二级缓存维护成本高、节点间数据不一致问题多,远不如Redis缓存可控。
<!-- 一个多表联查的例子,容易产生脏读 --> <select id="selectOrderWithUser" resultType="map"> select o.id, o.total, u.name from t_order o left join t_user u on o.user_id = u.id </select>这种SQL写在UserMapper里,二级缓存会缓存结果。但如果用户修改了昵称,UserMapper的缓存并不会自动刷新,除非显式调用了clearCache。
3.3 缓存失效场景实战记录
有一回线上反馈,后台改了用户手机号,但前端页面显示的还是旧号码。第一反应是Redis缓存问题,排查了一圈发现Redis缓存时长设的10分钟,但用户等了半小时依然显示旧号码。
后来把焦点放到MyBatis二级缓存上。这个项目没引入外部缓存中间件,完全靠MyBatis自带二级缓存撑着。定位后发现,更新用户手机号的接口调用的是CustomerMapper,而查询用户详情的接口走UserMapper。两个Mapper的namespace不同,虽然操作的是同一张表,但缓存之间完全没有关联。CustomerMapper执行update之后清空的只是CustomerMapper的缓存,UserMapper里的缓存依然顽固地存在。那次修完之后,我对MyBatis二级缓存的结论就一句话:只在纯单表操作、且对数据实时性要求不高的场景下使用,否则宁可不用。
4. SQL日志配置打印:让每条SQL都清清楚楚
4.1 最基础的配置方式
MyBatis配置打印SQL,本质上是开启日志框架对MyBatis包的DEBUG级别输出。用的日志实现不同,配置方法也不同。
采用Logback的项目,在logback.xml里加:
<logger name="com.example.mapper" level="DEBUG"/>这里的包名,最好定义成存放Mapper接口的包。这样日志里能直观看到每个Mapper的执行情况。如果直接配置成org.mybatis的DEBUG级别,会把框架内部启动信息也刷出来,日志量大且没必要。
如果你用的是Spring Boot,这个配置通常就够了。如果项目里同时存在多个数据源或者多个Mapper包,可以分别指定。
4.2 打印带参数的完整SQL
很多人配置完上面那步发现,SQL是打印出来了,但参数值却看不到。MyBatis默认打印的SQL模板是带占位符的:
==> Preparing: select id, name, age from user where id = ? and name = ? ==> Parameters: 1(Long), 张三(String)能到这一步其实已经够用了。但调Bug的时候,特别是复杂条件查询,我更希望能看到可以直接复制执行的完整SQL。
实现方式有几种,我常用的方案是引入p6spy。这是个SQL拦截打印组件,它能输出完整的、带实际参数的SQL语句,还会打印执行耗时。
p6spy的使用很简单:引入依赖,然后修改数据源驱动配置。把原来的URL和Driver都替换成p6spy的包装版本:
spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver spring.datasource.url=jdbc:p6spy:mysql://localhost:3306/shop再在classpath下放一个spy.properties文件,配置日志输出格式、过滤掉一些无用的语句。做完这一步,你就能在控制台看到类似:
select id, name, age from user where id = 1 and name = '张三'这种可直接执行的SQL。排查问题效率会提升不止一个档次。
4.3 参数占位符的特殊情况
配置打印SQL的过程中,我还遇到过#{}和${}打印差异的问题。如果你的SQL里用了${}拼接动态表名,那打印出来的SQL就是完整拼好的,不需要额外还原。但如果全是#{}参数,即使p6spy帮你还原了参数,也要注意时间类型参数的格式。比如LocalDateTime类型的参数,p6spy默认打印出来可能是带T的ISO格式,直接复制到Navicat里执行有时会报错。这时候要注意看日志配置里有没有针对时间格式的处理,没有的话手动调整一下即可。
还有一个小技巧,很多时候我们需要打印SQL执行耗时。MyBatis本身的日志不输出耗时,但如果用了druid连接池,它的DruidFilter里自带慢SQL统计。配置一下慢SQL阈值:
spring.datasource.druid.filter.stat.slow-sql-millis=500超过500毫秒的SQL就会单独记录下来。这是排查慢查询最有效的途径,比你自己看日志肉眼找快得多。我每次接手一个老项目的第一件事,就是把慢SQL统计打开,先看一遍TOP N慢查询,心里就有底了。
5. Spring Boot + MyBatis集成实战:从配置到避坑
5.1 依赖引入与基础配置
Spring Boot集成MyBatis,现在走的是mybatis-spring-boot-starter这条线。引入方式很固定:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency>注意版本号要跟Spring Boot版本匹配。Spring Boot 3.x时代用3.0.3,Spring Boot 2.x时代用2.3.x这样的版本。版本不匹配会导致自动配置失效,最典型的现象是Mapper注入报错或者SqlSessionFactory初始化失败。
配置方面,最常用的是这几项:
mybatis.mapper-locations=classpath:mapper/*.xml mybatis.type-aliases-package=com.example.entity mybatis.configuration.map-underscore-to-camel-case=truemapper-locations指定XML文件的位置。type-aliases-package能让你在XML里写resultType时只写类名,不用写完整包路径。map-underscore-to-camel-case这个配置我强烈建议打开,否则每个字段都要手动写resultMap,非常痛苦。
5.2 Mapper扫描的两种方式
Spring Boot里让容器感知Mapper接口,有两种常用方式。
一种是在启动类或者配置类上使用@MapperScan:
@MapperScan("com.example.mapper") @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }另一种是直接在Mapper接口上加@Mapper注解,然后在启动类上没有@MapperScan也能生效。
实际项目中我更推荐@MapperScan,因为它是批量扫描,不用每个接口都加注解,代码看起来干净。而且@MapperScan支持指定多个包,扫描路径可以灵活控制。
这里有个容易踩的坑:如果多个Mapper目录里有重复的接口名,或者XML文件的id冲突,启动时可能不报错,但运行时查出来的数据就会牛头不对马嘴。遇到过几次之后,我给自己定了条规矩:XML的namespace必须完整写成接口的全限类名,不要偷懒,这样至少能保证启动阶段就发现重复绑定问题。
5.3 分页插件与多数据源配置
几乎所有实战项目都会用到分页。MyBatis里最常用的分页方案就是PageHelper。它使用ThreadLocal原理,把你的分页参数绑定到当前线程,然后拦截后续SQL自动拼接limit。用起来很简单:
PageHelper.startPage(1, 10); List<User> users = userMapper.selectAll(); PageInfo<User> pageInfo = new PageInfo<>(users);但PageHelper有几个使用细节必须注意。第一,startPage之后必须紧跟第一个查询,中间不能穿插其他耗时逻辑,否则分页参数会被下一个查询消费掉。第二,多数据源环境下,PageHelper的分页只会对拦截到的数据源生效。第三,如果SQL里已经有limit,PageHelper再拼接一次limit,就会出现双重分页的Bug。
多数据源的配置在集成MyBatis时稍微复杂一些。我需要分别配置两个DataSource、两个SqlSessionFactory、两个MapperScan,配置文件里分别指定不同的包路径和XML路径。这个场景下我建议把每个数据源对应的Mapper包分开放,千万别混在一起,不然事务管理会乱成一锅粥。
6. 高频面试题速查:从缓存问到源码
6.1 面试必问的几个问题
MyBatis相关面试题里,出现频率最高的是下面这么几个。我整理时候也顺便把答题要点标了出来。
第一个是关于#{}和${}的区别。答题时抓住预编译和字符串拼接这个核心区别,然后补充说明${}的SQL注入风险以及动态表名排序字段只能用${}的场景,基本就能覆盖绝大部分考点。
第二个是MyBatis执行流程。这个问题考察的是整体理解。可以从SqlSessionFactory读取配置开始,说到SqlSession的创建,Mapper代理的获取,MappedStatement的查找,Executor执行器处理SQL,再到StatementHandler、ParameterHandler、ResultSetHandler四大组件的协作。能把这条链路讲清楚,基本功就过关了。
第三个是一级缓存和二级缓存。答题关键点在于讲清楚一级缓存是SqlSession级别的、无法跨SqlSession共享、增删改会清空缓存;二级缓存是Mapper namespace级别的、需要手动开启、存在脏读风险。如果面试官继续追问生产环境中缓存方案怎么选,可以顺势说出外部缓存中间件的优势,会加分不少。
第四个是映射器Mapper接口为什么不能重载。这题的答案在于方法名绑定SQL id的机制。方法重载会导致方法名相同,MyBatis无法区分到底该关联哪一条SQL,所以MyBatis的Mapper接口不允许重载。
第五个是MyBatis中Executor的作用。它处在核心位置,所有SQL执行最终都经过Executor。Executor有几种类型,简单执行器SimpleExecutor、复用语句的ReuseExecutor、批量操作的BatchExecutor。Spring Boot默认使用SimpleExecutor,可以配置defaultExecutorType切换。缓存相关的装饰器模式也体现在Executor上,比如CachingExecutor就是给Executor套上了一层缓存能力。
6.2 源码阅读路线建议
想深入MyBatis源码的,我建议按这个顺序来读。
先看SqlSessionFactoryBuilder,理解配置是怎么被解析的。再看XMLConfigBuilder,重点看它如何把mybatis-config.xml里的元素解析成Configuration对象。接着看Configuration类,它是全局配置的“大字典”,里面包含了MappedStatement、ResultMap、Cache等所有注册信息。看完这两个,你会对MyBatis的初始化逻辑有一个完整认知。
然后看MapperProxy和MapperProxyFactory,理解动态代理。再看MappedStatement,它用来封装一条SQL的所有元信息,包括SqlSource、StatementType、ResultMaps。紧接着可以进入四大组件阶段:Executor、StatementHandler、ParameterHandler、ResultSetHandler。你会发现,整个SQL执行流程就是一条责任链,每个环节各司其职。
最后可以单独看缓存相关源码,重点看PerpetualCache、LruCache、SerializedCache这些类。它们是二级缓存的实现基础。理解了LruCache的回写机制,你就能回答出淘汰策略到底是怎么触发的。
6.3 面试答题思路参考
单纯背答案效果不好,我给你一个答题结构的参考。面试官问你MyBatis的问题,回答时可以套用“定义—场景—原理—细节”这个框架。
拿“MyBatis是什么”举例。定义部分:它是一个半自动的ORM框架,专注于SQL的灵活控制与结果映射。场景部分:适合SQL复杂、需要对执行性能精细控制的场景。原理部分:底层使用JDBC,通过动态代理生成Mapper实现,通过Xml或注解维护SQL与方法的映射。细节部分:讲一个你在实际项目中踩过的坑,比如二级缓存脏读,或者分页插件误用。这么一段下来,深度和实战感都体现出来了。
7. 一个更实用的配置:Mybatis配置打印与多环境切换
7.1 不同环境的日志级别控制
项目部署到生产环境,不可能像开发环境那样把SQL全部打到日志里去,日志量太大会影响性能。所以MyBatis的SQL打印,应当只在开发环境和测试环境开启。
我习惯的做法是配置logback的springProfile来区分环境:
<springProfile name="dev"> <logger name="com.example.mapper" level="DEBUG"/> </springProfile> <springProfile name="prod"> <logger name="com.example.mapper" level="INFO"/> </springProfile>这样同样一套配置,部署到dev环境自动打印SQL,生产环境只打印INFO级别的日志,干净又安全。还有个小细节,生产环境即使用DEBUG级别打印SQL,也一定不要开着p6spy,它的性能损耗在低并发时看不出来,高并发下直接放大接口响应时间。
7.2 一个自定义拦截器的思路
如果想在SQL执行前后做统一处理,比如记录操作日志或者修改某些字段,可以写一个MyBatis的Interceptor插件。实现Interceptor接口,加上@Intercepts注解指定拦截目标,然后在intercept方法里通过Invocation对象拿到当前执行的SQL语句,做统一处理。
我写过最常用的一个拦截器,是自动填充创建时间和修改时间的。数据库表里这两个字段几乎是每张表必有的,但每次插入或更新时手动set非常容易漏。用拦截器统一处理,在SQL执行之前判断当前操作是insert还是update,然后自动把当前时间设置到对应的参数对象里。从那之后,新表直接加字段,代码里一行都不用改。这也是大家常说的公共字段自动填充的底层逻辑。
如果你用的是MyBatis-Plus,这个功能一个注解就搞定了。但如果你坚持用原生MyBatis,那拦截器就是绕不开的路径。通过读源码理解Interceptor机制,再落地成一个公共字段填充插件,这个过程特别能锻炼人对MyBatis内部运行机制的把控能力。
7.3 从配置到代码的细节习惯
用MyBatis这几年,我养成了几个习惯。第一个是XML里尽量不写复杂的动态SQL嵌套,如果某个查询的条件组合超过三种,我会拆成多个查询方法,而不是在一个SQL里堆一堆if判断。可读性和维护效率远比减少那几行代码重要。第二个是SQL的别名规范,多表查询时每张表都给固定缩写别名,关联字段一律带上表别名,这样执行计划分析的时候一眼就能看清字段来源。第三个是尽量把常用查询和更新语句写简单、写精准,能用一条SQL完成的就不要拆成两条在代码里做二次计算。数据库的压力控制,很多层面是靠这些平时的细节积累出来的。
8. 个人实际使用中的几个心得体会
写到这里,再分享几个我在项目实践中总结出来的零散心得。
第一,刚接手一个老项目时,建议先把Mapper XML从头到尾扫一遍。重点看三件事:有没有select *,有没有懒加载关联,有没有不合理的大表全量查询。这三个问题基本能覆盖掉大部分SQL性能隐患。你会发现很多老项目的SQL写得非常随意,几张几十万行的表直接join,没有任何索引提示,就是靠数据库硬扛。这根本不是MyBatis的问题,是写SQL的人没把运维当回事。
第二,关于Mapper接口和XML文件的管理上,我一直保持一个原则:每个Mapper接口对应一个XML文件,文件的命名、存放路径全部一致,绝不出现多个XML文件塞进同一个目录却没按Mapper区分的写法。如果你的项目里出现了两个接口共用一个XML的情况,趁早拆开,不然后面做代码搜索和重构时会疯掉。
第三,二级缓存以及外部缓存的选择,一定要在项目设计阶段就定好,不要等数据一致性出问题以后再来补。前文提到的脏读案例,如果当时我们在设计阶段就明确所有Mapper不开启二级缓存、统一走Redis缓存,就不会有后续的半夜排查事故了。MyBatis自带的二级缓存本身是个很好的机制,但它有自己的适用边界,不适合就果断放弃,不要舍不得。
第四,框架本身的知识,和学习任何工具一样,永远是围绕实际问题去学才记得牢。如果你正在准备MyBatis的技术面试,不要死记硬背答案,把面试题当成一个个待验证的小项目,启动一个Spring Boot工程,把目标场景复现出来,通过调试一步一步去看框架在干什么。比如,你可以在断点里看MapperProxy的invoke方法执行时返回的对象类型,观察一级缓存生效时查出来的对象是不是同一个引用,验证二级缓存反序列化后独占的对象实例,这些从实践里得到的感觉,比看十篇源码分析文章都管用。
最后一件事,团队协作项目里,MyBatis XML里SQL的统一风格很重要。如果团队里有的同事喜欢把动态SQL条件都写在WHERE后面用1=1拼接,有的喜欢用 标签自动处理多余条件,尽管两种写法都能跑,但代码风格的不统一会让后续维护的人非常痛苦。我倾向用 片段来抽离公共查询条件,配合 、 标签做条件拼接,结构清楚,减少重复,新同事接手也容易上手。这些度量都是代码之外的管理经验,但同样值得在技术文章里占一席之地。
关于MyBatis,从入门到进阶的核心内容基本就是这些了。用一句话来总结我的整体感受:MyBatis的定位从来不是深奥的框架,它更像是一把经过了市场检验的工具,简单、直接、可控。但正因为简单,很多人反而忽略了它内部的精巧和边界。真正把它用明白的人,会在SQL执行链路、缓存边界、日志可观测性这些层面都有清晰的认识,而这些认识,恰恰是区分“会用”和“懂”的分水岭。