做 Java 后端这几年,排查线上问题最头疼的就是 SQL 相关的疑难杂症。接口突然变慢、数据对不上、偶发超时,光靠看代码你根本定位不到是具体哪条 SQL、传了什么参数、返回了什么结果。Spring Boot 项目里把 SQL 日志和结果完整打出来,这件事看着不起眼,真做起来细节非常多。从 logback 到配置文件,从 MyBatis 到 JPA,从只打预编译语句到把参数和结果集都搞出来,我这次把两套方案的完整做法、参数含义、踩过的坑一次讲清楚。
1. 先搞清楚要解决什么问题
1.1 排查慢SQL和脏数据时的真实痛点
先说个我自己的经历。之前负责一个订单查询服务,某天凌晨收到告警,说某个接口 P99 延迟从 200ms 飙到了 3 秒。我登录服务器翻了半天日志,发现业务日志里只有"开始查询订单列表""查询结束"这两行,中间那条 SQL 到底执行了多久、传了什么条件、扫了多少行,完全没有记录。最后只能靠猜,加上人工复现,折腾了两个多小时才定位到是某条 SQL 因为一个参数没走索引,全表扫描了。
这种场景在真实项目里太常见了。平时开发环境数据量小,SQL 怎么写都能秒回;一到生产环境,几百万行的表、复杂的多表关联、批量更新,问题立刻暴露。而多数项目里的日志配置是"能跑就行",SQL 属于 MyBatis 或者 Hibernate 内部输出,默认根本不会打到你的日志文件里。等出了问题再回头补日志配置,改代码、发版、等生效,黄花菜都凉了。
另一个痛点是没有参数上下文。很多团队的日志里只有一条 Preparing: select * from user where id = ?,问号后面到底是什么值完全看不到。排查数据问题时,你根本没法确认是不是传入了空值、超长字符串或者不该有的条件,只能凭口型和手气去猜。所以我的观点是:SQL 日志不打印参数,等于没打;不打印结果集,等于只打了一半。
1.2 SQL日志的三个层次
我习惯把 SQL 日志分成三个层次,排查问题时直接对照自己处于哪个层次。
第一层,只打印预编译后的 SQL 语句。能看到"执行了哪条 SQL",但参数是问号占位符,无法还原完整语句。适用于确认 SQL 有没有发出去、有没有走 MyBatis 的缓存这类粗粒度问题。
第二层,SQL 语句加参数绑定。能看到 Parameters: 1001(String), 2024-01-01(Date) 这种真实的入参,基本可以还原出完整的可执行 SQL。排查参数传错、类型不匹配、空值问题都够了。
第三层,SQL、参数、结果集全打。除了入参,还能看到返回的行数、每个字段的原始值,甚至映射后的业务对象。用于排查"为什么这条 SQL 返回的数据不对""为什么这个 List 里多了一条脏数据"这类问题。
层数越高,日志量越大,对性能的影响也越大。所以我建议开发环境打满三层,测试环境打第二层,生产环境只对指定接口或者按需临时打开第二层。后面讲的方案里,logback 配置和配置文件能打到第二层到三层之间,自定义拦截器可以自由控制第三层怎么打、打多少。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案全景:logback和配置文件两条路怎么选
2.1 两条路的底层逻辑
很多人分不清"用 logback 配置"和"用 application.yml 配置"到底是不是同一种东西,其实它们底层是同一套日志门面,只是配置入口不一样。
Spring Boot 默认使用 logback 作为日志实现,application.yml 里的 logging.level.* 配置,最终也是翻译成对 logback logger 的级别设置。所以你可以理解为:配置文件方式是一种"快捷开关",logback 方式是一种"完整控制"。两者并不冲突,甚至可以混用。
区别在于粒度。logging.level.com.example.mapper=debug 只能帮你把某个包或者某个类的日志级别调到 DEBUG,但 MyBatis 到底用什么方式输出 SQL、输出什么格式、要不要分环境处理,这些都需要靠 logback 的 logback-spring.xml 或者 MyBatis 自己的 log-impl 配置来控制。
还有一个容易忽略的点:MyBatis 内部有一套日志适配器机制。它启动时会自动探测 classpath 里存在哪个日志框架,然后选择对应的实现。如果探测到 slf4j(Spring Boot 项目基本都有),SQL 日志就会走 slf4j 输出,此时 logger 的名称是 Mapper 接口的全限定名,所以 logging.level.你的Mapper包名=debug 就能生效。如果你显式指定 mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,那它就是绕过 slf4j 直接往标准输出打印,这时候 logback 的配置就管不到它了。这个坑后面细说。
2.2 四条主流方案的横向对比
我实际用下来,最常见的方案大概是这四种。
| 方案 | 配置成本 | 能否打参数 | 能否打结果 | 样式控制 | 侵入性 |
|---|---|---|---|---|---|
| logging.level 配置文件开关 | 最低,几行 YAML | 能(MyBatis 自带 Parameters 行) | 部分(MyBatis 自带 Row 行) | 低,只能用框架默认格式 | 无 |
| logback-spring.xml 定制 logger | 中,需要写 XML | 能 | 部分 | 中,可控制 appender、格式、分环境 | 无 |
| 自定义 MyBatis 拦截器 | 高,需要写代码 | 能 | 能(对象级输出) | 高,完全自己定义 | 低,侵入代码但不需要改 SQL |
| p6spy 代理 JDBC | 中,加依赖加配置 | 能 | 部分 | 中,用配置文件定制 | 低,但需换驱动和 URL |
如果只是本地开发想看 SQL,用第一种就够了。如果项目要区分开发、测试、生产环境,而且希望日志格式统一、能带上请求追踪 ID,重点考虑第二种。如果公司有严格的日志规范,比如要求 JSON 格式输出、敏感字段脱敏、慢 SQL 自动告警,那就绕不开第三种方案。p6spy 作为第三方增强,功能很全但引入了额外依赖,而且版本和数据库驱动兼容性偶尔会让人头疼,我一般是在老项目里用得多一点。
2.3 我的选型建议
我的原则是:能用框架自带能力解决的,不引入新依赖;要写代码的,只写一次,沉淀成公共组件。
单模块的简单项目,直接 logging.level + mybatis.configuration.log-impl=Slf4jImpl,五分钟搞定。多模块、多环境、有日志规范的公司项目,必须上 logback-spring.xml,把 mapper 包的 logger 单独拎出来,配合 springProfile 区分环境。如果项目里 SQL 特别复杂、经常需要排查数据问题,或者有慢 SQL 治理需求,再花半天时间写一个 MyBatis 拦截器组件,后面所有项目都能复用。
千万不要干的一件事:在生产环境直接打开 spring.jpa.show-sql=true 或者把 mapper 包级别调到 DEBUG。这个操作会让每条 SQL 都往控制台和日志文件里灌,高并发场景下日志量爆炸,性能损耗肉眼可见。我第一次这么干的时候,一个每天几百万请求的服务,日志文件 20 分钟就被打满了,磁盘告警直接触发。
3. 实操第一篇:logback-spring.xml完整配置
3.1 多环境骨架与 appender 定义
先放一个我在多个项目里反复用的 logback-spring.xml 骨架。注意文件名必须是 logback-spring.xml 而不是 logback.xml,因为带 spring 后缀的文件才能使用 <springProfile> 这个 Spring Boot 扩展标签,否则启动时会直接报错。
xml复制<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} [%X{traceId}] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 开发环境:mapper 包输出 DEBUG -->
<springProfile name="dev">
<logger name="com.example.demo.mapper" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
</springProfile>
<!-- 测试环境:输出 DEBUG,同时限制只能输出到 SQL 日志文件 -->
<springProfile name="test">
<appender name="SQL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH:-logs}/sql.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH:-logs}/sql.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} [%X{traceId}] - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.example.demo.mapper" level="DEBUG" additivity="false">
<appender-ref ref="SQL_FILE"/>
</logger>
</springProfile>
<!-- 生产环境:mapper 包保持 INFO,不让 SQL 刷屏 -->
<springProfile name="prod">
<logger name="com.example.demo.mapper" level="INFO"/>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
几个关键点说明一下。
additivity="false" 表示当前 logger 的日志不再向上传递给 root,配合单独的 appender 可以做到"SQL 日志进独立文件,不污染业务日志"。如果不写这一行,SQL 日志会被当前 logger 处理一次,又会传给 root 输出到控制台,结果就是重复打印。
[%X{traceId}] 是从 MDC 里取请求追踪 ID。排查跨服务调用问题时,有了这个 ID 才能把一次请求的所有日志串起来。我会在网关或者入口过滤器里往 MDC 放一个 UUID,代码很简单,后面单独说。
${LOG_PATH:-logs} 是 logback 的属性占位符,冒号后面是默认值。Spring Boot 的 logging.file.path 配置会自动映射到 LOG_PATH 变量,这样可以做到不修改 XML 就能切换日志目录。
3.2 关键logger配置的粒度把控
配置 logger 的时候,name 属性的粒度直接影响你能看到什么。
如果你用的是 MyBatis,SQL 日志的 logger 名称是 Mapper 接口的全限定名。也就是说,com.example.demo.mapper 这个包下所有 Mapper 接口的 SQL 都会输出。但如果你的 Mapper 分散在多个包,或者你的项目里既有 MyBatis 又有 JdbcTemplate,那就要分别配置。
JdbcTemplate 的 SQL 日志 logger 是 org.springframework.jdbc.core.JdbcTemplate,而且它打印的是 Executing prepared SQL statement 这种格式,不会带参数绑定信息,除非你自己把 StatementCreatorUtils 的级别也调起来。
我再补充一个容易被忽略的点:如果有自定义 XML 映射文件放在 resources/mapper 目录,但对应的 Mapper 接口在 com.example.demo.mapper 包下,日志级别还是看接口的包名,和 XML 文件位置没关系。别在 XML 文件的路径上找半天,找不到原因。
3.3 让MyBatis的结果集也打出来
配置好 mapper 包 DEBUG 之后,你会在日志里看到类似这样的内容:
code复制==> Preparing: SELECT id, user_name, order_no FROM t_order WHERE user_id = ?
==> Parameters: 1001(String)
<== Columns: id, user_name, order_no
<== Row: 1, 张三, NO20240101001
<== Row: 2, 李四, NO20240101002
<== Total: 2
这里面 ==> 开头的行是入参阶段,<== 开头的行是结果阶段。Columns 列出返回的列名,Row 列出每行原始值,Total 是总行数。这些日志只有在 MyBatis 使用 slf4j 适配器时才会走 logback 输出,格式是 MyBatis 写死的,你没法改。
如果结果集打印得不够直观——比如你更想看映射后的 OrderDTO 对象、字段名和类型,Total: 2 这种原始格式就不够用了。这时候只能上自定义拦截器,我放在后面第 5 节详细写。如果你的项目用的是 JPA/Hibernate,结果打印又完全是另一套配置,第 4 节里会提到。
4. 实操第二篇:纯配置文件搞定SQL与结果打印
4.1 MyBatis + application.yml 的快速配置
不需要写任何 XML,在 application.yml 里加两段配置就行。
yaml复制logging:
level:
com.example.demo.mapper: debug
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
logging.level.com.example.demo.mapper: debug 是给 mapper 包下面的日志器设置 DEBUG 级别。mybatis.configuration.log-impl 指定 MyBatis 的日志实现,这里填 Slf4jImpl 意思是让 MyBatis 的 SQL 日志走 slf4j,这样才能被 logback 接管、被 logging.level 控制。
如果你图省事把 log-impl 写成 org.apache.ibatis.logging.stdout.StdOutImpl,那 SQL 日志会直接打印到标准输出。直观是直观,但问题有三个:第一,输出到 stdout 的日志不会进 logback 的文件,生产上你根本捞不到;第二,它不受你 logging.level 控制,想临时关掉都关不了;第三,格式完全写死,没有时间戳没有线程名,排查问题根本没法对齐上下文。所以我的建议是永远用 Slf4jImpl,别碰 StdOutImpl。
还有一种情况需要留意:如果 log-impl 不配置,MyBatis 会自动探测。但探测顺序不一定是你想要的,如果 classpath 里同时有 log4j2 和 slf4j,或者出现多版本冲突,MyBatis 选错适配器的情况真的会发生。显式指定 Slf4jImpl 能把这层不确定性直接干掉。
4.2 JPA/Hibernate的show-sql与参数绑定
用 JPA 的项目,配置路径完全不同,而且 Spring Boot 2.x 和 3.x 差异很大,我先列一个对比。
Spring Boot 2.x 搭配 Hibernate 5,SQL 级别配置:
yaml复制logging:
level:
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql.BasicBinder: trace
Spring Boot 3.x 搭配 Hibernate 6,SQL 级别配置:
yaml复制logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
第一行 org.hibernate.SQL: debug 负责打印 Hibernate 生成的 SQL,第二行打印参数绑定。Hibernate 6 重构了内部日志分类,老配置在新版本上不生效,网上很多文章没区分版本,照着抄容易翻车。
还有 spring.jpa.show-sql=true 这个开关,我建议你直接忘掉它。它的实现方式是往标准输出打印 SQL,也就是 System.out,不走日志框架,没有时间戳,没有级别,生产环境开了它只能污染控制台,而且性能比走 slf4j 还差。真正要做的是上面那两行 logging.level 配置,走正常日志管道,后面接 logback 的滚动策略和过滤都方便。
需要补充一点:JPA 查询结果集的字段值,Hibernate 也会在 trace 级别下输出,对应 BasicExtractor 或者 Hibernate 6 的 org.hibernate.orm.jdbc.extract。如果你是想看映射前的原始 JDBC 返回值,把这两个分类也调到 trace 就能看到。
4.3 结合配置文件的动态开关技巧
配置文件方案最大的短板是改级别要重启。但 Spring Boot 提供了 spring-boot-starter-actuator,里面带了一个日志端点,可以在不重启的情况下动态调整某个 logger 的级别。
bash复制# 把 mapper 包的日志临时调到 DEBUG
curl -X POST http://localhost:8080/actuator/loggers/com.example.demo.mapper \
-H "Content-Type: application/json" \
-d '{"configuredLevel":"DEBUG"}'
# 用完改回原来的级别
curl -X POST http://localhost:8080/actuator/loggers/com.example.demo.mapper \
-H "Content-Type: application/json" \
-d '{"configuredLevel":"INFO"}'
这个操作在排查生产问题时非常救命。平时生产环境保持 INFO,某条 SQL 出问题了,临时把对应 Mapper 接口调到 DEBUG,抓到那条 SQL 和参数之后再调回去,全程不用发版不用重启。前提是服务暴露了 actuator 端点,而且做好了权限控制,不能让所有人随手就改日志级别。
我实际用过这个技巧很多次,效果比登录服务器手动改 logback.xml 再重启进程好太多了。有一回排查一个偶发超时问题,就是靠这个端点临时抓到了某条 SQL 实际传入的时间范围参数,发现跨了 30 天的数据,索引直接失效。
5. 高阶方案:自定义拦截器掌控一切
5.1 为什么要自己写拦截器
框架自带的日志有几个硬伤:格式写死、无法输出映射后的业务对象、无法做慢 SQL 统计、无法对敏感字段脱敏。如果你只需要"能看",框架配置足够了;如果你要"可控、可查、可告警",就得自己写一个 MyBatis 拦截器。
MyBatis 的拦截器基于 JDK 动态代理,可以拦截 Executor、StatementHandler、ParameterHandler 等核心组件。拦截 Executor 的 query 和 update 方法,就能拿到 MappedStatement(包含 SQL 和参数映射)以及执行结果,这一层是最容易下手的。
注意事项先说一下:拦截器会对所有 Mapper 方法生效,写的时候要考虑性能。比如批量插入时参数对象是一个 List,你要避免对几千条数据进行耗时的反射操作;还有,拦截器内部尽量不要打印无关 debug 信息,否则日志量可能比业务日志还大。
5.2 完整的拦截器代码实现
下面这个拦截器是我在公共组件里沉淀过的版本,主要功能包括:打印可执行的 SQL、打印参数、打印返回结果的行数、记录耗时、支持超过指定阈值自动打 WARN。
java复制@Component
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class SqlTraceInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(SqlTraceInterceptor.class);
/** 慢 SQL 阈值,单位毫秒 */
private static final long SLOW_SQL_MILLIS = 500L;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
try {
Object result = invocation.proceed();
long cost = System.currentTimeMillis() - start;
BoundSql boundSql = ms.getBoundSql(parameter);
String targetSql = buildExecutableSql(boundSql, parameter);
if (cost >= SLOW_SQL_MILLIS) {
log.warn("[SQL慢查询] 耗时={}ms, 方法={}.{}, SQL={}",
cost, ms.getId(), getMethodName(invocation), targetSql);
} else {
log.info("[SQL] 耗时={}ms, 方法={}, SQL={}, 结果行数={}",
cost, ms.getId(), targetSql, countResult(result));
}
return result;
} catch (Exception e) {
log.error("[SQL异常] 方法={}, 参数={}", ms.getId(), parameter, e);
throw e;
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以从 properties 里读取慢SQL阈值配置
}
/** 把 ? 占位符替换为真实的参数值 */
private String buildExecutableSql(BoundSql boundSql, Object parameter) {
String sql = boundSql.getSql().replaceAll("\\s+", " ");
List<ParameterMapping> mappings = boundSql.getParameterMappings();
if (mappings == null || mappings.isEmpty()) {
return sql;
}
StringBuilder result = new StringBuilder();
int idx = 0;
for (ParameterMapping mapping : mappings) {
String property = mapping.getProperty();
Object value = resolveParamValue(parameter, property);
String display = (value == null) ? "NULL" : "'" + value + "'";
int pos = sql.indexOf("?", idx);
// 理论上每个映射都会对应一个 ?,但防御性处理
if (pos < 0) {
break;
}
result.append(sql, idx, pos).append(display);
idx = pos + 1;
}
result.append(sql.substring(idx));
return result.toString();
}
private Object resolveParamValue(Object parameter, String property) {
if (parameter == null) {
return null;
}
// 单参数:如果只有一个参数映射,直接返回参数本身
// 不严谨但常用,实际要结合 SimpleTypeRegistry 判断
if (parameter instanceof Map) {
return ((Map<?, ?>) parameter).get(property);
}
try {
// 如果是 POJO,通过反射取字段
Field field = parameter.getClass().getDeclaredField(property);
field.setAccessible(true);
return field.get(parameter);
} catch (NoSuchFieldException | IllegalAccessException e) {
return "[无法解析]";
}
}
private Object countResult(Object result) {
if (result instanceof List) {
return ((List<?>) result).size();
}
return result;
}
private String getMethodName(Invocation invocation) {
return invocation.getMethod().getName();
}
}
几个地方的说明。
@Component 注解配合 mybatis-spring-boot-starter,拦截器会被自动识别并注册到 SqlSessionFactory。如果你不是用 starter,而是手写 SqlSessionFactoryBean,那要把这个拦截器加到 factoryBean.setPlugins(new Interceptor[]{sqlTraceInterceptor}) 里面,否则不会生效。
buildExecutableSql 里的反射逻辑对单参数、Map 参数、POJO 参数做了兼容,但如果是批量插入这种参数是 List 的场景,需要额外处理。我的做法是检测到参数类型是 List 时循环取出每个元素,这里为了可读性省略了,实际用的时候要补上。
countResult 把 List 大小打出来,比打整个结果集清爽。如果你确实想打结果对象,把 result.toString() 打出来就行,但要注意结果集如果不重写 toString(),打出来可能是一堆对象引用地址,实用性为零。所以我的经验是:行数必打,对象内容按需打,且对象一定要维护好 toString()。
5.3 慢SQL告警与敏感字段脱敏扩展
代码写完之后,扩展点就都在你自己手里了。我后来在这个拦截器上加了两个能力,非常推荐你也试试。
第一个是慢 SQL 告警。把超过阈值的 SQL 打 WARN 只是第一步,进一步可以接入监控指标,比如把耗时通过 Micrometer 暴露到 Prometheus,或者把慢 SQL 内容发到内部的消息队列,由告警平台统一处理。实现不复杂,拦截器里拿到耗时后调一下监控 SDK 就行。
第二个是敏感字段脱敏。日志里直接打印用户手机号、身份证号、支付账号,是合规事故隐患。在 buildExecutableSql 里对已知敏感列名做正则替换,比如把 mobile = '138****1234' 这样的参数打码。我这个版本里没有做,但实际落地时这是必须考虑的一环。
我个人的体会是,写拦截器最大的收益不是"打印 SQL"本身,而是让你拥有了一套公司级别的 SQL 可观测底座。后面接什么东西都顺理成章。
6. 常见问题与排查实录
6.1 配置了但日志完全不出现
最常遇到的问题就是"我明明配置了,为什么日志里什么都没有"。我见过的情况无外乎三种。
第一种,文件名写错。用了 logback.xml 还想用 <springProfile>,启动直接报错,或者 Spring Boot 根本没有加载你的自定义配置。解决办法是改成 logback-spring.xml。
第二种,logger 名称和 Mapper 接口包名对不上。比如 Mapper 接口实际在 com.example.repository 包下,你却在配置里写了 com.example.mapper,那自然什么都打不出来。排查方法很简单,先把 logging.level.com.example=debug 整体调起来,如果能看到日志,再缩小范围到正确的包名。
第三种,MyBatis 的 log-impl 被显式配置成了 StdOutImpl,日志走了标准输出,你的文件 appender 当然收不到。把配置改成 Slf4jImpl 就能解决。
另外提一个隐蔽场景:部分老项目里 MyBatis 用的是 @MapperScan 扫描接口,但接口是在 jar 包里依赖进来的。这时候包名要写 jar 包里的全限定包名,不是你代码里的包名,别在本地代码里翻半天。
6.2 SQL打出来了,参数还是问号
这种情况几乎都出在 JPA/Hibernate 项目里,或者出现在你只配置了 org.hibernate.SQL: debug 但没配置参数绑定级别的时候。
正确的做法是再开一行 trace 级别的配置。Spring Boot 2.x 用 org.hibernate.type.descriptor.sql.BasicBinder: trace,Spring Boot 3.x 用 org.hibernate.orm.jdbc.bind: trace。两个都配上也不会报错,但级别开得越多日志越密,生产上别常开。
MyBatis 项目如果只看到 Preparing 没有 Parameters,优先检查是不是 SQL 日志走了 StdOutImpl。这两个实现的输出差异非常大,我一开始也以为是 MyBatis 抽风了,结果是配置选错了适配器。
6.3 生产环境日志量爆炸
日志量爆炸的原因基本就两个:某个 mapper 包级别开成了 DEBUG,或者开了 show-sql=true。
解决思路也分两步。第一,把全局级别保持 INFO,只对出问题的单个 Mapper 接口临时开 DEBUG,用 actuator 端点动态调整,排查完立即恢复。第二,如果确实需要在生产保留一定比例的 SQL 日志,那就用 logback 采样或者过滤,比如只记录耗时超过阈值的 SQL,这个用拦截器实现最合适。
我还遇到过一次日志量翻倍不是因为 SQL,而是因为 MyBatis 的 BaseJdbcLogger 在 DEBUG 级别下会把每个参数的附加信息也打出来,包括参数类型、是否为 null 等。虽然每一行很小,但在大促高峰期放大效应很可观。所以大流量项目里,SQL 日志真的不能常开。
6.4 多数据源场景的配置注意点
多数据源是另一个让我踩过坑的地方。如果项目里有多个 SqlSessionFactory,你只靠 @Component 注册拦截器,通常会只生效到默认的数据源上,另一个数据源的 SQL 日志就是没声音。
解决办法是手动为每个 SqlSessionFactoryBean 显式注册插件。比如:
java复制@Bean
public SqlSessionFactory sqlSessionFactoryOne(@Qualifier("dataSourceOne") DataSource dataSource,
SqlTraceInterceptor sqlTraceInterceptor) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setPlugins(new Interceptor[]{sqlTraceInterceptor});
return factory.getObject();
}
同理,日志级别的配置也要为两套 Mapper 包分别设置。多数据源项目里"配置了没生效"的问题,九成都是这个原因。
再提醒一点,多数据源如果用了分布式事务中间件,SQL 日志的追踪 ID 一定要从上游传递进来,不然你拿到一条 SQL 日志,根本不知道它属于哪个请求全局事务,排查起来会特别痛苦。
7. 最后分享一点我的实操心得
这套配置我前前后后在好几个项目里打磨过,最大的体会是:别追求一步到位,从最简方案起步,按需加码。
新项目刚起步时,logging.level 加两行就够了,够用就好。等项目到了有线上排障需求的阶段,再引入 logback-spring.xml 分割日志文件,把 mapper 包日志独立出来。等团队开始做 SQL 性能治理、慢查询大盘的时候,拦截器组件就该提上日程了。这三步之间不冲突,反而是一层一层叠上去的。
还有一个小技巧分享给做公共组件的朋友:把 SqlTraceInterceptor 做成自动配置类,通过 spring.factories 或者自动配置注解注册,项目里只要引用依赖,什么都不用配置就能生效。这样一套代码,团队所有服务都复用,排查问题时体验完全一致,不用每个服务单独调日志格式。
排查 SQL 问题这件事,日志配置永远只是第一步。真正要养成的习惯是:带着追踪 ID 看日志、带上参数看 SQL、带上行数看结果,三样齐全,绝大多数问题都能在五分钟内定位。希望这篇整理能让你少走我之前走过的弯路。
