资讯动态

MyBatis中#{}和${}的区别:从SQL注入到预编译原理与实战

发布时间:2026/9/15 6:16:04 来源:尧图企业网站定制
1. 从一次生产事故说起你确定真的搞懂#{}和${}了吗我在维护一个老项目的时候遇到过这么一件事某天晚上的定时任务突然报错排查发现是一条统计SQL在执行时被注入了异常字符串导致整个批次回滚。过程不复杂就是有人图省事在SQL里用了${}拼接了一个从接口传入的排序字段结果被塞入了一段恶意内容。虽然最终没有造成数据泄露但那一次之后我要求团队里所有写MyBatis的人必须能说清楚#{}和${}在底层到底经历了什么说不清的就不要碰生产SQL。这个话题每次聊起来都觉得太基础但实际问一圈你会发现做Java开发三年五年的人很多也只知道#{}是预编译、${}是拼接、${}有注入风险再往深问一层——MyBatis是怎么把#{}转成?的${}的SQL注入为什么能在预编译机制下依然发生什么时候你不得不放弃#{}改用${}能完整答上来的真的不多。这篇文章我打算从源码执行链路、JDBC底层协议、实际业务场景三个维度把这两个占位符从原理到实战彻底拆一遍。不管你是刚接触MyBatis的新手还是已经用了好几年但是总在${}上踩坑的老开发这篇文章都值得你耐着性子看完。最后我还会附上高频面试题的答题思路以及一套我自己在项目里使用的SQL安全审查清单。2. 底层原理完全拆解MyBatis到底对这两个符号做了什么2.1 从一次SQL执行的生命周期看起要理解#{}和${}的区别我们不能停留在MyBatis这一层得把视野拉到JDBC层面。因为MyBatis无论封装得多优雅最终还是要通过JDBC驱动和数据库交互。在MyBatis中一条SQL的执行大体要经过这么几步解析XML或注解中的SQL语句生成SqlSource对象然后通过SqlSession调用ExecutorExecutor从SqlSource中获取BoundSql再通过StatementHandler创建JDBC的Statement最后执行。这个过程中#{}和${}的分歧点在第一步就已经出现了。${}是字符串替换它发生在MyBatis解析SQL文本的阶段。MyBatis会把SQL中所有的${}占位符直接替换成传入参数的toString()结果替换完成之后再交给后续流程处理。也就是说你写的是SELECT * FROM user WHERE name ${name}MyBatis在解析阶段就把它变成了SELECT * FROM user WHERE name 张三然后拿这个做好的字符串去创建Statement对象。#{}则完全不同。MyBatis遇到#{}时会在SQL解析阶段把这个位置替换成一个JDBC标准的占位符?同时把参数值单独提取出来存进ParameterMapping列表。等到真正创建PreparedStatement的时候MyBatis再把参数值通过JDBC驱动提供的setObject/setString等方法绑定到?上。你写的是SELECT * FROM user WHERE name #{name}实际传给数据库的SQL是预编译好的SELECT * FROM user WHERE name ?参数张三是作为独立的数据对象传过去的。这两条路径的差异本质上是SQL结构和SQL参数是否分离的问题。用生活化的类比来说${}就像是你把话直接说进了句子里今天天气很好我吃了一碗面内容已经变成句子本身不可分割的一部分。而#{}好比是填表表格已经印好了我吃了____你只是在空格里填上一碗面表格的结构是固定的填什么内容都改变不了表格的形状。2.2 源码层面验证从XMLScriptBuilder到DynamicSqlSource如果你觉得刚才的结论还是有点抽象我们直接翻源码。MyBatis解析XML中的SQL语句时核心处理逻辑在XMLScriptBuilder这个类里。它里面有个方法叫parseDynamicTags会逐段扫描SQL节点里的文本内容根据是否包含${}或者MyBatis动态标签比如if、where来判断这是一个动态SQL还是一个静态SQL。如果SQL文本里包含${}MyBatis会为这段文本创建TextSqlNode对象并标记这是一个DynamicSqlSource。TextSqlNode内部有个isDynamic()方法判断标准就是看文本里有没有${}。等到执行阶段DynamicSqlSource.getBoundSql()方法里会调用TextSqlNode.apply()这个方法做的核心操作就是拿到入参对象替换掉SQL里所有的${}占位符。这个阶段发生在SQL被发送到数据库之前纯字符串层面的替换没有任何数据库参与。而#{}的处理就完全不在文本层面了。XMLScriptBuilder遇到#{}会走StaticTextSqlNode分支把#{}解析成?结构同时生成对应的ParameterMapping。参数值保存在ParameterMapping里绑定操作交由JDBC驱动的PreparedStatement完成。这里有一个非常关键的点就是${}的替换发生在MyBatis框架内部JDBC驱动根本不知道你原本写的是啥收到的就是一个完整的SQL语句。而#{}是让MyBatis把占位符和参数分开管理JDBC驱动收到的是SQL骨架加参数列表。这就是${}无法防SQL注入的根本原因——你传入的值已经被拼进SQL文本了如果值里面有引号、分号、注释符它们都会成为SQL指令的一部分而不是单纯的数据。注意很多人对预编译有误解以为只要数据库连接开启了预编译就天然免疫SQL注入。实际上预编译防注入的前提是SQL结构中不包含任何用户可控的拼接内容。一旦你用了${}预编译机制直接被绕过了因为发过去的已经是最终的SQL文本驱动只能老老实实创建Statement而不是PreparedStatement。2.3 JDBC层面的对照Statement和PreparedStatement的根本差异我们再往底层看一眼JDBC。Java的Statement接口和PreparedStatement接口分别对应了我上面说的两条路径。如果用Statement你需要自己拼好完整的SQL字符串然后通过stmt.executeQuery(sql)执行。这个时候字符串里的任何内容都被数据库当成SQL指令去解析。你拼进去的值里如果包含 OR 11那数据库就会认为这是两个条件是或关系的查询。如果用PreparedStatement调用的是conn.prepareStatement(sql)此时数据库会先对这个SQL进行语法解析和优化生成执行计划并缓存。注意这个阶段SQL结构已经定型了占位符?的位置在数据库看来就是一个将来要填数据的位置。随后你再调用pstmt.setString(1, name)参数值是通过协议单独传到数据库的数据库只把它当数据看而不会重新解析成SQL指令。这个机制是SQL注入防护的地基。MyBatis的#{}就是帮你自动走了PreparedStatement这条路而${}帮或者说害你走了Statement那条路。理解了这个层次面试官再问你#{}和${}的区别你就能从JDBC协议层面给出一针见血的回答而不是停留在一个是预编译一个是拼接这种表面套话。3. 实战场景剖析什么时候必须用${}以及怎么安全地用3.1 必须使用${}的四类场景讲了这么多${}的风险但实际开发中确实有一些场景非它不可。我归纳下来主要有四类。第一类是动态表名。例如分库分表场景中要根据用户ID取模路由到不同的表SQL可能是SELECT * FROM order_${year}年份这个信息是运行期才确定的不可能在Mapper接口里提前写死固定表名。这种场景用#{}会直接报语法错误因为占位符不能用在表名位置。第二类是ORDER BY的动态排序字段。SELECT * FROM product ORDER BY ${sortField} ${sortOrder}排序字段和排序方向是前端传过来的参数。注意这里如果用#{}也是不行的因为数据库的ORDER BY语法要求字段名是标识符而不是字符串字面量。第三类是动态列名类似SELECT ${columns} FROM table这种常用于报表查询或动态导出功能里列的集合由用户配置决定。第四类是某些数据库特性和关键字不同的数据库方言里同样含义的SQL语法可能不同这种场景下用${}处理数据库类型相关的片段也是常见的做法。这些场景的共同特征是需要替换的是SQL的结构片段表名、列名、关键字而不是值数据。PreparedStatement的占位符?只能绑定值无法绑定结构。3.2 困境中的安全防线白名单校验是最有效的手段既然${}不可不用那重点就在于怎么降低风险。我在项目里定了一条硬性规则任何使用${}的位置入参必须经过白名单校验禁止把原始入参直接拼进去。举个例子排序字段这个场景前端传进来的值是sortFieldprice我的代码不能直接拿来就用而是要先通过一个映射表转换private static final MapString, String SORT_FIELD_MAP new HashMap(); static { SORT_FIELD_MAP.put(price, price); SORT_FIELD_MAP.put(sales, sales_count); SORT_FIELD_MAP.put(time, create_time); } public String getSortField(String input) { String field SORT_FIELD_MAP.get(input); if (field null) { // 不在白名单内直接使用默认值或抛出异常 throw new IllegalArgumentException(非法排序字段: input); } return field; }这样即使是${}拼接拼接的内容也是经过映射之后的固定值注入者根本没有机会把恶意内容传进来。如果要用原字段名也可以做一层正则校验字段名只允许字母、数字、下划线且不能以数字开头。表名场景同理可以维护一张表名白名单或者用正则[a-zA-Z_][a-zA-Z0-9_]*先过滤一遍再拼接。另外一个容易被忽略的细节是使用${}的SQL一定要单独在代码审查中标记出来。我见过的很多事故问题不是出在使用${}本身而是出现在长期维护过程中后来接手的人看不懂上下文把一个原本经过校验的入参改成了直接透传风险就悄悄埋下了。所以在代码里用${}的位置注释里写清楚此处为什么不能使用#{}以及入参必须经过什么校验这比什么都管用。3.3 LIKE模糊查询和IN语句这几个高频场景的最佳写法模糊查询是${}的重灾区。不少人习惯这么写WHERE name LIKE %${keyword}%或者为了避免注入但不知道怎么传%写成WHERE name LIKE % #{keyword} %这个其实是错的会把空格也拼进匹配内容。这些写法要么有注入风险要么结果不对。正确做法是用CONCAT函数把参数拼接进来保持#{}的参数化查询方式SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %)这样SQL结构是完整的参数通过占位符绑定%是SQL函数拼接进去的字符串完全不影响执行计划。MySQL、PostgreSQL、Oracle都支持CONCAT函数兼容性也没什么问题。如果你的数据库不支持CONCAT极少见可以用#{keyword}传参时直接把%拼进参数里String keyword % input %; // mapper调用时直接传 keywordIN语句的推荐写法也比较固定用foreach遍历集合SELECT * FROM user WHERE id IN foreach collectionidList itemid open( separator, close) #{id} /foreach这里要注意foreach整体解析之后得到的SQL是有固定个数的?占位符参数值仍然是通过PreparedStatement绑定的所以它和#{}一样安全。需要注意的是如果idList为空集合生成的SQL会变成WHERE id IN ()这在MySQL里是语法错误。所以使用之前一定要判空或者在SQL里用if testidList ! null and idList.size() 0包裹。至于IN语句里传逗号分隔的字符串然后配合${}拆开这种操作我建议想都不要想这就是邀请SQL注入来你家做客。4. 从Spring Boot集成到批处理完整实操与核心配置4.1 Spring Boot项目集成MyBatis的基础配置既然聊到实战先铺垫一个最基础但很多人配置不完整的部分。Spring Boot集成MyBatis第一步是引入依赖。我以自己常用的组合为例dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency然后是在application.yml里配置MyBatis相关参数mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有两个点值得展开说。map-underscore-to-camel-case设置为true之后数据库的create_time字段就能自动映射到实体类的createTime属性省去一堆ResultMap的配置。log-impl设置为StdOutImpl会在控制台打印实际执行的SQL语句。注意MyBatis打印日志时#{}参数对应的位置会显示为?而${}替换后的内容会直接出现在SQL字符串里。这是一个非常直观的判断当前代码用的是哪种占位符的方法。如果你的项目把日志输出到了文件里通过观察日志中SQL语句是否包含?占位符以及参数列表也能快速判断线上代码是否误用了${}。4.2 批量写操作的底层机制与参数配置批量操作是MyBatis使用中另一个高频场景而且它和#{}/${}的关系容易让人混淆。常见写法是使用foreach标签生成批量INSERTinsert idbatchInsert parameterTypelist INSERT INTO user (name, age, email) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.email}) /foreach /insert这种写法的本质是生成一条多VALUES的INSERT语句。它在MyBatis层面仍然是预编译的每个#{}都会变成?多个VALUES组只影响SQL语句的长度不影响参数绑定方式。安全性没有问题。但是这里有个隐藏的性能问题一条SQL里拼接大量VALUES时SQL文本本身会变得很长数据库在解析超长SQL时可能触发max_allowed_packet报错。另外由于是一整条大SQL执行计划缓存效率不一定比循环单条插入高多少。MySQL对这种多VALUES插入的优化通常都不错但SQL长度确实是一个硬限制。另一个批量写入的方式是使用MyBatis的BatchExecutor。在Spring Boot里可以在配置类中定义一个批量操作的SqlSessionTemplateBean public SqlSessionTemplate batchSqlSessionTemplate( Qualifier(sqlSessionFactory) SqlSessionFactory sqlSessionFactory) { ExecutorType executorType ExecutorType.BATCH; return new SqlSessionTemplate(sqlSessionFactory, executorType); }使用Batch模式后每次执行INSERT时不会立即提交到数据库而是缓存一批SQL到会话中最后统一flush。这种方式避免了一次拼大SQL的长度问题但它有自己的一套坑比如批量模式下MyBatis的getGeneratedKeys可能拿不到自增ID需要额外的处理。关于批量写操作的底层细节其实可以单独写一篇这里先点到为止——核心是记住foreach标签本身不代表放弃预编译它只是帮你生成SQL文本参数依然安全地走#{}绑定。注意使用Batch模式时二级缓存和一级缓存的行为都会变化。如果你在同一个SqlSession里先做批量插入再立刻查询刚插入的数据某些实现下可能查不到因为数据还没真正flush到数据库。遇到这种诡异问题先想想是不是BatchExecutor的缓存机制在捣鬼。4.3 MyBatis日志插件一眼识破SQL到底是#{}还是${}排查线上问题的时候我经常需要确认一条SQL最终执行的实际内容。如果项目里没配日志或者日志刷得太快不好定位我通常会借助MyBatis的日志插件。这类插件的原理是拦截MyBatis的Executor或StatementHandler把执行前和执行后的SQL打印出来。社区里常用的一款叫MyBatis Log Plugin它能在IDEA里以控制台插件的形式运行把MyBatis打印的带?占位符的SQL自动替换成实际的参数值生成可以直接在数据库客户端执行的完整SQL。这个小工具非常适合本地调试时分析${}拼接后的真实SQL长什么样。有一点需要提醒你在IDEA控制台里看到插件帮你还原的完整SQL仅仅是把日志里的参数值填进?位置帮助你在数据库客户端手动执行验证用的。它不代表数据库执行时收到了这条字符串数据库收到的仍然是预编译的SQL和独立的参数。理解这个区别你在和别人讨论MyBatis到底有没有把参数拼进SQL的时候就不会被绕晕了。5. 高频问题与排查技巧实录缓存、if test、单个数字比较等经典坑5.1 一级缓存和二级缓存下的${}特殊表现MyBatis的缓存机制和${}之间有一些很有意思的交互。先说我遇到过的一个问题一个查询接口第一次查询很快第二次查询居然报错了。排查了半天最后定位到SQL里用了${}拼接了一个前端传入的查询条件而MyBatis一级缓存默认是开启的缓存key包含SQL文本、参数值等元素。问题出在MyBatis的CacheKey计算逻辑上。#{}模式下参数值变化不影响SQL文本所以同一个Mapper方法不同的查询条件通过CacheKey中其他字段区分缓存命中率较高。而${}模式下参数值直接嵌入SQL文本不同的参数值生成完全不同的SQL字符串导致CacheKey也不同。这本身不算Bug但它带来两个后果一是缓存几乎无法命中每次都生成新的SQL执行计划缓存优势消失二是在某些极端边界情况下SQL文本差异可能导致缓存key冲突或缓存雪崩。另外有一点在二级缓存下特别要小心二级缓存是跨SqlSession共享的它会缓存查询结果而缓存的key是语句ID加SQL。如果SQL里用了${}且传入的值包含随机数、时间戳之类的动态内容那缓存基本等于摆设还会把Cache区域撑爆。所以如果某个查询语句因为业务原因必须使用${}同时你又开了二级缓存请谨慎评估缓存的意义必要时对这类语句禁用缓存。5.2if teststate 1的坑单个数字字符比较的陷阱这个坑是我在代码Review时发现的非常经典。有人在XML里写了这么一段if teststatus 1 AND status #{status} /if表面看没有毛病但当status从接口传入时是String类型值为1在OGNL表达式里1 1的比较规则是String和Integer做比较OGNL会尝试把String转成数字再比多数情况下结果是true这个if能正常进去。但是如果传入的值是单个数字字符比如1某些OGNL版本下比较结果却可能是false导致这个if分支不生效查询条件就丢了。网上搜mybatis 单个数字字符比较能搜到一堆讨论本质上就是OGNL的字符串和数字比较规范在不同版本间存在诡异行为。我的建议很简单在if test里写参数判断时类型要明确。比如入参是String就写teststatus 1注意单引号入参是Integer就写teststatus 1。千万不要糊里糊涂地在String和Integer之间做隐式比较也不要依赖OGNL帮你做类型转换。另外再补充一个经常被忽略的if testname ! null and name ! 这种判断本身没问题但如果在test里同时用了和||务必加括号OGNL的优先级经常坑人。我见过有的同事写testa 1 or b 2 and c 3结果执行起来完全不是预想的逻辑就是因为括号缺失导致优先级错乱。5.3 慢SQL排查日志里看到的是?还是真实值排查慢SQL时判断一条SQL是不是预编译执行最直接的办法就是看日志。MyBatis配置了StdOutImpl或类似的日志实现后会输出类似这样两行日志 Preparing: SELECT * FROM user WHERE name ? AND age ? Parameters: 张三(String), 18(Integer)第一行是SQL骨架第二行是参数列表。如果你的日志里第一行直接就是完整的SQL没有?比如SELECT * FROM user WHERE name 张三那不用怀疑这肯定是${}拼接。这种SQL如果查询特别慢你就要警惕两点一是它大概率没有走预编译数据库每次都要重新解析并生成执行计划二是如果name字段上有索引由于SQL文本每次都在变数据库的SQL归一化将SQL模板化并复用执行计划机制无法命中索引选择可能也不是最优的。反过来想这也是一个快速定位线上代码是否误用${}的方法在日志里搜 Preparing看后面有没有?。如果某个本该参数化的查询从来没有?那就可以在代码仓库里搜索对应Mapper方法的XML重点检查是不是有人在里面写了${}。5.4 面试题速答这些问题这样回答更显深度关于#{}和${}面试官通常会从这几个角度提问。我整理了一份回答思路供参考。问#{}和${}的区别是什么基础答法是#{}是预编译占位符${}是字符串拼接。加分的答法是补充完整链路${}在MyBatis解析阶段就被替换成真实值走的是Statement路径#{}被解析成?占位符参数通过PreparedStatement绑定走的是预编译路径能够有效防止SQL注入。问${}存在SQL注入风险为什么MyBatis不直接禁用它回答要点是因为存在动态表名、动态排序字段等用${}更合理的场景MyBatis选择把选择权留给开发者。同时要说出这些场景下的安全实践——白名单校验、正则过滤、代码审查等。问为什么${}无法防止SQL注入这时把JDBC的Statement和PreparedStatement对比讲清楚核心是SQL结构混乱导致预编译机制被绕过用户输入成了SQL指令的一部分而不是数据。问什么情况下#{}会失效比如在ORDER BY字段位置使用#{}数据库会把参数值当成字符串字面量而不是列名导致SQL语法或语义错误。这时必须换用${}。问一条SQL里同时用#{}和${}MyBatis如何处理可以回答MyBatis按顺序解析每个占位符独立处理#{}部分参数化${}部分拼接两者互不影响。回答面试题的时候不要背定义尽量带上执行链路和业务场景。面试官想听的往往不是标准答案而是你真实踩过坑之后提炼出来的理解。6. 一份可以直接抄作业的SQL安全审查清单最后分享一份我目前在团队里推行使用的SQL审查清单。每次提交包含MyBatis Mapper修改的代码我都会要求对照这几条过一遍。每条都是实际踩坑换来的。第一全局搜索XML和注解中的${}每处逐一确认这里为什么不能用#{}入参是否经过白名单或正则校验如果回答不上来一律改写成#{}。第二排序和表名相关的${}确认参数来源不是用户直接输入或者已经通过映射表转换过。严禁把前端参数透传到${}位置。第三模糊查询统一使用CONCAT(%, #{keyword}, %)禁止手写%${keyword}%。第四IN操作统一使用foreach配合#{}集合入参前必须判空。第五检查if test里的类型比较String和Integer不要混用优先使用明确的字面量类型。第六日志配置打开SQL输出上线后抽几条关键业务SQL的日志确认Preparing行是?而不是实值。第七如果项目里开了二级缓存标记那些使用${}的查询语句评估是否需要关闭缓存或定期清理。第八代码Review时重点关注注释里写了这里必须用${}的地方——这种注释往往是后人埋雷的信号要么深入理解并加安全校验要么重构掉。这套清单不复杂但执行到位能挡掉绝大多数SQL注入和潜在的性能问题。7. 我的一些个人体会写了这么多最后聊几句感性的东西。MyBatis的#{}和${}看起来只是两个字符的差别但背后牵扯的是SQL结构安全、数据库执行计划、应用缓存机制、团队代码规范这些层层叠叠的东西。我见过太多开发者在项目初期毫无感觉等到线上出了事故或者被安全扫描工具通报了高危漏洞才回来恶补这个知识点。如果你正在看这篇文章我希望你至少能带走三个记忆点第一${}的本质是绕过预编译机制做字符串替换任何安全措施都是补救而不是根治第二能用#{}的地方绝不滥用${}必须用${}的地方必须有一套安全校验的配套约束第三遇到执行结果诡异的SQL第一反应去看日志里是?还是实值这个动作一秒就能定位一半的问题。工具是死的规则也是死的真正能守住代码质量底线的永远是写代码的人对每一个占位符背后机制的理解。希望这篇指南能帮你把那层理解补扎实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价