ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis面试必问:#{}和${}区别,从预编译到SQL注入全解析

MyBatis面试必问:#{}和${}区别,从预编译到SQL注入全解析 聊到 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 idinsertUser insert into user(id, name, create_time) values(#{id}, #{name, jdbcTypeVARCHAR}, #{createTime, typeHandlerMyLocalDateTimeHandler}) /insert这里的 ParameterMapping 会记录 id、name、createTime 三个属性以及对应的 jdbcType 和 typeHandler。真正执行时DefaultParameterHandler 遍历 parameterMappings从参数对象里取出属性值交给 TypeHandler 的setParameter方法最终写入 PreparedStatement。TypeHandler 解决的是 Java 类型和 JDBC 类型之间的转换问题比如 LocalDateTime、枚举、自定义对象。而${}没有这套机制它通常只是拿到值的toString()结果直接拼字符串所以遇到复杂类型时非常容易出错也没有空值处理能力。这也是为什么我强烈建议能写#{}的地方绝对不要用${}。3. 五大实战场景什么时候必须用 ${}3.1 动态表名、列名与 ORDER BY面试里最经典的陷阱题就是动态排序。有人写select idgetUserList resultTypeUser 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 MapString, 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 idsearchUser resultTypeUser select * from user where name like %${keyword}% /select表面上功能正常但这行代码把keyword直接拼进 SQL一旦用户输入引号、注释符、or 条件就可能改变整条查询语义。正确写法是用#{}配合数据库函数拼接通配符select idsearchUser resultTypeUser select * from user where name like concat(%, #{keyword}, %) /select如果考虑到不同数据库兼容性还可以用bind先拼好参数select idsearchUser resultTypeUser bind namepattern value% keyword %/ select * from user where name like #{pattern} /select这里需要补充一个细节即使用#{}如果用户输入的内容里本身包含%或_这两个字符在 LIKE 语义里仍是通配符。业务上如果不需要通配能力最好在 Java 层先做转义或者明确告诉用户这是普通模糊搜索避免数据范围被意外放大。3.3 IN 集合参数别用 ${} 拼接还有一种常见的坏味道是把 List 拼成1,2,3字符串再用${}放进IN (...)。这种方式不仅有注入风险还会因为参数值里出现特殊内容导致 SQL 语法错乱。MyBatis 内置的foreach就是为了解决这个问题select idselectByIds resultTypeUser select * from user where id in foreach collectionids itemid open( separator, close) #{id} /foreach /select这段 XML 会生成id in (?, ?, ?)每个元素都是独立参数由 PreparedStatement 统一绑定。需要注意的是当ids为空集合时foreach不会生成元素SQL 会变成id in ()这在某些数据库上直接报语法错误。所以 mapper 接口里一般要先判空或者在 XML 里加if testids ! null and ids.size() 0包裹整个条件。这类细节在面试时主动说出来会非常加分因为说明你真的处理过真实业务而不是只背过标签名。3.4if test中的 OGNL 判断与占位符无关很多新人会把if testname ! null看成“某种占位符”甚至有人会问“test 里要不要写 #{}”。这一点需要说清楚if test里是 OGNL 表达式不是 SQL 标签它只负责控制动态 SQL 片段是否参与拼接。正确写法是select idselectByCondition resultTypeUser select * from user where if testname ! null and name ! and name #{name} /if if testage ! null and age #{age} /if /where /selecttest 表达式里直接写参数对象的属性名不需要#或$。判断字符串时值要用单引号包起来比如name ! 判断数字时直接age ! null。至于${}能不能出现在if test里${}本身是文本替换它可以用在 SQL 片段里也可以出现在动态标签的属性值中但这和“预编译参数”是两个概念。面试时能把if test和占位符的关系讲清楚说明你没有被一些半吊子博客带偏。3.5 后端白名单兜底别把安全寄托在 SQL 层最后一条实战经验也是我在团队里反复强调的如果某个需求确实必须用${}那绝对不能直接把前端传参交给 SQL必须在 service 层做白名单或格式校验。操作步骤可以固化下来先确定允许的动态片段范围比如排序字段枚举、固定表名列表将用户传入的原始值映射成内部常量映射不上就直接报错而不是拼进 SQL排序方向单独校验只允许 asc 或 desc动态表名场景下禁止用户输入直接作为表名。注意用 ${} 拼动态排序字段之前先问自己一句“这个值经过白名单校验了吗”没有就回去加。这一条能拦住绝大多数靠拼接 SQL 引发的生产事故。把安全兜底放在 Java 层而不是指望 MyBatis 或者数据库是真正有生产经验的人会做的事。4. SQL 注入攻防面试里必须讲清的安全边界4.1 ${} 为什么会引发 SQL 注入用最简单的例子解释。假设有一条 SQLselect idgetUserById resultTypeUser select * from user where id ${id} /select请求传入的id是1 or 11拼接后变成select * from user where id 1 or 11这样的条件恒为真查询会把整张表返回。这还只是最简单的攻击形态如果攻击者知道表结构拼接出id1; delete from user之类的语句后果更严重。这不是 MyBatis 的问题而是${}本身的语义就是“文本替换”。开发者一旦把用户可控的输入直接放进${}就等于主动邀请输入内容参与 SQL 结构拼接注入只是时间问题。4.2 预编译为什么能挡住值注入再看#{}版本select idgetUserById resultTypeUser select * from user where id #{id} /selectMyBatis 发到数据库的 SQL 是select * from user where id ?参数1 or 11会通过 PreparedStatement 绑定成字符串数据库把它当成一个普通值所以最终查询语义变成where id 1 or 11而不是变成多个条件。核心原因是 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 idlistUser resultTypeUser select * from user where status ${status} /selectstatus 传 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 解析阶段的两个 ParserSqlSourceBuilder 与 TextSqlNode如果准备往更深一层聊就可以直接讲源码了。MyBatis 加载 mapper XML 时XMLScriptBuilder 会调用 LanguageDriver 创建 SqlSource。对于包含${}或动态标签的 SQL会创建 DynamicSqlSource对于只有#{}的静态 SQL会创建 RawSqlSource。RawSqlSource 内部用 SqlSourceBuilder 做解析SqlSourceBuilder 里维护了一个 GenericTokenParseropenToken 是#{closeToken 是}。解析器碰到一个完整 token就把#{...}替换成?同时调用 parameterMappingBuilder 把 token 里的属性名、jdbcType、typeHandler 等信息封装成 ParameterMapping。DynamicSqlSource 则不同。它最终执行时会调用 TextSqlNodeTextSqlNode 内部同样使用了 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, jdbcTypeVARCHAR}可以规避问题自带的 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、缓存、分页、多数据源这类和占位符相关的案例补进这套面试手册让这道题的答案始终保持完整。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进