1. 面试官到底在考什么:占位符问题背后的四个考点
先说说我对这道题的理解。面了这么多年,MyBatis相关的问题里#{}和${}的区别大概是出场率最高的一道,没有之一。但这道题能问到什么深度,完全取决于你怎么答。
你要是只说"#{}是预编译,${}是字符串拼接"——这基本就是入门级答案,面试官不会追问,但也不会给你加分。要是你能从JDBC层面讲清楚PreparedStatement和Statement的本质差异,再往上扯到MyBatis的SqlSource解析流程,最后落到SQL注入、缓存命中、动态SQL使用场景这些实际问题上——那这道题就不是一道送分题了,而是你的展示题。
我总结了一下,面试官问这个问题,背后其实藏了四个考点:
- 你到底有没有真正理解"预编译"这个底层概念,还是只是背了结论
- 你有没有SQL注入的安全意识,知道什么时候能用
${} - 你有没有在真实项目里踩过占位符的坑,比如动态排序、模糊查询、
LIKE拼接这些场景 - 你能不能从源码层面讲清楚MyBatis处理SQL的全流程,而不是停留在写Mapper的层面
所以这篇文章我就按这个逻辑来拆:先讲清楚底层原理,再上源码,然后给几个高频追问的标准答案,最后给一份可以直接用的面试应答话术。你要是能把这篇吃透,这道题基本就稳了。
顺便说一下,这个问题我后面会持续更新,每次面试遇到新的问法或者看到有意思的坑,都会补进来。你可以先收藏,回头有新内容直接看增量部分。
2. 核心区别拆解:#{}与${}的底层原理
2.1#{}的底层逻辑:JDBC预编译源码实例
#{}在MyBatis里最终会被解析成JDBC的?占位符,然后通过PreparedStatement的setXxx()方法传入参数。这是什么概念呢?我直接给你扒到JDBC层面看一下。
JDBC原生写法是这样的:
String sql = "SELECT * FROM user WHERE id = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setLong(1, 10086L); ResultSet rs = ps.executeQuery();这条SQL在执行之前就会被数据库编译,生成执行计划,后面传入的10086L只是一个纯参数值,不会再参与SQL语句本身的编译。
MyBatis里的#{}本质上就是帮你做了上面这几行代码的事情。你写的:
<select id="selectById" resultType="User"> SELECT * FROM user WHERE id = #{id} </select>在MyBatis底层会被改写成:
SELECT * FROM user WHERE id = ?然后执行时调用:
ps.setLong(1, id);这里有两个关键点你需要深刻理解。
第一,参数是"值"而不是"SQL片段"。数据库看到的就是一条带问号的完整SQL和一组干净的参数值,参数不可能改变SQL的结构,这就是SQL注入防护的本质。第二,预编译的SQL可以被缓存复用。如果SQL语句结构完全相同,只是参数值不同,数据库连接池可以直接复用同一个PreparedStatement对象,不用每来一个请求就重新编译一遍SQL,这对高并发场景的性能提升是实打实的。
2.2${}的底层逻辑:字符串拼接引发的安全问题
${}就完全不一样了。MyBatis会对这段文本做纯字符串替换,替换完之后才形成最终的SQL语句,然后再交给数据库去执行。也就是说,${}拼进去的是"SQL片段",它本身参与了SQL语句的编译。
典型写法是这样的:
<select id="selectByOrder" resultType="Order"> SELECT * FROM order_info ORDER BY ${orderBy} </select>传入orderBy = "create_time DESC",最终生成的SQL是:
SELECT * FROM order_info ORDER BY create_time DESC如果你传入的是orderBy = "create_time DESC; DROP TABLE order_info; --",那最终生成的SQL就变成了一条带破坏性语句的拼接结果。这就是为什么我一再强调,能用#{}的地方绝对不要用${}。
2.3 一条老生常谈的SQL注入案例
SQL注入不是理论概念,是真真切切能打到线上系统的。我给你还原一个最简单也最经典的登录绕过场景。
假设你的Mapper写的是:
<select id="login" resultType="User"> SELECT * FROM user WHERE username = '${username}' AND password = '${password}' </select>正常用户输入用户名admin、密码123456,拼出来的SQL很干净。但如果有人在用户名框里输入这个:
admin' --那最终拼出来的SQL就变成了:
SELECT * FROM user WHERE username = 'admin' --' AND password = '123456'--是MySQL的注释符,后面的条件全被注释掉了。这条SQL的查询结果就是整个user表里的admin账户记录,如果有人用这套代码做登录接口,那他根本不需要知道密码,直接就能以admin身份登录系统。
你用#{}改写一下就完全不存在这个问题:
<select id="login" resultType="User"> SELECT * FROM user WHERE username = #{username} AND password = #{password} </select>不管输入什么,都会被当成一个字符串参数丢给PreparedStatement处理,数据库层面就把"代码"和"数据"区分开了。这就是预编译防注入的核心理念。
2.4 两者多维度对比速查表
区别讲完了,整理一张对照表,面试的时候你可以边说边在心里过一遍这些维度:
| 对比维度 | #{} | ${} |
|---|---|---|
| 底层实现 | PreparedStatement的占位符? | 纯字符串拼接 |
| 是否预编译 | 是 | 否 |
| SQL注入风险 | 无 | 有 |
| 参与SQL结构变更 | 否,只传值 | 是,可拼接任意片段 |
| 排序字段/表名/列名 | 不支持 | 支持 |
| 模糊查询 | 支持(配合CONCAT) | 支持(直接拼) |
| 缓存命中影响 | 同一SQL结构始终复用 | SQL文本变化,缓存失效 |
| 日志可读性 | 参数和SQL分开打印 | 完整SQL,一眼看清 |
这张表基本把面试官能追问的点都覆盖到了。背下来只能算及格,真正把每一行的"为什么"讲清楚,才算深度解析。
3. 源码层面:MyBatis是如何处理这两种占位符的
如果面试官追问了一句"你从源码角度讲讲",这时候你的回答层级就上去了。我按MyBatis的执行链路拆给你看。
3.1 SQL解析阶段:SqlSourceBuilder和DynamicSqlSource的分工
MyBatis在解析Mapper XML文件的时候,会根据SQL中是否包含动态SQL标签(<if>、<where>、<foreach>等)或者${},来决定生成哪种SqlSource。
如果SQL里只有#{}和静态文本,MyBatis会生成RawSqlSource——这是最理想的情况。如果SQL里混了动态SQL或者${},就会生成DynamicSqlSource,每次执行都需要重新解析SQL。
关键点来了:在解析阶段,#{}会被解析成ParameterMapping,并替换为JDBC的占位符?;而${}根本不会被替换,它会在执行阶段通过TextSqlNode里面的BindingTokenParser做文本替换。也就是说,${}的解析发生在SQL拼接的运行时,而#{}的解析发生在SQL结构确认的编译期。
一个简单的判断方法:你去翻MyBatis源码的SqlSourceBuilder类,里面核心方法就是处理#{}的,它会遍历SQL文本,把形如#{property}的部分替换成?,同时生成对应的ParameterMapping列表。而${}的替换逻辑在GenericTokenParser配合TokenHandler的实现类里,说白了就是找到${},取出内部的表达式,然后调用相关类得到结果值,把这个值替换到SQL文本中。
3.2 参数绑定阶段:PreparedStatementHandler的setParameters
再往后走,#{}和${}的分化就更明显了。
${}在之前的阶段已经把值拼进SQL了,所以走到Executor执行的时候,它就是一串已经定型的SQL字符串。而#{}的SQL是带?的,需要通过PreparedStatementHandler.instantiateStatement()创建PreparedStatement,再调用parameterize()方法。
parameterize()内部会调用DefaultParameterHandler.setParameters(),这个方法遍历之前解析好的ParameterMapping列表,对每一个ParameterMapping,根据JavaType和JdbcType调用PreparedStatement上对应的setString、setLong、setInt等方法,把参数值绑定进去。
这个过程你不需要把所有源码背下来,但你要能说出这个链路:
Mapper方法调用 -> SqlSession -> Executor -> StatementHandler(得到BoundSql) -> ParameterHandler(处理#{}参数绑定) -> PreparedStatement.setXxx() -> 执行SQL你把这个链路讲出来,面试官基本就会认为你确实看过源码了。
3.3 项目实战结合:spring boot + mybatis 项目里的真实应用
说回实际项目。现在我们做spring boot + mybatis开发,大多数业务SQL都用#{},但也有些场景逼着你用${}。我把我在项目里总结的判断标准放这里:
能用#{}的场景:
- 查询的等值匹配条件
- 插入和更新操作中的字段值
- 批量插入的每条记录值
- 子查询的过滤条件
- 分页查询的limit和offset参数(配合PageHelper时内部会处理)
不得不用${}的场景:
ORDER BY后面的排序字段GROUP BY后面的分组字段- 表名或数据库名,比如月份分表的逻辑查询
IN子句里的动态字段名(但值还是要用#{})
举个例子,做国际化电商项目时,多商户跨境商城的订单查询经常需要按不同币种、不同汇率表查询。如果表设计成order_usd、order_eur这种按月或按币种分表结构,那表名就必须用${}动态传入:
<select id="selectByCurrency" resultType="Order"> SELECT * FROM ${tableName} WHERE merchant_id = #{merchantId} AND create_time BETWEEN #{startTime} AND #{endTime} </select>这种写法${}只承接表名,条件值全是#{},既保证结构灵活,又把注入面压到最低。前提是tableName必须在代码层做白名单校验,绝不能直接把前端传参透传过来。
GROUP BY场景也类似:
<select id="selectGroupSummary" resultType="Map"> SELECT ${groupColumn}, COUNT(*) AS cnt, SUM(order_amount) AS totalAmount FROM order_info WHERE status = #{status} GROUP BY ${groupColumn} </select>${groupColumn}这个值如果是从枚举或者固定配置表里取的,安全风险可控。要是直接从前端接收——早晚会出事。
4. 高频追问:缓存、动态SQL和模糊查询的大坑
4.1 缓存命中:${}如何在无意识中击穿缓存
很多人在实际项目里配了MyBatis的一级缓存和二级缓存,却没注意过占位符对缓存命中率的影响。这是个非常隐蔽的问题。
MyBatis缓存的核心Key中包含了一条SQL的完整文本。如果SQL完全一致,缓存才能命中。#{}参数化后,SQL文本是固定不变的,比如:
SELECT * FROM user WHERE id = ?传id等于1、2、3,SQL文本一模一样,属于同一个缓存Key,只是参数值不同。而${}每次替换不同值后SQL文本都在变:
-- 传入1时 SELECT * FROM user WHERE id = 1 -- 传入2时 SELECT * FROM user WHERE id = 2 -- 传入3时 SELECT * FROM user WHERE id = 3这几条SQL在数据库层面的执行计划缓存里是完全不同的语句,在MyBatis缓存里的Key也不一样,等于每次都强制走全流程解析,缓存形同虚设。
我之前在项目里排查过一个线上慢查询的问题,有个报表查询接口每次响应都超过3秒,明明配了二级缓存却没生效。后来翻日志发现Mapper里写的是WHERE id = ${id},改成#{}之后这个接口的响应时间直接降到了200毫秒以内。这个坑不踩一次,你真的不会意识到占位符和缓存之间还有这层关系。
顺带说一句,面试里如果被问到"MyBatis一级缓存和二级缓存的区别",你可以把这个点带进去讲,会让你的回答内容明显更充实。
4.2 动态SQL的经典陷阱:if判空和等于条件的坑
动态SQL是MyBatis最实用的能力之一,但也是占位符相关坑的重灾区。最常见的一个坑是<if>标签的判定条件写错,导致条件不生效。
比如你写了这么一段:
<select id="selectUsers" resultType="User"> SELECT * FROM user WHERE status = 1 <if test="name != null and name != ''"> AND name = #{name} </if> <if test="type != null"> AND type = ${type} </if> </select>当type = null时,这段SQL拼接出来是正常的。但当type = 1的时候,注意:${type}拼进去的是1,不带引号,如果你的type字段在数据库里是varchar类型,这个查询条件大概率匹配不到数据,而且type值如果被传入恶意内容,注入风险直接拉满。
正确写法是这样:
<select id="selectUsers" resultType="User"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name = #{name} </if> <if test="type != null"> AND type = #{type} </if> </where> </select>里面还有两个细节值得注意。一个是把WHERE改成<where>标签,它能自动处理条件前面多余的AND,不会出现SQL语法错误。另一个是name != ''这个判断,很多初学者写<if test="name != null">就结束了,如果前端传了空字符串,条件就会带上一个AND name = '',查不到任何数据。
4.3 模糊查询LIKE的占位符写法
模糊查询几乎是每个项目都逃不掉的场景,这里的占位符坑也非常典型。TRIM_TOOK_LIKE的常见写法有三种,我给你逐个对比一下:
第一种,错误示范,也是新手最常写的:
<select id="searchUser" resultType="User"> SELECT * FROM user WHERE name LIKE '%${keyword}%' </select>这个写法有两个问题:一是SQL注入风险,keyword直接拼进SQL;二是如果keyword里本身就带了单引号,这条SQL直接就语法报错了。
第二种是#{}直接配合字符串拼接的写法:
<select id="searchUser" resultType="User"> SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%') </select>这种写法安全,参数值不会参与SQL编译,但要注意:如果keyword传了%或_这种MySQL通配符,它会被当成通配符处理,可能匹配到超预期范围的数据。用户搜索"100%"这种文本时就会踩坑。
第三种是我在项目中比较推荐的做法:
<select id="searchUser" resultType="User"> SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%') ESCAPE '/' </select>配合在Java层对关键字做转义:
public String escapeLikeWildcard(String keyword) { if (keyword == null) { return null; } return keyword .replace("/", "//") .replace("%", "/%") .replace("_", "/_"); }这样既防了SQL注入,又规避了通配符误匹配,搜索准确度会明显提升。
4.4 配置打印SQL:排查占位符问题的神器
遇到占位符相关疑难杂症,第一件事就是把MyBatis的SQL日志打印打开。我通常在application.yml里配:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.your.mapper.package: debug这样控制台会输出Preparing和Parameters两行:
==> Preparing: SELECT * FROM user WHERE id = ? AND status = ? ==> Parameters: 10086(Long), 1(Integer) <== Total: 1重点来了,如果是${}拼接的SQL,Preparing那行会直接看到完整的替换后的SQL内容;如果是#{},你只会看到问号,参数在Parameters行里。这对快速判断代码里到底用的是哪种占位符非常有帮助。
还有一个经验:加了mybatis-plus的项目可以在配置里打开mybatis-plus.configuration.log-impl,或者直接用p6spy来做更直观的SQL监控,效果都差不多。面试里被问到"你怎么排查SQL相关问题时",能说出这个工具链,印象分会上去不少。
5. 面试现场:一份可以直接用的应答模板和加分话术
5.1 精简应答框架(45秒版本)
如果面试官只是让你简单说两句,你可以按这个框架来:
#{}会被MyBatis解析成JDBC的预编译占位符,对应PreparedStatement的?,参数通过setXxx传入,不会改变SQL结构,能防SQL注入,也是我的默认选择;${}是纯字符串替换,拼进去的内容会参与SQL编译,存在注入风险,所以只在排序字段、表名这类无法参数化的场景下使用,并且这个值必须在Java代码侧做白名单校验。
这个回答的核心亮点是最后半句——提到白名单校验。大多数候选人只会背概念,你能主动说出安全兜底方案,面试官会觉得你是真的在项目里做过。
5.2 追问场景拆解:不同深度的加分回答
追问一:"为什么#{}能防SQL注入?"
不要只说"因为它用了预编译",要往下说一层。数据库在编译SQL时,已经确定了语句的角色——哪些部分是关键字、哪些部分操作符、哪些部分是参数值。PreparedStatement在SQL编译阶段就把?的位置预留了,之后set进去的任何值都只会被当作"参数值"处理,不会重新改变SQL语句的结构。而${}是先拼SQL再编译,那参数内容就已经成为SQL结构的一部分了。结构可被输入改变,就是注入产生的原因。
追问二:"那${}是不是一无是处?"
不是。凡是SQL的结构化部分需要动态变化,#{}就无能为力了,比如排序字段、分组字段、表名。但这种情况下,正确的做法是在Service层做严格的值的枚举校验。
给你一个完整的示例,排序白名单校验:
private static final Set<String> SORT_WHITELIST = Set.of("create_time", "order_amount", "status", "id"); public List<Order> queryOrderList(String sortField, String sortOrder) { if (!SORT_WHITELIST.contains(sortField)) { throw new BizException("非法的排序字段"); } String safeOrder = "DESC".equalsIgnoreCase(sortOrder) ? "DESC" : "ASC"; return orderMapper.selectOrderList(sortField + " " + safeOrder); }反编译看这行代码的逻辑:sortField确认在枚举列表里,sortOrder强制二选一,用户输入根本没有到达SQL层面的机会。
追问三:"分页插件PageHelper和占位符的关系?"
加分项来了。PageHelper本质上会在你的SQL外面包一层LIMIT ?,它内部用的就是参数化的方式。所以你在Mapper里别写死limit条件,交给PageHelper更安全也更优雅。这个点不在原问题范围内,但你能主动带出来,就显得纵向知识体系比普通候选人扎实。
5.3 面试避坑清单
根据我过往的面试经验,这道题有一批高频踩坑点,专门列出来警醒一下:
- 只背结论说不清原理。
#{}比${}安全只是一句话,没有JDBC层面的支撑,这个回答没有说服力。 - 说"
#{}不能做模糊查询"。这是完全错误的。配合CONCAT完全可以做,只是不能直接写在LIKE '%#{}%'里那种写法——那会变成字面量LIKE '%?',不对,实际上是%#{keyword}%压根不会被替换成参数,因为MyBatis不认为这是一个合法的#{}表达式。严谨一点说,LIKE '%#{keyword}%'这种写法里的#{}会被当成普通字符串,查询结果永远为空。类似但不完全一样。 - 盲目说"
${}不能用"。太绝对了。动态表名、动态排序字段这些场景绕不开${},关键是配套的安全机制。 - 忽略了缓存层面的影响。高级面试官会拿缓存命中率来追加提问,提前准备好答案很有必要。
- 不能主动说出"什么时候用
#{},什么时候必须用${}"。这个判断标准就是工作经验的分水岭。
5.4 持续更新的内容计划
这个答题模板我会保持更新。后面计划补的内容包括:
#{}和${}在多商户跨境电商项目里的实际应用案例拆解- MyBatis二级缓存与
${}冲突时的优化方案 - spring boot + mybatis做动态表名分库分表时的占位符设计模式
- SQL注入绕过的常见变体和对应的代码层防御策略
有新的面试真题进来,我会第一时间把答案和源码验证补上。
6. 一个过来人的最后忠告
我见过很多开发者在面对这个问题时,把大部分精力放在背诵区别列表上。说实话,背下来并不难,难的是你把这个知识点放进真实的工程场景里,理解它为什么这么设计,以及什么时候必须打破默认选择。
如果你是在准备面试,我建议你按这个顺序过一遍:先把这个文档看明白,然后打开MyBatis源码把SqlSourceBuilder和ParameterHandler这两个类翻一下,最后回到自己的项目里,用日志打印的方式把#{}和${}的拼装结果对比看一遍。这套动作做完,这道题你就真正掌握了。
如果你已经工作了,那我给你一个实在的建议:回去检查一下代码仓库,搜一遍Mapper XML里的${},看看哪些是能用#{}替代的,哪些是必须保留但缺少白名单校验的。每一次这样的自查,都是在给你的系统减少一个潜在的线上事故。