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