ARTICLE DETAIL

资讯详情

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

MyBatis插件开发与性能优化:分页、慢SQL与批量插入实战

MyBatis插件开发与性能优化:分页、慢SQL与批量插入实战 接手过几个祖传项目之后我越来越觉得MyBatis属于那种“用起来简单用好了很见功力”的框架。早期做CRUD大家都差不多.xml文件写一写、Mapper接口一定义活儿就干完了。但一旦涉及数据量大、业务链长、SQL复杂问题就全浮出来了上千条数据插入慢、分页要每个地方手写LIMIT、生产环境没有一条有效的SQL日志只能对着数据库慢查询日志猜。这些场景没有框架层面的思路根本撑不住。想在这条路上往前走绕不开两个方向插件开发和性能优化。前者让你有能力在框架的SQL执行链路上插入自定义逻辑后者让系统在数据量涨起来之后依然保持稳定。这篇文章会先拆解MyBatis插件机制的原理再带手写两个能在生产环境落地的插件分页和慢SQL追踪最后集中聊缓存、批量写入、连接池这些性能优化里见效最明显的几个点。1. 插件开发的底层逻辑拦截器机制不只是层AOP很多人把MyBatis的插件机制理解成Spring的AOP拦截器这个类比方向没问题但细节上有很大差异。Spring AOP拦截的是Bean方法调用而MyBatis插件拦截的是框架内部四个核心对象的方法这四个对象负责了从SQL解析、参数绑定、语句执行到结果映射的完整链条。1.1 四大对象的分工与拦截时机这四个对象分别是Executor、StatementHandler、ParameterHandler、ResultSetHandler。可以这样去记忆Executor是全局调度者相当于整个MySQL请求的“项目经理”StatementHandler负责把一条SQL真正喂给JDBC执行ParameterHandler在语句执行前完成参数填充ResultSetHandler处理返回结果到Java对象的映射。Executor管理一级缓存、事务提交/回滚query/update方法的入口都在这分页插件、读写分离插件基本上都拦截这一层。StatementHandler负责SQL预处理、参数替换、执行查询。处理SQL改写比如分页时拼接LIMIT时经常要在这一层做。ParameterHandler对PreparedStatement的占位符进行参数赋值。ResultSetHandler把数据库返回的ResultSet转成目标对象做数据脱敏、字典翻译时偶尔会拦截这里。这四个对象在每次数据库操作里都会依次被调用。拦截器什么时候能生效答案是在创建这些对象时MyBatis通过Plugin.wrap方法动态代理了一层。调用流程变成Mapper方法调用→Executor代理→StatementHandler代理→ParameterHandler代理→ResultSetHandler代理→实际JDBC操作。1.2 Intercepts 注解背后的代理链写一个自定义插件先要明确拦截哪个对象、哪个方法、哪些参数。这个声明放在Intercepts和Signature注解里。比如下面这段声明含义就是“拦截Executor对象的query方法目标方法有两个参数MappedStatement和Object”Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class MyPlugin implements Interceptor { // ... }需要特别注意的是写错type、method或args中的任何一个插件都不会生效而且MyBatis不会直接报错它只会在创建Executor时拿着你的Signature去匹配匹配不到就静默跳过。这个问题我亲眼见过同事找了一下午最后发现args类型写成了Object[].class。签名里的参数类型必须和接口源码里的完全一致一个都不能少。Interceptor接口里有三个方法intercept(Invocation invocation)被拦截的方法执行后会走进这里你在这里做增强逻辑最后调用invocation.proceed()放行原方法。plugin(Object target)通常直接返回Plugin.wrap(target, this)由框架帮你生成代理对象无需手动处理。setProperties(Properties properties)读取你在全局配置XML里给插件设置的自定义参数。代理链的概念也在这里体现多个插件同时存在时每个插件都会wrap一次目标对象形成层层代理。前面插件执行的proceed()会进入下一个插件的intercept直到最终调用真实方法。这条链的顺序决定了插件的执行顺序配置文件中越靠前的插件越在外层。2. 写一个能落地的分页插件从需求到代码分页是MyBatis插件开发里最经典的例子没有之一。原因很简单原生MyBatis虽然内置了RowBounds做物理分页但作用是假的物理分页它在执行时会把所有数据查出来后用内存截取数据一多直接内存溢出。这也是为什么MyBatis-Plus里会内置一个分页插件因为大家都在用相同的方式补这块短板。2.1 为什么不做通用Mapper而是拦截Executor最简单的一条语句加LIMIT当然不需要插件但复杂SQL带子查询、多表join、动态条件想在每个XML里都手动拼分页就很痛苦而且一旦要切换数据库方言所有XML都得改。分页插件帮你做的是拦截原有查询→自动改写SQL→统计总数→注入LIMIT/OFFSET参数。业务代码无感XML无感。我的实现思路选择拦截Executor的query方法。因为Executor是最外层的调度入口能拿到MappedStatement和参数对象。在query里做两件事先改写SQL变成SELECT COUNT(*) FROM (原SQL)执行一次统计再改写原SQL追加LIMIT ? OFFSET ?把分页参数传给JDBC。设计上需要约定分页参数怎么传入。我的做法是定义了一个PageQueryT基类里面包含pageNum、pageSize、totalMapper方法参数中只要包含PageQuery子类插件就生效。这样业务方法无需额外传参插件也能从参数对象里取到分页数值。提示不要把分页请求对象散落在各个DTO里再手工Set定一个统一基类后续插件处理、AOP填充用户信息都方便得多。2.2 拦截Executor的三个关键实现细节先看核心拦截逻辑的骨架代码再逐行说细节Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; RowBounds rowBounds (RowBounds) args[3]; // 1. 没带分页参数就直接放行 PageQuery page findPageQuery(parameter); if (page null || rowBounds ! RowBounds.DEFAULT) { return invocation.proceed(); } // 2. 拿到BoundSql并改写统计SQL BoundSql boundSql ms.getBoundSql(parameter); String countSql SELECT COUNT(*) FROM ( boundSql.getSql() ) TEMP; // 3. 执行count实际代码建议复用Executor // 4. 改写原SQL为分页SQL并重新构建BoundSql String pageSql dialect.getPageSql(boundSql.getSql(), page); // 5. 通过反射/MyBatis内置工具替换BoundSql.sql字段 MetaObject metaObject SystemMetaObject.forObject(boundSql); metaObject.setValue(sql, pageSql); return invocation.proceed(); }第一个细节是findPageQuery必须递归查找。参数有可能是单个对象也有可能是Param注解包裹的Map。写的时候要循环Map的values也要处理嵌套对象里的属性不然业务层封了一层DTO之后插件直接失配。第二个细节是改写BoundSql时不能直接用反射去改Private字段。MyBatis提供了SystemMetaObject.forObject(object)工具它是官方为插件开发准备的属性操作入口用MetaObject.setValue(sql, newSql)比手写反射安全得多同时还能处理参数值变化。如果你手动反射改了字段遇到CGLIB生成的子类就会踩坑。第三个细节是对RowBounds的判定。特别注意RowBounds类提供了常量RowBounds.DEFAULT内部值为offset0、limit2147483647。当执行逻辑里出现这个默认值时就不能重复分页。很多二次开发的分页插件靠这个常量判断当前SQL是否已经被某个分页逻辑处理过写的时候务必加这个判断否则会出现“一次查询被拼两次LIMIT”的经典问题。2.3 Count查询与缓存分页插件容易踩的坑Count语句的好坏直接决定慢查询。如果你用SELECT COUNT(*) FROM (原SQL) TEMP虽然功能正确但在原SQL包含ORDER BY、join了大量表、或使用了DISTINCT时性能很差因为外层临时表必须等内层结果完全计算出来。更合理的做法是用方言解析器去掉ORDER BY在无GROUP BY时直接改写SELECT COUNT(*) FROM 主表 WHERE 条件有GROUP BY才保留嵌套查询。实际项目中我会做一次简化处理用jsqlparser解析SQL解析失败就回退到包一层临时表的写法。一个成熟的分页插件必须处理“解析失败”的兜底逻辑否则遇到一些数据库特殊语法比如MySQL的FOR UPDATE时directly抛异常生产事故就是这么来的。分页插件还和一级缓存有交互。executor.query方法内部自带一级缓存查询逻辑但分页插件在改写SQL之后如果继续走executor.query缓存key会变得很难控。我的经验是测试阶段把localCacheScope设为STATEMENT避免缓存干扰插件效果验证。确认分页SQL每次都正确执行后再调回默认的SESSION。3. SQL 打印与慢 SQL 告警插件给系统装上仪表盘生产环境排查性能问题第一步永远是拿到真实的SQL和真实的执行耗时。但很多团队连这一步都没有做扎实。3.1 打印SQL的三种路线对比打印SQL可以分成三种路线第一改配置。设置mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImplMyBatis会用标准输出把SQL打印到控制台。这是最快的方式开发阶段足够用了。缺点是日志会全部打印没有级别划分生产环境开这个很危险。第二用现成工具。IDEA里装MyBatis Log Free它会把拦截到的日志还原成可执行的完整SQL带参数的那种调试动态SQL特别有用。这类工具更多是面向开发调试无法在生产环境做监控。第三自己写插件。在StatementHandler或Executor层统一打印SQL和耗时再通过日志框架按级别输出。这是生产环境的最佳方案能达到SQL审计、慢SQL告警的目的。我推荐组合方案开发环境用IDEA插件测试/生产环境用自研插件。如果你接手的是一个老项目改动要小直接用StdOutImpl做临时排查也行但记得上线前一定改回去。3.2 拦截StatementHandler实现耗时统计SLA上一般要求数据库单次查询不超过100ms超过这个阈值的SQL需要被单独拉出来处理。我的插件实现里拦截StatementHandler的query方法原因有两点第一StatementHandler层能拿到完整BoundSql以及参数对象打印出的SQL信息完整第二在这个层面记录的耗时是SQL真正执行的耗时不会包含上游业务代码的时间。Intercepts({ Signature(type StatementHandler.class, method query, args {Statement.class, ResultHandler.class}), Signature(type StatementHandler.class, method update, args {Statement.class}) }) public class SlowSqlPlugin implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { long start System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost System.currentTimeMillis() - start; StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); String sql boundSql.getSql().replaceAll(\\s, ).trim(); if (cost 100) { log.error(slow sql cost{}ms sql{}, cost, sql); } else { log.debug(sql cost{}ms sql{}, cost, sql); } } } }打印SQL时有个容易踩的坑boundSql.getSql()是预编译模板占位符全是?看不到真实参数值。要还原完整SQL需要手动遍历boundSql.getParameterMappings()从boundSql.getAdditionalParameter()或参数对象中取出每个?对应的值。这一步要小心处理String类型的引号也要小心参数是null的情况不然日志输出本身就会抛异常反而影响正常SQL执行。提示比较推荐的做法是单独封装SqlLogUtil.getExecutableSql(boundSql)不要在主流程里写太长逻辑。生产环境打印参数时会面临另一项隐私问题对包含敏感字段的SQL要提前做脱敏处理。3.3 用耗时分布定位慢SQL的真实案例有个项目线上突然出现周期性超时通过这个插件发现有一条SQL平时执行40ms但每半小时会跳到1.8s。当时我第一反应是数据量涨了EXPLAIN看了执行计划后发现索引没变走的是全表扫描。后来深入排查是因为缓存失效该表数据更新频率不高但存在一个按时间批量删除的定时任务每次删除后InnoDB的统计信息更新MySQL优化器重新选择了错误的执行计划。这个案例给到的经验是慢SQL优化不能只看单次执行计划要结合时间点和系统行为一起看。没有插件的耗时日志很难定位到这种周期性行为。后来我们在插件里加了入库表把慢SQL记录落到ES之后就直接在Kibana上看到耗时涨幅和时间戳的对应关系。4. 性能优化缓存、批量与连接池的取舍插件开发解决的是“链路可控”的问题性能优化更多要盘算“每一层不开源的成本到底值不值”。这块我从四个方向展开。4.1 一级缓存的生命周期与失效场景MyBatis一级缓存是默认开启的作用范围是SqlSession。也就是说同一个SqlSession里执行两次完全相同的查询第二次直接命中缓存不再查数据库。听起来是好事但在Spring管理下SqlSession的存活周期是和事务绑定的通常一次业务方法就是一个SqlSession。于是出现一个经典场景同一个事务里先查了一次用户信息后面用户信息被另外一个Service调用更新掉更新会清空当前SqlSession里的一级缓存再查询时如果缓存没被清掉读到的是旧数据。在复杂业务链路里这个问题非常隐蔽因为它不是每次都出错而是取决于更新操作是否走同一个SqlSession。一级缓存还容易出现“缓存污染”问题。XML里配置了useCachefalse后MyBatis执行查询并不会往一级缓存里存数据但它并不会在每次查询前自动清掉之前已经缓存的数据。如果你在同一事务里先执行了一个useCachefalse的查询、再执行了一个普通查询可能读到的是第一次查询在缓存中的旧数据。要避免这类问题最稳妥的方式是对于不希望走缓存的查询统一在SQL中加SELECT DISTINCT或显式配置flushCachetrue不要只依赖useCachefalse。处理方式看场景。如果业务允许读到最近提交的数据可以接受默认的一级缓存如果一致性要求高可以在MyBatis配置里把localCacheScope从SESSION改成STATEMENT相当于每条SQL执行完就清一次缓存牺牲一点性能换精确性。我的做法通常是默认SESSION但在有明显脏读风险的高频查询上用flushCachetrue单独处理。4.2 二级缓存为什么不建议默认开启二级缓存的作用域是Namespace即一个Mapper跨SqlSession共享。配置倒是很简单Mapper XML里加一行cache/就行。问题在于收益和风险严重不对等。收益只是“同一条SQL在多个SqlSession之间共享结果”但风险包括第一如果存在多表关联SQL关联表发生更新时所有涉及此表的namespace缓存不会自动清空你只能手工在XML里配置cache-ref或者更新时调用clearCache()漏一处就是脏数据第二缓存的对象默认需要可序列化实体类改动成本高第三分布式环境下缓存只存在于单机内存和原本想要的“共享缓存”根本不是一回事。如果你真的想解决热点数据的高频查询问题建议绕开二级缓存用外置缓存如Caffeine或Redis。外置缓存的好处是淘汰策略、命中率监控、失效通知都可控不会被MyBatis的Namespace边界卡住手脚。4.3 BATCH 批量模式的正确姿势批量插入的性能优化是优化里收益最明确的一项。MyBatis默认的ExecutorType是SIMPLE每调一次sqlSession.insert就单独提交一次JDBC执行1000条数据就是1000次网络往返。改成BATCH模式后SQL会被攒着批量执行。在Spring Boot里的开启方式是注入一个配置了executorType的SqlSessionTemplateBean public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) { ExecutorType executorType ExecutorType.BATCH; return new SqlSessionTemplate(sqlSessionFactory, executorType); }需要留意BATCH模式下同一事务里的查询语句会触发批量语句的隐式刷新这个刷新时机可能不是最优的。更常见的处理是不要在全局配置BATCH只在特定批量操作里获取一个临时的BatchSqlSession来执行执行完后单独刷新一次。我在项目里给批量导入场景封装了一个工具类批量插入时用BatchSqlSession查询时始终用普通Session两边互不干扰也没有性能消耗。还有个最常见的坑BATCH模式下动态生成的INSERT INTO VALUES语句没有减少仍然是1000次执行只是减少了网络往返。如果插入字段特别多、单条SQL特别长性能提升有限。更彻底的方式是用MySQL的多值语句一次插入几十行这正是下一节要说的驱动参数配合点。4.4 连接池与驱动参数rewriteBatchedStatements 值多少钱JDBC驱动层面有个参数叫rewriteBatchedStatements默认false。设置为true时MySQL驱动会把一批同构的批量INSERT语句重写成多行INSERT比如INSERT INTO t(a,b) VALUES(?,?),(?,?),(?,?)。配合ExecutorType.BATCH使用插入性能常常提升几倍到十几倍。Spring Boot的application.yml配置里可以这样打开spring: datasource: url: jdbc:mysql://localhost:3306/db?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrueuseSSLfalse加上之后实测十万条数据从10秒级别降到1秒级别效果非常直观。同样值得关注的还有连接池配置HikariCP默认maximumPoolSize10这个值在很多默认项目里就是原封不动的但偏偏生产流量一大就频繁出现连接等待。连接池的大小不是“越大越好”它和数据库连接数、线程池数、IO模型都有关系在MySQL单实例上一般建议10~20之间先压测再逐步往上调。5. 实战翻车现场插件与性能问题的常见雷区工具写好了规则也定好了但还是会在一些边界情况下翻车。这些坑单独说都很小组合在一起能折腾掉你一整天。5.1 分页拦截器导致的多表统计错误有个业务场景是查询订单列表订单表和用户表JOIN然后按订单创建时间分页。XML里的SQL长这样SELECT o.*, u.phone FROM t_order o LEFT JOIN t_user u ON o.user_id u.id WHERE o.status ? ORDER BY o.create_time DESC分页插件先生成COUNT语句。使用包一层临时表的方式统计结果通常是500但实际总记录数是520。排查发现这张订单一笔被用户删除后LEFT JOIN产生了两行NULL关联数据于是COUNT查询把因JOIN产生的多行重复行也计数了。这个问题在业务上有时候不算错但分页总数确实不是用户期望值。修复方式是COUNT SQL去重当主表有唯一主键且没有GROUP BY时将COUNT语句改写成SELECT COUNT(DISTINCT o.id) FROM ...模式。有些插件会进一步检查SELECT里是否有DISTINCT有则说明SQL本身已去重不能重复加。这里再一次说明为什么分页插件不像看起来那么简单它对SQL语义需要有足够的理解能力。5.2 缓存引起的脏读同一事务的翻车记录某次生产问题一个定时任务先批量更新订单状态再查同一批订单的最新状态但查询结果全是旧状态。排查后发现更新操作走的是另一个Mapper这个Mapper没有配置flushCachetrue而查询走的Mapper配置了二级缓存并且两个Mapper的namespace不同二级缓存没有被更新操作清掉。这里的问题本质是“多表更新导致缓存失效范围无法自动覆盖”。解决方式是涉及频繁关联更新的表禁用二级缓存或者不要跨Mapper用二级缓存热点字段通过Redis做失效发布。踩过这次坑之后我给自己定了一条规矩任何关联查询命中二级缓存必须能保证所有参与表的写操作都能触发该namespace缓存清理否则就不开二级缓存。5.3 插件顺序和Signature写错导致的连锁失败项目里同时配置了分页插件、慢SQL插件和租户隔离插件结果上线之后租户条件偶尔丢失。排查发现是插件执行顺序问题租户隔离插件在Executor层修改参数Map时分页插件已经提前copy了参数对象导致后续修改在分页插件设置的参数中不可见。因为MyBatis插件的代理链是后配置的插件在里层、先配置的在外层先执行的插件还没等到后执行的把参数改完后才进入真正的查库。正确的顺序应该是把影响SQL语义的插件租户隔离、数据权限放在靠外的位置让它们先修改完参数再进入分页逻辑。Signature写错的静默失败问题也在此处一并提醒在args里填错一个类型或者漏一个参数插件在创建的时候不会崩只是永远不会进你的intercept方法日志里看不到任何痕迹。排查这类问题时建议先写个临时的日志输出检查代理对象是否生成再逐步排查参数列表。6. 插件机制还能扩展到哪里分页和慢SQL只是插件开发的切入点了解原理之后能做的事其实很多。比如自动填充公共字段创建时间、修改人、数据权限隔离、逻辑删除自动拼接、SQL字段脱敏。这些能力如果散落在业务代码里每个方法都要手动维护容易漏做成插件配置好就全系统生效。以字段加密为例可以在ParameterHandler层拦截写语句的SQL参数对加密字段进行统一加密在ResultSetHandler层拦截查询结果对需要解密字段的字段做解密。这个方案比在业务代码各处调用加密工具类要优雅得多而且加密字段的增删只改配置不用动Service层。做这些扩展时有一个共同原则插件越通用对性能和对漏判的影响面就越大。所以每加入一个全局插件都要做一轮全回归测试特别要关注分页、批量、嵌套查询这几种特殊路径。插件不是越多越好而是每次让框架做一件事把这件小事做稳。7. 配置与测试环节的个人经验最后说一下实际项目里我处理插件和性能配置的一些习惯遇到类似场景可以直接照着来。配置插件时MyBatis全局配置文件里用plugins标签注册参数通过property注入。例如plugins plugin interceptorcom.example.plugin.PaginationInterceptor property namedialect valuemysql/ property namemaxLimit value2000/ /plugin plugin interceptorcom.example.plugin.SlowSqlInterceptor property namethreshold value100/ property namesqlOutputLevel valueERROR/ /plugin /plugins在Spring Boot中也可以直接用Bean注册Interceptor效果一致。建议从第一个插件开始就建立“独立测试工程”的习惯把Mapper、XML、数据库全部隔离出来专门用于插件回归不掺业务逻辑。插件的出问题往往不在正常路径而在特殊边界比如PageQuery传null、参数Map为空、SQL包含分号、count为0。这些边界情况在独立工程里全部写进单测后续迭代才有安全感。性能这块的具体建议先测量再优化。用你的慢SQL插件持续采集数据基于耗时分布决定优化目标。缓存、批量、连接池、索引优化、SQL改写按投入产出比排序不要一上来就重写SQL或者改架构。我见过太多项目数据库索引结构乱成一团却在应用层拼命调缓存参数最后缓存命中率上去了数据还是慢因为慢在磁盘IO不在应用逻辑。先找到真正的瓶颈所有的技能才用得上。
返回列表