1. 项目背景与核心诉求
在基于SpringBoot和MyBatis-Plus的后端开发中,排查数据库操作问题是个高频场景。无论是线上偶发的数据不一致,还是测试环境里某个查询结果不符合预期,我们第一时间想到的就是:“刚才执行的SQL到底是什么?它带的参数又是什么?” 如果只能去翻看控制台那转瞬即逝的输出,或者更糟,在生产环境根本没有输出,那排查效率会大打折扣。
把SQL日志和参数持久化到独立的日志文件里,这几乎是一个成熟项目的标配。它不仅仅是调试的利器,更是线上问题追溯、审计甚至性能分析(结合慢SQL)的重要依据。很多开发者知道需要在application.yml里配个logging.level,但实际做下来,经常会遇到日志文件里只有SQL没有参数、参数显示为?占位符、或者日志输出过于杂乱影响可读性等问题。
今天,我们就来彻底解决这个问题。目标很明确:在SpringBoot + MyBatis-Plus的项目中,将完整可执行的SQL语句及其真实的参数值,清晰、结构化地输出到我们指定的日志文件里,而不是淹没在控制台的海量信息中。我会基于常见的日志框架组合(Logback/SLF4J),从配置到原理,再到避坑细节,手把手带你实现。
2. 日志框架选型与基础配置解析
在动手之前,我们需要理清SpringBoot默认的日志生态。SpringBoot 2.x/3.x 默认使用的是SLF4J作为日志门面,并集成了Logback作为默认的实现。我们的配置将主要围绕Logback展开。如果你使用的是Log4j2,核心思路相通,但配置文件语法不同。
首先,我们得明确MyBatis-Plus(以及底层的MyBatis)是通过哪个Logger来输出SQL的。MyBatis内部使用其内置的日志工厂来适配各种日志框架。当我们设置了logging.level时,实际上是控制了对应Logger的日志级别。
核心配置(application.yml):
logging: level: # 关键配置:将MyBatis-Plus相关的Mapper接口所在包的日志级别设为DEBUG com.yourcompany.yourproject.mapper: DEBUG # 另一种更精确的方式,直接指定MyBatis使用的JDBC Logger org.apache.ibatis: DEBUG # 如果你需要看到更底层的连接池、事务等信息,可以开启以下(通常不需要) # org.springframework.jdbc.core.JdbcTemplate: DEBUG # org.springframework.jdbc.core.StatementCreatorUtils: TRACE file: # 指定日志文件路径和名称。不配置此项,日志默认只输出到控制台。 name: ./logs/app.log logback: rollingpolicy: # 配置滚动策略,避免单个日志文件过大 max-file-size: 10MB max-history: 30这段配置做了两件事:
- 设置日志级别:将我们Mapper接口所在包(或MyBatis核心包)的日志级别设为
DEBUG。这是必须的,因为SQL语句的执行信息在MyBatis中属于DEBUG级别。如果设为INFO,你将看不到任何SQL输出。 - 指定日志文件:通过
logging.file.name指定了日志输出的文件路径。这样,所有日志(包括我们将要输出的SQL)都会写入到./logs/app.log这个文件中。
然而,仅仅这样配置,你可能会发现日志文件里出现了SQL,但参数是像==> Parameters: 1(Integer), “test”(String)这样的形式,或者更糟,只有?。这并没有达到我们“完整可执行SQL”的终极目标。接下来,我们需要更精细地控制日志的输出格式和内容。
3. 定制Logback配置以实现清晰SQL输出
默认的SpringBoot Logback配置可能不符合我们的需求,比如我们希望将SQL日志单独输出到一个文件,或者调整其格式。我们需要创建一个自定义的logback-spring.xml文件,放在src/main/resources目录下。
完整的 logback-spring.xml 配置示例:
<?xml version="1.0" encoding="UTF-8"?> <configuration scan="true" scanPeriod="60 seconds"> <!-- 定义统一的时间格式和彩色编码(控制台用) --> <property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/> <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/> <!-- 1. 控制台输出 (彩色,适合开发环境) --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${CONSOLE_LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 2. 将所有DEBUG及以上级别的日志输出到主应用文件 --> <appender name="FILE-APP" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>./logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 按日期和大小滚动 --> <fileNamePattern>./logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>10MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>3GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 3. 【关键】将SQL相关日志单独输出到一个文件 --> <appender name="FILE-SQL" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>./logs/sql.log</file> <!-- 过滤器:只接受特定Logger的日志 --> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>DEBUG</level> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>./logs/sql.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>10MB</maxFileSize> <maxHistory>7</maxHistory> <!-- SQL日志通常保留更短时间 --> <totalSizeCap>1GB</totalSizeCap> </rollingPolicy> <encoder> <!-- 简化格式,专注于SQL内容本身 --> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} | %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- Logger配置 --> <logger name="com.yourcompany.yourproject.mapper" level="DEBUG" additivity="false"> <!-- additivity="false" 表示此logger的日志不再向上传递至root,避免重复打印 --> <appender-ref ref="FILE-SQL"/> </logger> <!-- 也可以直接控制MyBatis的JDBC Logger --> <logger name="org.apache.ibatis" level="DEBUG" additivity="false"> <appender-ref ref="FILE-SQL"/> </logger> <logger name="jdbc.sqlonly" level="DEBUG" additivity="false"> <appender-ref ref="FILE-SQL"/> </logger> <logger name="jdbc.sqltiming" level="INFO" additivity="false"/> <!-- 关闭其他过于冗长的JDBC日志 --> <logger name="jdbc.audit" level="OFF"/> <logger name="jdbc.resultset" level="OFF"/> <logger name="jdbc.resultsettable" level="OFF"/> <logger name="jdbc.connection" level="OFF"/> <!-- 根Logger,控制其他所有日志的输出 --> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE-APP"/> </root> <!-- 开发环境Profile,在控制台也打印SQL --> <springProfile name="dev"> <logger name="com.yourcompany.yourproject.mapper" level="DEBUG" additivity="false"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE-SQL"/> </logger> </springProfile> <!-- 生产环境Profile,可能只记录WARN以上级别,且SQL日志单独收集 --> <springProfile name="prod"> <root level="WARN"> <appender-ref ref="FILE-APP"/> </root> <logger name="com.yourcompany.yourproject.mapper" level="DEBUG" additivity="false"> <appender-ref ref="FILE-SQL"/> </logger> </springProfile> </configuration>这个配置的核心在于:
FILE-SQLAppender:我们创建了一个专门用于记录SQL的滚动文件Appender。通过<filter>和特定的<logger>配置,将Mapper或MyBatis相关的日志定向到这里。additivity="false":这个属性至关重要。它阻止了日志事件向上传递到根Logger (root)。如果不设置为false,那么SQL日志既会被FILE-SQL记录,也会被根Logger配置的FILE-APP和CONSOLE记录,导致日志文件中出现重复条目。<springProfile>:利用Spring的Profile功能,我们可以实现环境差异化的配置。在dev环境下,SQL同时在控制台和sql.log文件中输出,方便调试;在prod环境下,SQL只输出到sql.log文件,控制台保持洁净。
4. 解决参数占位符与获取完整可执行SQL
即使配置了正确的日志级别和文件,默认的MyBatis日志输出格式可能仍然不是最理想的。它通常将SQL语句和参数分开打印,例如:
DEBUG 12345 --- [nio-8080-exec-1] c.y.p.m.UserMapper.selectById : ==> Preparing: SELECT id, name, age FROM user WHERE id=? DEBUG 12345 --- [nio-8080-exec-1] c.y.p.m.UserMapper.selectById : ==> Parameters: 1(Integer) DEBUG 12345 --- [nio-8080-exec-1] c.y.p.m.UserMapper.selectById : <== Total: 1这对于阅读来说不够直观,我们更希望看到的是拼接好参数的完整SQL,类似于:
DEBUG 12345 --- [nio-8080-exec-1] c.y.p.m.UserMapper.selectById : Executing SQL: SELECT id, name, age FROM user WHERE id=1方案一:使用MyBatis-Plus的SqlInjector与自定义插件(推荐)
MyBatis-Plus提供了强大的插件机制。我们可以通过一个简单的插件,在SQL执行前后拦截并打印出我们想要的格式。
- 创建自定义SQL日志打印插件:
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; import org.apache.ibatis.executor.statement.StatementHandler; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.*; import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.reflection.SystemMetaObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.sql.Connection; import java.util.Properties; @Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) @Component public class SqlLoggerInterceptor implements Interceptor { private static final Logger log = LoggerFactory.getLogger("SQL_LOGGER"); // 可以使用特定Logger @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaObject = SystemMetaObject.forObject(statementHandler); // 分离代理对象链,获取真实的StatementHandler while (metaObject.hasGetter("h")) { Object h = metaObject.getValue("h"); metaObject = SystemMetaObject.forObject(h); } while (metaObject.hasGetter("target")) { Object target = metaObject.getValue("target"); metaObject = SystemMetaObject.forObject(target); } MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement"); String mapperId = mappedStatement.getId(); BoundSql boundSql = statementHandler.getBoundSql(); String rawSql = boundSql.getSql(); Object parameterObject = boundSql.getParameterObject(); // 获取并处理参数,这里简化处理,实际可能需要递归处理复杂对象 String formattedSql = formatSql(rawSql, parameterObject, boundSql); // 使用我们指定的Logger和格式进行输出 log.debug("\n===== SQL LOG =====\nMapper ID: {}\nExecuting SQL: {}\n===== END =====\n", mapperId, formattedSql); return invocation.proceed(); } /** * 一个简单的SQL参数格式化示例(生产环境建议使用更健壮的库,如Apache Commons Lang) */ private String formatSql(String sql, Object parameterObject, BoundSql boundSql) { if (parameterObject == null) { return sql; } // 这里是一个极其简单的替换,仅用于演示。实际参数映射非常复杂。 // 更安全的做法是直接使用MyBatis提供的ParameterHandler和TypeHandler来获取真实值。 // 或者,可以直接打印 rawSql 和 boundSql.getParameterMappings()/boundSql.getParameterObject() // 对于简单的数字、字符串参数,以下逻辑可能工作。 String formatted = sql; for (Object paramValue : boundSql.getParameterObject() instanceof Map ? ((Map<?, ?>) boundSql.getParameterObject()).values() : new Object[]{boundSql.getParameterObject()}) { if (paramValue != null) { formatted = formatted.replaceFirst("\\?", "'" + paramValue.toString() + "'"); } } return formatted; } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }- 将插件注册到MyBatis-Plus的拦截器链中:
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(SqlLoggerInterceptor sqlLoggerInterceptor) { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加分页插件(如果需要) interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 添加我们自定义的SQL日志插件 // 注意:需要将自定义的Interceptor适配为InnerInterceptor,这里为了演示,假设SqlLoggerInterceptor实现了InnerInterceptor接口。 // 更常见的做法是,自定义插件直接通过@Bean注入,MyBatis会自动扫描@Intercepts注解。 // 因此,通常不需要在这里添加,只需要确保SqlLoggerInterceptor被Spring管理即可。 return interceptor; } // 确保自定义拦截器被Spring管理,如上文的SqlLoggerInterceptor已标注@Component }重要提示:上面的
formatSql方法是一个非常简陋的示例,仅用于演示思路。真实生产环境中,SQL参数替换涉及复杂的类型(如日期、Clob、Blob)、参数映射(#{}和${}的区别)、以及集合类型(如IN语句)。强烈不建议自己手写复杂的替换逻辑,容易出错且不安全。更推荐下面的方案二。
方案二:使用P6Spy(第三方JDBC驱动代理)
P6Spy是一个开源的JDBC驱动代理框架,它可以拦截并记录所有通过JDBC发出的SQL语句及其参数,并格式化成完整的可执行SQL。这是获取“完整SQL”最直接、最可靠的方式之一。
- 添加依赖(以Maven为例):
<dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> <!-- 请使用最新版本 --> </dependency>修改数据源配置:将你原来的JDBC URL中的驱动类和URL进行修改。
- 原配置(如使用MySQL):
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/your_db?useSSL=false&serverTimezone=UTC - 修改为P6Spy配置:
spring: datasource: driver-class-name: com.p6spy.engine.spy.P6SpyDriver # 驱动类改为P6Spy的 url: jdbc:p6spy:mysql://localhost:3306/your_db?useSSL=false&serverTimezone=UTC # URL前加上 `jdbc:p6spy:`
- 原配置(如使用MySQL):
创建P6Spy配置文件(
spy.properties,放在src/main/resources下):
# 指定真实JDBC驱动的完整类名 modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory # 自定义日志打印 logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger # 使用日志系统记录sql appender=com.p6spy.engine.spy.appender.Slf4JLogger # 是否开启慢SQL记录 outagedetection=true # 慢SQL记录标准,单位:秒 outagedetectioninterval=2 # 开启自动刷新日志文件 autoflush=true- MyBatis-Plus对P6Spy的增强:MyBatis-Plus提供了一个更美观的日志格式类
P6SpyLogger(已在上述配置中指定)。它会将SQL输出为如下格式,非常清晰:
Consume Time:15 ms 2023-10-27 14:33:25 Execute SQL:SELECT id, name FROM user WHERE id = 1通过P6Spy,你可以非常方便地获得拼接好参数的完整SQL,并且它支持多种日志输出方式(文件、Slf4J等)。缺点是它作为一层代理,对性能有微小的开销,并且在极少数情况下可能与某些连接池或监控工具冲突。
5. 生产环境下的优化与注意事项
将SQL日志输出到文件后,在生产环境中我们需要考虑更多运维相关的问题。
1. 日志级别动态调整在生产环境,我们可能默认只记录WARN或ERROR级别的日志以节省IO。但当出现数据问题需要排查时,又需要临时开启DEBUG级别的SQL日志。我们可以借助Spring Boot Actuator的Loggers端点来实现动态调整。
- 添加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>- 在
application.yml中暴露端点(注意安全,应配合权限管理):
management: endpoints: web: exposure: include: loggers,health,info- 通过HTTP
POST请求动态修改日志级别:
这样可以在不重启应用的情况下,临时开启SQL日志,问题排查完毕后,再将其改回curl -X POST -H "Content-Type: application/json" -d '{"configuredLevel":"DEBUG"}' http://your-server:port/actuator/loggers/com.yourcompany.yourproject.mapperINFO或WARN。
2. 日志文件管理与切割我们之前的logback-spring.xml已经配置了按日期和大小滚动(SizeAndTimeBasedRollingPolicy)。在生产环境,还需要关注:
- 磁盘空间:设置合理的
maxHistory(保留天数)和totalSizeCap(总大小上限),并配合监控告警。 - 日志清理:可以考虑使用Linux的
logrotate工具或Kubernetes的Sidecar容器进行更复杂的日志生命周期管理。
3. SQL日志的安全与脱敏将完整的SQL(特别是参数)记录到日志文件存在安全风险,例如可能暴露用户密码、手机号等敏感信息(如果这些信息作为查询条件)。必须在输出前进行脱敏处理。
- 在自定义拦截器中脱敏:在上述的
SqlLoggerInterceptor的formatSql方法中,在拼接SQL字符串前,对特定参数进行脱敏。这需要你定义一套规则,例如识别出参数名包含password、phone等字段时,将其值替换为***。 - 使用P6Spy的过滤器:P6Spy也支持自定义过滤器(
com.p6spy.engine.spy.appender.CustomLineFormat),可以在日志行输出前进行字符串替换和脱敏。 - 架构层面:最根本的,应避免将明文密码等敏感信息作为查询条件。对于日志的访问权限也要严格控制。
4. 性能考量频繁的DEBUG级别日志记录,尤其是IO操作(写文件),会对应用性能产生影响。
- 异步日志:考虑使用Logback的异步Appender (
AsyncAppender) 来包装FILE-SQLAppender。这样日志事件会被放入一个队列,由单独的线程负责写入磁盘,避免阻塞主业务线程。
然后在Logger中引用<appender name="ASYNC-SQL" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <!-- 队列满时,是否丢弃低于某级别的日志,0为不丢弃 --> <queueSize>256</queueSize> <!-- 队列大小 --> <includeCallerData>false</includeCallerData> <!-- 是否包含调用者数据,获取它开销大 --> <appender-ref ref="FILE-SQL"/> </appender>ASYNC-SQL。 - 采样记录:在极高并发场景下,可以考虑只采样记录SQL日志,例如每100条记录1条,但这会丢失部分信息,需权衡。
5. 与监控系统集成单独的日志文件不利于集中分析和告警。可以考虑:
- 使用ELK Stack或Graylog:通过Filebeat或Logstash收集
sql.log文件,发送到Elasticsearch,再利用Kibana或Graylog进行可视化查询、分析和设置告警规则(例如,出现特定错误SQL或慢SQL时触发告警)。 - 使用APM工具:如SkyWalking、Pinpoint等,它们通常能提供更强大的链路追踪和SQL分析功能,包括SQL执行时间、调用链关联等,比单纯的日志更强大。
6. 常见问题排查与解决思路
在实际配置过程中,你可能会遇到以下问题:
问题1:配置了logging.level,但日志文件里还是没有SQL。
- 检查点:
- 确认配置的包路径是否正确。最准确的方式是查看应用启动时,执行SQL的Mapper接口的完整类名。
- 确认日志级别确实是
DEBUG。Spring Boot的配置优先级:命令行参数 >application-{profile}.yml>application.yml。检查是否有其他配置覆盖了你的设置。 - 检查
logback-spring.xml是否被正确加载。可以在启动日志中搜索Loaded configuration from 'classpath:logback-spring.xml'。如果没有,可能是文件名不对或位置不对。 - 检查自定义的Logger配置是否设置了
additivity="false",并且没有同时被根Logger的级别过滤掉。
问题2:SQL日志输出到了控制台,但没有输出到指定的文件。
- 检查点:
- 确认
logback-spring.xml中FILE-SQL这个Appender的<file>路径应用有写入权限。 - 确认对应的Logger(如
com.yourcompany...mapper)确实引用了FILE-SQL这个Appender (<appender-ref ref="FILE-SQL"/>)。 - 检查是否有其他日志框架冲突。确保项目中排除了Spring Boot默认的
spring-boot-starter-logging(如果使用Log4j2),或者没有引入多个日志实现jar包。
- 确认
问题3:使用P6Spy后,应用启动报错或连接数据库失败。
- 检查点:
- 检查P6Spy版本与你的JDK、Spring Boot版本是否兼容。
- 检查
spy.properties配置文件是否正确,特别是modulelist和driverlist(如果使用)的配置。 - 检查数据库连接池配置(如HikariCP)。有时需要在P6Spy的URL中传递一些原始驱动需要的参数,或者需要调整连接池的驱动类识别。
- 查看更详细的P6Spy启动日志,它通常会输出在应用日志的开头部分。
问题4:日志文件过大,增长过快。
- 解决思路:
- 优化
logback-spring.xml中的滚动策略,减小maxFileSize,减少maxHistory。 - 确保只在必要的时候开启
DEBUG级别的SQL日志。生产环境默认应为INFO或WARN,通过Actuator动态开启。 - 检查是否有循环调用或N+1查询问题导致产生了大量重复或低效的SQL,这本身也是性能问题需要优化。
- 优化
问题5:脱敏规则复杂,难以维护。
- 解决思路:
- 考虑使用成熟的脱敏工具库,如阿里云的
fastjson(配合@JSONField的serialize/deserialize属性)或jackson的注解,在序列化到日志之前处理。但这通常需要将SQL参数对象进行序列化。 - 将脱敏逻辑抽象成独立的服务或规则引擎,通过配置文件来管理哪些字段需要脱敏以及脱敏规则,提高可维护性。
- 从源头避免,审视业务设计,尽量减少敏感信息在查询条件中的使用。
- 考虑使用成熟的脱敏工具库,如阿里云的
通过以上六个部分的详细拆解,从基础配置、高级定制、生产优化到问题排查,你应该能够在你SpringBoot + MyBatis-Plus的项目中,游刃有余地配置和管理SQL日志了。记住,清晰的日志是快速定位问题的基石,花点时间搭建好这套基础设施,会在未来的开发运维中节省大量时间。