系列文8:改MyBatis源码1行,搞定Oracle物理分页,避开硬解析大坑

张开发
2026/4/20 17:00:31 15 分钟阅读

分享文章

系列文8:改MyBatis源码1行,搞定Oracle物理分页,避开硬解析大坑
系列文8改MyBatis源码1行搞定Oracle物理分页避开硬解析大坑非科班野生程序员深耕政务信息化20年这套自研Java Web框架支撑过省级新农保、全国跨省医保结算等核心民生系统18年稳定运行至今。本系列拆解10个核心架构决策全是政务场景踩坑后的实用解法不求优雅但求落地愿同赛道朋友少走弯路也欢迎懂行大佬轻拍指正。最后感谢豆包、智谱、OpenCode决策是我做的代码是我搓的文字是他们总结的。背景MyBatis 的RowBounds默认是内存分页——先把所有数据查到内存里再取子集。数据量一大直接 OOM。通用的做法是写一个Interceptor拦截StatementHandler.prepare在 SQL 执行前改写数据库方言的分页语句。但这里面有一个不太容易注意到的坑为什么不能用拼接 SQL大部分分页插件是这样做的StringpagingSqloriginalSql limit offset,limit;这种方式有一个隐含的问题每次分页都产生一个新的 SQL 字符串。-- 第1页SELECT*FROMt_userWHEREage18limit0,10-- 第2页SELECT*FROMt_userWHEREage18limit10,10-- 第3页SELECT*FROMt_userWHEREage18limit20,10Oracle 数据库认为这是不同的 SQL 模板每次都要做硬解析Hard Parse。硬解析要消耗大量 CPU 和 shared pool 内存。一旦并发量上来AWR 报告里parse time elapsed和library cache lock会非常严重系统越跑越慢。我之前排查 AWR 报告、定位硬解析问题时发现的——分页插件必须用绑定变量?不能拼接字符串。-- 不管第几页SQL 模板是同一个Oracle 只解析一次后续全部软解析SELECT*FROMt_userWHEREage18limit?,?另一个坑ParameterMappings 是不可修改的MyBatis 的BoundSql.getParameterMappings()返回的是Collections.unmodifiableList()包装的 List直接add()或remove()会抛UnsupportedOperationException。我的方案改了 MyBatis 源码一行——把不可修改的 List 改成普通的ArrayList。然后我的PaginationInterceptor就能自由操作了。核心思路是删除 MyBatis 自动生成的maxRow/minRow映射手动按正确顺序添加绑定变量确保参数顺序和 SQL 中的?顺序一致。PaginationInterceptor.java 完整源码packagecom.browise.core.util.ds;Intercepts({Signature(typeStatementHandler.class,methodprepare,args{Connection.class})})publicclassPaginationInterceptorimplementsInterceptor{privatefinalstaticLoglogLogFactory.getLog(PaginationInterceptor.class);publicObjectintercept(Invocationinvocation)throwsThrowable{StatementHandlerstatementHandler(StatementHandler)invocation.getTarget();BoundSqlboundSqlstatementHandler.getBoundSql();MetaObjectmetaStatementHandlerMetaObject.forObject(statementHandler);RowBoundsrowBounds(RowBounds)metaStatementHandler.getValue(delegate.rowBounds);if(rowBoundsnull||rowBoundsRowBounds.DEFAULT){returninvocation.proceed();}Daodao(Dao)boundSql.getParameterObject();dao.setMaxRow(rowBounds.getOffset()rowBounds.getLimit());dao.setMinRow(rowBounds.getOffset());ListParameterMappinglistboundSql.getParameterMappings();Configurationconfiguration(Configuration)metaStatementHandler.getValue(delegate.configuration);intilist.size()-1;for(;i0;i--){ParameterMappingobjPlist.get(i);if(maxRow.equals(objP.getProperty())){list.remove(i);}if(minRow.equals(objP.getProperty())){list.remove(i);}}BuilderbuildernewBuilder(configuration,maxRow,IntegerTypeHandler.class);Dialect.TypedatabaseTypenull;try{IntegerTypeHandlerhandlernewIntegerTypeHandler();builder.typeHandler(handler);list.add(builder.build());buildernewBuilder(configuration,minRow,IntegerTypeHandler.class);builder.typeHandler(handler);list.add(builder.build());databaseTypeDialect.Type.valueOf(myDbProvider.getDialect().toUpperCase());}catch(Exceptione){e.printStackTrace();}if(databaseTypenull){thrownewRuntimeException(the value of the dialect property in configuration.xml is not defined : configuration.getVariables().getProperty(dialect));}Dialectdialectnull;switch(databaseType){caseORACLE:dialectnewOracleDialect();break;caseMSSQL:dialectnewMsSqlDialect();break;caseMYSQL:dialectnewMySqlDialect();break;default:dialectnewOracleDialect();}StringoriginalSql(String)metaStatementHandler.getValue(delegate.boundSql.sql);metaStatementHandler.setValue(delegate.boundSql.sql,dialect.getLimitString(originalSql,dao.getOrderByParam()));metaStatementHandler.setValue(delegate.rowBounds.offset,RowBounds.NO_ROW_OFFSET);metaStatementHandler.setValue(delegate.rowBounds.limit,RowBounds.NO_ROW_LIMIT);returninvocation.proceed();}publicObjectplugin(Objecttarget){returnPlugin.wrap(target,this);}publicvoidsetProperties(Propertiesproperties){}}关键逻辑1. 判断是否需要分页if(rowBoundsnull||rowBoundsRowBounds.DEFAULT){returninvocation.proceed();}没有RowBounds就不分页直接放行。2. 把分页参数传递给 DaoDaodao(Dao)boundSql.getParameterObject();dao.setMaxRow(rowBounds.getOffset()rowBounds.getLimit());dao.setMinRow(rowBounds.getOffset());把RowBounds的分页信息转存到 Dao 的maxRow和minRow字段上。3. 删除原有的 maxRow/minRow 映射重新添加为什么要删了再添因为 Dao 里本来就声明了maxRow和minRow字段MyBatis 解析 SQL 遇到#{maxRow}时会自动生成对应的ParameterMapping。但自动生成的顺序不确定而分页 SQL 的?占位符顺序是固定的先rownum ?再rownum ?顺序不对值就串了。intilist.size()-1;for(;i0;i--){ParameterMappingobjPlist.get(i);if(maxRow.equals(objP.getProperty())){list.remove(i);}if(minRow.equals(objP.getProperty())){list.remove(i);}}BuilderbuildernewBuilder(configuration,maxRow,IntegerTypeHandler.class);IntegerTypeHandlerhandlernewIntegerTypeHandler();builder.typeHandler(handler);list.add(builder.build());buildernewBuilder(configuration,minRow,IntegerTypeHandler.class);builder.typeHandler(handler);list.add(builder.build());从后往前删除防止删除后索引错位然后在末尾按正确顺序添加maxRow和minRow的映射。这样参数值和 SQL 中的?顺序就能对上。4. 根据数据库方言改写 SQLDialectdialectnull;switch(databaseType){caseORACLE:dialectnewOracleDialect();break;caseMSSQL:dialectnewMsSqlDialect();break;caseMYSQL:dialectnewMySqlDialect();break;default:dialectnewOracleDialect();}StringoriginalSql(String)metaStatementHandler.getValue(delegate.boundSql.sql);metaStatementHandler.setValue(delegate.boundSql.sql,dialect.getLimitString(originalSql,dao.getOrderByParam()));5. 清除 RowBounds防止内存分页metaStatementHandler.setValue(delegate.rowBounds.offset,RowBounds.NO_ROW_OFFSET);metaStatementHandler.setValue(delegate.rowBounds.limit,RowBounds.NO_ROW_LIMIT);把RowBounds重置为不分页这样 MyBatis 就不会在内存里再做一次分页了。方言基类——Dialect.javapackagecom.browise.core.util.ds;publicabstractclassDialect{publicstaticenumType{MYSQL,ORACLE,MSSQL}publicabstractStringgetLimitString(Stringsql,StringorderByParam);}Oracle 方言——OracleDialect.javapackagecom.browise.core.util.ds;publicclassOracleDialectextendsDialect{publicStringgetLimitString(Stringsql,StringorderByParam){sqlsql.trim();booleanisForUpdatefalse;if(sql.toLowerCase().endsWith( for update)){sqlsql.substring(0,sql.length()-11);isForUpdatetrue;}StringBufferpagingSelectnewStringBuffer(sql.length()100);pagingSelect.append(select * from ( select row_.*, rownum rownum_ from ( );intnPosWhere0,nPosGroup0,nPosOderby0;StringappendWhere;Stringsqltempsql.replaceAll(\\([^\\(\\)]*(\\([^\\(\\)]*\\)[^\\(\\)]*)*\\),);if(sqltemp!nullsqltemp.length()0){sqltempsqltemp.toLowerCase();nPosWheresqltemp.indexOf(where);nPosGroupsqltemp.toLowerCase().indexOf(group);nPosOderbysqltemp.toLowerCase().indexOf(order);if(nPosWhere0){appendWhere and rownum ? ;}else{appendWhere where rownum ? ;}if(nPosGroup0nPosOderby0){sqlsqlappendWhere;}}pagingSelect.append(sql);pagingSelect.append( ) row_ );pagingSelect.append() where );if(nPosGroup0||nPosOderby0){pagingSelect.append( rownum_ ? and);}pagingSelect.append( rownum_ ? );if(isForUpdate){pagingSelect.append( for update);}returnpagingSelect.toString();}}Oracle 方言处理了几个复杂情况有group by或order by时rownum条件必须在外层不能嵌套有where时追加and没有时追加where支持for update语句最终生成的 SQL 大致为select*from(selectrow_.*,rownum rownum_from(原SQL)row_)whererownum_?andrownum_?MSSQL 方言——MsSqlDialect.javapackagecom.browise.core.util.ds;publicclassMsSqlDialectextendsDialect{OverridepublicStringgetLimitString(Stringsql,StringorderByParam){sqlsql.trim();booleanisForUpdatefalse;if(sql.toLowerCase().endsWith( for update)){sqlsql.substring(0,sql.length()-11);isForUpdatetrue;}sqlsql.replaceAll(\r, ).replaceAll(\n, ).replaceAll(\t, );Stringsqltemp;StringBufferpagingSelectnewStringBuffer(sql.length()100);if(sql.toUpperCase().lastIndexOf(ORDER)0){Stringordertempsql.substring(sql.toUpperCase().lastIndexOf(ORDER)5,sql.length());sqlsql.substring(0,sql.toUpperCase().lastIndexOf(ORDER));orderByParamordertemp.substring(ordertemp.toUpperCase().indexOf(BY)2,ordertemp.length());}booleanrsfalse;rs(sql.toUpperCase().indexOf(WITH)0);if(rs){intpossql.toUpperCase().lastIndexOf() SELECT);sqltempsql.substring(0,pos2);pagingSelect.append(sqltemp);sqlsql.replace(sqltemp,);}pagingSelect.append(select * from(select cte1.*,row_number() over (order by );if(.equals(orderByParam)||orderByParamnull){Stringtempsql.split(,)[0].toUpperCase();temptemp.replace(SELECT,);if(temp.indexOf(AS)0){temptemp.substring(temp.indexOf(AS)2);}orderByParamtemp asc;}pagingSelect.append(orderByParam);pagingSelect.append( ) rownum_ from( );pagingSelect.append(sql);pagingSelect.append( ) as cte1) as cte where rownum_ ? and rownum_ ?);if(isForUpdate){pagingSelect.append( for update);}returnpagingSelect.toString();}}MSSQL 方言处理了 CTEWITH语句的情况使用row_number() over(order by)实现分页。为什么不用 PageHelperPageHelper 发布时我的分页方案已经运行多年了我的方案更贴合这个框架——分页参数通过 Dao 的maxRow/minRow传递在 Mapper SQL 里直接用#{maxRow}最关键的是用绑定变量而非拼接 SQL避免 Oracle 硬解析问题决策原则尽量不依赖外部有源码。第一天解决的事用了十几年还在用。这个分页方案是我在框架初期就写好的一行源码修改 一个拦截器 三个方言类简单直接十几年没改过。你的项目中分页是怎么做的有没有遇到过 Oracle 硬解析导致的性能问题欢迎评论区聊聊。系列导航上一篇[系列文7零侵入提升查询性能MongoDB混合存储不用Redis也能抗]- 下一篇[系列文9政务报表自由Excel模板驱动业务人员改格式开发零参与]作者许彰午| 非科班野生程序员深耕政务信息化20年标签#Java #MyBatis #分页 #Oracle #SQL优化 #硬解析 #绑定变量 #政务信息化 #技术复盘

更多文章