☰
MyBatis中#{}和${}的区别:预编译、SQL注入与动态SQL防坑指南
2026/10/12 5:34:14 网站建设 项目流程

面试官问我“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 '1'='1,整张表就给你拖出来。面试官提这个问题,很大程度是想确认你在写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 '1'='1,拼接出来的SQL就是:

select * from user where name = '' or '1'='1'

注意,这已经改变了原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 id="selectUser" resultType="User"> select * from user where name = #{name} order by ${orderCol} ${orderType} </select>

调用时参数是name等于张三,orderCol等于age,orderType等于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掉到800,CPU占用还翻倍。面试里如果能把“预编译缓存执行计划”这个点讲出来,很加分。

3. 从SQL注入角度看两者风险

SQL注入是最能体现两种占位符差异的战场。很多开发只记住了“不要用${}”,但并不知道注入到底是怎么发生的,也不知道${}在哪些位置风险更高。

3.1 一次注入的完整演示

假设有个登录接口,SQL写成:

<select id="login" resultType="User"> 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 id="getUserList" resultType="User"> select * from user order by ${sort} </select>

sort传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 id="queryByTableName" resultType="map"> select * from ${tableName} where id = #{id} </select>

这里的${tableName}只能字符串替换,没法用?,因为JDBC不允许给表名绑定参数。

排序字段。用户点击表头排序,需要把前端传来的orderBy字段拼入SQL:

<select id="pageUsers" resultType="User"> select * from user <choose> <when test="orderCol == 'create_time'"> order by create_time </when> <when test="orderCol == 'user_name'"> order by user_name </when> </choose> </select>

这种用<choose>白名单的方式,比直接${orderCol}安全得多。能写白名单就写白名单。

批量插入或IN列表长度动态变化。#{}处理IN集合时需要通过foreach生成多个?,但用${}直接拼逗号字符串会导致SQL无预编译,性能差,也不安全。所以这里推荐用foreach处理IN,别图省事。

列名动态指定。某些报表查询需要根据条件选择查询哪一列,比如:

<select id="querySum" resultType="map"> select sum(${colName}) from sales </select>

这种场景下,colName必须来自服务端字典,而不是直接拿用户输入。

4.2#{}的适用边界

#{}适合所有“值”位置:where条件、limit偏移量、like参数(配合concat)、时间范围等。它的优势是安全、性能好、代码可读性强。即使只是把参数放进SQL,也用#{},这是个好习惯。

模糊查询就是个典型的#{}配合数据库函数的使用场景。比如:

<select id="searchUsers" resultType="User"> 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 id="queryUser" resultType="User"> select * from user <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="orderCol != null"> order by ${orderCol} </if> </where> </select>

MyBatis执行时,先解析<where>和<if>,根据name和orderCol是否为null决定是否拼入SQL。然后对拼好的SQL片段做占位符处理。如果你把${orderCol}写在<if>判断里,这个test表达式用的是OGNL,完全是另一套东西,和SQL占位符没关系。

5.5 “能不能用#{}处理LIKE '%' + keyword + '%'?”

可以,但不建议这么写:

<select id="search" resultType="User"> select * from user where name like #{keyword} </select>

调用时传"%" + keyword + "%",虽然也能用,但把%拼在Java代码里,一是容易忘记加,二是不同团队对这个拼接位置的理解不一致。我比较推荐在XML里用concat('%', #{keyword}, '%'),这样SQL自己就能表达模糊匹配意图,参数绑定更清晰,也避免因为前后端传参格式不一致导致%丢失。

还有一个隐藏陷阱:如果keyword本身带有%或_,这两种写法都会把它们当成通配符,而不是字面量。比如用户搜索50%,可能会匹配到很多奇怪数据。如果业务确实需要按字面量搜索,可以在Java里先转义:

keyword = keyword.replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_");

然后在SQL侧注明ESCAPE:

where name like concat('%', #{keyword}, '%') escape '\'

这种细节很难在面试中聊到,但写进技术方案里会显得非常扎实。

6. 踩坑实录:动态SQL中的占位符陷阱

讲完原理和面试题,再把我在项目里遇到的几个真实坑展开说一下。这些坑几乎都是“看起来没毛病,上线就出事”的典型,希望你看完能少走弯路。

6.1 坑一:${}拼接导致数字和字符串隐式转换

有一次写统计报表SQL,日期字段用${}拼接,传进来的参数是前端组件格式化好的2024-01-01。MySQL里日期字段和字符串比较本来没问题,但为了统一,写成了:

where date(create_time) >= '${startDate}'

有次前端传参带了时区后缀,拼出来变成'2024-01-01T00:00:00.000+08:00',MySQL日期类型转换直接报错,接口一片500。后来我彻底改成#{startDate},再配合数据库的CAST函数,才算稳住。

教训就是:所有值的位置,一律配合#{}使用,别给数据库做隐式转换的机会。

6.2 坑二:foreach配合#{}处理超长IN列表性能异常

有人图方便,用${}把IN列表拼成一个长字符串,比如in (1,2,3,4,5,...),看日志还好,但到了线上几千个ID时,SQL文本巨大,数据库解析吃力,慢查询一天一堆。后来我在一次优化中改成foreach:

<select id="selectByIds" resultType="User"> select * from user <where> <if test="ids != null and ids.size() > 0"> id in <foreach collection="ids" item="id" open="(" close=")" separator=","> #{id} </foreach> </if> </where> </select>

这样生成的是id in (?, ?, ?),参数数量固定情况下执行计划还能复用。如果ID数量经常超过1000,就得分批处理,或者改用临时表join。

6.3 坑三:排序字段白名单没做,被人撞库

这个坑最疼。我在某系统里负责用户列表接口,排序字段直接写成order by ${sort},前端传sort=id,password,结果后端拼SQL时,靠逗号把两个列都放进排序了。虽然数据库没报错,但返回结果里能看到加密密码的排序差异。后来安全组扫描出了这个漏洞,要求所有排序字段必须走白名单映射,否则不允许上线。

现在我的排序处理规范是:前端传排序标识,后端用一个Map映射成真实列名,映射不到就置为默认排序字段,再配合正则校验。这样即使有人恶意传参,也只能在既定字段里打转。

6.4 坑四:${}在XML注释里的奇怪行为

还有一个冷门坑。动态SQL里如果写了类似:

<select id="query" resultType="User"> 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.mapper=DEBUG

如果日志里显示Parameters: 张三(String),说明走的是#{};如果SQL文本里直接嵌了“张三”,那肯定是${}干的。

我每次写完一段带排序、带表名、带模糊查询的SQL,都会把这段日志翻出来看一眼,确认没有不该出现的字符串拼接,再提交测试。这套流程帮我拦下了不少可能在排期晚点上线的隐患。

回到开头面试官那个“ORDER BY怎么防注入”的问题,我现在会给出完整回答:先用<choose>或Java层白名单映射字段名,排序方向再专门校验,最后才把合法的字段名用${}拼进去,其他参数一律走#{}。这样既满足了动态SQL的需求,又把风险锁死在白名单内。如果你准备面试,把这条逻辑讲顺了,比背几百道八股文都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询