有一次,一个测试同事跑过来问我,说某个查询接口在测试环境一直查不出数据,但同样的条件在开发环境就能查出来。我第一反应不是去看业务代码,而是让他把后端日志里的SQL语句发给我看。结果那头半天没动静——项目里明明挂了MyBatis-Plus,SQL日志却根本没打开。
这其实是个特别常见的现象:很多人以为MyBatis-Plus默认就会打印SQL,实际上它默认是关掉的。更麻烦的是,网上搜“mybatisPlus打印sql配置”能找到一堆答案,有让配log-impl的,有让配logging.level的,还有给插件推荐的,结果配完有的生效、有的不生效,甚至同一套配置换个项目就失灵了。这一篇就专门把这件事讲透,覆盖三种主流方案的原理、适用场景、完整配置和坑点,适合刚上手Spring Boot + MyBatis-Plus的人照着抄,也适合那些配过但没搞懂“为什么这个配置能用、那个配置没反应”的人。
1. 配置了半天看不到SQL,先弄懂MyBatis的日志门控
1.1 两级门控:SQL交给谁、谁愿意喊出来
先说结论:MyBatis-Plus打印SQL这件事,本质上不是MyBatis-Plus的功能,而是MyBatis框架的日志机制。MyBatis把SQL执行信息通过LogFactory交给当前项目里可用的日志实现,MyBatis-Plus只是在此基础上多给了几个配置入口。所以如果你不了解这套日志机制,配置很容易配了个“寂寞”。
这套机制可以理解成两级门控:
第一级,决定SQL日志“交给谁”。MyBatis启动时会自动探测项目里可用的日志框架,按顺序找 Slf4j、Log4j2、Log4j、JdkLog、StdOut 等,找到哪个用哪个。如果你显式指定了log-impl,就相当于跳过自动探测,直接指定一个实现类。
第二级,决定这个日志实现“喊不喊得出来”。即便SQL日志已经交给了某个日志框架,它能不能输出到控制台,还取决于对应的logger有没有达到输出阈值。比如你用的是Slf4j + Logback,MyBatis调用的是logger.debug()方法,那这条日志能不能打出来,就取决于这个logger的级别是否允许debug级别通过。
很多配置失效的根源都在第二级——日志已经交给Slf4j了,但mapper包名下没有任何logging.level配置,默认级别继承自root,通常是info,debug级别的SQL日志就被拦掉了。
1.2 MyBatis-Plus的log-impl到底做了什么
MyBatis-Plus在配置文件中提供了一个mybatis-plus.configuration.log-impl参数。它的本质作用是覆盖MyBatis的LogFactory探测逻辑,指定使用哪个org.apache.ibatis.logging.Log实现类。
常见的可选实现类有:
| 实现类 | 效果 |
|---|---|
org.apache.ibatis.logging.stdout.StdOutImpl | 直接往控制台System.out打印,不经过任何日志框架 |
org.apache.ibatis.logging.slf4j.Slf4jImpl | 通过Slf4j输出,受日志级别控制 |
org.apache.ibatis.logging.log4j2.Log4j2Impl | 通过Log4j2输出 |
org.apache.ibatis.logging.nologging.NoLoggingImpl | 关闭SQL日志输出 |
如果你设置了StdOutImpl,那么SQL日志会以==> Preparing:这样的形式直接打到控制台,不进入日志文件,也不受日志格式配置影响。如果你设置了Slf4jImpl,那么SQL依然是以debug级别打到Slf4j,日志格式、文件输出、级别控制都走你配置的日志框架。
这解释了为什么有人配了log-impl: StdOutImpl之后马上就看到SQL,非常粗暴有效;也解释了为什么有人配了log-impl: Slf4jImpl之后什么都看不到——因为Slf4j那一级门控没有打开,mapper包名的logging.level没配成debug。
1.3 SQL日志不输出时的排查决策顺序
如果你现在项目里正好看不到SQL,不用慌,按下面这个顺序排查,基本能定位:
- 先确认项目里是否有日志框架。纯Spring Boot项目默认带Logback,一般都有。如果你用了无日志依赖的轻量封装,那么
Slf4jImpl可能根本找不到实现。 - 确认配置的是哪条路。如果配了
log-impl: StdOutImpl,直接看控制台System.out输出,别去看日志文件;如果配了Slf4jImpl,去查日志框架配置和logger级别。 - 确认mapper接口包路径是否写对。配
logging.level时写的是Mapper接口所在的包路径,不是Mapper XML文件路径,更不是实体类包路径。一个常见低级错误是把com.example.demo.mapper写成了com.example.demo.entity,那当然打不出来。 - 检查
log-impl是否被写成了全限定类名但类名拼错。拼错一个字母不会启动报错,但日志实现会回退到默认探测,极难发现。
有一次我在一个老项目里排查了半天,最后发现是同事在配置文件里把stdout.StdOutImpl写成了stdout.StdoutImpl——大小写错了。类不存在,MyBatis静默回退,日志照旧不打,这种问题不看源码根本不会往这儿想。
2. 三种主流打印方式:适用场景与完整配置
2.1 方式一:logging.level配合Slf4j,走日志框架
这是我最推荐的一种方式。它的好处是SQL日志会进入统一的日志体系,可以单独设置输出级别、输出文件、日志格式,也可以和生产环境其他日志统一归档。
核心配置就两行:
logging: level: com.example.demo.mapper: debug其中com.example.demo.mapper换成你自己的Mapper接口包路径。如果项目里同时存在多模块、多包,可以配置多个:
logging: level: com.example.demo.mapper: debug com.example.demo.other.mapper: debug如果想控制得更细,可以在logback配置文件中单独指定logger,并且关闭继承,只让SQL日志进指定文件。下面是一个独立的logback配置片段,把mapper包下的所有SQL日志单独写到一个 rolling 文件里:
<appender name="SQL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/sql.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/sql.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern> </encoder> </appender> <logger name="com.example.demo.mapper" level="DEBUG" additivity="false"> <appender-ref ref="SQL_FILE"/> </logger>注意additivity="false"这一行,如果不设成false,SQL日志会同时往root logger里输出一遍,控制台、主日志文件、SQL文件各打一份,重复且难查。
这种方式下,MyBatis-Plus的配置文件中不需要额外设置log-impl,让MyBatis自动探测到Slf4j即可。如果项目里确实需要显式指定,可以写:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl但多数情况下没必要,自动探测就够了。
2.2 方式二:log-impl StdOutImpl,开发环境最快见效
如果你是本地开发调试,图省事,可以直接用StdOutImpl。它不经过日志框架,直接把SQL打印到System.out,也就是IDE的Console窗口。
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配置完重启应用,执行一条SQL,控制台会输出这样的内容:
==> Preparing: SELECT id,name,age,email FROM user WHERE id=? ==> Parameters: 1(Long) <== Columns: id, name, age, email <== Row: 1, 张三, zhangsan@example.com <== Total: 1这种方式有几个明确的局限:
- 不走日志框架,所以日志格式、文件归档、级别过滤全部失效。
- 输出会混在System.out里,和业务代码里的
System.out.println挤在一起,不容易筛选。 - 生产环境基本没人敢这么干,因为没法控制开关,日志量大,还会把敏感字段值打出来。
所以我的建议是:StdOutImpl只适合在本地开发的时候用,而且用完最好就改回去,别顺手提测。线上如果要用SQL日志,请用下一节的方式三,或者配合Actuator动态开关(后面会讲)。
2.3 方式三:P6Spy,打印带真实参数的完整SQL
前面两种方式打印出来的SQL,参数值和SQL语句是分开的:一是Preparing行的占位符语句,一是Parameters行的参数列表。遇到中文模糊查询、时间区间查询的时候,把两者拼起来才能看懂。但人眼拼多了总会有对不上的时候,如果追求“拿来就能直接执行”的SQL,就需要P6Spy。
P6Spy是一个JDBC驱动的代理层,它拦截真实数据库驱动执行前的SQL,把预编译语句和参数值拼成一条可以直接执行的SQL再打印出来。使用方法分三步:
第一步,引入依赖:
<dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency>第二步,修改数据源配置,让数据源走P6Spy驱动:
spring: datasource: driver-class-name: com.p6spy.engine.spy.P6SpyDriver url: jdbc:p6spy:mysql://localhost:3306/demo如果是Druid或者其他连接池,记得把连接池配置里的驱动类名也一起改过来,只改Spring的driver-class-name不一定够。
第三步,在资源目录下新建spy.properties:
driver=com.mysql.cj.jdbc.Driver driverlist=com.mysql.cj.jdbc.Driver appender=com.p6spy.engine.logging.appender.StdoutLogger logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat executionThreshold=1000driverlist里写你真实的JDBC驱动类名。executionThreshold是慢SQL阈值,单位毫秒,设为1000表示超过1秒的SQL会额外标记出来。P6Spy打印的SQL长这样:
143 ms|1|connection 0|SELECT id,name,age FROM user WHERE id=1|SELECT id,name,age FROM user WHERE id=1可以看到,参数已经被拼进SQL语句里了,直接拷到数据库客户端执行即可。这对于分析业务SQL、复现慢SQL非常方便。
2.4 三种方式的取舍对比
很多人在博客搜到三种方案后就挨个试,不如一开始就按需求选好。我做了一张对比表,你可以直接拿去用:
| 对比维度 | logging.level + Slf4j | log-impl StdOutImpl | P6Spy |
|---|---|---|---|
| 是否容易配置 | 简单 | 最简单 | 中等,要动数据源 |
| SQL是否带真实参数 | 分开打印 | 分开打印 | 拼接后完整打印 |
| 日志进日志框架 | 是 | 否 | 可配置,默认进控制台 |
| 支持日志文件归档 | 是 | 否 | 可配置 |
| 生产环境可用性 | 可临时开 | 不建议 | 不建议持续开启 |
| 对SQL执行性能影响 | 极小 | 极小 | 有一定额外开销 |
| 是否能看到慢SQL统计 | 否 | 否 | 是,executionThreshold |
依赖优先级:P6Spy的拦截发生在MyBatis之下、JDBC驱动之上,所以它打印的SQL是最终真正交给数据库执行的那一条。而logging.level和log-impl打印的是MyBatis组装出来的预编译语句。两者在某些边界情况下会不一致,比如MyBatis-Plus的分页插件会在发出SQL前自动拼接LIMIT,你从Preparing里一般看不到拼接细节,但P6Spy里能看到最终带LIMIT的完整SQL。
3. 打印出来的SQL到底该怎么读
3.1 Preparing与Parameters的对应关系
用logging.level或StdOutImpl打印出来的SQL,经常是两行配合着看:
==> Preparing: SELECT id,name,age FROM user WHERE status = ? AND name LIKE ? ==> Parameters: 1(Integer), '%张%'(String)第一行是预编译后的SQL模板,第二行是执行时绑定的参数列表,参数顺序和SQL里的?顺序一一对应。很多人刚接触时看不懂1(Integer)这种写法,其实就是在告诉你这个参数的类型是Integer,值是1。如果你只拷贝Preparing那一行去数据库客户端执行,会报参数数量不对,必须手动把?替换成第二行的参数值。
MyBatis-Plus的LambdaQueryWrapper生成的SQL有一个好处,就是条件字段是实体字段映射过来的,打印出来的SQL可以直接看到实际执行的字段名。比如你写user::getName但在数据库里字段名是name,打印出来的SQL会显示name,一眼就能发现字段映射是否正常。我在实际维护一个老项目时,就靠这个特性抓过好几次字段映射错误——代码里Lambda写错了字段,程序不报错,但SQL条件明显不对,看日志立刻现形。
3.2 为什么有些SQL看不到预编译语句行
有些人配好之后,执行saveBatch批量插入时发现日志只输出一行Preparing,后面没有对应的参数行,或者SQL数量明显比插入的条数少。这不是配置有问题,而是MyBatis的批量执行特性导致的。
MyBatis-Plus的saveBatch走的是ExecutorType.BATCH模式,多条插入语句会合并到同一个PreparedStatement上批量执行。MyBatis的日志机制在这种情况下不会逐条打印参数绑定信息,所以你看到的是被合并后的少数几条日志。如果此刻你正在排查批量插入的SQL为什么慢,建议改用P6Spy看最终发送到数据库侧的语句,得到的信息更接近真实执行情况。
3.3 从日志篇幅判断SQL是否异常
除了看SQL内容本身,日志的行数和耗时也能反映问题。正常情况下,一次查询会输出Preparing、Parameters、Columns、Row、Total这几行。其中Columns和Row是结果集相关的信息,如果查询结果特别大,Row会非常多,日志会被刷屏。如果一个SELECT连Columns都没有,那就是执行出错了,得去异常堆栈里找原因。
另外,日志里的====分隔线和<== Total: 0这种内容,很多人会紧张,以为是配置有问题。其实Total是查询返回的记录条数,为0只是说明没有匹配数据,不代表SQL执行失败。我刚带团队的时候,有个新人看到Total: 0就怀疑是SQL日志打漏了,反复调试了很久,最后才发现是数据真的不存在。这种解读经验,写在文档里没人看,踩过一次就记住了。
4. 坑点复盘:重复配置、多数据源与线上误开
4.1 log-impl和logging.level同时配置时的冲突
很多人为了保险,既配置了log-impl: StdOutImpl,又配置了logging.level.xxx.mapper: debug,结果控制台出现双份SQL日志。这是因为StdOutImpl不受日志级别控制,它直接打印到System.out,同时如果你配了logging.level,项目的日志框架也会输出一份Slf4j的SQL日志。
这属于配置冗余造成的混乱。正确的做法是“二选一”:
- 走
logging.level方式时,不要配log-impl,或者显式配Slf4jImpl。 - 走日志框架管理时,只使用
logging.level加debug阈值。 - 只有当你明确想通过System.out输出,且不关心日志框架整合时,才用
StdOutImpl。
还有一种更隐蔽的情况:项目里同时引用了mybatis-plus-boot-starter和mybatis-plus-extension等不同模块,各自版本不一致,导致配置项的兼容性出现偏差,log-impl不生效。这种问题很难从表面看出来,排查时最好先统一MyBatis-Plus相关依赖的版本。
4.2 多数据源下的配置边界
如果你的项目配置了多个数据源,比如主库master和从库slave,并且两个数据源都使用MyBatis-Plus,配置方式会有微妙差别。
用logging.level方式时,只需要保证两个数据源对应的Mapper接口包路径都在级别配置里:
logging: level: com.example.demo.master.mapper: debug com.example.demo.slave.mapper: debug用log-impl方式时,mybatis-plus.configuration.log-impl是全局生效的,没办法单独给某个数据源指定不同的打印方式。如果你两个数据源的日志需求不同(比如一个要StdOut、一个要Slf4j),就需要分别构建SqlSessionFactory时显式设置Configuration的LogImpl,这属于Spring配置高级用法,有需要的可以去查SqlSessionFactoryBean的自定义配置,这里不展开。
值得一提的是,如果你只是希望“某个包下打印SQL、另一个包不打印”,那用logging.level更容易做到——到mapper包级别设置debug,全局保持info。这个粒度比log-impl灵活得多。
4.3 线上误开SQL日志的代价
我在一个数据敏感的业务模块里处理过一个线上问题:运维为了排查一个慢查询,直接在生产环境上把logging.level调成了debug,跑了一个小时。事后发现日志库多了几个GB的SQL日志,而且里面包含了带参数的实际值——个人信息、未加密的状态字段、业务序号全在里面。更麻烦的是,这些日志在日志系统里默认保留了30天,等于把数据又复制了一份放在日志存储里,安全风险远大于排查收益。
所以关于生产环境,我的建议非常明确:
- 不要持续开启SQL日志。如果非开不可,用Actuator临时开,开完马上关。
- 优先用P6Spy的
executionThreshold只记录慢SQL,不是每条都打。 - 敏感字段多的表,SQL日志里直接会出现明文数据,评估风险后再决定开多久。
有不少团队会在配置文件里用profile隔离,开发环境开启SQL日志,测试环境按需开启,生产环境完全关闭。这个做法我认可,但更稳妥的是再加一层动态开关——需要排查时随时打开,排查完立即关闭,不用发布新包。
5. 把SQL日志收进独立文件,并做到随时开关
5.1 借助Spring Boot Actuator在线调整日志级别
Spring Boot Actuator提供了一个/actuator/loggers端点,可以在不重启应用的情况下调整任意logger的日志级别。第一步先暴露端点:
management: endpoints: web: exposure: include: loggers然后通过接口查询当前mapper包的日志级别:
curl http://localhost:8080/actuator/loggers/com.example.demo.mapper再通过POST请求临时打开debug:
curl -X POST http://localhost:8080/actuator/loggers/com.example.demo.mapper \ -H "Content-Type: application/json" \ -d '{"configuredLevel":"DEBUG"}'排查完成后,把级别改回INFO即可:
curl -X POST http://localhost:8080/actuator/loggers/com.example.demo.mapper \ -H "Content-Type: application/json" \ -d '{"configuredLevel":"INFO"}'这套组合拳配合logging.level方式使用效果最好。因为SQL日志走的是Slf4j + Logback体系,Actuator调整的就是这个体系里的logger级别,所以开和关都很干净,不会留下System.out刷屏的后续影响。也是这一点,让我在生产环境需要看SQL时几乎只用这种方式。
5.2 使用P6Spy只记录慢SQL
如果你不想看每条SQL,只想知道“哪些SQL超过1秒”,用P6Spy的executionThreshold是最舒服的。在spy.properties里设置:
executionThreshold=1000超过1秒执行的SQL会被标记,其他SQL忽略。这种方式既拿到真实SQL,又不会刷屏,适合日常性能巡检。另外P6Spy还支持exclude配置,把某些无关紧要的SQL排除掉,比如查询表结构信息的元数据SQL:
exclude=select.*from information_schema.*,SELECT.*FROM information_schema.*注意P6Spy默认也会打印连接管理相关的日志,如果觉得太吵,可以在spy.properties里调整模块启停。这里不逐一列全,只提醒一句:P6Spy的配置项不算少,别指望加了依赖就有理想输出,第一次配置花10分钟看下spy.properties的注释是值得的。
5.3 和日志框架联动,把慢SQL写入独立文件
P6Spy默认通过appender=com.p6spy.engine.logging.appender.StdoutLogger输出到控制台。如果你想让它进入日志文件,可以改用Slf4jLogger:
appender=com.p6spy.engine.logging.appender.Slf4jLogger这样P6Spy的日志会交给Slf4j,配合logback可以单独设置logger名字。
P6Spy的Slf4jLogger输出到哪个logger?默认logger名是p6spy。因此可以在logback里单独给p6spy配置独立appender、独立级别,这样生产环境即使开着P6Spy,也不会污染主业务日志:
<logger name="p6spy" level="INFO" additivity="false"> <appender-ref ref="SQL_FILE"/> </logger>配合前面的独立SQL文件appender,慢SQL就能单独沉淀出来,后续做分析、做报表都很方便。
这套组合我在一个每日百万级SQL的业务系统上试用过,开启后对整体性能的影响不明显,但排查问题的效率提升非常明显——以前遇到一个慢SQL要在日志文件里翻半天,现在直接查看慢SQL文件,按耗时排序,一眼锁定。不过还是那句话,生产环境需要评估安全风险,尤其是数据敏感性高的业务,SQL日志本身也是一种数据泄露渠道,能少开就少开。
写在最后:配置简单,但机制得吃透
我在实际排查项目问题时发现,MyBatis-Plus打印SQL这个配置本身不难,难的是不同项目里的日志体系不一样,配置方式组合起来五花八门。有人直接在application.yml里写log-impl: StdOutImpl就跑通了,有人配了logging.level却始终没反应,还有人把P6Spy装上去之后发现数据源连接不起来——这些都遇到过。
我的建议是,不管选哪种方案,先把MyBatis日志的两级门控机制想明白:第一级是SQL日志交给谁,第二级是交给之后能不能打出来。把这两步理清了,哪怕换一个日志框架、换一个项目结构,也能瞬间反应过来该改哪里,而不是一个个配置挨着试。
最后分享一个我自己常用的组合:本地开发用logging.level加上 Lombok 的@Slf4j体系,开启时写log-impl: Slf4jImpl,配合IDE的Console过滤,清爽不刷屏;生产环境排查用Actuator端点临时开、临时关;慢SQL巡检用P6Spy的executionThreshold,全部进独立文件。这套组合我用了很久,稳定、干净、可控,希望对你有参考价值。