1. 从一次线上排查说起:为什么需要搞懂MyBatis内部链路
去年帮一个朋友排查一个诡异问题:他们的服务在压测时,某个查询接口的响应时间会周期性飙高,但数据库侧的慢查询日志里却什么都看不到。Druid连接池监控显示活跃连接数正常,GC日志也没有明显异常。最后定位到问题出在MyBatis的SqlSession没有及时关闭,导致连接被长时间占用,连接池在高峰期频繁触发创建新连接的开销。
这个案例让我意识到,很多人用MyBatis停留在“写Mapper接口、配XML、调方法”的层面,对builder → factory → session → mapper → 查询这条完整链路缺乏系统认知。一旦出问题,排查方向全靠猜。这篇文章就把这条链路从头到尾拆一遍,把每个环节的职责、关键对象、常见坑点讲清楚。不管你是刚接触MyBatis的新手,还是用了几年但没深入过源码的老手,应该都能从中找到对自己有用的东西。
整条链路的核心角色有五个:SqlSessionFactoryBuilder、SqlSessionFactory、SqlSession、Mapper接口代理对象、以及最终的查询执行器。它们之间的关系可以类比成一条生产线:Builder是建厂房的施工队,Factory是厂房本身,Session是每次开工时领到的工位,Mapper是工位上的操作面板,Executor则是真正干活的机器。下面逐层拆解。
2. 全局视角:五个核心角色的职责与生命周期
2.1 一张表看清各组件定位
在深入细节之前,先用一张表把五个核心角色的职责、生命周期和线程安全性说清楚。这张表是我自己在学习源码时整理的,放在手边随时对照,理解起来会快很多。
| 组件 | 核心职责 | 生命周期 | 线程安全 |
|---|---|---|---|
| SqlSessionFactoryBuilder | 解析配置文件,构建Factory | 方法级,用完即弃 | 安全(局部变量) |
| SqlSessionFactory | 生产SqlSession的工厂 | 应用级,全局唯一 | 安全(构建后不可变) |
| SqlSession | 一次数据库会话 | 请求级/方法级 | 不安全 |
| Mapper代理对象 | 将接口方法调用转为SQL执行 | 随Session创建 | 不安全(绑定Session) |
| Executor | 执行SQL、处理缓存和结果映射 | 随Session创建 | 不安全 |
这张表里最关键的信息是生命周期和线程安全两列。很多线上问题追根溯源,都是因为把Session或Mapper代理对象当成了可以跨线程共享的东西。Factory之所以安全,是因为它在构建完成后内部状态不再变化;而Session之所以不安全,是因为它内部持有Connection、事务状态、一级缓存等可变状态。
2.2 为什么这样分层
你可能会问:为什么不直接用一个类搞定所有事情,非要拆成Builder、Factory、Session这么多层?这其实是建造者模式和工厂模式的经典组合应用。
Builder负责“怎么造”,把复杂的解析逻辑(XML解析、注解扫描、配置合并)封装起来;Factory负责“造出来的东西长什么样”,它是一个不可变的配置快照;Session负责“怎么用”,每次数据库交互都需要独立的会话上下文。这种拆分带来的好处是:配置解析只做一次,Factory可以复用,Session按需创建和销毁,职责边界非常清晰。
如果不用Builder模式,每次创建Factory都要重复写一大堆解析代码;如果不用Factory模式,Session的创建逻辑就会散落在各处。分层之后,每一层只关心自己的事,修改配置解析逻辑不会影响Session的行为,替换Session实现也不会影响Factory的构建。
3. 第一步:SqlSessionFactoryBuilder如何解析配置
3.1 入口方法与核心流程
SqlSessionFactoryBuilder的入口方法通常是build(InputStream inputStream)。这个方法内部会创建一个XMLConfigBuilder,然后调用它的parse()方法。整个解析过程大致分三步:
- 解析configuration节点:读取
<configuration>下的所有子节点,包括<properties>、<settings>、<typeAliases>、<environments>、<mappers>等。 - 构建Configuration对象:把解析结果填充到一个
Configuration实例中,这个对象是MyBatis的全局配置中心。 - 返回DefaultSqlSessionFactory:用填充好的Configuration创建Factory实例。
这里有个细节值得注意:XMLConfigBuilder在解析<mappers>节点时,会进一步创建XMLMapperBuilder来解析每个Mapper XML文件。也就是说,配置解析是一个递归的过程,主配置解析到Mapper映射时,会触发Mapper文件的解析。
3.2 解析过程中的关键决策
在解析<settings>节点时,有几个参数对后续行为影响很大,我列出来供参考:
cacheEnabled:是否开启二级缓存,默认true。如果项目里没用二级缓存,建议显式设为false,避免不必要的开销。lazyLoadingEnabled:是否开启延迟加载,默认false。关联查询多的时候可以开启,但要注意N+1问题。defaultExecutorType:执行器类型,默认SIMPLE。可选REUSE和BATCH,批量操作场景下BATCH能显著提升性能。mapUnderscoreToCamelCase:下划线转驼峰,默认false。现在大部分项目都会设为true,省去手写resultMap的麻烦。
这些参数在解析时会被存入Configuration对象,后续创建Executor和映射结果时都会用到。我个人的习惯是:在项目初期就把这些参数明确配置好,不要依赖默认值,因为默认值不一定适合你的业务场景。
3.3 实操心得:Builder用完就丢
SqlSessionFactoryBuilder的生命周期非常短,它的使命就是构建出Factory,构建完成后就可以被GC回收。所以标准的写法是这样的:
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);注意这里new SqlSessionFactoryBuilder()是临时的,不需要保存引用。有些初学者会把Builder存成成员变量,这完全没有必要,反而占内存。Factory才是需要全局保存的那个对象,通常用单例模式管理。
提示:如果项目中同时存在多个数据源,可以创建多个SqlSessionFactory,每个对应一个数据源。但要注意每个Factory的Configuration是独立的,二级缓存也是独立的。
4. 第二步:SqlSessionFactory的构建与配置快照
4.1 DefaultSqlSessionFactory内部结构
DefaultSqlSessionFactory是SqlSessionFactory接口的默认实现,它的内部结构非常简单:只持有一个Configuration对象的引用。这个Configuration就是Builder阶段解析出来的全局配置快照。
Factory的核心方法是openSession(),它有多个重载版本,主要区别在于三个参数:ExecutorType、TransactionIsolationLevel、autoCommit。这些参数决定了创建的Session的行为特征。
Factory本身不持有任何数据库连接,连接是在Session创建时才从DataSource获取的。这一点很关键:Factory是轻量的,创建Factory不会消耗数据库资源;真正消耗资源的是Session。
4.2 openSession的参数选择
openSession()的几个重载版本在实际项目中的使用场景不太一样,我整理了一个对照表:
| 方法签名 | 适用场景 | 注意事项 |
|---|---|---|
| openSession() | 默认,手动提交事务 | 需要显式commit |
| openSession(boolean autoCommit) | 自动提交 | 每条SQL独立事务,性能差 |
| openSession(ExecutorType) | 指定执行器类型 | BATCH模式需注意flush时机 |
| openSession(ExecutorType, boolean) | 批量+自动提交 | 一般不推荐组合使用 |
我个人的经验是:在Spring集成环境下,这些参数通常由Spring的SqlSessionTemplate管理,不需要手动调用openSession()。但理解这些参数的含义,对于排查事务相关问题非常有帮助。比如当你在Spring中配置了ExecutorType.BATCH,但发现批量插入没有生效,很可能是因为没有在合适的时机调用flushStatements()。
4.3 配置快照的不可变性
Factory持有的Configuration对象在构建完成后,理论上不应该再被修改。但MyBatis并没有用final修饰Configuration的字段,所以如果你在运行时通过Factory获取Configuration并修改它,是能改成功的。但这种做法非常危险,因为Configuration是全局共享的,修改会影响所有Session。
我踩过一次坑:在运行时动态添加了一个Mapper映射,结果导致二级缓存出现脏数据。后来改成在启动阶段就完成所有配置,运行时不再修改Configuration,问题就消失了。所以我的建议是:把Configuration当成不可变对象来用,所有配置在启动时确定,运行时只读。
5. 第三步:SqlSession的创建与事务管理
5.1 Session创建时发生了什么
当调用factory.openSession()时,内部会执行以下步骤:
- 从Configuration中获取Environment对象,进而获取TransactionFactory和DataSource。
- 通过TransactionFactory创建一个Transaction对象,Transaction内部会从DataSource获取一个Connection。
- 根据ExecutorType创建对应的Executor(SIMPLE、REUSE或BATCH)。
- 用Executor和Configuration创建DefaultSqlSession实例。
这个过程里最耗资源的是第2步,从DataSource获取Connection。如果使用连接池,这一步可能涉及连接的创建、验证或复用。在高并发场景下,连接池的配置直接影响Session创建的效率。
5.2 一级缓存的秘密
每个SqlSession内部都有一个一级缓存,它是一个PerpetualCache,底层用HashMap实现。一级缓存的作用域是Session级别,同一个Session内执行相同的查询(相同的statementId、相同的参数、相同的SQL),第二次会直接从缓存返回,不再查数据库。
这里有个容易踩的坑:一级缓存不会自动失效。如果在同一个Session中先查询了一次,然后通过其他方式(比如另一个Session或直接操作数据库)修改了数据,再查询时拿到的还是旧数据。解决办法是在修改操作后调用session.clearCache(),或者干脆让Session的生命周期足够短。
在Spring集成环境下,如果不开启事务,每次Mapper方法调用都会创建一个新的Session,一级缓存实际上不会生效。只有在事务范围内,多个Mapper调用共享同一个Session,一级缓存才会起作用。这也是为什么有些开发者觉得一级缓存“时灵时不灵”的原因。
5.3 事务管理的正确姿势
MyBatis的事务管理有两种方式:JdbcTransaction和ManagedTransaction。前者由MyBatis自己管理commit和rollback,后者把事务控制权交给外部容器(如Spring)。
在Spring集成环境下,通常使用ManagedTransaction,事务的提交和回滚由Spring的DataSourceTransactionManager负责。这时候如果你手动调用session.commit(),可能会抛出异常,因为事务已经由Spring接管了。
我见过一个典型的错误用法:在Spring管理的Service方法中,手动获取SqlSession并调用commit,结果导致事务状态混乱。正确的做法是:在Spring环境下,不要手动管理Session和事务,交给Spring的SqlSessionTemplate和事务管理器处理。
注意:
SqlSession不是线程安全的,绝对不能在多线程之间共享。每个线程应该有自己的Session实例。在Web应用中,Session的生命周期通常与请求或事务绑定。
6. 第四步:Mapper接口代理对象的生成机制
6.1 从接口到代理对象
当你调用sqlSession.getMapper(UserMapper.class)时,MyBatis会通过JDK动态代理为这个接口生成一个代理对象。这个代理对象的核心逻辑在MapperProxy类中,它实现了InvocationHandler接口。
代理对象的工作流程是这样的:
- 调用Mapper接口的任意方法时,会触发
MapperProxy.invoke()方法。 - invoke方法根据方法签名查找对应的
MapperMethod对象(如果缓存中没有则创建)。 - MapperMethod根据方法类型(SELECT/INSERT/UPDATE/DELETE)执行相应操作。
- 对于查询方法,最终会调用Executor的query方法。
这里有个性能优化点:MapperMethod对象会被缓存在MapperProxy的methodCache中,避免每次调用都重新解析方法签名。这个缓存用ConcurrentHashMap实现,是线程安全的。
6.2 MapperMethod的内部结构
MapperMethod内部有两个核心属性:SqlCommand和MethodSignature。
SqlCommand封装了SQL语句的ID和类型。ID的格式是“接口全限定名.方法名”,类型由方法上的注解或XML配置决定。如果同一个方法既有@Select注解又有XML配置,MyBatis会抛出异常,因为无法确定用哪个。
MethodSignature封装了方法的参数信息、返回类型、是否返回多结果等。它负责把Java方法的参数转换成MyBatis能识别的参数对象。对于单参数方法,直接传参;对于多参数方法,会封装成Map或使用@Param注解指定的名称。
我遇到过一个问题:Mapper接口方法有多个参数但没有加@Param注解,XML中直接用#{param1}、#{param2}引用,结果在Java 8编译时因为参数名丢失导致BindingException。解决办法是加@Param注解,或者在编译时加上-parameters参数保留参数名。推荐前者,更明确。
6.3 代理对象的生命周期
Mapper代理对象是绑定到SqlSession的,它的生命周期不能超过Session。如果Session关闭了,代理对象再调用方法就会抛出ExecutorException。
在Spring集成环境下,Mapper代理对象的生命周期由Spring管理。每次注入的Mapper接口实际上是一个代理的代理:Spring创建了一个代理,内部再通过SqlSessionTemplate获取真正的Mapper代理。这种双重代理机制让开发者不需要手动管理Session,但也增加了理解成本。
提示:不要在类的成员变量中保存Mapper代理对象,除非你能确保它的生命周期与类的生命周期一致。在Web应用中,通常每次请求注入一次即可。
7. 第五步:查询执行的完整链路
7.1 从Mapper方法到SQL执行
一次完整的查询调用,从Mapper接口方法开始,到返回结果,大致经过以下环节:
- MapperProxy.invoke():拦截方法调用,找到对应的MapperMethod。
- MapperMethod.execute():根据SqlCommand的类型,调用SqlSession的selectOne、selectList等方法。
- DefaultSqlSession.selectList():从Configuration中获取MappedStatement,然后调用Executor的query方法。
- Executor.query():先查一级缓存,命中则返回;未命中则查二级缓存;再未命中则查数据库。
- StatementHandler:创建Statement对象,设置参数,执行SQL。
- ResultSetHandler:处理结果集,映射为Java对象。
- 返回结果:逐层返回,最终由MapperMethod处理返回类型(单个对象、List、Map等)。
这条链路里,Executor是最核心的组件,它负责缓存管理和查询调度。StatementHandler负责与JDBC打交道,ResultSetHandler负责结果映射。
7.2 一级缓存和二级缓存的查询顺序
查询时的缓存查找顺序是:一级缓存 → 二级缓存 → 数据库。
一级缓存是Session级别的,用PerpetualCache实现,key由statementId、参数、SQL、分页信息等组成。二级缓存是Namespace级别的,需要显式配置<cache>标签才会生效。二级缓存的key与一级缓存类似,但作用域更大,可以跨Session共享。
这里有个细节:二级缓存的数据在Session提交或回滚时才会写入。也就是说,在同一个Session中,即使配置了二级缓存,查询结果也不会立即进入二级缓存,要等事务提交后才行。这个设计是为了保证缓存数据的一致性。
7.3 参数映射与结果映射
参数映射由ParameterHandler负责,它把Java对象的值设置到PreparedStatement的占位符上。对于简单类型,直接设置;对于复杂对象,通过OGNL表达式或反射获取属性值。
结果映射由ResultSetHandler负责,它根据ResultMap的定义,把ResultSet的列映射到Java对象的属性上。如果没有配置ResultMap,MyBatis会自动根据列名和属性名进行映射(开启mapUnderscoreToCamelCase后支持下划线转驼峰)。
我踩过的一个坑:查询返回的列名有重复时,MyBatis只会映射第一个匹配的列。比如关联查询中两张表都有id列,如果不加别名,映射结果可能不符合预期。解决办法是在SQL中给列加别名,或者在ResultMap中明确指定column属性。
7.4 实操记录:一次完整的查询链路追踪
为了更直观地理解这条链路,我写了一个简单的示例,在关键节点打上日志,观察执行顺序:
// 1. 构建Factory SqlSessionFactory factory = new SqlSessionFactoryBuilder() .build(Resources.getResourceAsStream("mybatis-config.xml")); // 2. 打开Session try (SqlSession session = factory.openSession()) { // 3. 获取Mapper代理 UserMapper mapper = session.getMapper(UserMapper.class); // 4. 执行查询 User user = mapper.selectById(1L); System.out.println("第一次查询:" + user); // 5. 再次查询,观察一级缓存 User user2 = mapper.selectById(1L); System.out.println("第二次查询:" + user2); // 6. 提交事务 session.commit(); }运行这段代码,观察日志输出,可以看到第二次查询没有发出SQL,说明一级缓存生效了。如果在两次查询之间调用session.clearCache(),第二次查询会重新发出SQL。
这个实验虽然简单,但能帮助理解Session、Mapper、Executor之间的协作关系。建议每个刚接触MyBatis源码的开发者都动手跑一遍。
8. 常见问题与排查技巧实录
8.1 问题速查表
在实际开发和运维中,与MyBatis链路相关的问题主要集中在以下几个方面。我整理了一个速查表,方便快速定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 查询结果与数据库不一致 | 一级/二级缓存未失效 | 检查Session生命周期、clearCache调用 |
| 连接池耗尽 | Session未关闭 | 检查try-with-resources、Spring事务配置 |
| 批量插入性能差 | ExecutorType不是BATCH | 检查openSession参数或Spring配置 |
| 参数绑定失败 | 多参数未加@Param | 检查Mapper方法签名和XML引用 |
| 结果映射为null | 列名与属性名不匹配 | 检查mapUnderscoreToCamelCase和ResultMap |
| 事务不回滚 | 异常被吞或事务管理器配置错误 | 检查异常类型和@Transactional配置 |
8.2 独家避坑技巧
技巧一:Session一定要用try-with-resources。我见过太多因为忘记关闭Session导致连接泄漏的案例。用try-with-resources可以确保Session在方法结束时自动关闭,即使发生异常也不会泄漏。
技巧二:在Spring环境下不要手动openSession。Spring的SqlSessionTemplate已经帮你管理了Session的创建和关闭,手动openSession会绕过Spring的事务管理,导致事务不一致。
技巧三:批量操作时注意flush时机。BATCH模式的Executor会把SQL攒在内存中,直到调用flushStatements或Session关闭时才批量发送。如果攒的SQL太多,可能导致内存溢出。建议每500条左右flush一次。
技巧四:二级缓存慎用。二级缓存虽然能提升性能,但一致性维护成本很高。如果多个Namespace共享同一张表,或者有跨表的关联查询,二级缓存很容易出现脏数据。我的建议是:除非有明确的性能需求和一致性方案,否则不要开启二级缓存。
技巧五:Mapper方法参数超过3个时,考虑封装成对象。虽然可以用@Param注解,但参数太多时方法签名会很长,可读性差。封装成DTO对象,既清晰又方便扩展。
8.3 一个真实的排查案例
回到开头提到的那个压测问题。最终定位到原因是:某个Service方法中手动获取了SqlSession,但在异常分支中没有关闭。压测时异常频繁触发,导致大量Session泄漏,连接池被耗尽。
修复方案很简单:把手动获取Session的代码改成try-with-resources,并确保异常时也能关闭。修复后压测通过,响应时间恢复正常。
这个案例给我的教训是:MyBatis的Session管理必须严格遵循“谁创建谁关闭”的原则。在Spring环境下,尽量交给Spring管理;在非Spring环境下,用try-with-resources确保关闭。不要依赖GC来回收Session,因为GC不会主动关闭Connection。
9. 关于Executor的补充说明
9.1 三种Executor的区别
MyBatis提供了三种Executor实现,它们的行为差异直接影响性能:
- SimpleExecutor:每次执行SQL都创建新的Statement,用完即关。适合大多数场景。
- ReuseExecutor:复用Statement,以SQL为key缓存Statement。适合相同SQL频繁执行的场景。
- BatchExecutor:批量执行SQL,把多条SQL攒在一起发送。适合批量插入、更新场景。
选择哪种Executor,取决于你的业务特征。如果大部分是单条查询,用SimpleExecutor;如果有大量重复SQL,可以考虑ReuseExecutor;如果是批量写入,用BatchExecutor。
9.2 Executor的包装器
MyBatis的Executor用了装饰器模式,在基础Executor之上包装了CachingExecutor(二级缓存)和LazyLoader(延迟加载)。所以实际创建的Executor对象是一个包装后的对象,调用链是:CachingExecutor → BaseExecutor → 具体的Executor实现。
理解这个包装结构,对于排查缓存和延迟加载相关的问题很有帮助。比如当你发现二级缓存没有生效时,可以检查CachingExecutor是否被正确包装。
9.3 自定义Executor的扩展点
如果MyBatis内置的Executor不能满足需求,可以通过实现Executor接口或继承BaseExecutor来扩展。常见的扩展场景包括:SQL执行监控、慢查询日志、分库分表路由等。
自定义Executor需要在Configuration中注册,通常通过@Intercepts注解或插件机制实现。插件机制是MyBatis提供的更灵活的扩展方式,可以拦截Executor、StatementHandler、ParameterHandler、ResultSetHandler四个组件的任意方法。
我在一个项目中用插件机制实现了SQL执行时间监控,拦截Executor的query方法,记录执行时间超过阈值的SQL。这个方案对业务代码零侵入,非常实用。
10. 关于Mapper代理的深入理解
10.1 为什么Mapper接口不需要实现类
这是很多初学者最困惑的地方:Mapper接口没有实现类,为什么能直接调用?答案就在JDK动态代理。MyBatis在运行时为Mapper接口生成了一个代理对象,所有方法调用都被MapperProxy拦截,然后根据方法签名找到对应的SQL并执行。
这种设计的巧妙之处在于:开发者只需要定义接口,不需要写实现类,SQL配置在XML或注解中。接口和SQL的绑定关系由MyBatis在启动时建立,运行时通过代理对象调用。
10.2 Mapper注册的两种方式
Mapper接口的注册有两种方式:XML配置和注解扫描。
XML方式是在mybatis-config.xml中添加<mapper resource="..."/>或<mapper class="..."/>。注解方式是在接口上添加@Mapper注解,或者在Spring配置中指定basePackage进行扫描。
两种方式各有优劣:XML方式更灵活,支持动态SQL和复杂映射;注解方式更简洁,适合简单的CRUD。实际项目中通常会混用:简单查询用注解,复杂查询用XML。
10.3 Mapper代理的线程安全问题
Mapper代理对象本身是线程安全的吗?严格来说,不是。因为Mapper代理对象绑定了SqlSession,而SqlSession不是线程安全的。如果多个线程共享同一个Mapper代理对象,实际上就是共享了同一个SqlSession,会导致事务混乱和数据不一致。
在Spring环境下,每次注入的Mapper代理对象实际上是不同的实例(通过SqlSessionTemplate动态获取),所以看起来是线程安全的。但本质上,线程安全的是Spring的管理机制,而不是Mapper代理对象本身。
注意:不要在多线程之间传递Mapper代理对象或SqlSession。每个线程应该独立获取自己的Session和Mapper。
11. 从源码角度看查询的完整调用栈
11.1 调用栈的层次结构
一次Mapper查询的完整调用栈,从上层到下层大致是这样的:
MapperProxy.invoke() → MapperMethod.execute() → DefaultSqlSession.selectOne() / selectList() → CachingExecutor.query() → BaseExecutor.query() → SimpleExecutor.doQuery() → StatementHandler.query() → ResultSetHandler.handleResultSets()每一层都有明确的职责:MapperProxy负责方法拦截,MapperMethod负责命令解析,SqlSession负责会话管理,Executor负责缓存和调度,StatementHandler负责JDBC交互,ResultSetHandler负责结果映射。
11.2 关键源码片段解读
在MapperMethod.execute()方法中,有一个switch语句根据SqlCommand的类型分发到不同的SqlSession方法。对于SELECT类型,会根据返回类型决定调用selectOne还是selectList。这个逻辑决定了Mapper方法的返回值处理方式。
在BaseExecutor.query()方法中,会先查一级缓存,如果命中则直接返回;否则调用queryFromDatabase(),再查二级缓存,最后查数据库。查询数据库后,结果会写入一级缓存。
在SimpleExecutor.doQuery()方法中,会创建StatementHandler,然后调用它的query方法。StatementHandler内部会创建PreparedStatement,设置参数,执行SQL,最后交给ResultSetHandler处理结果。
11.3 源码阅读的建议
阅读MyBatis源码时,建议从MapperProxy开始,沿着调用链一路往下看。不要一开始就陷入细节,先把握整体流程,再深入关键环节。重点关注:缓存查找逻辑、参数映射逻辑、结果映射逻辑。这三个环节是问题高发区,也是面试常问的点。
我个人的习惯是:在关键方法上打断点,用调试模式跑一个简单的查询,观察每一步的变量变化。这种方式比单纯看源码更直观,也更容易记住。
12. 性能优化的几个切入点
12.1 减少Session创建开销
Session创建的主要开销在于获取Connection。如果使用连接池,这个开销可以降到很低。但如果连接池配置不当(比如最大连接数太小),Session创建就会成为瓶颈。
优化建议:合理配置连接池的初始连接数、最大连接数、最小空闲连接数。根据压测结果调整,不要凭感觉设置。
12.2 合理使用缓存
一级缓存默认开启,不需要额外配置。二级缓存需要显式配置,且要注意一致性。如果业务允许短暂的数据不一致,可以考虑开启二级缓存;如果要求强一致,建议关闭。
对于查询频繁但更新很少的数据(如字典表),二级缓存效果很好。对于更新频繁的数据,二级缓存反而会增加维护成本。
12.3 批量操作的正确姿势
批量插入时,使用ExecutorType.BATCH可以显著提升性能。但要注意:BATCH模式下,SQL不会立即执行,而是攒到一定数量后批量发送。如果攒的太多,可能导致内存溢出;如果攒的太少,批量效果不明显。
我的经验值是每500条flush一次。具体数值需要根据SQL的大小和可用内存调整。可以通过session.flushStatements()手动触发flush。
12.4 结果映射的优化
结果映射的开销主要在于反射和类型转换。如果查询返回大量数据,结果映射可能成为瓶颈。优化方式包括:使用ResultMap明确指定映射关系,避免自动映射的开销;使用基本类型而不是包装类型;避免在结果映射中使用复杂的OGNL表达式。
另外,如果查询结果不需要映射为Java对象,可以直接返回Map或使用@MapKey注解,减少映射开销。
13. 一些容易忽略的细节
13.1 Configuration的全局性
Configuration对象是全局唯一的,它持有所有Mapper映射、缓存、类型处理器等。这意味着:如果你在运行时动态修改Configuration,会影响所有Session。所以,不要在运行时修改Configuration,所有配置在启动时确定。
13.2 TypeHandler的注册时机
TypeHandler负责Java类型和JDBC类型之间的转换。MyBatis内置了很多TypeHandler,也支持自定义。自定义TypeHandler需要在Configuration中注册,注册时机是在Builder阶段。如果注册晚了,可能不生效。
13.3 插件的执行顺序
MyBatis插件可以拦截Executor、StatementHandler、ParameterHandler、ResultSetHandler。多个插件同时存在时,执行顺序由配置顺序决定。后配置的插件先执行(类似栈的结构)。理解这个顺序,对于排查插件相关的问题很重要。
13.4 日志配置的影响
MyBatis支持多种日志实现(SLF4J、Log4j、Log4j2、JDK Logging等)。日志级别设为DEBUG时,会打印SQL和参数,方便调试。但在生产环境,DEBUG级别会带来性能开销,建议设为INFO或WARN。
我见过一个案例:生产环境误开了DEBUG日志,导致大量SQL日志写入磁盘,IO飙升,响应变慢。后来把日志级别调回INFO,问题解决。所以,生产环境的日志级别一定要检查。
14. 关于版本差异的提醒
不同版本的MyBatis在内部实现上有一些差异。比如MyBatis 3.5.x相比3.4.x,在Executor和缓存实现上有调整。如果你在阅读源码或排查问题时,发现行为和文档不一致,先确认版本。
另外,MyBatis和MyBatis-Spring的版本需要匹配。版本不匹配可能导致事务管理异常或Mapper注入失败。建议使用官方推荐的版本组合,不要随意混搭。
我在一个项目中遇到过MyBatis 3.5.6和MyBatis-Spring 2.0.6不兼容的问题,表现为事务不回滚。升级MyBatis-Spring到2.0.7后问题解决。所以,版本兼容性检查是排查问题的第一步。
15. 最后分享几个实用技巧
技巧一:用@MapperScan替代逐个注册。在Spring Boot项目中,用@MapperScan("com.example.mapper")可以批量扫描Mapper接口,省去逐个配置的麻烦。
技巧二:用@Options注解控制主键回填。插入数据后需要获取自增主键时,用@Options(useGeneratedKeys = true, keyProperty = "id")可以自动回填,不需要手动查询。
技巧三:用@SelectProvider实现动态SQL。对于复杂的动态SQL,注解方式写起来很痛苦,可以用@SelectProvider指定一个Provider类,在Java代码中拼接SQL。这种方式比XML更灵活,但可读性差一些。
技巧四:用session.selectCursor()处理大结果集。如果查询返回百万级数据,一次性加载到内存会OOM。用Cursor可以流式处理,逐条读取,内存占用恒定。
技巧五:用Interceptor实现SQL监控。通过拦截Executor的query方法,可以记录每条SQL的执行时间、参数、返回行数等信息。这对于性能分析和慢查询排查非常有用。
这些技巧都是我在实际项目中用过并且觉得有效的,你可以根据自己的场景选择使用。MyBatis的灵活性很高,不同的用法适合不同的场景,没有绝对的好坏,关键是理解原理,做出适合自己项目的选择。