1. 为什么说插件机制是MyBatis的灵魂扩展点
先聊个实在话题:MyBatis 在国内Java项目中的普及率,估计不用我多讲。但不少人用 MyBatis 也就是纯 CRUD 操作,连插件机制都没碰过。实际上,插件(Interceptor)是 MyBatis 里最灵活的扩展点,相当于给你留了一条“偷偷改 SQL 执行过程”的暗道,很多通用能力都是靠它实现的。
举个例子,市面上用的高频分页插件 PageHelper,底层就是一个 MyBatis 拦截器。它干的事情很简单:拦截你要执行的 SQL,拼接上 LIMIT 或 ROWNUM 后继续执行。还有我们常用的 SQL 日志打印插件、数据权限插件、字段自动填充插件,本质上都是同一套机制。如果你理解了插件开发,其实就理解了 MyBatis 的半壁江山。
这篇文章会围绕两个主轴展开:一是把插件机制彻底讲透,从源码层面看它到底怎么拦截、怎么代理,再用几个能直接落地的插件案例(分页、SQL日志、数据权限、防全表更新)把开发流程走一遍;二是基于插件和缓存机制做性能优化,讲清楚一级缓存、二级缓存、批量操作、N+1 问题的处理思路,以及如何用插件来做慢 SQL 监控。
适合谁来读?两类人。第一类是项目里用了 MyBatis 但想进一步扩展它、解决通用问题的后端开发;第二类是面试前想搞懂 MyBatis 底层原理的求职者。这篇文章不会用大篇幅讲 UserMapper.xml 怎么配,那太基础了,我们直接踩在进阶点上。
2. 插件机制的核心原理:四大对象被谁“劫持”了
2.1 四大对象和它们各自的分工
MyBatis 执行一条 SQL 时,核心工作其实是由四个对象协作完成的:
- Executor:执行器,负责维护一级缓存、二级缓存,并调用 StatementHandler 执行数据库操作。
- StatementHandler:负责创建 JDBC Statement,以及设置参数、执行查询。
- ParameterHandler:负责将用户传入的参数预编译到 SQL 中。
- ResultSetHandler:负责将 JDBC 返回的 ResultSet 映射成 Java 对象。
插件机制就是让你能在这四个对象的方法调用前后“插一脚”。MyBatis 通过 JDK 动态代理生成这些对象的代理类,代理类在执行目标方法前,会先经过一层拦截器链。每个拦截器可以根据自己的注解声明要拦截哪个类的哪个方法。
2.2 @Intercepts 注解和拦截器链的配置方式
写一个自定义插件,核心是三步:
- 实现 org.apache.ibatis.plugin.Interceptor 接口。
- 使用 @Intercepts 和 @Signature 注解声明要拦截的目标类型、方法名和参数类型。
- 在 MyBatis 配置中注册,或用 Spring Boot 的 @Component 交给容器管理。
拦截器接口里主要有三个方法:
- intercept(Invocation invocation):核心逻辑在这里,invocation.proceed() 表示放行,调用原方法。
- plugin(Object target):决定是否要为 target 生成代理。一般直接写成 Plugin.wrap(target, this) 即可。
- setProperties(Properties properties):用于读取配置参数。
一个典型的分页插件拦截器大概是长这个样子的:
@Intercepts({ @Signature( type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class PageInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 1. 从 invocation 中取出参数 MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; Object parameter = invocation.getArgs()[1]; RowBounds rowBounds = (RowBounds) invocation.getArgs()[2]; // 2. 生成 count 语句,执行总数统计 // 3. 改造原始 SQL,拼接分页条件 // 4. 替换参数后,调用 invocation.proceed() 放行 return invocation.proceed(); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { // 读取配置,比如数据库类型、默认页大小等 } }这里要特别提醒一点:@Signature 的参数类型必须和目标方法签名完全一致,否则拦截不生效。比如 Executor.query 方法有多个重载,常见的是带 RowBounds 的版本,而有一部分 MyBatis 版本还有不带 RowBounds 的 query 方法,如果你两个都想拦,就得写两条 @Signature。
2.3 代理链的执行顺序:先声明先执行还是后声明后执行
不少人在多插件同时存在时踩过坑。比如同时配了分页插件和 SQL 日志插件,发现日志里打印的 SQL 没有分页条件。这就是代理顺序的问题。
MyBatis 构建代理链时,采用的是“后注册先代理”的方式,即最后一个配置的插件最外层,最先被执行。也就是说,插件 A 和 B 同时存在,如果 A 后配置,那么 A 在最外层,A.intercept() 先执行,然后是 B.intercept(),最后才是真正的方法调用。
所以如果你的日志插件想打印最终带分页条件的 SQL,就得让日志插件后配置,或者让日志插件在内部手动调用分页插件处理后的 SQL。这个细节在实际项目里很容易让人挠头。
3. 从零实现四个能直接落地的插件
3.1 自定义分页插件:拦截 Executor.query 是主流方案
分页插件是插件开发里最经典的案例。虽然 PageHelper 已经很成熟,但很多大厂自研框架里还是会写自己的分页插件,原因包括:统一分页规范、适配公司内部 SQL 规范、避免 PageHelper 在嵌套查询和自定义 SQL 上的一些“灵异问题”。
实现思路如下:
第一步,拦截 Executor 的 query 方法,拿到 MappedStatement 和参数。第二步,从参数里提取分页对象(比如 PageParam),把原始 SQL 改写成 count 语句,先执行一次统计。第三步,将原始 SQL 改写成带分页方言的语句,例如 MySQL 就是追加 limit 和 offset,Oracle 就是套一层 rownum 子查询。第四步,把分页参数设置到 BoundSql 中,然后继续调用原方法。
代码核心片段:
private String buildPageSql(String originalSql, int offset, int limit, String dbType) { if ("mysql".equalsIgnoreCase(dbType)) { return originalSql + " limit " + offset + "," + limit; } else if ("oracle".equalsIgnoreCase(dbType)) { return "select * from (select t.*, rownum rn from (" + originalSql + ") t where rownum <= " + (offset + limit) + ") where rn > " + offset; } return originalSql; }这里最大的坑就是:改写 SQL 后,原有参数占位符(?)的数量可能发生变化,比如 Oracle 分页方式不会增加参数,但如果是动态排序字段拼接,就可能导致参数索引错乱。所以一定要用 BoundSql 的 additionalParameters 机制去传递新增参数,而不是去改原参数对象的结构。
3.2 SQL 日志与慢 SQL 监控插件:改写后 SQL 才是灵魂
很多人用 MyBatis 自带的日志,发现输出的 SQL 里参数都是问号,没法直接拿去数据库执行。这个痛点催生了“可执行 SQL 日志插件”这类需求,也就是热搜里一直有人问的 mybatis log 怎么用、mybatis 打印可执行 SQL 插件怎么实现的。
实现原理:拦截 Executor 的 query 和 update 方法,取出 BoundSql,拿到原始 SQL 和 ParameterMappings,再通过 ParameterHandler 的 getParameterObject 获取参数对象,逐个替换占位符。这一步要注意类型判断:字符串和日期要加单引号,数字和布尔值不需要。
替换完 SQL 之后,顺带记录一下执行时长,就能顺便做慢 SQL 监控了:
long start = System.currentTimeMillis(); Object result = invocation.proceed(); long cost = System.currentTimeMillis() - start; if (cost > slowSqlThreshold) { log.warn("slow sql cost={}ms, sql={}", cost, executableSql); }踩过的坑有两个。第一个:如果你在日志插件里把参数替换错了,比如字符串没有加引号,那打印出来的 SQL 就没法直接在 Navicat 里执行,这个插件就废了一半。第二个:插件中不要在高并发场景下用字符串拼接方式去拼 SQL 日志,容易产生大量临时对象,其实可以用 StringBuilder 复用,或者干脆异步打印。不过我在实践中发现,性能影响其实很小,真正要注意的是日志级别不要打印到文件里太多,否则磁盘 IO 会飙升。
3.3 数据权限插件:拦截 SQL 片段实现行级控制
数据权限是后台管理系统里绕不开的功能。常见需求就是:不同角色登录后,只能看到自己部门的数据、自己负责的客户数据等。如果业务代码里到处写权限条件,后期维护非常痛苦。插件方案是在 SQL 执行前自动拼接权限条件。
实现上一般是拦截 Executor 的 query 方法,解析原始 SQL 中的主表别名,然后拼接类似 and org_id = #{userId} 的条件。注意这里有个高风险点:如果 SQL 里有子查询、UNION、JOIN 等复杂结构,简单的字符串截取容易出错,轻则拼错 SQL,重则注入风险。
我建议的做法是:利用 JSqlParser 这类 SQL 解析库,把原始 SQL 解析成抽象语法树,替换 WHERE 条件后重新生成 SQL。这样虽然引入了一个依赖,但能大幅提升解析的准确性。
Statement statement = CCJSqlParserUtil.parse(originalSql); Select select = (Select) statement; PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), buildDataPermissionExpression()));这个方案唯一的问题就是性能:解析 SQL 本身有开销。所以一般会配一层缓存,用原始 SQL 字符串做 key,缓存解析后的结果,避免每个请求都重新解析一次。
3.4 防全表更新插件:力挽狂澜的保命插件
分享一个我保命到现在的插件。某个凌晨两点,有人手滑执行了一个没有 where 条件的 update,全表数据被重置成同一个值。从那以后,我在所有核心系统里都加了一个防全表更新插件。
原理很简单:拦截 Executor 的 update 方法,取出 BoundSql,判断 SQL 是否为 update/delete 语句,如果是,则检查是否有 where 条件。没有 where 条件就抛异常拒绝执行,或者根据配置自动追加“1=2”让语句失效。
这里有两个需要仔细拿捏的地方:
- 允许某些表强制更新:比如配置表、计数器表,可能确实需要无 where 更新,可以用注解或配置白名单放行。
- 保留最后一道逃生通道:在异常信息中打上醒目标记,比如“禁止无 WHERE 条件的 UPDATE 操作,已强制阻断。如需执行,请联系 DBA”,避免凌晨被人打电话骂醒。
代码结构其实很简单,主要是 @Intercepts 注解要拦截 Executor.update,同时注意 update 方法在 MyBatis 里可能被 batch 等多个重载方法调用,签名要写准确:
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})4. 性能优化实战:从缓存到批处理的完整优化链路
4.1 一级缓存:小心脏也有大隐患
一级缓存是 MyBatis 默认开启的,作用域是 SqlSession。同一个 SqlSession 中执行两次完全相同的查询,第二次会直接命中缓存,不再查数据库。听起来很美好,但实际上,Spring 管理的 SqlSession 生命周期和 Mapper 方法调用是绑定的,每个方法执行完就关闭了,所以一级缓存对“方法内重复查询”的场景基本没啥用。
真正的坑在于:如果你在同一个方法里手动开启了一个 SqlSession,然后先查一条数据,接着执行了一个 update 操作,再查同一条数据,你就会发现查出来的还是修改前的值。因为一级缓存在执行 update/delete/insert 时会自动清空。这个机制本身没问题,但如果你在事务里对同一条记录反复查询并修改,依赖一级缓存可能会拿到脏数据。实际编码中,我建议大家把一级缓存理解为“避免重复查询的保护机制”,而不是业务缓存来用。
4.2 二级缓存:跨 SqlSession 的共享缓存,开启前先想清楚
二级缓存是 Mapper 级别的,也就是同一个 namespace 下的查询结果可以被多个 SqlSession 共享。开启方式是在 Mapper XML 里加:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>但是我真实建议是:如果你的项目里做了读写分离,或者有较多的关联查询,或者你的表经常被多张表 join,那二级缓存很容易成为数据一致性隐患。因为它只感知当前 namespace 的更新操作,如果另一个 namespace 修改了同一张表而它不知道,那缓存就会返回旧数据。
我当时接手过一个老项目,就发生过“改完订单状态,列表却还是旧状态”的问题,排查到最后发现就是二级缓存惹的祸。处理方案一般有两种:一种是直接关闭二级缓存;另一种是明确划分哪些是低频修改且可容忍短时不一致的数据,才开启二级缓存。比如数据字典、省市区列表、系统配置项,这类数据才适合放到二级缓存里。
4.3 N+1 查询问题和 foreach 批量优化
N+1 问题是性能优化中最常见也最容易被忽略的。典型场景:查订单列表,然后循环遍历每个订单去查它的用户信息。如果订单有 100 条,那就是 1 次查询 + 100 次查询,总共 101 次数据库请求。数据库连接是有上限的,应用程序线程池也有上限,这种 N+1 在高并发下直接拖垮数据库。
解决手段有不少:
- 用 MyBatis 的 collection/association 一次性关联查出嵌套对象。
- 用 distinct 或者 join 查询代替循环内单查。
- 用 IN 查询替代 for 循环内的单条查询。
如果你选了 IN 查询,那就牵扯出 MyBatis 动态 SQL 里的 foreach 标签。它的写法很简单:
<select id="selectByUserIds" resultType="User"> select * from user where id in <foreach collection="userIds" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>重点在于:当集合元素很多(比如超过 1000 个)时,数据库对 IN 列表的长度、SQL 解析的复杂度都会直线上升。所以批量操作建议分段处理,比如每 500 个一组,控制单条 SQL 的复杂度。
4.4 批处理:不要对着数据库循环 insert
另一个高频问题是批量插入。很多人写循环单条 insert,不仅慢,而且造成大量 SQL 解析开销。MyBatis 提供了 BatchExecutor,但实际使用中直接在 Mapper 接口里写 foreach 批量 insert 是更简单的方式:
<insert id="batchInsert" parameterType="list"> insert into user(name, age, dept_id) values <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.deptId}) </foreach> </insert>需要注意的是,MySQL 的 max_allowed_packet 限制了单条 SQL 的最大长度。如果一次插入 1 万条记录,很有可能直接报 PacketTooBigException。我的经验做法是:手动分批,每批 500-1000 条,分批执行。也可以用 ExecutorType.BATCH 配合 SqlSessionTemplate 来执行,但那个时候要特别注意事务边界和缓存清空的问题。
5. 与 MyBatis-Plus 的关系:插件思维同样适用
5.1 MyBatis-Plus 的热搜背后:逻辑删除与插件扩展
MyBatis-Plus 目前是国内使用率很高的增强框架,它内置了很多插件,比如分页插件、乐观锁插件、多租户插件、数据权限插件等。它的设计思路和原生 MyBatis 插件机制一脉相承,但做了很多封装。
热搜词里有一个是“mybatis plus 查询 禁用逻辑删除”,这其实是 MyBatis-Plus 的高频痛点。MyBatis-Plus 默认开启了逻辑删除之后,所有查询都会自动追加逻辑删除字段的条件,比如deleted = 0。但有时候我们就是想查出来“已删除”的数据,比如做回收站功能或数据恢复时,怎么办?
在 MyBatis-Plus 3.x 版本中,可以通过自定义 SQL 或者在 Mapper 方法上使用 @InterceptorIgnore 注解来跳过拦截器。具体来说:
@InterceptorIgnore(tenantLine = "true", dynamicTableName = "true") @Select("select * from user where deleted = 1") List<User> selectDeletedUsers();或者使用@SqlParser注解(旧版本)来忽略某些解析逻辑。这个问题的本质就是:增强框架的逻辑删除功能是用插件(拦截器)实现的,只是我们平时不太感知。一旦遇到“我要绕过通用逻辑”的需求,就得回到插件机制的底层去理解。
5.2 当自定义插件遇到 MyBatis-Plus:代理链的顺序有多重要
自定义插件和 MyBatis-Plus 内置插件一起使用时,经常遇到不生效或冲突的问题。比如你写了一个 SQL 日志插件,注册后却发现没有日志输出。这时候首先要检查插件注册的顺序,看你的插件是不是被 MyBatis-Plus 内置插件替换掉了,或者因为代理链顺序问题,你的插件虽然执行了,但参数已经被 MyBatis-Plus 的插件修改过了,你拿不到原始 SQL。
如果你用 Spring Boot 的 starter 方式集成 MyBatis-Plus,可以这样注册自定义插件:
@Configuration public class MybatisConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } @Bean public ConfigurationCustomizer customizer() { return configuration -> configuration.addInterceptor(new SqlLogInterceptor()); } }在 MyBatis-Plus 3.4 之后的版本中,InnerInterceptor 机制更像一种责任链模式,和原生 Interceptor 的代理顺序既有联系又不同。建议你用的时候多打日志,观察执行顺序,而不是靠猜。
5.3 MyBatis-Plus 中动态 SQL 的“or”陷阱
热搜词里有一条“mybatis flex两个or”和“mybatis if test indexof”,这类问题通常出现在动态 SQL 拼写中。很多人会把 MyBatis-Plus 的条件构造器(LambdaQueryWrapper)和原生 XML 里的动态 SQL 混在一起用。比如在 XML 里用了<if test="name != null">,但实际传入的参数是一个包装类或 Map,test 表达式取不到值时,SQL 条件就缺失了。
关于 or 条件,很容易出现的问题是两个 or 之间没有括号,导致 SQL 语义被改变。比如:
select * from user where name = #{name} or age = #{age} and deleted = 0这个 SQL 的实际执行顺序是name = ? or (age = ? and deleted = 0),但你可能期望的是(name = ? or age = ?) and deleted = 0。所以用动态 SQL 拼 or 的时候,一定要手动加括号,或者用 MyBatis-Plus 的 and(condition, wrapper) 方法确保括号正确。
6. 常见问题和排查技巧实录
6.1 插件不生效:检查四个层面
第一,检查 @Intercepts 注解里 @Signature 的 method 和 args 是否完全匹配。第二,检查插件是否被 Spring 容器管理(如果项目用 Spring Boot,有没有加 @Component 或者配置类里有没有注册)。第三,检查是否真的走了代理对象。如果你在代码里直接 new 了一个 SqlSession,那拦截器可能不会生效,因为代理是在 Configuration 构建时生成的。第四,检查 mybatis-config.xml 里<plugins>标签的位置是否合法(必须放在 environments 前面)。
6.2 拦截器里修改参数后,SQL 没有变化
这个问题典型的场景是:你打算在拦截器里替换 MappedStatement 的 BoundSql 或 SQL 语句,但改完发现执行时还是旧 SQL。原因在于:MappedStatement 对象的属性被缓存了,你需要重新构建一个 MappedStatement 并更新到 Configuration 中。直接修改注入的 MappedStatement 不会生效,尤其是 Spring Boot 中 MyBatis 使用了SqlSessionFactory的缓存机制。
处理办法是赋值一个新的 MappedStatement:
MappedStatement newMs = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType()) .resource(ms.getResource()) .fetchSize(ms.getFetchSize()) .timeout(ms.getTimeout()) .statementType(ms.getStatementType()) .resultMaps(ms.getResultMaps()) .cache(ms.getCache()) .build(); configuration.addMappedStatement(newMs);注意要保留好原有的 resultMaps、keyGenerator 等属性,否则查询结果映射会出问题。
6.3 慢 SQL 排查:如何定位到底是哪条 SQL 慢
有次我发现线上接口响应变慢,DBA 说数据库 CPU 飙升,但不知道是哪条 SQL 导致的。排查方式一般是开慢查询日志,看看具体是哪些 SQL 执行时间长。如果你已经加了慢 SQL 日志插件,那就能很快定位。如果没有,也别急,用 MySQL 的show processlist看看当前正在执行的 SQL,或者用performance_schema查询events_statements_summary_by_digest表,按平均耗时排序,前几条基本就是问题来源了。
发现某条 SQL 慢之后,再用explain分析执行计划。重点看 type 列,如果出现了 ALL(全表扫描)或者索引使用不合理,那就得考虑加索引或改写 SQL 了。这里有个不太常见但很实用的经验:加了索引但查询还是慢,可以看看是不是索引失效了。比如在 where 条件中对索引列使用了函数,比如where date(create_time) = '2024-01-01',这种情况下索引会失效,应该改成create_time >= '2024-01-01 00:00:00' and create_time < '2024-01-02 00:00:00'。
6.4 Read-only 模式下写操作报错的处理思路
热搜词里有一条关于write operations are not allowed in read-only mode (flushmode.man...的报错。这通常是 Spring 事务配置里把事务设置成了 readOnly=true,或者连接的 FlushMode 是 MANUAL 导致的。解决方案是:确认业务是否真的只需要只读事务,如果确实要写入,就移除 @Transactional(readOnly = true) 配置,或者把写操作放到单独的事务方法中。
很多时候,这个报错并不是因为业务逻辑写错了,而是因为接口设计时把查询和更新混在同一个方法里,然后顺手加了只读事务。我建议把写操作拆出来,不要让查询方法连带更新操作,这样既能满足事务隔离,也能避免误配置。
6.5 插件中如何优雅地拿参数和新增参数
还有一个高频踩坑点:拦截器里拿参数,直接invocation.getArgs()[1]得到的是用户传入参数,它可以是一个实体对象、一个 Map,甚至是一个包装类(比如 PageParam)。如果你要新增一个参数(比如分页的 limit、offset),不是在 args 数组里硬塞,而是在 BoundSql 的 additionalParameters 里添加:
BoundSql boundSql = ms.getBoundSql(parameter); boundSql.getAdditionalParameter("offsetParam"); // 或者 MetaObject metaObject = SystemMetaObject.forObject(boundSql); metaObject.setValue("additionalParameters.offset", offset);这么做的好处是:MyBatis 的参数处理过程会统一从 additionalParameters 里取值,不会干扰原有参数映射。如果你强行改 args 里的参数对象,可能会造成参数索引错乱,或者动态 SQL 里引用不到新增变量。
7. 性能优化进阶:线程池、连接池和 SQL 执行计划
7.1 连接池参数调整:别让数据库连接成为瓶颈
MyBatis 本身不管理连接池,它依赖第三方连接池,比如 HikariCP、Druid、Tomcat JDBC Pool。连接池参数对性能的影响,往往比 SQL 本身还大。
HikariCP 的核心参数:
- maximumPoolSize:最大连接数,不是越大越好,太大会导致数据库连接数占用过高。
- minimumIdle:最小空闲连接数,调太高浪费资源,调太低频繁创建连接。
- connectionTimeout:获取连接的超时时间,默认 30 秒,在高并发场景下调到 2-3 秒比较合理,避免请求长时间阻塞。
- maxLifetime:连接最大生命周期,一般要小于数据库的 wait_timeout 值,避免使用被数据库强制断开的连接。
我见过很多项目的配置:maximumPoolSize 设成 50,但数据库连接数上限只有 60,结果除了这个应用,其他应用都连不上库了。这个值要结合压测数据来定,而不是拍脑袋。
7.2 使用插件做 SQL 执行频率统计
除了单条 SQL 慢,还有一类性能问题是“某条 SQL 被高频执行,且结果集基本不变”。这种问题用慢 SQL 监控发现不了,因为单次执行只要 2ms。但如果每天执行几百万次,那对数据库的压力就很可观了。
这时候,可以在 SQL 日志插件基础上扩展:统计每个 MappedStatement 的执行次数、平均耗时、最大耗时,定期打印。依赖的其实就是拦截器每次 invocation.proceed() 前后记录的时长:
String msId = ms.getId(); // 用 ConcurrentHashMap 做计数器 countMap.computeIfAbsent(msId, key -> new AtomicLong()).incrementAndGet(); costMap.computeIfAbsent(msId, key -> new LongAdder()).add(cost);统计结果输出后,你可能会发现某条 SQL 执行量极大,但业务上其实可以加缓存或者合并查询。
7.3 动态 SQL 与 resultMap 写法对性能的影响
很多人觉得动态 SQL 只是拼接字符串,性能影响不大。但实际上,如果动态 SQL 中包含了大量<if>判断,MyBatis 每次执行时都需要解析 XML 并构建 SQL,这部分开销在小并发下不明显,但在高并发下还是能感知到的。
一个直接的优化手段是:尽量让同一个 Mapper 方法对应的 SQL 语句形态固定。比如把变化的条件放到参数里,而不是频繁改动 SQL 结构。如果确实需要动态条件,也尽量控制<if>的嵌套层级,避免在 XML 里写几十个 if 的“超级动态语句”。
resultMap 的写法也很关键。当查询出的字段很多时,如果你用 Map 接收结果,MyBatis 会自动做驼峰映射和类型转换。如果字段名和 Java 属性名不一致,建议在 resultMap 中显式指定,而不是依赖自动映射去猜。另外,<association>和<collection>的嵌套查询(select 属性)会额外执行 SQL,这就是前文说的 N+1 问题来源。推荐使用嵌套结果映射(join 查询 + resultMap 的 association/collection 映射属性)来一次性查出所有字段。
7.4 一个真实场景:从 300ms 优化到 20ms
拿我之前做过的一个列表接口举例。这个接口返回用户列表,每条数据要带部门名称、角色名称、最近登录时间。最初的写法是:查用户列表,循环里分别查部门、查角色、查登录记录。300 个用户就是 900 次查询,接口压测耗时 300ms。数据库连接池很快就打满了。
优化后,先把循环里查询改写为批量 IN 查询,把 900 次查询降为 3 次。接着给用户表的 dept_id 加上索引,给登录记录表的 user_id 加上索引。再在应用层把用户列表用 MyBatis 的 collection 一次性映射到 DTO 里。最后压测结果稳定在 20ms 左右。这里面最有价值的不是具体哪个优化,而是排查思路:从数据库请求次数入手,而不是一上来就想着换数据库或者加缓存。
8. 聊聊我在插件和性能优化上踩过的坑和最终沉淀的心得
写这篇文章时,我回忆了一下这些年做 MyBatis 相关工作的经历。插件机制确实是我用过最顺手也最容易踩坑的扩展点。很多问题看起来是框架的 bug,实际是我对代理顺序、参数传递机制理解不够导致的。所以如果你读完这篇文章,打算在自己的项目里写插件,我有几条比较实际的建议:
第一,不要一开始就写大而全的插件。比如想写一个既分页又打印日志又做数据权限的“万能插件”,最后你会发现代码耦合严重,排查问题非常痛苦。分开写,一个插件只负责一件事,必要时按顺序组合。第二,写拦截器时一定要打印入参和出参的关键信息。很多诡异问题,其实是因为传入参数类型和你预期的不一样。我在排查一个分页插件 bug 时,就发现调用方传入的是一个 Page 对象的子类,导致参数提取失败。第三,性能优化的优先级应该是:先理清数据库请求次数 -> 再分析单条 SQL 执行计划 -> 再考虑加缓存 -> 最后才是调 JVM、调线程池。顺序反了,往往事倍功半。
最后再分享一个小技巧:如果你在 Spring Boot 项目里用了 mybatis-spring-boot-starter,又想让自定义插件很早执行,可以尝试实现BeanPostProcessor或者关注SqlSessionFactory的构建时机。但更稳妥的方式还是通过@Bean注册ConfigurationCustomizer,按顺序添加拦截器。这比在 mybatis-config.xml 里配置更直观,也更容易调试。
写到这里,主体内容其实已经讲得差不多了。我没有刻意写个“总结”来收尾,因为像插件开发这种话题,真正的收获往往不是读完一篇文章,而是自己动手改几行代码、跑几个测试之后悟出来的经验。如果你在实践过程中遇到什么奇怪的问题,欢迎带着现象和数据来找我聊,很多时候,问题本身就藏着答案。