MyBatis 打印的 SQL 长这样:select * from article where id = ?,下一行才是 Parameters: 42(Long)。两行拼起来才是真正发到数据库的语句——参数一多、类型一复杂,靠肉眼拼就是灾难;而慢查询到底慢在解析、执行还是网络,MyBatis 的日志一个字都不提。
P6Spy 补的正是这个缺口:它在 JDBC 驱动外面包一层,拦住 PreparedStatement 的最终执行,把参数已经填好的 SQL、耗时、连接信息一起吐出来。
1. 它站在哪:不是日志框架,是驱动代理
| 层 | 组件 | 看到的东西 |
|---|---|---|
| ORM | MyBatis / JPA | 带 ? 的模板 SQL + 参数数组 |
| 连接池 | HikariCP | Connection 对象 |
| P6Spy | P6SpyDriver |
真实下发的 SQL、执行毫秒数、连接 id |
| 真驱动 | com.mysql.cj.jdbc.Driver |
实际网络协议 |
机制很朴素:把 driver-class-name 换成 com.p6spy.engine.spy.P6SpyDriver,由它加载真驱动,再把 URL 的 jdbc: 前缀改写成 jdbc:p6spy:。连接池拿到的还是标准 Connection,业务代码一行不用改。
2. 接入三步
第一步,加依赖:
// <dependency>
// <groupId>com.p6spy</groupId>
// <artifactId>p6spy</artifactId>
// <version>3.9.1</version>
// </dependency>
第二步,改驱动与 URL:
spring:
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:mysql://${DB_HOST:127.0.0.1}:3306/clarity?useSSL=false&serverTimezone=Asia/Shanghai
username: ${DB_USER:clarity}
password: ${DB_PASS:}
第三步,把 spy.properties 放到 src/main/resources 根目录。driver-class-name 必须显式写,不能靠 Spring Boot 从 URL 推断——jdbc:p6spy: 不是任何真驱动的合法前缀,自动推断会抛 Failed to determine a suitable driver class,这是接入 P6Spy 最高频的一次失败。
3. spy.properties 关键项
| 键 | 作用 | 建议值 |
|---|---|---|
driverlist |
真驱动类名 | com.mysql.cj.jdbc.Driver |
appender |
输出目的地 | dev 用 StdoutAppender,排障用 FileLogger |
logfile |
文件名 | spy.log,注意它落在进程工作目录 |
append |
追加还是覆盖 | true,否则每次启动清空 |
logMessageFormat |
格式器 | CustomLineFormat 才能自定义 |
customLogMessageFormat |
自定义模板 | 见下 |
excludecategories |
不打印的类别 | info,debug,result,commit,resultset |
deregisterdrivers |
关闭时注销驱动 | true,避免重复注册告警 |
useprefix |
是否保留 p6spy 前缀 |
false 时输出更干净 |
driverlist=com.mysql.cj.jdbc.Driver
appender=com.p6spy.engine.spy.appender.StdoutAppender
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=%(currentTime) | %(executionTime)ms | %(category) | conn %(connectionId) | %(sqlSingleLine)
excludecategories=info,debug,result,commit,resultset
deregisterdrivers=true
%(sqlSingleLine) 把换行压成空格,一行一条便于 grep;想看原始缩进用 %(sql)。
输出量还能再收一层:executionThreshold 只打印超过阈值的语句,filter 用正则整条跳过。这比在日志框架里写过滤器精确,因为它在拼串之前就把语句放掉了:
# 只记录超过 500ms 的语句,配合 logfile 当轻量慢日志用
executionThreshold=500
# 整条排除健康检查与心跳表
filter=true
exclude=.*from health_check.*,.*from qrtz_locks.*
executionThreshold 的单位是毫秒。它适合临时排查某个接口的慢 SQL,不适合长期开着——阈值只看耗时,看不出参数是否敏感。
4. 典型输出长什么样
开启后单条查询的形态是这样(此为典型输出,实际毫秒数随环境变化):
2026-11-09 12:00:01.234 | 12ms | statement | conn 3 | select id, title, created_at from article where status = 1 and tag = 'java' order by created_at desc limit 20
2026-11-09 12:00:01.301 | 3ms | commit | conn 3 |
注意三点:参数已被替换成字面量,日期是带引号的字符串,字符串里的单引号被转义。这条日志可以直接粘进 MySQL 客户端复现,这是它相对 MyBatis 日志的唯一硬优势。
5. 为什么生产必须关
| 成本 | 具体来源 | 后果 |
|---|---|---|
| CPU | 每次执行都要抓调用栈定位真实调用方、拼完整 SQL 字符串 | 与 QPS 线性相关,高并发下可见 |
| 内存 | 参数展开要复制大字段 | 一次查 10 万字符的正文,日志里就是 10 万字符 |
| 磁盘 | FileLogger 默认同步 append |
单条 1KB × 日 20 万条 ≈ 200MB/天 |
| 泄漏 | 参数是明文,含密码、手机号、身份证、Token | 日志一旦外流就是事故 |
| 干扰 | 混进应用日志,冲掉 traceId 关联 | 排障反而更慢 |
磁盘这一项在容器里尤其致命:spy.log 写在工作目录,很多镜像的工作目录就是挂载卷,日志爆盘会连带把应用写崩。这与"日志轮转"是两件事,P6Spy 自己不做轮转。
6. 只在 dev 生效
用 profile 隔离,让 URL 前缀跟着环境走:
# application-dev.yaml
spring:
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:mysql://127.0.0.1:3306/clarity?useSSL=false
---
# application-prod.yaml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST}:3306/clarity?useSSL=false&serverTimezone=Asia/Shanghai
再加一道启动自检,防止有人把 dev 配置带进生产镜像:
@Component
public class DriverGuard implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
String driver = System.getProperty("spring.datasource.driver-class-name", "");
String active = System.getProperty("spring.profiles.active", "");
if (active.contains("prod") && driver.contains("p6spy")) {
throw new IllegalStateException("p6spy driver detected in prod profile");
}
}
}
更隐蔽的形态是把 driver 写在 DB_DRIVER 环境变量里,生产环境忘了覆盖。自检比代码评审可靠。
如果不想维护 spy.properties,可以用 com.github.gavlyukovskiy:p6spy-spring-boot-starter,它把配置收进 decorator.datasource.p6spy.*,并自动处理驱动替换。代价是多一层封装,出问题时排查路径变长。
7. 与 MyBatis 日志怎么选
| 维度 | P6Spy | MyBatis log-impl |
|---|---|---|
| 输出内容 | 最终 SQL(参数已替换)+ 耗时 | 模板 SQL + 参数数组,两行 |
| 覆盖范围 | 所有走 JDBC 的语句,含 JPA、手写 JDBC | 只管 MyBatis 的 Mapper |
| 耗时可信度 | 单条 execute 的墙钟时间 | 无 |
| 直接可复现 | 是,可粘进客户端 | 否,要手工拼 |
| 开销 | 较高(拼串 + 抓栈) | 低 |
| 生产可用 | 不应开启 | 可开 debug 级别并限包名 |
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.example.clarity.mapper: debug
选择逻辑很简单:开发期要复现某条 SQL → P6Spy;长期开着的可观测性 → MyBatis debug 日志 + 慢查询日志。两者同时开只会得到重复且互相打断的输出。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
启动报 Failed to determine a suitable driver class |
只改了 URL 没改 driver-class-name |
两个一起改 |
| 没有任何输出 | spy.properties 不在 classpath 根,或 appender 指向了不存在的类 |
确认文件位置,检查 target/classes |
找不到 spy.log |
写在了进程工作目录 | 用绝对路径配 logfile |
报 No suitable driver |
driverlist 里的类名拼错或驱动未引入 |
与 pom 里的驱动核对 |
输出里 SQL 仍是 ? |
拦截到了 Statement 而非 PreparedStatement 的最终执行 |
确认 excludecategories 没把 statement 排除 |
| 连接池报连接失效 | 版本与 Hikari 的 isValid 探测不兼容 |
升级到 3.9.x 并开 deregisterdrivers |
9. 边界
| 场景 | 更合适的工具 | 理由 |
|---|---|---|
| 生产慢查询定位 | MySQL 慢查询日志 / performance_schema |
零应用侧开销,含执行计划 |
| 只要执行耗时分布 | Micrometer + DataSource 指标 |
可聚合、可告警 |
| 需要 SQL 审计留痕 | 数据库审计插件 | 应用日志可被绕过 |
| MyBatis 参数绑定问题 | log-impl + Parameters: 行 |
不需要驱动级代理 |
| 分库分表环境 | ShardingSphere 的 SQL 日志 | 代理层看不到逻辑 SQL 全貌 |
10. 自测
- 为什么换了
driver-class-name之后,Spring Boot 仍然报"找不到合适驱动"? spy.log默认写在哪,为什么容器里容易爆盘?excludecategories默认排除哪几类,排除statement会怎样?%(sql)和%(sqlSingleLine)的差别在什么场景下重要?- 生产环境判断"P6Spy 是否被误开",除了看配置还能看什么?
- 一条慢查询,P6Spy 的耗时和 MySQL 慢日志的
Query_time为什么可能不一致?