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