聊到 MyBatis 面试,占位符这道题基本绕不开。面试官只要问一句“#{} 和 ${} 的区别”,看似是一个随口问题,背后却能牵出 SQL 预编译、JDBC 底层接口、动态 SQL、SQL 注入,甚至缓存 key 设计,几乎是一张完整的 MyBatis 知识地图。这篇文章就从真实面试场景出发,把题目的考点、原理、源码路径、实战坑点和作答思路一次讲透,不背答案,直接给到能落地的理解。
先给结论:在 MyBatis 里,#{} 是预编译参数占位符,最终会转换成 JDBC 的 ?,再通过 PreparedStatement 的参数设置方法填充值;${} 是字符串直接替换,最终拼进 SQL 文本。很多人能把这句话背出来,但一旦被追问为什么、底层怎么走、什么时候必须用 ${},就答不上来。这篇文章要解决的问题,就是把“结论”变成“理解”。
1. 面试官到底在考什么:占位符背后的核心考点
1.1 一道看似简单实则连环追问的面试题
我面试过不少 Java 开发,问“#{} 和 ${} 区别”时,最常见的答法是“#{} 安全,${} 不安全”。这个回答放到实际评分里,只能算及格线,因为面试官真正想听的远不止这一句。
真正拉开差距的追问通常是这样的:
- 你知道 ${} 在 MyBatis 里是何时被替换的吗?它的解析路径和 #{} 一样吗?
- 为什么 #{} 能防 SQL 注入,而 ${} 不能?
- 如果我要动态拼排序字段,能用 #{} 吗?不能的话你怎么保证安全?
- 动态 SQL 里的
<if test>判断和占位符有什么关系? - 如果这条 SQL 里用了 ${},对 MyBatis 一级、二级缓存的 key 会有什么影响?
这几连问下来,能把“背答案”和“真理解”区分得干干净净。所以这道题在面试体系里,其实是一个考综合能力的入口:语法、原理、安全、实战、源码,每个环节都能往下钻。
1.2 大多数人答不好的三个原因
根据我观察,答不好的人通常卡在三个地方。
第一个是只背结论,不理解 PreparedStatement 为什么能防注入。很多人以为预编译就是“数据库自动处理了特殊字符”,实际上核心是 SQL 结构在执行前已经固定,参数只作为数据传入,不会参与 SQL 语法解析。
第二个是不了解 MyBatis 解析 SQL 有两条路径。包含 #{} 的 SQL 在加载阶段就会把占位符换成 ?,同时记录参数映射;包含 ${} 的 SQL 会走运行时文本替换。说不清这两条路径,自然回答不了“什么时候替换”这种问题。
第三个是缺少实战场景。很多候选人不知道 order by、表名这类标识符无法用 #{} 预处理,于是遇到动态排序需求时,要么用 ${} 裸拼,要么写ORDER BY #{sort}导致排序不生效。这块经验缺失,面试官一眼就能看出来。
2. 占位符的底层原理:从 MyBatis 到 JDBC 的完整链路
2.1 SQL 解析阶段:谁把 #{} 换成了问号
要理解 #{} 和 ${} 的区别,先要看 MyBatis 在启动时怎么处理 mapper XML 里的 SQL。
MyBatis 加载 SQL 时,XMLScriptBuilder 会判断这段 SQL 是静态的还是动态的。判断条件主要看是否包含<if>、<foreach>、<where>这类动态标签,以及是否包含${}。注意,只有#{}的 SQL 不算动态 SQL,会走 RawSqlSource 路径;只要出现了${}或动态标签,就会走 DynamicSqlSource 路径。
在静态 SQL 解析过程中,SqlSourceBuilder 会使用 GenericTokenParser 扫描#{和}包围的内容,把#{id}替换成?,同时生成一个 ParameterMapping 对象,记录参数属性名、jdbcType、typeHandler 等元信息。也就是说,#{}最终变成了“参数槽位”。
而${}的替换发生在运行时。DynamicSqlSource 里有一个 TextSqlNode,它内部也用 GenericTokenParser,但 openToken 是${,closeToken 是}。每次执行 SQL 时,TextSqlNode 直接从上下文参数里取出对应值,转换成字符串拼进 SQL。所以${}最终变成了“SQL 文本的一部分”。
这段话是源码级解释,面试时能说出来,就已经超过大多数候选人。
2.2 PreparedStatement 与 Statement 的本质差异
从 JDBC 层面看,这两种占位符对应两种完全不同的执行方式。
使用#{}时,MyBatis 最终调用的是connection.prepareStatement(sql),生成的 SQL 里是若干个?,再通过PreparedStatement.setString、setInt或setObject把参数填进去。数据库在收到带?的 SQL 时,会先对 SQL 结构做语法解析和语义分析,生成执行计划,之后参数再以独立数据的形式传过去。
使用${}时,参数在 application 层已经拼进了 SQL 字符串,最终执行时往往走的是普通Statement。数据库看到的是完整 SQL,不会区分哪部分是代码、哪部分是数据。
用一个生活化的类比:#{}像一张已经印好题目的答题卡,考生只能在留空的地方填答案,不能改写题目;${}像把考生说的一段话直接印进题目里,他说了什么,题目就变成了什么。
这也是为什么#{}能让数据库复用执行计划,而${}每次都像是新 SQL。
2.3 参数映射与 TypeHandler 的完整配合
#{}还有一个容易被忽略的细节:它不只是替换成?,还携带了一整套参数映射规则。
你可以这样写:
<insert id="insertUser"> insert into user(id, name, create_time) values(#{id}, #{name, jdbcType=VARCHAR}, #{createTime, typeHandler=MyLocalDateTimeHandler}) </insert>这里的 ParameterMapping 会记录 id、name、createTime 三个属性,以及对应的 jdbcType 和 typeHandler。真正执行时,DefaultParameterHandler 遍历 parameterMappings,从参数对象里取出属性值,交给 TypeHandler 的setParameter方法,最终写入 PreparedStatement。
TypeHandler 解决的是 Java 类型和 JDBC 类型之间的转换问题,比如 LocalDateTime、枚举、自定义对象。而${}没有这套机制,它通常只是拿到值的toString()结果直接拼字符串,所以遇到复杂类型时非常容易出错,也没有空值处理能力。
这也是为什么我强烈建议:能写#{}的地方,绝对不要用${}。
3. 五大实战场景:什么时候必须用 ${}
3.1 动态表名、列名与 ORDER BY
面试里最经典的陷阱题就是动态排序。有人写:
<select id="getUserList" resultType="User"> select id, name, age from user order by #{sortColumn} #{sortDirection} </select>这份 SQL 表面看用了预编译占位符,好像安全又规范,但实际执行时,数据库拿到的 SQL 是:
select id, name, age from user order by ? ?ORDER BY ?里的参数会被数据库当成一个字符串常量,而不是列名,所以排序结果不会按预期生效,甚至直接报错。原因是预编译占位符只能占“值”,不能占“标识符”。表名、列名、排序字段这类 SQL 结构,必须用${}拼进去。
正确做法是后端先做白名单校验,只允许预定义的字段名进入${}:
private static final Map<String, String> SORT_COLUMNS = Map.of( "id", "id", "name", "name", "createTime", "create_time" ); public String resolveSortColumn(String input) { String column = SORT_COLUMNS.get(input); if (column == null) { throw new IllegalArgumentException("illegal sort column"); } return column; }排序方向同样要校验,只允许asc或desc,不能直接把用户输入拼进去。这里的关键不是“不用 ${}”,而是“用之前必须让输入变得可信”。
3.2 LIKE 模糊查询的正确姿势
模糊查询是另一个高频坑。很多人图省事会这样写:
<select id="searchUser" resultType="User"> select * from user where name like '%${keyword}%' </select>表面上功能正常,但这行代码把keyword直接拼进 SQL,一旦用户输入引号、注释符、or 条件,就可能改变整条查询语义。正确写法是用#{}配合数据库函数拼接通配符:
<select id="searchUser" resultType="User"> select * from user where name like concat('%', #{keyword}, '%') </select>如果考虑到不同数据库兼容性,还可以用<bind>先拼好参数:
<select id="searchUser" resultType="User"> <bind name="pattern" value="'%' + keyword + '%'"/> select * from user where name like #{pattern} </select>这里需要补充一个细节:即使用#{},如果用户输入的内容里本身包含%或_,这两个字符在 LIKE 语义里仍是通配符。业务上如果不需要通配能力,最好在 Java 层先做转义,或者明确告诉用户这是普通模糊搜索,避免数据范围被意外放大。
3.3 IN 集合参数别用 ${} 拼接
还有一种常见的坏味道,是把 List 拼成"1,2,3"字符串,再用${}放进IN (...)。这种方式不仅有注入风险,还会因为参数值里出现特殊内容导致 SQL 语法错乱。
MyBatis 内置的<foreach>就是为了解决这个问题:
<select id="selectByIds" resultType="User"> select * from user where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>这段 XML 会生成id in (?, ?, ?),每个元素都是独立参数,由 PreparedStatement 统一绑定。需要注意的是,当ids为空集合时,<foreach>不会生成元素,SQL 会变成id in (),这在某些数据库上直接报语法错误。所以 mapper 接口里一般要先判空,或者在 XML 里加<if test="ids != null and ids.size() > 0">包裹整个条件。
这类细节在面试时主动说出来,会非常加分,因为说明你真的处理过真实业务,而不是只背过标签名。
3.4<if test>中的 OGNL 判断与占位符无关
很多新人会把<if test="name != null">看成“某种占位符”,甚至有人会问“test 里要不要写 #{}”。这一点需要说清楚:<if test>里是 OGNL 表达式,不是 SQL 标签,它只负责控制动态 SQL 片段是否参与拼接。
正确写法是:
<select id="selectByCondition" resultType="User"> select * from user <where> <if test="name != null and name != ''"> and name = #{name} </if> <if test="age != null"> and age = #{age} </if> </where> </select>test 表达式里直接写参数对象的属性名,不需要#或$。判断字符串时,值要用单引号包起来,比如name != '';判断数字时直接age != null。
至于${}能不能出现在<if test>里?${}本身是文本替换,它可以用在 SQL 片段里,也可以出现在动态标签的属性值中,但这和“预编译参数”是两个概念。面试时能把<if test>和占位符的关系讲清楚,说明你没有被一些半吊子博客带偏。
3.5 后端白名单兜底,别把安全寄托在 SQL 层
最后一条实战经验,也是我在团队里反复强调的:如果某个需求确实必须用${},那绝对不能直接把前端传参交给 SQL,必须在 service 层做白名单或格式校验。
操作步骤可以固化下来:
- 先确定允许的动态片段范围,比如排序字段枚举、固定表名列表;
- 将用户传入的原始值映射成内部常量;
- 映射不上就直接报错,而不是拼进 SQL;
- 排序方向单独校验,只允许 asc 或 desc;
- 动态表名场景下,禁止用户输入直接作为表名。
注意:用 ${} 拼动态排序字段之前,先问自己一句“这个值经过白名单校验了吗?”没有就回去加。这一条能拦住绝大多数靠拼接 SQL 引发的生产事故。
把安全兜底放在 Java 层,而不是指望 MyBatis 或者数据库,是真正有生产经验的人会做的事。
4. SQL 注入攻防:面试里必须讲清的安全边界
4.1 ${} 为什么会引发 SQL 注入
用最简单的例子解释。假设有一条 SQL:
<select id="getUserById" resultType="User"> select * from user where id = ${id} </select>请求传入的id是1 or 1=1,拼接后变成:
select * from user where id = 1 or 1=1这样的条件恒为真,查询会把整张表返回。这还只是最简单的攻击形态,如果攻击者知道表结构,拼接出id=1; delete from user之类的语句,后果更严重。
这不是 MyBatis 的问题,而是${}本身的语义就是“文本替换”。开发者一旦把用户可控的输入直接放进${},就等于主动邀请输入内容参与 SQL 结构拼接,注入只是时间问题。
4.2 预编译为什么能挡住值注入
再看#{}版本:
<select id="getUserById" resultType="User"> select * from user where id = #{id} </select>MyBatis 发到数据库的 SQL 是:
select * from user where id = ?参数1 or 1=1会通过 PreparedStatement 绑定成字符串,数据库把它当成一个普通值,所以最终查询语义变成where id = "1 or 1=1",而不是变成多个条件。
核心原因是 SQL 结构和参数值被分离了。结构先解析,参数后填充,攻击者的输入再特殊,也只能在“数据”的范围内打转,触碰不到“结构”。这也是 PreparedStatement 被称为防注入利器的最根本原因。
4.3 哪些场景即使是 #{} 也防不住
这里要澄清一个误区:#{}不是万能安全锁。它只能防“值注入”,防不住“结构注入”。
结构注入发生在表名、列名、排序字段、SQL 关键字这类必须拼接的位置。比如动态表名如果用了${},攻击者传入一个包含子查询或者联合查询的片段,就能改变整条 SQL 的结构。GROUP BY、ORDER BY、LIMIT的分页部分,在某些数据库方言里也可能存在特殊拼接需求,同样需要谨慎。
所以安全边界应该这样理解:
- 值的位置,一律用
#{}; - 标识符的位置,必须用
${},但输入必须先做白名单校验; - 任何来自用户的内容,都不能直接作为标识符拼接。
面试官听到这里,基本就能确认你对 SQL 注入的理解是真正成体系的。
5. 占位符与缓存联动:二级缓存背后的隐性考点
5.1 MyBatis 缓存 key 是怎么生成的
MyBatis 缓存相关的题目也常和占位符一起出现,因为两者直接相关。
一级缓存是 SqlSession 级别的,默认开启;二级缓存是 namespace 级别的,需要手动配置。无论哪一级,缓存都要先计算 CacheKey。MyBatis 的 CacheKey 通常会包含 mapped statement 的 id、参数对象、RowBounds,以及最终执行的 SQL 文本。
这里的重点在于“最终执行的 SQL 文本”。如果一条 SQL 用#{}传参,那 SQL 文本始终是固定的,比如select * from user where id = ?,变化的是参数值;如果一条 SQL 用${}拼参,那每个不同的值都会生成一个新的 SQL 文本,缓存 key 自然也就不同。
5.2 ${} 对缓存命中率的影响
假设你用${}做状态过滤:
<select id="listUser" resultType="User"> select * from user where status = ${status} </select>status 传 1 时,最终 SQL 是where status = 1;传 2 时是where status = 2。这两条 SQL 文本完全不同,MyBatis 会认为它们是两个不同的查询。一级缓存在同一个 SqlSession 内可能还能命中一次,但到了二级缓存层面,key 变化频繁,缓存价值几乎为零。
更严重的是动态表名场景。如果同一个 namespace 的 SQL 通过${}切换不同表,缓存命中错乱的风险极高。一个 mapper 下缓存的数据是 namespace 级别的,不同表的数据混在同一个缓存区域,一旦 SQL 语义和表不匹配,就可能读到“别的表的数据”。所以我的建议是:有动态表名需求的 mapper,干脆不要开二级缓存,或者明确配置 flushCache。
5.3 缓存与动态 SQL 的取舍
在实际项目里,我见过不少性能问题,不是因为数据库慢,而是因为 SQL 文本碎片化导致缓存无法复用。排查时一看日志,经常出现大量只有参数不同的 SQL 被重新执行,这也是滥用${}带来的隐性代价。
我通常的做法分三步:
- 高频基础查询,全部用
#{},保持 SQL 结构固定; - 动态排序这类不可避免的场景,用
${}但配合白名单,同时把字段基数控制住; - 动态表名场景,优先考虑拆 SQL、拆 mapper,或者用视图,而不是硬拼。
面试时主动提到“缓存 key 会受 SQL 文本影响”,比单纯背“#{} 支持缓存”要高级得多,因为这涉及到你对 MyBatis 内部机制的真实观察。
6. 源码跟踪:从解析到执行的完整路径
6.1 解析阶段的两个 Parser:SqlSourceBuilder 与 TextSqlNode
如果准备往更深一层聊,就可以直接讲源码了。
MyBatis 加载 mapper XML 时,XMLScriptBuilder 会调用 LanguageDriver 创建 SqlSource。对于包含${}或动态标签的 SQL,会创建 DynamicSqlSource;对于只有#{}的静态 SQL,会创建 RawSqlSource。
RawSqlSource 内部用 SqlSourceBuilder 做解析,SqlSourceBuilder 里维护了一个 GenericTokenParser,openToken 是#{,closeToken 是}。解析器碰到一个完整 token,就把#{...}替换成?,同时调用 parameterMappingBuilder 把 token 里的属性名、jdbcType、typeHandler 等信息封装成 ParameterMapping。
DynamicSqlSource 则不同。它最终执行时会调用 TextSqlNode,TextSqlNode 内部同样使用了 GenericTokenParser,但 openToken 是${。它不会生成?,也不会生成 ParameterMapping,而是直接从 bindings 中取出变量值,调用toString()后替换到 SQL 字符串中。
所以从源码角度,两者的区分非常清晰:一个是“参数映射”,一个是“字符串替换”。
6.2 执行阶段:ParameterHandler 如何填充参数
到了真正执行时,MyBatis 会创建 DefaultParameterHandler,这是核心类之一。
DefaultParameterHandler 的setParameters方法会遍历 BoundSql 里的 parameterMappings。对每个 ParameterMapping,它从参数对象中提取对应属性值,再拿到对应的 TypeHandler,调用typeHandler.setParameter(ps, i, parameter, jdbcType)。这一步完成之后,PreparedStatement 里的第 i 个?就被填上了值。
这里有几个实际工程经验:
- 如果参数值为 null,但没指定 jdbcType,某些驱动会因为不知道类型而报错;
- 这时写成
#{name, jdbcType=VARCHAR}可以规避问题; - 自带的 TypeHandler 能满足绝大多数场景,自定义 TypeHandler 主要用于枚举、JSON、时间类型等特殊转换。
而${}走不到这套逻辑。它没有 ParameterMapping,也没有 TypeHandler,值早就变成了 SQL 文本的一部分。要说性能差异,#{}在参数绑定上多了些处理开销,但它换来的安全性和可执行计划复用,远比这点开销值得。
6.3 打印 SQL 日志时看到的差异
源码层面的差异,其实通过日志就能验证。在 Spring Boot 里可以这样配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl如果 SQL 用的是#{},日志里通常是两行:Preparing: select ... where id = ?,以及Parameters: 1(String)。如果用的${},日志里直接就是Preparing: select ... where id = 1,看不到参数行,因为参数已经被拼进 SQL 了。
这个排查技巧很有用。遇到线上问题,第一件事就是开 SQL 日志对照:只要发现某条 SQL 的 Preparing 里带着具体值,就能判断有人在写${}。接下来再根据注入风险和缓存风险决定要不要重构。
7. 面试回答模板:从 60 分到 90 分的表达框架
7.1 30 秒基础版
如果面试官只给很短时间,可以直接这样回答:
“#{} 是预编译参数占位符,最终会变成 JDBC 的 ?,通过 PreparedStatement 绑定参数,能够防止 SQL 注入;${} 是字符串替换,直接拼进 SQL 执行,有注入风险。所以默认优先用 #{},只有动态表名、排序字段这些无法参数化的地方才考虑 ${},而且必须做白名单校验。”
说完这段话,可以补一句:“如果面试官想深入,我可以从 MyBatis 的 SqlSourceBuilder 和 TextSqlNode 两条解析路径展开。”这句话相当于抛出一个钩子,让面试官知道你不是背的。
7.2 90 秒进阶版(结合项目场景)
想要拿高分,必须把答案和项目经验绑定。参考模板:
“我之前在做用户列表时,前端会传 sortField 和 order,一开始直接写order by ${sortField} ${order},联调没问题。后来做安全评估发现,这个参数完全由用户控制,存在结构注入风险。我改成后端维护一个字段白名单,比如 id、name、createTime 映射到数据库真实列名,再在 service 层统一校验,映射不上的直接拒绝请求。排序方向也只允许 asc 或 desc。而用户注册、登录这类写操作和常规查询,我全部用 #{} 参数化,SQL 结构固定,数据库也能复用执行计划。”
这段话既点出了 ${} 的必要性,又展示了安全意识,还包含实际重构过程,比干巴巴讲概念强很多。
7.3 被追问时如何继续深入
面试官大概率会追问几个边界问题,这里提前准备好应对。
如果问“#{} 一定安全吗?”,可以答:“对于值参数是安全的,但表名、列名、排序字段这类标识符无法参数化,如果用 ${} 且没有白名单,仍然存在结构注入风险。”
如果问“${} 能出现在动态 SQL 的 test 里吗?”,可以答:“test 属性是 OGNL 表达式,不是 SQL 占位符,它通过属性名判断条件是否成立。${} 是文本替换,两者没有直接关系。但 ${} 允许在 XML 的 SQL 片段中使用。”
这样答,说明你区分了“SQL 参数”和“SQL 文本”两个维度,也是这道题真正的知识边界。
8. 持续更新:这套面试手册我还在补充什么
8.1 我在面试中常用的追问套路
作为面试官,我经常用这道题做能力分层的起手式。先说一句“聊聊 #{} 和 ${} 区别吧”,然后看候选人会往哪个方向走。
能说清“预编译、PreparedStatement、SQL 注入”的,属于基础扎实;能提到“SqlSourceBuilder、TextSqlNode、两条解析路径”的,说明对 MyBatis 源码有了解;能主动讲出“order by 白名单、空集合 IN、二级缓存 key 碎片化”的,基本可以判断他真写过复杂业务。
我还有个固定的压轴追问:“如果动态排序字段必须用 ${},你怎么做安全控制?”能答出白名单映射的人,我会直接给正向评价。这个细节虽然简单,但能看出一个开发者在写代码时,有没有把“用户输入不可信”当成默认前提。
8.2 给准备面试的读者的最后一个建议
我不建议背面经。这道题真正有效的准备方式是:找一个小项目,把每条 SQL 都打印出来,分别用#{}和${}改一改,观察日志里 PreparedStatement 和最终 SQL 的差异,再打开 MyBatis 源码,把 GenericTokenParser、SqlSourceBuilder、TextSqlNode、DefaultParameterHandler 这几个类顺着读一遍,整个链路就通了。
我个人在实际项目里遵循一条原则:能用#{}的地方绝不用${},被迫用${}时,在 service 层先做白名单。这条原则帮我少踩了很多坑。后续我也会继续把动态 SQL、缓存、分页、多数据源这类和占位符相关的案例补进这套面试手册,让这道题的答案始终保持完整。