面试官问我“MyBatis里的#{}和${}有什么区别”时我第一反应是“这题太基础了”直接把背过的答案甩了出去#{}是预编译${}是字符串拼接。结果面试官接着追问了一句“那如果我把${}用在ORDER BY后面你打算怎么防注入”我当场卡住了。这才意识到光背结论没用得搞清楚这两个占位符在MyBatis底层到底怎么被处理的。这篇内容不是简单的“区别对比表”而是从SQL语句的生成过程、JDBC的执行机制、实际项目的选型取舍、动态SQL里的坑再到面试官常挖的追问方向一层层拆开讲。不管你是在准备面试还是写项目时被${}炸过线上数据这篇都值得从头看完。1. 面试官为什么要问占位符区别这个问题几乎出现在每一轮Java开发的技术面里但大多数人只是机械地记住了“#{}安全${}不安全”。面试官真正想看的是你有没有踩过动态SQL的坑有没有想过SQL语句在MyBatis里从XML到数据库执行中间发生了什么变化。1.1 这题考察的不是语法而是安全意识如果你答“#{}会加引号${}不会”面试官会点头但大概率马上追一句“那你能说说为什么有些场景非用${}不可吗”这个问题背后是对SQL注入风险、JDBC底层PreparedStatement工作机制、MyBatis动态SQL解析顺序的综合考察。我在实际项目中见过不止一次类似事故有人为了拼动态表名直接写select * from ${tableName}参数从接口传到SQL里结果删库的悲剧就不说了。更隐蔽的是在模糊查询里用${}比如like %${keyword}%前端传个% or 11整张表就给你拖出来。面试官提这个问题很大程度是想确认你在写SQL时有没有“把用户输入当代码”的警觉性。1.2 从“背答案”到“讲原理”的分水岭基础回答是“#{}是预编译占位符${}是字符串替换。”这个回答能过及格线但拿不到高分。高分回答应该包含三层第一层说清楚MyBatis解析SQL的时机。MyBatis在加载XML时会先解析动态SQL标签if、where、foreach等然后生成一个包含占位符的SQL模板。#{}会被解析成JDBC的?${}会直接替换成字符串。第二层说明替换对象的位置差异。#{}替换的是参数值发生在PreparedStatement的参数绑定阶段${}替换的是SQL片段发生在SQL语句组装阶段替换进去的内容会被当成SQL语法的一部分。第三层给出适用场景的边界判断。什么时候用#{}什么时候用${}不是喜好问题是安全性和功能性的平衡问题。能确定是值的就用#{}必须动态拼SQL结构的才用${}并且要严格白名单校验。2.#{}和${}的底层执行差异要真正理解这两种占位符的区别必须回到JDBC层面。MyBatis只是个ORM框架它最终还是要靠JDBC驱动把SQL扔给数据库。这个环节里PreparedStatement和Statement的差异就是两种占位符差异的根源。2.1 JDBC里PreparedStatement和Statement的区别先看一段原生JDBC代码感受一下差别。用Statement拼接SQL是这样String sql select * from user where name name ; Statement statement connection.createStatement(); ResultSet rs statement.executeQuery(sql);这段代码的危险之处在于name里的内容会被直接嵌进SQL字符串。如果name传 or 11拼接出来的SQL就是select * from user where name or 11注意这已经改变了原SQL的逻辑。数据库执行时会把整段字符串当成SQL语句跑注入就这么发生了。再看PreparedStatement的写法String sql select * from user where name ?; PreparedStatement ps connection.prepareStatement(sql); ps.setString(1, name); ResultSet rs ps.executeQuery();这里先让数据库编译SQL模板编译时?是参数占位符数据库不知道这个位置未来是什么值。编译完成后再用setString把值传进去。数据库在绑定参数时只会把值当作“数据”处理不会再参与SQL语法解析。这就是#{}安全的原因。2.2 MyBatis解析两种占位符的过程MyBatis在SqlSource构建时会对XML中的SQL片段做语法树解析。#{}会被替换为?同时参数会进入parameterMappings列表等待后续绑定。${}则会被if等标签处理完后直接通过字符串拼接的方式嵌入SQL。用代码演示一下。假设有这样一个查询select idselectUser resultTypeUser select * from user where name #{name} order by ${orderCol} ${orderType} /select调用时参数是name等于张三orderCol等于ageorderType等于desc。MyBatis内部生成的SQL模板大概是select * from user where name ? order by age desc其中#{name}变成了?而${orderCol}和${orderType}是直接把值替换进去了。为什么模板里?还在而age desc已经拼好了因为MyBatis处理顺序是先做${}的字符串替换再把#{}转成?。这个顺序非常重要也是理解两种占位符的核心。2.3 参数绑定细节对性能的影响除了安全性两者在性能上也有差别。#{}走的是预编译通道SQL模板只编译一次后续每次执行只是绑定新参数数据库缓存了执行计划所以高并发场景下#{}的性能通常更优。${}因为每次拼接的SQL文本都可能不同数据库无法复用执行计划极容易导致硬解析暴涨。我做过一次压测对比同一个查询#{}写法TPS能稳定在2000左右${}写法因为SQL文本变化频繁每秒触发上千次硬解析TPS掉到800CPU占用还翻倍。面试里如果能把“预编译缓存执行计划”这个点讲出来很加分。3. 从SQL注入角度看两者风险SQL注入是最能体现两种占位符差异的战场。很多开发只记住了“不要用${}”但并不知道注入到底是怎么发生的也不知道${}在哪些位置风险更高。3.1 一次注入的完整演示假设有个登录接口SQL写成select idlogin resultTypeUser select * from user where account ${account} and password ${password} /select前端传account为admin --password随便填。拼接后SQL变成select * from user where account admin -- and password xxx--后面的内容被当成注释整个密码校验逻辑失效。这个例子很老套但是只要把用户输入直接拼进SQL任何位置都可能变成注入点。更危险的是排序字段比如select idgetUserList resultTypeUser select * from user order by ${sort} /selectsort传id; drop table user--后果自己体会。这种写法在管理后台非常常见因为开发图省事直接把前端的排序字段名映射到SQL里。3.2#{}的防注入原理#{}在JDBC层对应PreparedStatement.setString()这类方法。数据库在预编译阶段把?当作一个占位符然后在绑定参数阶段传入的值会被驱动转义或编码。比如MySQL驱动处理字符串时会根据连接的字符集设置对特殊字符做转义确保输入内容不会被解释为SQL语法。注意PreparedStatement并不是“对参数值做魔术处理”而是“让数据库区分了SQL代码和数据”。占位符位置就是数据的位置这个位置的值无论是什么都不会被当成SQL语句的一部分。这个机制是数据库层面的不是MyBatis的功劳。所以#{}的安全性本质上是借助了PreparedStatement的预编译能力。3.3 即使有转义也千万别把用户输入交给${}MyBatis的${}也内置了一个字符串安全过滤不网上有些人说MyBatis对${}做了转义其实并没有。MyBatis只是把字符串原样替换进去唯一的“保护”是如果参数为null会拼成null字符串而不是空字符串。这个行为在where条件里很容易引发NPE之外的SQL问题。我在公司内部排过一次故障一个导出功能日期范围用${startTime}拼接结果前端不传值MyBatis拼了个and create_time null在MySQL里这个条件永远为false所有数据都没导出来。排查半天才发现是空值问题。换成#{}就不会有这个行为。4. 实际开发中的选型原则与场景很多人以为${}应该完全弃用这是不对的。MyBatis保留${}是因为有些SQL结构无法通过预编译占位符实现。选型的关键不是“要不要用”而是“用了之后怎么控制风险”。4.1 必须用${}的场景有一类场景参数位置在SQL语句中属于“结构性的”不能用?占位比如表名、列名、排序字段、LIMIT子句的某些写法。举几个典型案例表名动态切换。分库分表或不同环境切换表名时比如select idqueryByTableName resultTypemap select * from ${tableName} where id #{id} /select这里的${tableName}只能字符串替换没法用?因为JDBC不允许给表名绑定参数。排序字段。用户点击表头排序需要把前端传来的orderBy字段拼入SQLselect idpageUsers resultTypeUser select * from user choose when testorderCol create_time order by create_time /when when testorderCol user_name order by user_name /when /choose /select这种用choose白名单的方式比直接${orderCol}安全得多。能写白名单就写白名单。批量插入或IN列表长度动态变化。#{}处理IN集合时需要通过foreach生成多个?但用${}直接拼逗号字符串会导致SQL无预编译性能差也不安全。所以这里推荐用foreach处理IN别图省事。列名动态指定。某些报表查询需要根据条件选择查询哪一列比如select idquerySum resultTypemap select sum(${colName}) from sales /select这种场景下colName必须来自服务端字典而不是直接拿用户输入。4.2#{}的适用边界#{}适合所有“值”位置where条件、limit偏移量、like参数配合concat、时间范围等。它的优势是安全、性能好、代码可读性强。即使只是把参数放进SQL也用#{}这是个好习惯。模糊查询就是个典型的#{}配合数据库函数的使用场景。比如select idsearchUsers resultTypeUser select * from user where name like concat(%, #{keyword}, %) /select把%放到SQL函数里参数由#{}绑定既安全又不会因特殊字符导致SQL错误。注意这里如果用%${keyword}%除了注入风险还要处理keyword里的单引号和反斜杠麻烦事一堆没理由不用concat方案。4.3 用了${}后如何把风险压到最低如果某些场景实在绕不开${}我的经验是三层防护。第一层来源可控。不允许直接将前端参数作为${}的变量必须在Service层做映射。比如前端传排序字段服务端先用Map把合法的字段名映射一遍映射不到就用默认值。第二层正则校验。在参数进入XML之前校验只允许字母数字和下划线。写一个简单工具方法public static String validateSqlFragment(String input) { if (input null || !input.matches([a-zA-Z0-9_])) { throw new IllegalArgumentException(非法参数); } return input; }这样即使${}拼进去了也只能是合法标识符不可能带空格、引号、注释符。第三层数据库权限最小化。数据库账号尽量按库按表授权不允许业务账号有DROP、ALTER等高危权限。这样就算SQL被注入了攻击面也被限制了。5. 面试追问与高频变种题面试官问完区别以后大概率会顺着你的回答继续深挖。这里把常见的几个追问方向整理一下每个方向都是我真实遇到过的。5.1 “那#{}能用在ORDER BY或者表名后面吗”这个问题直接暴露你是否理解占位符的本质。#{}在MyBatis中最终变成?JDBC的PreparedStatement不支持对表名、列名、排序方向绑定参数。如果你强行写order by #{sort}数据库会报错因为MySQL里order by后面跟一个字符串常量是没有意义的。我之前见过有人试图用order by #{colName}绕过${}风险测试时直接抛异常原因是数据库把?绑定的值当成一个字符串常量来排序根本不是期望的字段名。正确答案是不能。结构化位置只能字符串替换。5.2 “MyBatis为什么要在面试题里区分这两个符号”这个问题其实是在问设计动机。MyBatis提供#{}是为了复用PreparedStatement保证参数安全提供${}是为了处理那些无法预编译的SQL结构。两者没有优劣是在不同需求下的产物。你能说出这个逻辑面试官就会觉得你是在使用框架而不是被框架使用。5.3 “如果你在维护老代码发现了一段用${}写的高风险SQL你会怎么改”这种开放题主要考重构能力和风险控制意识。我会先看这段SQL用到${}的位置如果是值的位置直接替换成#{}跑一遍完整的测试用例确认参数绑定没问题。如果是结构位置我会考虑用白名单映射或者choose标签重构。如果实在改不了那就加上正则校验同时把数据库权限收掉。在一次实际重构里我碰到过一个动态查询多个报表的存储过程调用参数直接拼接存储过程名当时我的方案是枚举所有允许的存储过程名称映射成常量再拼进SQL。改完以后安全扫描直接通过线上也没再出过因为传参导致的风险事件。5.4 “动态SQL标签解析和占位符替换的先后顺序是怎样的”这个问题细节感很强。MyBatis在解析SQL时先处理动态SQL标签比如if、where、choose、foreach根据传入参数生成最终的SQL文本。生成过程中遇到#{}时会记录参数映射替换成?遇到${}则直接拼接内容。所以在if标签里写的参数引用是在动态判断阶段使用的而#{}和${}是在最终SQL文本生成阶段处理的。举个例子select idqueryUser resultTypeUser select * from user where if testname ! null and name ! and name like concat(%, #{name}, %) /if if testorderCol ! null order by ${orderCol} /if /where /selectMyBatis执行时先解析where和if根据name和orderCol是否为null决定是否拼入SQL。然后对拼好的SQL片段做占位符处理。如果你把${orderCol}写在if判断里这个test表达式用的是OGNL完全是另一套东西和SQL占位符没关系。5.5 “能不能用#{}处理LIKE % keyword %”可以但不建议这么写select idsearch resultTypeUser select * from user where name like #{keyword} /select调用时传% keyword %虽然也能用但把%拼在Java代码里一是容易忘记加二是不同团队对这个拼接位置的理解不一致。我比较推荐在XML里用concat(%, #{keyword}, %)这样SQL自己就能表达模糊匹配意图参数绑定更清晰也避免因为前后端传参格式不一致导致%丢失。还有一个隐藏陷阱如果keyword本身带有%或_这两种写法都会把它们当成通配符而不是字面量。比如用户搜索50%可能会匹配到很多奇怪数据。如果业务确实需要按字面量搜索可以在Java里先转义keyword keyword.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_);然后在SQL侧注明ESCAPEwhere name like concat(%, #{keyword}, %) escape \这种细节很难在面试中聊到但写进技术方案里会显得非常扎实。6. 踩坑实录动态SQL中的占位符陷阱讲完原理和面试题再把我在项目里遇到的几个真实坑展开说一下。这些坑几乎都是“看起来没毛病上线就出事”的典型希望你看完能少走弯路。6.1 坑一${}拼接导致数字和字符串隐式转换有一次写统计报表SQL日期字段用${}拼接传进来的参数是前端组件格式化好的2024-01-01。MySQL里日期字段和字符串比较本来没问题但为了统一写成了where date(create_time) ${startDate}有次前端传参带了时区后缀拼出来变成2024-01-01T00:00:00.00008:00MySQL日期类型转换直接报错接口一片500。后来我彻底改成#{startDate}再配合数据库的CAST函数才算稳住。教训就是所有值的位置一律配合#{}使用别给数据库做隐式转换的机会。6.2 坑二foreach配合#{}处理超长IN列表性能异常有人图方便用${}把IN列表拼成一个长字符串比如in (1,2,3,4,5,...)看日志还好但到了线上几千个ID时SQL文本巨大数据库解析吃力慢查询一天一堆。后来我在一次优化中改成foreachselect idselectByIds resultTypeUser select * from user where if testids ! null and ids.size() 0 id in foreach collectionids itemid open( close) separator, #{id} /foreach /if /where /select这样生成的是id in (?, ?, ?)参数数量固定情况下执行计划还能复用。如果ID数量经常超过1000就得分批处理或者改用临时表join。6.3 坑三排序字段白名单没做被人撞库这个坑最疼。我在某系统里负责用户列表接口排序字段直接写成order by ${sort}前端传sortid,password结果后端拼SQL时靠逗号把两个列都放进排序了。虽然数据库没报错但返回结果里能看到加密密码的排序差异。后来安全组扫描出了这个漏洞要求所有排序字段必须走白名单映射否则不允许上线。现在我的排序处理规范是前端传排序标识后端用一个Map映射成真实列名映射不到就置为默认排序字段再配合正则校验。这样即使有人恶意传参也只能在既定字段里打转。6.4 坑四${}在XML注释里的奇怪行为还有一个冷门坑。动态SQL里如果写了类似select idquery resultTypeUser select * from user !-- 筛选条件 ${condition} -- where status #{status} /select这里的${condition}在XML注释里MyBatis做字符串替换时会把注释里的内容也换掉。如果condition里有--或者其他标记可能会导致SQL文本在注释边界上出问题。我遇到过一条SQL因为注释里的参数带了个*/直接让数据库解析报错。改法很简单别在XML注释里放任何占位符。7. 我的习惯写SQL时的安全检查清单项目里每天都有新代码进来光靠技术文章和经验约束不够我给自己定了一套写SQL时的自查流程顺手分享出来。第一先问自己这个位置是“值”还是“结构”。值位置闭眼用#{}结构位置才考虑${}而且必须加白名单校验。第二看到${}就触发条件反射参数来源是接口还是内部是不是可枚举如果可枚举就写死映射关系如果不可枚举宁可拆成多段SQL。第三写动态SQL时把所有输入都当成恶意输入来设计。第四联调前把实际生成的SQL打印出来看一眼MyBatis里可以通过配置输出SQL语句mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl日志里能看到 Preparing:和 Parameters:一眼就能分辨哪些位置是用?绑定哪些位置被直接拼进去了。还有一个更直接的做法在日志配置里加上参数打印比如logging.level.com.yourproject.mapperDEBUG如果日志里显示Parameters: 张三(String)说明走的是#{}如果SQL文本里直接嵌了“张三”那肯定是${}干的。我每次写完一段带排序、带表名、带模糊查询的SQL都会把这段日志翻出来看一眼确认没有不该出现的字符串拼接再提交测试。这套流程帮我拦下了不少可能在排期晚点上线的隐患。回到开头面试官那个“ORDER BY怎么防注入”的问题我现在会给出完整回答先用choose或Java层白名单映射字段名排序方向再专门校验最后才把合法的字段名用${}拼进去其他参数一律走#{}。这样既满足了动态SQL的需求又把风险锁死在白名单内。如果你准备面试把这条逻辑讲顺了比背几百道八股文都管用。