一、引言为什么需要获取“完整可执行 SQL”在使用MyBatis或MyBatis-Plus进行 Java 后端开发时开发者常常面临一个高频痛点日志中打印的 SQL 语句包含?占位符参数值单独列出无法直接复制到数据库客户端如 Navicat、DBeaver、MySQL Workbench中执行。例如Preparing:SELECT*FROMuserWHEREid?ANDname?Parameters:1001(Integer),张三(String)虽然我们知道参数顺序但手动替换不仅繁琐还容易出错尤其是涉及日期、布尔值、NULL、转义字符等复杂类型时。更严重的是在排查慢查询、死锁、数据异常等问题时无法快速复现问题 SQL极大拖慢调试效率。因此获取一条“带真实参数值、语法正确、可直接执行”的完整 SQL 语句成为开发、测试、运维环节的刚需。本文将从原理剖析 → 拦截点选择 → 完整实现 → 高级优化 → 常见陷阱 → MyBatis-Plus 适配 → 生产建议等维度全面、深入、系统地讲解如何通过自定义 MyBatis 拦截器实现这一目标并确保代码健壮、安全、高效。全文约 22,000 字涵盖所有细节适合深度学习与生产参考。二、MyBatis 拦截器机制核心原理2.1 什么是 MyBatis 拦截器MyBatis 的“插件”Plugin本质上是基于动态代理的拦截器Interceptor机制。它允许开发者在不修改 MyBatis 源码的前提下对 MyBatis 内部核心组件的方法调用进行拦截、增强或修改。官方文档明确指出MyBatis 只允许拦截以下4 个核心接口的特定方法接口作用常见拦截方法ExecutorSQL 执行器update,query,flushStatementsParameterHandler参数处理器setParametersResultSetHandler结果集处理器handleResultSetsStatementHandlerSQL 语句处理器prepare,parameterize,update,query✅关键结论要获取最终可执行 SQL必须拦截StatementHandler因为它是负责构建PreparedStatement并设置参数的核心组件。2.2 拦截器工作流程配置注册在 MyBatis 配置文件或 Spring Boot 中注册自定义拦截器。代理包装MyBatis 在创建上述 4 个接口的实现类时会检查是否有匹配的拦截器。若有则通过Plugin.wrap()方法生成 JDK 动态代理对象。方法调用当调用被拦截的方法时代理对象会将调用转发给Interceptor.intercept(Invocation)。自定义逻辑在intercept方法中开发者可以获取原始 SQL 和参数修改参数或 SQL记录日志、统计耗时抛出异常中断执行继续执行调用invocation.proceed()继续原方法执行。2.3 为什么选择StatementHandler.prepare()prepare()方法在PreparedStatement创建之前被调用。此时BoundSql对象已包含原始 SQL含?参数映射列表ParameterMapping参数对象parameterObject尚未执行参数绑定因此我们可以安全地“模拟”参数填充过程生成完整 SQL。⚠️ 注意不要拦截parameterize()因为它内部调用ParameterHandler.setParameters()此时参数已绑定到PreparedStatement我们无法再获取原始 SQL 字符串。三、自定义拦截器完整实现基础版3.1 创建拦截器类packagecom.example.mybatis.interceptor;importorg.apache.ibatis.executor.statement.StatementHandler;importorg.apache.ibatis.mapping.BoundSql;importorg.apache.ibatis.mapping.ParameterMapping;importorg.apache.ibatis.plugin.*;importorg.apache.ibatis.reflection.MetaObject;importorg.apache.ibatis.reflection.SystemMetaObject;importorg.apache.ibatis.type.TypeHandlerRegistry;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importjava.lang.reflect.Field;importjava.sql.Connection;importjava.text.SimpleDateFormat;importjava.util.*;Intercepts({Signature(typeStatementHandler.class,methodprepare,args{Connection.class,Integer.class})})publicclassFullSqlInterceptorimplementsInterceptor{privatestaticfinalLoggerlogLoggerFactory.getLogger(FullSqlInterceptor.class);privatestaticfinalStringDATE_FORMATyyyy-MM-dd HH:mm:ss;OverridepublicObjectintercept(Invocationinvocation)throwsThrowable{// 1. 获取 StatementHandlerStatementHandlerstatementHandler(StatementHandler)invocation.getTarget();// 2. 通过反射获取真实的 StatementHandler可能被代理StatementHandlerrealStatementHandlergetRealStatementHandler(statementHandler);// 3. 获取 BoundSqlBoundSqlboundSqlrealStatementHandler.getBoundSql();StringoriginalSqlboundSql.getSql();ObjectparameterObjectboundSql.getParameterObject();ListParameterMappingparameterMappingsboundSql.getParameterMappings();// 4. 如果没有参数直接输出if(parameterMappingsnull||parameterMappings.isEmpty()){log.info(【完整SQL】: {},originalSql);returninvocation.proceed();}// 5. 构建 MetaObject 用于安全获取参数值MetaObjectmetaObjectSystemMetaObject.forObject(parameterObject);// 6. 替换 ? 为实际参数值StringfullSqlbuildFullSql(originalSql,parameterMappings,metaObject,parameterObject);// 7. 输出完整 SQLlog.info(【完整SQL】: {},fullSql);// 8. 继续执行原方法returninvocation.proceed();}/** * 获取真实的 StatementHandler处理代理嵌套 */privateStatementHandlergetRealStatementHandler(StatementHandlerhandler){try{Fieldfieldhandler.getClass().getDeclaredField(delegate);field.setAccessible(true);return(StatementHandler)field.get(handler);}catch(Exceptione){returnhandler;// 若无 delegate 字段说明已是真实对象}}/** * 构建完整 SQL核心逻辑 */privateStringbuildFullSql(Stringsql,ListParameterMappingparameterMappings,MetaObjectmetaObject,ObjectparameterObject){StringBuilderfullSqlnewStringBuilder(sql);intoffset0;// 用于计算替换位置for(ParameterMappingmapping:parameterMappings){Stringpropertymapping.getProperty();Objectvalue;// 处理单个参数 vs 多参数Map/POJOif(metaObject.hasGetter(property)){valuemetaObject.getValue(property);}elseif(parameterObjectinstanceofMap){value((Map?,?)parameterObject).get(property);}else{valueparameterObject;// 单个参数}// 格式化参数值StringformattedValueformatParameterValue(value);// 查找第一个 ? 的位置并替换intindexfullSql.indexOf(?,offset);if(index-1)break;// 安全兜底fullSql.replace(index,index1,formattedValue);offsetindexformattedValue.length();// 更新偏移量}returnfullSql.toString();}/** * 格式化参数值关键处理各种类型 */privateStringformatParameterValue(Objectvalue){if(valuenull){returnNULL;}// 字符串加单引号并转义if(valueinstanceofString){returnescapeString((String)value);}// 日期类型if(valueinstanceofDate){SimpleDateFormatsdfnewSimpleDateFormat(DATE_FORMAT);returnsdf.format((Date)value);}// 布尔值if(valueinstanceofBoolean){return((Boolean)value)?1:0;// 或 true/false依数据库而定}// 数字、枚举等直接 toStringreturnString.valueOf(value);}/** * 转义字符串中的单引号防止 SQL 注入式错误 */privateStringescapeString(Stringstr){returnstr.replace(,);// SQL 标准转义}OverridepublicObjectplugin(Objecttarget){// 使用 MyBatis 提供的 Plugin 工具类生成代理returnPlugin.wrap(target,this);}OverridepublicvoidsetProperties(Propertiesproperties){// 可选读取配置属性如是否启用、日志级别等}}3.2 注册拦截器方式一Spring Boot 自动配置推荐ConfigurationConditionalOnClass(SqlSessionFactory.class)publicclassMyBatisInterceptorConfig{BeanConditionalOnMissingBeanpublicFullSqlInterceptorfullSqlInterceptor(){returnnewFullSqlInterceptor();}BeanpublicConfigurationCustomizermybatisConfigurationCustomizer(FullSqlInterceptorinterceptor){returnconfiguration-configuration.addInterceptor(interceptor);}}方式二MyBatis XML 配置!-- mybatis-config.xml --configurationpluginsplugininterceptorcom.example.mybatis.interceptor.FullSqlInterceptor//plugins/configuration并在 Spring Boot 中引用# application.ymlmybatis:config-location:classpath:mybatis-config.xml四、高级优化生产级健壮实现基础版在简单场景下可用但在复杂业务中存在诸多隐患。以下是生产级优化方案。4.1 问题一参数类型识别不全基础版仅处理了String、Date、Boolean、null。但实际还有LocalDateTime,LocalDateBigDecimal,BigInteger自定义枚举需调用TypeHandler数组、ListIN 查询解决方案利用 MyBatis 内置的TypeHandlerRegistry获取正确格式。privateStringformatParameterValue(Objectvalue,ParameterMappingmapping,Configurationconfiguration){if(valuenull){returnNULL;}TypeHandlerRegistrytypeHandlerRegistryconfiguration.getTypeHandlerRegistry();TypeHandler?typeHandlermapping.getTypeHandler();// 尝试使用 TypeHandler 转换if(typeHandler!null){try{// 注意TypeHandler 通常用于设置 PreparedStatement此处模拟// 更安全的方式是判断类型并格式化}catch(Exceptione){log.warn(TypeHandler format failed, fallback to toString,e);}}// 按类型精细化处理if(valueinstanceofLocalDateTime){return((LocalDateTime)value).format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss));}if(valueinstanceofLocalDate){return((LocalDate)value).format(DateTimeFormatter.ofPattern(yyyy-MM-dd));}if(valueinstanceofBigDecimal){return((BigDecimal)value).toPlainString();}if(value.getClass().isEnum()){return((Enum?)value).name();}// ... 其他类型returnString.valueOf(value);}4.2 问题二SQL 注入风险日志层面虽然只是日志输出但若参数包含恶意内容如 OR 11拼接后可能误导开发者或触发日志系统异常。解决方案严格转义 限制长度privateStringescapeAndTruncate(Stringvalue,intmaxLength){if(valuenull)returnNULL;Stringescapedvalue.replace(,).replace(\\,\\\\);returnescaped.length()maxLength?escaped.substring(0,maxLength)...:escaped;}4.3 问题三性能开销每次 SQL 执行都进行字符串替换和日志输出对高并发系统有性能影响。解决方案添加开关配置仅在 DEBUG 日志级别输出异步日志// 在 intercept 开头添加if(!log.isDebugEnabled()){returninvocation.proceed();}4.4 问题四多数据源支持在读写分离、分库分表场景下拦截器需确保对所有SqlSessionFactory生效。解决方案遍历所有SqlSessionFactoryBeanComponentpublicclassMultiDataSourceInterceptorRegistrarimplementsInitializingBean{AutowiredprivateListSqlSessionFactorysqlSessionFactoryList;AutowiredprivateFullSqlInterceptorinterceptor;OverridepublicvoidafterPropertiesSet(){for(SqlSessionFactoryfactory:sqlSessionFactoryList){factory.getConfiguration().addInterceptor(interceptor);}}}五、MyBatis-Plus 专属实现MyBatis-Plus 提供了更优雅的拦截器机制——InnerInterceptor。5.1 实现InnerInterceptorpublicclassMpFullSqlInterceptorimplementsInnerInterceptor{privatestaticfinalLoggerlogLoggerFactory.getLogger(MpFullSqlInterceptor.class);OverridepublicvoidbeforePrepare(StatementHandlersh,Connectionconnection,IntegertransactionTimeout){try{BoundSqlboundSqlsh.getBoundSql();StringsqlboundSql.getSql();// ... 同上构建 fullSqllog.info(【MP完整SQL】: {},fullSql);}catch(Exceptione){log.error(MpFullSqlInterceptor error,e);}}}5.2 注册到 MyBatis-PlusBeanpublicMybatisPlusInterceptormybatisPlusInterceptor(){MybatisPlusInterceptorinterceptornewMybatisPlusInterceptor();interceptor.addInnerInterceptor(newMpFullSqlInterceptor());returninterceptor;}✅ 优势自动兼容 MP 的分页、租户、乐观锁等插件无需担心拦截顺序。六、常见陷阱与避坑指南陷阱现象解决方案拦截器未生效控制台无输出检查是否注册到正确的SqlSessionFactory确认Intercepts签名正确参数替换错位SQL 语法错误使用offset跟踪替换位置避免replaceAll特殊字符未转义SQL 执行报错严格转义和\NULL 处理不当WHERE id null输出IS NULL而非 NULL性能下降QPS 降低仅在开发环境启用或使用异步日志多参数 Map 错误#{userId}无法获取优先从parameterObjectMap中取值七、替代方案对比方案优点缺点适用场景自定义拦截器灵活、可控、可扩展需自行处理类型、转义生产环境、定制需求p6spy零代码侵入、精准引入额外依赖、配置复杂开发/测试环境Druid SQL 监控内置、可视化仍含?需开启filter.stat.mergeSqltrue监控大盘断点调试快速临时无法批量、效率低紧急排查推荐组合开发用 p6spy生产用自定义拦截器开关控制。八、总结通过自定义 MyBatis 拦截器获取完整可执行 SQL是一项高价值、低风险、易实施的工程实践。本文从原理到代码从基础到生产全面覆盖了所有关键点。核心要点回顾拦截StatementHandler.prepare()方法通过BoundSql获取 SQL 和参数映射安全地替换?为格式化后的参数值处理各种数据类型和转义通过配置开关控制启用范围MyBatis-Plus 用户优先使用InnerInterceptor。掌握此技能你将告别“手动拼接 SQL”的低效时代大幅提升数据库调试与问题定位能力。