
1. 項目概述從一次線上事故說起那天下午監控系統突然報警數據庫CPU瞬間飆到100%。緊急排查后發現是一條看似普通的MyBatis查詢語句引發的全表掃描。問題的根源就出在一個小小的$符號上。開發同學為了圖方便在動態排序字段上直接用了ORDER BY ${sortField}而前端傳入的參數被惡意拼接最終導致了性能雪崩。這件事讓我意識到盡管#和$這兩個占位符是MyBatis入門必學的基礎但真正能透徹理解其差異、并在生產環境中游刃有余使用的開發者其實并不多。很多人只是模糊地知道“#能防SQL注入$不能”至于背后的原理、各自的最佳實踐場景以及那些隱藏的坑往往是在踩過之后才恍然大悟。今天我們就拋開那些教科書式的定義從一個一線開發者的視角深入聊聊MyBatis中#{}和${}這對“孿生兄弟”。我會結合真實的業務場景、源碼層面的簡單剖析以及我這些年積累下來的實戰經驗和踩坑記錄幫你建立起一套完整、深刻的理解框架。無論你是正在面試準備中被問到“#和$的區別”還是在日常開發中糾結于到底該用哪個這篇文章都能給你清晰、可落地的答案。我們會從最根本的“預編譯”與“字符串替換”原理講起延伸到動態SQL、排序、表名處理等復雜場景的選型策略最后再分享幾個能極大提升開發效率和代碼安全性的高級技巧與配置。2. 核心原理拆解預編譯與字符串替換的本質差異要理解#{}和${}絕不能停留在“一個安全一個不安全”的表面認知。它們的本質區別在于MyBatis處理SQL語句的時機和方式完全不同這直接決定了SQL的執行計劃、安全性和適用場景。2.1#{}安全的參數化查詢基石當你使用#{}時MyBatis會創建一個PreparedStatement對象。這是JDBC中用于執行預編譯SQL語句的接口。關鍵步驟在于“預編譯”SQL解析與編譯數據庫服務器會先對SQL語句的骨架進行解析和編譯生成一個執行計劃。例如對于SELECT * FROM user WHERE id ?數據庫會知道這是一個在user表上根據id字段進行等值查詢的操作并可能決定使用id索引。參數傳遞那個問號?就是一個占位符。之后程序再將具體的參數值比如123單獨傳遞給這個已編譯好的語句。執行數據庫將參數值與執行計劃結合完成查詢。這個過程帶來了兩大核心優勢杜絕SQL注入因為參數值是在SQL結構被編譯之后才傳入的它永遠只被當作“數據”來處理。即使你傳入‘1‘ OR ‘1‘‘1‘這樣的惡意字符串它也會被當作一個完整的字符串值去匹配id字段而不會被解析成SQL指令的一部分。數據庫會去查找id等于這個奇怪字符串的記錄顯然找不到從而保證了安全。提升性能同一條SQL語句僅參數不同可以被預編譯一次然后多次執行。數據庫無需每次都對SQL進行完整的語法解析、優化和編譯這對于高頻執行的查詢如根據主鍵查詢能帶來可觀的性能提升。在MyBatis的XML映射文件中它看起來是這樣的select idselectUserById resultTypeUser SELECT * FROM user WHERE id #{userId} /selectMyBatis在底層會將其處理為SELECT * FROM user WHERE id ?并將userId參數安全地設置進去。2.2${}靈活的字符串替換利器而${}的工作方式則簡單粗暴得多字符串拼接。在MyBatis解析XML時它會直接將${}中的內容替換為對應的參數值然后拼接到SQL語句中最后才將整條完整的SQL字符串發給數據庫。select idselectUserByOrder resultTypeUser SELECT * FROM user ORDER BY ${orderByField} /select如果傳入的orderByField是“name“那么最終生成的SQL就是SELECT * FROM user ORDER BY name然后直接交給數據庫執行。這種方式的特點非常鮮明靈活性高它可以替換SQL語句中的任何部分不僅僅是WHERE子句中的值還可以是列名、表名、ORDER BY字段等。極高的SQL注入風險正因為是直接拼接如果替換的內容來自不可信的用戶輸入風險極大。假設上面例子中用戶傳入“name; DROP TABLE user; --“拼接后的SQL將變成SELECT * FROM user ORDER BY name; DROP TABLE user; --這將導致災難性后果。無預編譯性能優勢每次都是全新的SQL語句數據庫需要重新解析編譯。核心理解你可以把#{}想象成給SQL語句“填空”空位的形狀是固定的你只能填規定類型的數據。而${}則是“剪貼替換”你給它一段文本它直接把這文本貼到SQL語句的指定位置至于貼上去的是數據還是指令它不管。2.3 對比表格與底層源碼視角為了讓區別更直觀我們用一個表格來總結特性#{}${}處理方式參數化查詢使用PreparedStatement字符串替換使用Statement或PreparedStatement(替換后)安全性高從根本上防止SQL注入低存在SQL注入風險性能高支持預編譯同語句可復用執行計劃低每次均為全新語句需重新編譯參數類型處理自動處理根據參數Java類型設置合適的JDBC類型如String設為VARCHAR原樣替換不處理類型可能導致語法錯誤如字符串缺引號適用場景WHERE條件中的值、INSERT的VALUES、存儲過程參數等數據值位置動態表名、列名、ORDER BY排序字段、GROUP BY字段等SQL關鍵字或標識符位置從MyBatis源碼如SqlSourceBuilder等類來看#{}在解析時會被標記為ParameterMapping最終在運行時通過PreparedStatement.setXXX()方法來設值。而${}在解析階段就被TextSqlNode處理直接通過OGNL表達式求值后替換到原始SQL字符串中。這也是為什么${}無法防止注入的根本原因——它在SQL語句成型前就完成了替換。3. 實戰應用場景與選型策略理解了原理我們來看實戰中如何選擇。記住一個基本原則能用#{}的地方絕對不用${}。${}的使用必須慎之又慎且通常只用于非數據值的替換。3.1 必須使用#{}的場景這是占位符使用的“安全區”和“主戰場”。所有傳入查詢條件的數據值這是最核心的用法。!-- 安全 -- select idselectByCondition resultTypeUser SELECT * FROM user WHERE username #{name} AND age #{minAge} AND create_time BETWEEN #{startTime} AND #{endTime} /select即使參數是Date或BigDecimal等復雜類型#{}也能正確轉換。INSERT/UPDATE語句的賦值部分insert idinsertUser parameterTypeUser INSERT INTO user (username, email, age) VALUES (#{username}, #{email}, #{age}) /insert update idupdateUser parameterTypeUser UPDATE user SET email #{email}, age #{age} WHERE id #{id} /update存儲過程的輸入/輸出參數select idcallProcedure statementTypeCALLABLE {call my_procedure(#{param1, modeIN}, #{param2, modeOUT, jdbcTypeVARCHAR})} /select3.2 謹慎使用${}的場景這些場景下${}提供了不可或缺的靈活性但必須配合嚴格的安全控制。動態排序ORDER BY這是${}最經典的合法使用場景。因為ORDER BY后面跟的是列名或表達式而不是數據值無法使用#{}#{}會給列名加上引號導致語法錯誤。select idselectUsersWithOrder resultTypeUser SELECT * FROM user if testorderBy ! null and orderBy ! ‘‘ ORDER BY ${orderBy} /if /select致命陷阱與解決方案直接使用${orderBy}如同打開潘多拉魔盒。攻擊者可以傳入“age; DROP TABLE user --”。必須進行白名單校驗// 在Service層或參數攔截器中進行校驗 public void validateOrderBy(String orderBy) { ListString allowedFields Arrays.asList(id, username, age, create_time); // 簡單校驗確保傳入的字符串是允許的字段名 // 復雜場景需解析逗號、空格等防止id, (SELECT ...) if (orderBy ! null) { // 這里只是一個簡單示例實際需要更嚴格的解析和校驗 String[] parts orderBy.split(\\s); if (!allowedFields.contains(parts[0].toLowerCase())) { throw new IllegalArgumentException(非法的排序字段: orderBy); } } }更安全的做法是前端傳遞枚舉值如“SORT_BY_AGE_DESC”后端映射成安全的數據庫列名。動態表名/列名在分表場景如按年月分表user_202301,user_202302或通用Mapper中可能會用到。select idselectFromDynamicTable resultTypeUser SELECT id, name FROM ${tableName} WHERE status #{activeStatus} /select核心安全原則${tableName}的值絕不能來自用戶輸入必須由后端邏輯根據規則生成如根據用戶ID哈希決定表后綴。這是鐵律。動態SQL片段拼接特殊場景極少數情況下需要根據條件完全改變SQL結構的一部分。select iddynamicWhere resultTypeUser SELECT * FROM user WHERE 11 if testtype ‘A‘ AND ${dynamicConditionA} /if if testtype ‘B‘ AND ${dynamicConditionB} /if /select警告${dynamicConditionA}這類用法風險極高通常意味著你的數據模型或查詢設計可能存在問題。應優先考慮使用MyBatis的動態SQL標簽if,choose,where,set來構建條件。如果必須使用確保其值來自可信的、內部定義的常量或經過嚴格校驗和清洗的配置。3.3 模糊查詢的經典誤區與正確寫法這是一個高頻踩坑點。很多人想實現LIKE ‘%張%‘查詢會錯誤地嘗試!-- 錯誤寫法1直接拼接有注入風險 -- WHERE username LIKE ‘%${name}%‘ !-- 錯誤寫法2使用#{}但語法錯誤 -- WHERE username LIKE ‘%#{name}%‘ !-- 最終變成 LIKE ‘%?%‘參數無法正確注入 --正確的寫法有以下幾種在Java代碼中拼接好再傳參推薦String name “張”; String likePattern “%” name “%”; // 然后將 likePattern 作為參數傳入select idselectLike resultTypeUser SELECT * FROM user WHERE username LIKE #{pattern} /select這樣既利用了#{}的安全預編譯又實現了功能。使用MySQL的CONCAT函數數據庫端拼接select idselectLike resultTypeUser SELECT * FROM user WHERE username LIKE CONCAT(‘%‘, #{name}, ‘%‘) /select注意數據庫兼容性。使用MyBatis的bind標簽select idselectLike resultTypeUser bind namelikePattern value“‘%‘ name ‘%‘ / SELECT * FROM user WHERE username LIKE #{likePattern} /selectbind標簽會在當前上下文創建一個變量其值可以在OGNL表達式中計算得出然后再通過#{}安全使用。4. 高級技巧、配置與深度避坑指南掌握了基礎用法我們來看看如何用得更好、更穩。這些技巧很多都是我在處理性能問題、排查詭異Bug時總結出來的。4.1#{}的額外屬性精細化控制#{}遠不止一個參數名那么簡單它支持一些非常實用的屬性來應對復雜場景。jdbcType指定參數對應的JDBC類型。在處理可能為null的參數時至關重要。當傳入的參數為null時MyBatis需要知道對應的JDBC類型否則某些驅動可能報錯。!-- 假設 age 可能為 null -- UPDATE user SET age #{age, jdbcTypeINTEGER} WHERE id #{id}常見的jdbcType有VARCHAR,INTEGER,DATE,TIMESTAMP,DECIMAL等。在全局配置中可以設置jdbcTypeForNull為NULL如jdbcTypeForNullNULL來避免為每個可為空的參數都指定。typeHandler指定自定義的類型處理器。用于處理Java類型和JDBC類型之間的特殊轉換。!-- 假設有一個將ListString轉換為JSON字符串存入數據庫的處理器 -- INSERT INTO user (tags) VALUES (#{tags, typeHandlercom.example.JsonArrayTypeHandler})numericScale指定數值類型的小數點后位數。!-- 確保存入的數值精確到兩位小數 -- UPDATE account SET balance #{amount, jdbcTypeDECIMAL, numericScale2}4.2 警惕${}的隱式類型問題由于${}是直接替換它不會幫你給字符串值加上引號。這經常導致隱蔽的錯誤。!-- 假設傳入的tableName是“user”status是數字1 -- SELECT * FROM ${tableName} WHERE status ${status} !-- 正確SELECT * FROM user WHERE status 1 -- !-- 假設傳入的status是字符串“ACTIVE” -- SELECT * FROM ${tableName} WHERE status ${status} !-- 錯誤SELECT * FROM user WHERE status ACTIVE (缺少引號) --對于非數值的動態值如果必須用${}你需要自己在SQL中或參數傳入前處理好引號但這又增加了復雜性和風險。這再次印證了${}只應用于標識符表名、列名的原則。4.3 結合動態SQL標簽的安全實踐MyBatis強大的動態SQL標簽if,choose,where,set,foreach與#{}是黃金搭檔可以安全地構建復雜的查詢。select idselectUsers resultTypeUser SELECT * FROM user where if testusername ! null and username ! ‘‘ AND username LIKE CONCAT(‘%‘, #{username}, ‘%‘) /if if testminAge ! null AND age #{minAge} /if if teststatusList ! null and statusList.size 0 AND status IN foreach collectionstatusList itemstatus open“(” separator“,” close“)” #{status} !-- 注意這里用的是#{}安全 -- /foreach /if /where ORDER BY create_time DESC /selectwhere標簽會智能地處理掉開頭多余的AND或ORforeach標簽配合#{}可以安全地生成IN語句避免了手動拼接IN列表的注入風險和語法麻煩。4.4 配置打印SQL與參數強大的調試利器當SQL執行結果不符合預期時查看MyBatis實際執行的SQL語句是排查問題的第一步。這里強烈推薦配置SQL日志打印。標準配置推薦在application.yml或application.properties中配置日志級別。# application.yml logging: level: com.example.mapper: DEBUG # 將你的Mapper接口所在包的級別設為DEBUG或者更精確地控制MyBatis的日志實現logging: level: org.apache.ibatis: INFO com.example.mapper: TRACE # TRACE級別會打印出參數值使用標準日志框架Logback/Log4j2輸出格式清晰且能與應用其他日志統一管理。mybatis.configuration.log-impl在MyBatis配置中指定具體的日志實現類如StdOutImpl直接打印到控制臺但在生產環境不推薦。注意安全在生產環境務必避免將TRACE或DEBUG級別開放給包含用戶敏感信息的Mapper以防參數日志泄露數據。4.5 批量操作中的占位符性能考量在進行批量插入或更新時如果使用#{item.property}在foreach循環中MyBatis會生成一條帶有多個占位符的SQL語句如INSERT ... VALUES (?, ?), (?, ?), (?, ?)。這仍然是預編譯的性能很好。但要警惕一種情況如果你因為某些原因如極度動態的列不得不使用${}在循環體內拼接值那會生成一條巨大的、每次都不一樣的SQL字符串完全無法利用預編譯且可能觸及數據庫或網絡包的大小限制。這時必須考慮分批次執行或尋找其他設計方案。5. 常見問題排查與面試要點實錄最后分享幾個我常被問到或在實際排查中遇到的問題。Q1明明用了#{}為什么日志里看到的SQL還是有參數值而不是問號A這是日志框架的功勞。像p6spy或某些配置了log-impl的驅動會在日志層面將參數值替換回SQL中方便開發者閱讀。實際發往數據庫的仍然是帶占位符的預編譯語句。你可以通過抓取網絡包或使用數據庫自身的日志來驗證。Q2${}在ORDER BY里用我做了枚舉映射是不是就絕對安全了A大大降低了風險但并非鐵板一塊。還要防止“SQL注入二級攻擊”例如攻擊者利用合法字段進行復雜查詢導致慢查詢拖垮數據庫。可以考慮對排序字段進行更嚴格的格式校驗只允許字母、數字、下劃線并限制排序字段的長度。Q3傳入一個List在foreach里用#{}生成的SQL是怎樣的安全嗎A假設傳入ids[1,2,3]SQL會生成... IN (?, ?, ?)然后分別用1,2,3去設置這三個占位符。這是安全的MyBatis內部處理了列表的展開和參數設置。絕對不要自己拼接成... IN (${ids})那會產生... IN (1,2,3)雖然語法對但失去了預編譯優勢且如果ids來自不可信源風險極高。Q4#{}可以防止所有的注入嗎A#{}可以防止SQL語法層面的注入。但它不能防止業務邏輯層面的問題例如通過#{password}傳入的密碼如果數據庫里存儲的就是明文那么查詢WHERE password #{input}如果input恰好是某個用戶的真實密碼依然能查詢出來。這屬于認證邏輯缺陷不是SQL注入。Q5關于${}和#{}的面試除了區別還常問什么A有經驗的面試官可能會追問“什么場景下你不得不使用${}”考察對SQL語法和占位符限制的理解“如果你必須用${}接一個用戶輸入的排序字段你會怎么設計來保證安全”考察安全意識和解決方案設計能力“#{}是如何處理Date、BigDecimal這些復雜類型的”考察對TypeHandler的了解深度“模糊查詢LIKE語句有哪些安全的寫法”考察實際編碼經驗和知識廣度理解#{}和${}是寫好MyBatis代碼的基石。它關乎安全、性能和代碼的健壯性。記住那句老話默認總是使用#{}把${}的使用當作一個需要特批和嚴格審查的例外事件。在每一次寫下${}時都問自己一句這個值來自哪里我是否完全信任它有沒有更安全的方式替代多這一份警惕就能在代碼層面規避掉許多潛在的風險。