
1. 为什么说插件机制是MyBatis的灵魂扩展点先聊个实在话题MyBatis 在国内Java项目中的普及率估计不用我多讲。但不少人用 MyBatis 也就是纯 CRUD 操作连插件机制都没碰过。实际上插件Interceptor是 MyBatis 里最灵活的扩展点相当于给你留了一条“偷偷改 SQL 执行过程”的暗道很多通用能力都是靠它实现的。举个例子市面上用的高频分页插件 PageHelper底层就是一个 MyBatis 拦截器。它干的事情很简单拦截你要执行的 SQL拼接上 LIMIT 或 ROWNUM 后继续执行。还有我们常用的 SQL 日志打印插件、数据权限插件、字段自动填充插件本质上都是同一套机制。如果你理解了插件开发其实就理解了 MyBatis 的半壁江山。这篇文章会围绕两个主轴展开一是把插件机制彻底讲透从源码层面看它到底怎么拦截、怎么代理再用几个能直接落地的插件案例分页、SQL日志、数据权限、防全表更新把开发流程走一遍二是基于插件和缓存机制做性能优化讲清楚一级缓存、二级缓存、批量操作、N1 问题的处理思路以及如何用插件来做慢 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 和 offsetOracle 就是套一层 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 条件就抛异常拒绝执行或者根据配置自动追加“12”让语句失效。这里有两个需要仔细拿捏的地方允许某些表强制更新比如配置表、计数器表可能确实需要无 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 evictionLRU flushInterval60000 size512 readOnlyfalse/但是我真实建议是如果你的项目里做了读写分离或者有较多的关联查询或者你的表经常被多张表 join那二级缓存很容易成为数据一致性隐患。因为它只感知当前 namespace 的更新操作如果另一个 namespace 修改了同一张表而它不知道那缓存就会返回旧数据。我当时接手过一个老项目就发生过“改完订单状态列表却还是旧状态”的问题排查到最后发现就是二级缓存惹的祸。处理方案一般有两种一种是直接关闭二级缓存另一种是明确划分哪些是低频修改且可容忍短时不一致的数据才开启二级缓存。比如数据字典、省市区列表、系统配置项这类数据才适合放到二级缓存里。4.3 N1 查询问题和 foreach 批量优化N1 问题是性能优化中最常见也最容易被忽略的。典型场景查订单列表然后循环遍历每个订单去查它的用户信息。如果订单有 100 条那就是 1 次查询 100 次查询总共 101 次数据库请求。数据库连接是有上限的应用程序线程池也有上限这种 N1 在高并发下直接拖垮数据库。解决手段有不少用 MyBatis 的 collection/association 一次性关联查出嵌套对象。用 distinct 或者 join 查询代替循环内单查。用 IN 查询替代 for 循环内的单条查询。如果你选了 IN 查询那就牵扯出 MyBatis 动态 SQL 里的 foreach 标签。它的写法很简单select idselectByUserIds resultTypeUser select * from user where id in foreach collectionuserIds itemid open( separator, close) #{id} /foreach /select重点在于当集合元素很多比如超过 1000 个时数据库对 IN 列表的长度、SQL 解析的复杂度都会直线上升。所以批量操作建议分段处理比如每 500 个一组控制单条 SQL 的复杂度。4.4 批处理不要对着数据库循环 insert另一个高频问题是批量插入。很多人写循环单条 insert不仅慢而且造成大量 SQL 解析开销。MyBatis 提供了 BatchExecutor但实际使用中直接在 Mapper 接口里写 foreach 批量 insert 是更简单的方式insert idbatchInsert parameterTypelist insert into user(name, age, dept_id) values foreach collectionlist itemitem 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) ListUser 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 testname ! null但实际传入的参数是一个包装类或 Maptest 表达式取不到值时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的缓存机制。处理办法是赋值一个新的 MappedStatementMappedStatement 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 事务配置里把事务设置成了 readOnlytrue或者连接的 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这就是前文说的 N1 问题来源。推荐使用嵌套结果映射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 里配置更直观也更容易调试。写到这里主体内容其实已经讲得差不多了。我没有刻意写个“总结”来收尾因为像插件开发这种话题真正的收获往往不是读完一篇文章而是自己动手改几行代码、跑几个测试之后悟出来的经验。如果你在实践过程中遇到什么奇怪的问题欢迎带着现象和数据来找我聊很多时候问题本身就藏着答案。