说实话,用 MyBatis Plus 开发的时候,最让人抓狂的场景是什么?不是业务逻辑写不出来,而是 SQL 出了错,控制台却只丢给你一个 Caused by java.sql.SQLException,你想确认到底拼出来的 SQL 长什么样,结果日志里干干净净,只能靠猜。今天这篇就把 MyBatis Plus 打印 SQL 的配置方式、背后原理和排坑经验一次性说完,照着抄就能用,不管是新手还是老手,排查数据问题至少能快上一倍。
这个配置解决的场景非常具体:你想知道 MyBatis 帮我们生成的那条 SQL 究竟是什么、参数绑定的是什么、最终影响了几行数据。它适用于所有用 MyBatis Plus 做持久层的 Java 项目,Spring Boot 单体、微服务都行。遇到查询结果不对、插入报错、性能变慢这类问题,SQL 日志就是你手上最早的"现场证据"。别嫌这个知识太基础,我见过不少项目上线半年了,同事连 log-impl 这个配置项都不知道,遇到问题全靠复制异常堆栈去问别人。把打印 SQL 这个开关用好,后面 80% 的数据问题你自己就能定位。
1. 先说结论:三种最常用的打印 SQL 配置
1.1 最快见效:StdOutImpl 一行配置
如果你只是想马上看到 SQL,别的先不管,那直接在 application.yml 里这样配:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
配完重启项目,随便执行一条 mapper 方法,控制台就会直接刷出类似这样的日志:
code复制==> Preparing: SELECT id,name,age,email FROM user WHERE id=?
==> Parameters: 1(Integer)
<== Total: 1
这就是 MyBatis 底层实际执行的那条 SQL。Preparing 表示预编译语句,Parameters 是绑定进去的参数,Total 是查询返回的总行数。对于日常调试来说,这一行配置基本够用了。
StdOutImpl 这个实现类的特点很直接:它通过 System.out 打印到控制台,完全不经过任何日志框架,也不受 logging.level 控制。好处是简单、零依赖、在任何项目里都能立竿见影;坏处是日志格式没法统一管理,线上如果开着它会直接把 SQL 全量打到标准输出,对日志采集和磁盘都不太友好。所以我的建议是:本地开发随便用,一旦牵扯到测试环境、预发环境,最好换一个更可控的方案。
1.2 更规范:Slf4jImpl + mapper 包 debug 级别
如果你的项目已经接了 SLF4J(Spring Boot 默认就是 Logback,底层就是 SLF4J),那更推荐用 Slf4jImpl。配置分两步:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
logging:
level:
com.example.demo.mapper: debug
先看 log-impl,它在告诉 MyBatis:打日志的时候走 SLF4J 门面,而不是直接 System.out。第二步是设置 mapper 包下的日志级别为 debug,这一步非常关键。MyBatis 打印 SQL 用的 logger 名称是 mapper 接口的全限定名,比如 com.example.demo.mapper.UserMapper,所以你必须把对应包的级别调到 debug,日志才会真正输出。
这里有个很容易踩的坑:很多人只配了 log-impl: Slf4jImpl,忘了把 mapper 包级别改成 debug,然后跑起来发现控制台啥也没有,就开始怀疑配置是不是写错了。其实配置没问题,是 info 级别把 debug 日志过滤掉了。反过来,如果你用了 StdOutImpl,却又去配置 logging.level,那也不会生效,因为 StdOutImpl 根本不走日志框架。这两种方式不要混着用,否则会让人很迷惑。
1.3 其他日志实现与选型对比
MyBatis 内置的日志实现其实不止这两个,我把常用的几个整理成一个表:
| 实现类 | 日志输出方式 | 是否受 logging.level 控制 | 适用场景 |
|---|---|---|---|
| StdOutImpl | System.out 直接打印 | 否 | 本地开发快速排查 |
| Slf4jImpl | 走 SLF4J 门面,交给 Logback/Log4j2 输出 | 是,需设置 mapper 包为 debug | 测试、预发、生产按需开启 |
| Log4j2Impl | 走 Log4j2 输出 | 是,需设置 mapper 包为 debug | 项目统一使用 Log4j2 时 |
| NoLoggingImpl | 不输出任何 SQL 日志 | 无 | 确认完全不需要 SQL 日志时 |
选型建议其实很朴素:看你的项目用的是什么日志体系。如果用的是 Spring Boot 默认的 Logback,就选 Slf4jImpl;如果项目单独接了 Log4j2,就选 Log4j2Impl。不要图省事在线上长期挂 StdOutImpl,因为线上日志基本都是走文件采集的,直接打到标准输出很容易把日志文件撑爆,而且没法按模块动态开关。另外还要提醒一点:如果老项目还在用 mybatis-config.xml 配置,对应的写法是 <setting name="logImpl" value="STDOUT_LOGGING"/>,效果等价于 StdOutImpl;想走日志框架就配 SLF4J,这些常量在 MyBatis 源码里都有对应枚举,记不住也没关系,配的时候查一下就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 配置背后发生了什么
2.1 log-impl 其实是 MyBatis 原生的核心配置
很多用 MyBatis Plus 的同学以为 log-impl 是 Plus 特有的配置,其实不是。这个配置在 MyBatis 时代就有了,它是 org.apache.ibatis.session.Configuration 里的 logImpl 属性,用来指定 MyBatis 内部用哪个日志适配器。MyBatis Plus 只是把 MyBatis 的配置项整体保留并暴露到了 mybatis-plus.configuration 下面,所以你在 YAML 里写的 log-impl,最终是设置到了 MyBatis 的全局 Configuration 上。
理解了这一点,很多衍生问题就能想通了。比如为什么配置要放在 mybatis-plus.configuration 而不是 mybatis.configuration?因为你的项目如果完全使用 MyBatis Plus 的自动装配,它读取的就是 mybatis-plus 前缀下的配置;如果你同时自定义了 SqlSessionFactory 或者其他 MyBatis 配置,那么真正生效的可能是你那套配置,而不是 YAML 里的。这也是后面"配置了没生效"问题的一个常见根源。
2.2 日志输出格式逐行拆解
打印出来的 SQL 日志,看起来格式很简单,但每一行都有讲究:
code复制==> Preparing: SELECT id,name,age,email FROM user WHERE (age >= ? AND name LIKE ?)
==> Parameters: 18(Integer), %张%(String)
<== Columns: id, name, age, email
<== Row: 1, 张三, 18, zhangsan@example.com
<== Total: 1
==> Preparing 那行是预编译时的 SQL 模板,里面所有参数都是 ? 占位符。==> Parameters 是真正的参数列表,MyBatis 会按顺序把每个参数的类型和值打出来,比如 18(Integer) 表示第一个参数是 Integer 类型的 18。这一步能帮你确认参数顺序和类型是不是对的,特别是多个条件时,参数顺序错位会导致查询结果完全不对,但又不报错,看日志一眼就能发现。
<== Columns 和 <== Row 只有在查询语句才会出现,分别代表结果集的列名和每一行的值。<== Total 是最终返回的行数。如果是 UPDATE 或 DELETE 语句,通常只会有 Preparing 和 Parameters 两行,外加一个更新影响行数的提示。通过这个数字,你可以判断 update 是否真的匹配到了目标记录,我排查"数据没更新成功"问题时,基本第一步就是看 Parameters 对不对、影响行数是不是 0。
2.3 参数显示为 ? 号不是 Bug,而是预编译的正常形态
第一次看到 WHERE id = ? 这种日志的人,经常会问:为什么没把真实的 id 拼进去?这不是配置没生效,也不是 MyBatis 偷懒,而是 JDBC 预编译机制决定的。MyBatis 底层用的是 PreparedStatement,它先把 SQL 模板发给数据库做预编译,再用 setInt、setString 这类方法把参数逐个绑定上去。这样做的好处是能防止 SQL 注入,同时也让数据库可以复用执行计划,性能更稳定。
所以你在日志里看到 ?,恰恰说明你的代码用的是安全的 #{} 参数占位。那有没有办法在日志里直接看到带真实参数的完整 SQL?有,常见方案是接 P6Spy,这个我在第 5 节专门讲。不过在那之前,如果你只是想临时看一眼完整语句,把 Parameters 那行的值手动替换掉 ? 再拿到数据库客户端执行,完全够用。记住:日志里出现 ? 是正常的,如果哪天你看到的 SQL 里直接把参数拼进去了,比如 WHERE name = 张三,那就要警惕是不是有人在 XML 里写了 ${}。
3. 进阶场景:分页、批量写入、条件构造器下的日志使用
3.1 分页查询:COUNT 和 LIMIT 两条 SQL 的观察方法
很多人一用 MyBatis Plus 分页就开始蒙圈,总觉得分页插件内部"变了魔法"。其实在 SQL 日志下,它做的事情暴露得很清楚。比如你执行这样一段代码:
java复制Page<User> page = new Page<>(1, 10);
LambdaQueryWrapper<User> wrapper = Wrappers.<User>lambdaQuery()
.ge(User::getAge, 18);
userMapper.selectPage(page, wrapper);
控制台会输出两类 SQL:
code复制==> Preparing: SELECT COUNT(*) AS total FROM user WHERE (age >= ?)
==> Parameters: 18(Integer)
<== Total: 1
==> Preparing: SELECT id,name,age,email FROM user WHERE (age >= ?) LIMIT ?,?
==> Parameters: 18(Integer), 0(Long), 10(Long)
<== Total: 10
第一条 COUNT 是分页插件自动生成的统计总数语句,第二条才是真正的分页数据。看到这个结构,你就明白为什么分页接口总是多一次查询。排查分页问题的时候,重点看两条 SQL 的 WHERE 条件是否一致。我遇到过不少次总数对不上,一查日志发现 COUNT 那条 SQL 条件丢了或者多了个 DISTINCT,那就是分页插件解析或者你代码里的 wrapper 在 selectPage 复用造成的。另外 LIMIT ?,? 后面跟着的两个参数,第一个是偏移量,第二个是每页条数,如果发现偏移量算错了,可以从这里反推当前页和页大小是怎么传的。
顺便提一句,分页插件本身是通过 @Bean 配置 MybatisPlusInterceptor 注入的,不是在 YAML 里配置的,网上有些旧文章说在 yml 里配分页,那是误导。分页插件和日志配置是两回事,一个管拦截器,一个管日志输出,别混在一起。
3.2 saveBatch 批量写入时日志里的门道
MyBatis Plus 的 saveBatch 是高频接口,但它底层日志的表现形式和普通单条 insert 完全不一样。看下面的例子:
java复制List<User> userList = new ArrayList<>();
userList.add(new User("张三", 18));
userList.add(new User("李四", 20));
userService.saveBatch(userList);
如果配置了 SQL 日志,你会发现它并不会干净利落地打两条独立的 INSERT。MyBatis 的批处理执行器 BatchExecutor 会复用同一条 PreparedStatement,把一条一条记录 addBatch 进去,最后统一执行。所以日志里 Preparing 可能只出现一次,但 Parameters 会跟着每条记录走,整体看起来像是"一条 SQL 多次绑定参数"。
这个现象有个实际用处:当批量数据量特别大、怀疑某条数据字段有问题(比如某个值过长、类型不对)时,从日志里按顺序核对 Parameters 就能快速定位到出问题的那条记录。但也别指望日志能完整还原每次批处理的提交情况,它和数据库实际执行批次不是一一对应的。要确认批量是否真正分批成功,看影响行数和数据库端记录数更靠谱,单纯数日志行数是没用的。另外,批量插入本身就比逐条 insert 性能高很多,开着 SQL 日志会影响一点写入性能,大批量导入场景建议先关掉日志再跑。
3.3 LambdaQueryWrapper 条件如何从日志验证
LambdaQueryWrapper 拼条件很爽,但条件多了以后,你很难凭肉眼确定它到底拼成了什么样子,尤其是涉及 and、or、in、嵌套条件的时候。SQL 日志就是最直接的验证工具。举个例子,你写了这样一个条件:
java复制LambdaQueryWrapper<User> wrapper = Wrappers.<User>lambdaQuery()
.eq(User::getStatus, 1)
.and(w -> w.like(User::getName, "张").or().eq(User::getAge, 18));
日志输出会是这样:
code复制==> Preparing: SELECT id,name,age,email,status FROM user WHERE (status = ? AND (name LIKE ? OR age = ?))
==> Parameters: 1(Integer), %张%(String), 18(Integer)
看到没,括号的嵌套关系在 SQL 里一目了然。如果 or 的优先级写错了,日志里括号的位置一定会反映出问题。很多新手写 wrapper 的时候不确定 and 和 or 到底会怎么组合,我的建议就是写完先跑一下看日志,条件结构一验便知。这比翻源码看优先级靠谱得多。
还有一个隐藏的好处:从日志可以看到 LIKE 语句是怎么处理模糊匹配的。MyBatis Plus 的 like 方法默认会自动加上 %,日志里显示 %张%,如果你传参时自己又加了 %,那就变成了 %%张%%,匹配结果就会不对。这类问题光看代码很难发现,但从参数日志里一眼就能看出来。
4. 常见问题与排查技巧实录
4.1 配置了但没有任何 SQL 日志输出
这个问题我见到过太多次了,总结下来基本是这么几个原因:
第一,配置位置写错了。YAML 缩进和层级非常重要,log-impl 必须写在 mybatis-plus.configuration 下面,不要写到 spring 或者 mybatis 下面去。写错位置不会报错,但配置就是读不到。
第二,日志实现和日志级别不匹配。用了 Slf4jImpl 却没有把 mapper 包级别调到 debug,日志被日志框架过滤掉了。解决方式就是确认 logging.level 下配了对应的 mapper 包路径,而且路径必须和 Mapper 接口的包名完全一致,少一层都不行。
第三,项目里自定义了 SqlSessionFactory。如果你通过 @Bean 手动创建了 SqlSessionFactory,并且没有把 MyBatis Plus 的配置传进去,那么 YAML 里写的东西可能压根没被使用。出现这种情况,优先检查你是否覆盖了 MyBatis Plus 的自动配置。还有一个冷门情况,项目里引入多个数据源时,每个数据源对应一个 SqlSessionFactory,日志配置只对绑定了该配置的那一个生效。
4.2 日志刷屏、线上环境如何安全关闭
开发环境开 SQL 日志没问题,但线上开着很容易出事故。尤其是高峰期,一个频繁调用的查询接口可能每秒产生几十条 SQL 日志,日志文件增长速度快得吓人,还会拖累 IO。我有一个比较稳妥的实践:开发环境用 StdOutImpl,什么花样都不用;测试环境换成 Slf4jImpl,把 mapper 包级别设成 debug,方便联调排查;生产环境把 log-impl 设成 Slf4jImpl,同时把 mapper 包日志级别保持为 info。这样一旦需要临时排查,只要通过配置中心把级别降成 debug,就能动态打开 SQL 日志,排查完再调回去,全程不用重启。
如果你真的遇到线上日志已经刷爆了的情况,第一步是马上把 mapper 包级别调回 info 或者把 log-impl 改成 NoLoggingImpl,先止血。第二步才去看是不是有慢查询或者哪儿触发了全表查询。要记住,SQL 日志是排查手段,不是常态运行配置,别让日志本身成为线上事故。
4.3 从 SQL 日志发现 ${} 拼接注入风险
这个技巧我认为每个 MyBatis 使用者都应该知道。正常情况下,#{} 写的参数在日志里一定是 ? 占位符;如果你在日志里看到 WHERE name = 张三 这种把参数直接拼接进去的 SQL,那代码里八成用了 ${}。
${} 在 MyBatis 里做的是字符串替换,如果参数来自用户输入,就存在 SQL 注入风险。排查历史代码时,先把整个项目的 mapper XML 或者注解里的 ${} 搜一遍,再用日志确认实际执行语句,是成本最低的做法。我之前帮朋友看过一个项目,某个导出接口查询条件就是用 ${} 拼接的排序字段,结果被人在参数里塞了恶意内容,幸好没造成什么损失。从那以后,我每到一个新项目,第一件事就是把 SQL 日志打开,然后写几个接口触发一下,看看有没有非占位符的 SQL 出现。
4.4 字段名/关键字导致的 SQL 语法错误
我还遇到过一个很经典的坑:表里有个字段叫 order,MyBatis Plus 生成的 SQL 里直接写 ORDER BY order,然后就报语法错误了。这种问题从报错堆栈很难一眼看出来,因为真正的 SQL 是运行时生成的,但打开日志,复制那条报错 SQL 丢到数据库客户端执行,立刻就能判断是不是关键字冲突。
我排查 SQL 语法错误有个固定套路:先把日志里的完整 SQL 和参数复制出来,参数手动替换进占位符,拿到客户端跑一遍。跑通了,说明 SQL 本身没问题,可能是 MyBatis 的参数绑定出了问题;跑不通,那就根据数据库报错位置反推 SQL,看看是哪一段语法不对。日志在这个过程里就是唯一的现场还原工具,没有它你只能盲猜。
4.5 日志太多但只想要部分 Mapper 的 SQL
还有一个很实用的技巧。如果只排查某个特定的 Mapper,不需要把整个项目所有 mapper 的 SQL 都打出来,那可以精确把日志级别控制到类级别,比如:
yaml复制logging:
level:
com.example.demo.mapper.UserMapper: debug
这样其他 Mapper 的 SQL 日志不会输出,只有 UserMapper 的 SQL 会被打印。在有一定流量的测试环境里,这个做法能大幅减少日志噪音,让你一眼就找到自己关心的那条 SQL。这个配置只对走日志框架的实现(如 Slf4jImpl)生效,用 StdOutImpl 是无法做到按类过滤的,这也是我推荐使用 Slf4jImpl 的原因之一。
5. 更专业的方案:用 P6Spy 输出带真实参数的 SQL
5.1 为什么还需要 P6Spy
MyBatis 自带的日志虽然能看到参数,但它是把"SQL 模板"和"参数列表"分开打的,你得手动把参数填进去才能得到一条完整的可执行 SQL。在排查分页、批量操作等复杂场景时,手动拼参数挺累的,而且容易拼错。
我之前也一直觉得 MyBatis 自带日志够用了,直到有一次排查一个极其隐蔽的数据问题:同一套代码在测试环境没问题,到生产环境查出来的数据不对。两条 SQL 看起来一样,参数也看不出差别,但结果就是不一样。后来我上了一个能打印真实 SQL 的工具,才发现生产环境实际执行的 SQL 和测试环境在参数类型上有细微差异,数据库触发了一个隐式转换。要是没有真实 SQL,这个问题我可能还要排查很久。
5.2 集成步骤:依赖、配置、spy.properties
这里我以 P6Spy 为例,它是最常用的一个方案。Spring Boot 项目可以引入官方 starter,也可以手动配置,原理一样。
第一步,引入依赖。starter 方式:
xml复制<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
<version>1.9.1</version>
</dependency>
第二步,改数据源连接配置。把原来的 JDBC URL 加上 jdbc:p6spy: 前缀,同时把 driver 改成 P6Spy 的代理驱动:
yaml复制spring:
datasource:
url: jdbc:p6spy:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
第三步,在 classpath 下新建 spy.properties:
properties复制# 日志输出到 SLF4J,和项目日志框架保持统一
appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 多行格式,输出完整的 SQL 和参数
logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat
# 超过 500ms 的 SQL 才会记录(慢 SQL 阈值)
executionThreshold=500
配好之后,日志里看到的就是类似这样的完整语句:
code复制2025-01-15 14:30:22.123 INFO 12345 --- [http-nio-8080-exec-1] p6spy : 2025-01-15 14:30:22.123
took 2ms
SELECT id, name, age, email FROM user WHERE (status = 1 AND name LIKE '%张%')
参数已经直接拼进 SQL 里了,复制出来就能在数据库客户端执行。
5.3 P6Spy 的注意事项与慢 SQL 统计思路
P6Spy 虽然好用,但有几个点必须注意。首先,它是在 JDBC 层做拦截打印的,比 MyBatis 自带的日志多了一层代理,性能有一定损耗,线上环境如果流量大,不建议长期开启。我一般只在开发、测试环境开,或者生产环境短时间开启做慢 SQL 分析。
其次,executionThreshold 可以设置慢 SQL 阈值,比如 500ms,超过这个时间的 SQL 才会被记录。这是一个非常实用的排查手段,配合日志里的耗时字段,可以快速定位哪些语句是拖慢接口的元凶。不过要注意,P6Spy 统计的耗时包含了网络传输和数据库执行的总时间,比数据库端看到的执行时间会长一点,属于正常现象。
最后,如果你想对 SQL 日志做数据脱敏,比如隐藏手机号、身份证字段,P6Spy 也支持自定义 logMessageFormat,实现一个自己的 Formatter 类,在输出前对敏感数据做掩码处理。这在大一点的公司里比较重要,因为 SQL 日志一旦发给第三方或者进入日志平台,敏感信息泄露就是事故。如果你的项目还没做这块治理,建议尽早规划。
在实际操作中,我自己的使用习惯是:开发环境把 log-impl 配成 StdOutImpl,图的就是简单直接,看 SQL 一眼到位;测试环境切成 Slf4jImpl,配合日志级别在需要的时候开 debug;线上环境默认关闭 SQL 日志,真出问题了先通过日志级别动态打开,还不够看再临时上 P6Spy 做慢 SQL 分析。踩过几次坑之后你会发现,打印 SQL 虽然只是一个小配置,但能不能用好它,直接决定你排查一个数据问题需要 5 分钟,还是 5 个小时。
