
1. 為什么你的控制臺里永遠只看到一堆問號先聊個真實場景。你用 MyBatis-Plus 做列表查詢代碼沒有任何問題但控制臺打出來的 SQL 長這樣 Preparing: SELECT id,name,age,email FROM user WHERE id ? Parameters: 1(Long)肉眼看著是不是挺痛苦小數(shù)據(jù)量倒無所謂一旦 SQL 復雜一點、參數(shù)多幾個你要想把這個 SQL 復制到 Navicat 里手動跑一遍就得上上下下比對哪個問號對應哪個參數(shù)。尤其是排查線上數(shù)據(jù)問題的時候這種半截子 SQL會讓你多花好幾倍時間。這個問題的根源其實很簡單MyBatis-Plus 底層是 MyBatis而 MyBatis 對 SQL 的處理遵循的是 JDBC 的預編譯規(guī)范——先用問號占位再通過PreparedStatement.setXxx()把參數(shù)一個個綁進去。這種機制在性能和防注入上沒得黑但它在日志輸出時天然就是SQL 和參數(shù)分家的控制臺里印出來的自然就是占位符版本。我見過不少人以為這是 MyBatis-Plus 的 bug跑到 GitHub 上提 issue其實不是。MyBatis 默認的日志工廠會根據(jù) classpath 里已有的日志框架自動選擇輸出方式而絕大多數(shù) Spring Boot 項目的日志級別是 infoMyBatis 的 SQL 日志又恰好掛在 debug 級別下所以很多人連上面那兩行都看不到更別提帶參數(shù)的完整 SQL 了。這篇文章就把我平時在項目里用的幾種讓控制臺打印完整帶參數(shù) SQL的方案全部攤開講一遍包括它們各自的適用場景、配置方法、不生效的排查思路以及我在生產(chǎn)環(huán)境里踩過的坑。內(nèi)容不算難但確實屬于那種沒配過就不知道哪里出錯的典型問題。2. 最直接的正統(tǒng)方案改log-impl配置讓 SQL 走標準輸出2.1 配置就一行但你要知道它背后做了什么MyBatis-Plus 在application.yml里提供了一個配置項官方文檔叫l(wèi)og-impl。它的作用是指定 MyBatis 的日志實現(xiàn)類。你只需要寫成這樣mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl如果是.properties后綴的配置文件對應寫法是mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl配置完成后重啟應用再跑一次剛才的查詢控制臺會變成這樣 Preparing: SELECT id,name,age,email FROM user WHERE id ? Parameters: 1(Long) Columns: id, name, age, email Row: 1, 張三, 18, zhangsanexample.com Total: 1看到?jīng)]有參數(shù)在線打印出來了查詢結(jié)果里的字段名和每一行數(shù)據(jù)也都印出來了。排查問題的時候你直接把Preparing那一行 SQL 里的問號手動替換成 Parameters 里的實際值就能拿去數(shù)據(jù)庫客戶端里執(zhí)行不用再猜來猜去。2.2 為什么選 StdOutImpl 而不是別的 Impllog-impl的值本質(zhì)上是org.apache.ibatis.logging.Log接口的實現(xiàn)類MyBatis 自帶了好幾個StdOutImpl、Slf4jImpl、Log4jImpl、Log4j2Impl、NoLoggingImpl、Jdk14LoggingImpl、CommonsLoggingImpl。這里面最值得玩味的是StdOutImpl。StdOutImpl的實現(xiàn)非常簡單粗暴它不走任何日志框架直接調(diào)用System.out.println()把 SQL 打到標準輸出流。這就帶來一個好處——它不受你項目的日志級別配置影響。哪怕你 logback 把根級別設成 error控制臺照樣能把 SQL 打出來因為標準輸出和日志框架壓根不是一條鏈路。這個特性在本地調(diào)試時非常香。你不需要關(guān)心項目里到底用的 Logback 還是 Log4j2也不需要去翻日志框架的配置文件一行配置搞定。我見過很多新手在log-impl和logging.level之間來回折騰搞了半天 SQL 還是不出來最后發(fā)現(xiàn)是 root 級別太高把 debug 日志過濾掉了。用StdOutImpl就沒這個煩惱。但反過來它也有天然的短板既然走了System.out那就意味著你沒法對這個 SQL 日志做統(tǒng)一歸檔、按天切分、按級別過濾這些操作它跟業(yè)務日志完全是兩套體系。所以我的建議是StdOutImpl適合開發(fā)環(huán)境、測試環(huán)境日常調(diào)試用別把它當成生產(chǎn)環(huán)境的長期配置。生產(chǎn)環(huán)境想觀察 SQL應該用后面介紹的方案。2.3 順手聊聊NoLoggingImpl和Slf4jImpl跟StdOutImpl對應的還有兩個實現(xiàn)類你可能會在其他項目里碰到。NoLoggingImpl顧名思義就是完全關(guān)閉 MyBatis 的 SQL 日志輸出。有時候團隊里有人嫌日志太多直接在配置里寫了它結(jié)果所有人排查問題時都看不見 SQL。如果你發(fā)現(xiàn)項目里 SQL 日志消失得無影無蹤先去log-impl里看一眼是不是被設成了NoLoggingImpl。Slf4jImpl則是走 SLF4J 門面輸出配合 Spring Boot 默認的 Logback 使用。配置它之后SQL 日志的級別是 debug并且在 Logback 配置里可以通過 logger name 來精細控制例如只讓某個 mapper 包輸出 SQL。它不像StdOutImpl那樣無視日志級別所以配置完還要記得把對應包的日志級別降到 debug或者用logging.level來覆蓋。這一點是很多人踩坑的重災區(qū)后面我會專門展開講。3. 更省心的logging.level方案配合日志系統(tǒng)精細控制生產(chǎn)環(huán)境也能用3.1 原理和配置方式如果說log-impl: StdOutImpl是粗暴輸出那logging.level方案就是精致管理。它的思路是不改變 MyBatis 的日志實現(xiàn)類讓它保持默認的 SLF4J 適配而是要靠日志框架本身來決定 SQL 日志是否輸出。具體做法是在application.yml里加一行l(wèi)ogging: level: com.example.demo.mapper: debug這里com.example.demo.mapper換成你自己的 Mapper 接口所在的包路徑或者直接指定某個 Mapper 類的全限定名例如com.example.demo.mapper.UserMapper: debug。配置完成后控制臺輸出的日志會帶 Logger 名稱長這樣2025-01-15 10:23:45.678 DEBUG 12345 --- [nio-8080-exec-1] c.e.d.mapper.UserMapper.selectById : Preparing: SELECT id,name,age,email FROM user WHERE id ? 2025-01-15 10:23:45.679 DEBUG 12345 --- [nio-8080-exec-1] c.e.d.mapper.UserMapper.selectById : Parameters: 1(Long) 2025-01-15 10:23:45.679 DEBUG 12345 --- [nio-8080-exec-1] c.e.d.mapper.UserMapper.selectById : Total: 1有日志框架統(tǒng)一的時間戳、線程號、Logger 名稱看起來是不是像正規(guī)軍了這里有個關(guān)鍵前提你的log-impl不能是NoLoggingImpl。如果項目里有其他地方把它關(guān)了那logging.level配得再對也沒用。比較穩(wěn)妥的做法是把log-impl配成org.apache.ibatis.logging.slf4j.Slf4jImpl或者干脆不配log-impl讓 MyBatis 自己從 classpath 里探測 SLF4J。3.2 生產(chǎn)環(huán)境怎么臨時看一眼這種方案在生產(chǎn)環(huán)境最大的價值在于你可以通過調(diào)整日志級別臨時打開某個 Mapper 的 SQL 日志不需要改代碼、不需要重新發(fā)版。比如你使用的是 Spring Boot 的 actuator 和 Spring Cloud Config那完全可以利用/actuator/loggers這個端點在線修改某個 logger 的級別。沒有 actuator 的話也可以登錄服務器改 Logback 配置再 reload??赐炅嗽僬{(diào)回 info整個過程對業(yè)務無感。我有一年在排查一個線上數(shù)據(jù)不一致的問題時就是靠這種方式把某個核心 Mapper 的日志級別臨時調(diào)成了 debug跑了幾分鐘拿到完整 SQL 和參數(shù)確認了是時間字段的時區(qū)轉(zhuǎn)換導致查詢范圍出錯。問題定位完日志級別立刻調(diào)回 info不會給磁盤帶來壓力。3.3 和StdOutImpl的取舍簡單總結(jié)一下這兩者的取舍對比維度StdOutImpllogging.level 方案輸出路徑System.out 直接輸出走 SLF4J落入日志文件日志級別不受日志框架限制受日志框架級別限制需 debug格式只有 MyBatis 自己的輸出格式有統(tǒng)一時間戳、線程號、Logger 名生產(chǎn)可用性不建議開可臨時開啟但要注意量局部關(guān)閉做不到可按 Mapper 包/類精確開關(guān)配置復雜度一行搞定需要理解日志體系要是你懶、只想本地趕緊看到 SQL選StdOutImpl。要是你想在生產(chǎn)環(huán)境排查問題時安全地開日志就必須走logging.level這條路線。兩條路不是互斥的本地開發(fā)用StdOutImpl運維環(huán)境把log-impl去掉或設成Slf4jImpl然后靠logging.level控制開關(guān)是我比較推薦的組合。4. 讓 SQL 完整拼出來的兩個高級姿勢p6spy 與自定義攔截器4.1 p6spyJDBC 驅(qū)動層的代理打印整條可執(zhí)行 SQL如果你覺得上面兩種方式打出來的 SQL 還是分家的——Preparing一行、Parameters一行拼接起來麻煩——那你想要的效果其實是控制臺直接輸出一條完整的、可以直接復制的 SQL例如SELECT id,name,age,email FROM user WHERE id 1這時候就輪到 p6spy 出場了。p6spy 做的事情是在 JDBC 驅(qū)動上包了一層代理。MyBatis 最終執(zhí)行 SQL 要通過 JDBC 驅(qū)動p6spy 就盯在這里等 MyBatis 把占位符和參數(shù)都傳給真正的驅(qū)動之前它把參數(shù)一個個取出來自動替換掉問號拼成一條完整 SQL再通過自己配置的 appender 輸出。所以它的優(yōu)點很明確輸出的是成品 SQL不是半成品。接入 p6spy 的步驟有三步。第一步在pom.xml里加依賴dependency groupIdp6spy/groupId artifactIdp6spy/artifactId version3.9.1/version /dependency第二步改數(shù)據(jù)源配置。原來的jdbc:mysql://localhost:3306/demo要改成jdbc:p6spy:mysql://localhost:3306/demo驅(qū)動類也要改成com.p6spy.engine.spy.P6SpyDriverspring: datasource: url: jdbc:p6spy:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8 driver-class-name: com.p6spy.engine.spy.P6SpyDriver注意數(shù)據(jù)庫驅(qū)動本身的 jar 包還是要保留的p6spy 只是代理不是替代品。第三步在src/main/resources下新建spy.properties最簡配置長這樣appendercom.p6spy.engine.spy.appender.Slf4JLogger logMessageFormatcom.p6spy.engine.spy.appender.CustomLineFormat customLogMessageFormat%(currentTime) | took %( executionTime)ms | %(sqlSingleLine)%(sqlSingleLine)這個格式符會把 SQL 占位符替換成真實參數(shù)并且壓縮成單行。executionTime是這次 SQL 的耗時排查慢 SQL 時非常好用。配置好再跑一次查詢控制臺會多出這么一行取決于你用的 appender2025-01-15 10:23:45.681 INFO 12345 --- [nio-8080-exec-1] p6spy : 2025-01-15 10:23:45 | took 1ms | SELECT id,name,age,email FROM user WHERE id 1有沒有很驚喜整條 SQL 是拼好的復制就能直接用還能看到耗時。我現(xiàn)在本地開發(fā)時配的 SQL 日志就是 p6spy 的格式排查問題的效率高出一截。不過要注意p6spy 是有性能損耗的。它在代理層做參數(shù)替換和字符串拼接如果你用了很多批量插入日志量會非常夸張。我在一個導入場景里試過一次萬級數(shù)據(jù)的批量插入p6spy 的日志直接讓控制臺滾動到?jīng)]法看。生產(chǎn)環(huán)境如果真要開一定要配合exclude配置把某些高頻的查詢排除掉或者限定只在測試環(huán)境使用。4.2 自定義 MyBatis 攔截器嫌棄 p6spy 重那就自己拼有的項目不方便引入第三方依賴但你又想要完整的帶參數(shù) SQL這時候可以寫一個 MyBatis 攔截器來實現(xiàn)。攔截器可以攔截Executor的query和update方法在方法執(zhí)行前后從Invocation參數(shù)里拿到MappedStatement再通過BoundSql取出原始 SQL 和參數(shù)映射關(guān)系手動把參數(shù)拼進去。核心代碼如下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 SqlLogInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; BoundSql boundSql mappedStatement.getBoundSql(parameter); String sql boundSql.getSql(); ListParameterMapping parameterMappings boundSql.getParameterMappings(); Configuration configuration mappedStatement.getConfiguration(); long start System.currentTimeMillis(); try { return invocation.proceed(); } finally { long end System.currentTimeMillis(); String fullSql buildSql(sql, parameterMappings, configuration, parameter); System.out.println( fullSql | cost: (end - start) ms); } } private String buildSql(String sql, ListParameterMapping parameterMappings, Configuration configuration, Object parameter) { // 遍歷 parameterMappings從 parameter 里取出對應字段值 // 注意處理 String 加單引號、日期格式化、null 值等情況 // 用值替換 SQL 中的問號 // 可以參考 MetaObject 來反射獲取參數(shù)值 return sql; // 此處僅為示意 } }我上面這段代碼只給了骨架真正實現(xiàn)的時候有幾個容易忽略的細節(jié)如果參數(shù)是nullSQL 里應該拼成NULL而不是亂拼字符串字符串類型要加單引號日期類型要格式化成數(shù)據(jù)庫認識的格式分頁插件、邏輯刪除插件可能已經(jīng)改了 SQL你拿到的是改寫后的 SQL打印的時候要注意批量參數(shù)foreach時參數(shù)集合需要循環(huán)取出這是最麻煩的地方。說實話完整實現(xiàn)一個健壯的 SQL 拼裝攔截器工作量不小要考慮的參數(shù)類型分支很多。所以除非團隊確實有特殊要求否則我更推薦用現(xiàn)成的 p6spy 或者日志框架方案攔截器我一般只用來做簡單的耗時統(tǒng)計SQL 拼接這種活交給專門的工具干。5. 配置半天不生效把這些坑排查一遍你就徹底明白了5.1 配置文件層級寫錯是最常見的翻車現(xiàn)場log-impl的正確層級是mybatis-plus.configuration.log-impl。但很多人會把它跟 MyBatis 原生的mybatis.configuration搞混寫出來變成mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl你看上去沒什么區(qū)別但log-impl項目在 MyBatis 原生的Configuration里根本不存在這個屬性會被忽略。結(jié)果就是項目啟動正常、查詢正常但控制臺死活不打印 SQL。還有的人會寫成mybatis-plus.log-impl少了一層configuration同樣不生效。排查方法很簡單看 IDEA 里 yml 編輯頁的自動提示。你輸入mybatis-plus后如果彈出configuration子項說明路徑是對的如果光標在log-impl上沒有任何提示大概率是層級不對或拼寫有誤。5.2 日志級別太高Slf4jImpl 被攔在門外如果你用的是logging.level方案或者log-impl寫的是Slf4jImpl那 SQL 日志能不能打出來完全取決于你配置的日志級別。Spring Boot 的根日志級別默認是 info而 MyBatis 的 SQL 日志是 debug 級別一邊是 debug、一邊是 info直接過濾掉。這時候正確的做法是給 Mapper 所在的包單獨開 debug在第 3 節(jié)已經(jīng)講過而不是去改根日志級別。改成根級別為 debug 雖然也能看到 SQL但項目里所有框架的 debug 日志會一起涌出來控制臺瞬間爆炸生產(chǎn)環(huán)境更是災難。5.3 PostgreSQL 和 MySQL 的 PreparedStatement 日志效果并不一樣如果你項目用的是 PostgreSQLMyBatis 的日志里參數(shù)可能不會顯示成Parameters: 1(Long)因為 PG 的驅(qū)動本身支持%s類型的參數(shù)日志風格。MySQL 這邊一般都會正常顯示但如果你同時用了某些連接池的 Prepare 緩存比如 Druid 的PSCache日志輸出可能被緩存優(yōu)化掩蓋。這種情況比較少見但排查時要知道這個方向別在配置上死磕。5.4 自定義了 SqlSessionFactory繞過了 MyBatis-Plus 的自動配置這個坑藏得很深。有些老項目或者組件化開發(fā)的項目會自己手動構(gòu)造SqlSessionFactory比如在配置類里寫了類似于Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); return factoryBean.getObject(); }一旦存在自定義的SqlSessionFactoryBeanMyBatis-Plus 的自動配置就有可能被繞過log-impl自然也就失效了。這種情況下就算 yml 里配置寫上天也沒用。解法是在手動構(gòu)建時顯式設置 Configuration或者在工廠 Bean 的屬性里補充對應配置。我在一個老項目里就碰到過這種情況Spring Boot 配置檢查了無數(shù)遍就是沒輸出 SQL最后翻代碼發(fā)現(xiàn)配置類里自己 new 了SqlSessionFactoryBean而且沒設 configuration。加上之后問題瞬間解決。5.5 批量插入時 SQL 日志瘋狂刷屏你怎么忍前文提過的批量插入這里單獨拿出來說。MyBatis-Plus 的saveBatch底層是循環(huán)執(zhí)行 SQL 還是拼接大 SQL取決于你用的版本和數(shù)據(jù)庫方言。如果是循環(huán)執(zhí)行那每一條都會單獨打日志日志量直接爆棚如果是拼接大 SQL一條 SQL 幾千個參數(shù)打出來的日志會非常長。這種場景下上面所有打印 SQL 的方案都會變成負擔。我的做法是批量導入的 mapper 方法在方法內(nèi)臨時關(guān)閉日志輸出或者干脆讓批量操作走獨立的 mapper 包這個包的日志級別保持 info不參與 SQL 輸出。排查批量問題時再臨時把級別改回來問題定位完之后記得關(guān)掉。5.6 一步步排查鏈路照著走一遍就能定位為了讓你不迷路我把排查順序整理成一條鏈路按這個順序檢查基本上不會漏確認項目里實際生效的配置文件是哪一個。多環(huán)境項目里application-dev.yml、application-prod.yml可能有不同的覆蓋關(guān)系你改的文件未必是最終生效的那個。檢查log-impl是不是被設置成了NoLoggingImpl如果是任何方案都白搭??纯刂婆_啟動日志里有沒有 MyBatis-Plus 的 banner 和配置加載信息確認mybatis-plus配置確實被讀取了。如果走logging.level方案確認 Mapper 包路徑寫對了且log-impl沒有顯式關(guān)閉 SLF4J。搜一下項目里有沒有手動創(chuàng)建SqlSessionFactory的代碼有的話看它有沒有繼承 MyBatis-Plus 的自動配置。還不行就在StdOutImpl或者 p6spy 的源碼里打一個斷點看是不是根本沒走到日志輸出那一步。6. 配置速查表把常用方案直接抄進你的項目最后給一張我自己常用的選型表你可以直接對號入座場景推薦配置備注本地快速調(diào)試mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl一行搞定日志級別不用管測試環(huán)境生產(chǎn)臨時排查logging.level.你的mapper包名debuglog-impl 保持Slf4jImpl或不配可以精確控制走統(tǒng)一日志體系需要打印拼裝完整 SQL引入 p6spy改 dataSource url 和 driver配 spy.properties帶耗時SQL 可直接復制執(zhí)行批量操作日志篩選單獨 mapper 包保持 info 級別避免日志刷屏按需開啟完全不想打 SQLlog-implorg.apache.ibatis.logging.nologging.NoLoggingImpl生產(chǎn)環(huán)境裁減日志量時用如果你只是本地調(diào)試我最推薦的組合是StdOutImpl加上 IDEA 控制臺的日志過濾。如果你要上生產(chǎn)臨時排查老老實實走logging.level別用StdOutImpl的 System.out因為它沒法納入日志文件歸檔出問題你回頭看日志啥都沒有。最后再分享一個我在實際項目中比較順手的小技巧本地開發(fā)環(huán)境配 p6spy 看完整 SQL 和耗時提交代碼前把p6spy的依賴和配置從pom里的 profile 區(qū)分出來只在本地 profile 生效這樣就不會污染測試環(huán)境和生產(chǎn)環(huán)境。用 Maven profile 把 p6spy 依賴標成dev專屬其他環(huán)境直接不加載既方便又能避免線上日志風暴。