
1. 项目概述从一次线上事故说起那天下午报警信息突然在钉钉群里炸开了锅。一个核心的报表查询接口响应时间从平时的几十毫秒飙升到了十几秒直接触发了慢SQL告警。我赶紧登录服务器查看日志发现一条本该走索引的查询语句在数据库层面变成了全表扫描。排查到最后问题出在Mapper XML里一个不起眼的$符号上——一位同事在拼接一个动态的排序字段时为了图省事直接用了${orderBy}。当外部传入的orderBy参数是“create_time desc”时一切正常但当某次前端传参因为编码问题变成了一个包含特殊字符和注释的复杂字符串时这条SQL语句的语义被彻底改变不仅索引失效还带来了潜在的安全风险。这次事故让我下定决心必须把MyBatis中这两个看似简单实则暗藏玄机的占位符——#{}和${}——给团队里的兄弟们讲透。#{}和${}是MyBatis框架中用于处理SQL参数的两种核心语法几乎每个使用MyBatis的开发者每天都会接触到它们。表面上看它们的功能都是“把值填到SQL里”但底层机制、适用场景和安全影响却天差地别。理解不透彻轻则导致性能问题重则引发SQL注入漏洞直接威胁系统安全。本文将从原理、使用、场景到避坑结合大量实战代码和场景分析帮你彻底搞懂这对“孪生兄弟”的本质区别让你在编写MyBatis SQL时能做出最安全、最高效的选择。2. 核心原理深度拆解预编译与字符串替换的本质区别要理解#{}和${}绝不能停留在“一个能防注入一个不能”的层面必须深入到JDBC和数据库驱动执行的底层逻辑。它们的根本区别在于SQL语句的构建时机和方式。2.1#{}安全的预编译占位符当你使用#{}时MyBatis在处理SQL语句时会执行以下操作解析与替换MyBatis的SQL解析引擎会识别出#{}标记的部分。例如对于SQLSELECT * FROM user WHERE id #{userId}它会将#{userId}识别为一个参数占位符。生成PreparedStatementMyBatis会向JDBC驱动发送一条未绑定具体值的SQL语句形如SELECT * FROM user WHERE id ?。这里的问号?就是一个标准的JDBC预编译占位符。参数安全设置随后MyBatis会通过PreparedStatement的setXxx()方法如setInt,setString将运行时获取到的实际参数值安全地设置到这个问号的位置上。这个过程的核心优势在于“预编译”。数据库服务器在首次接收到带?的SQL时会对其进行编译、解析生成一个执行计划并缓存起来。之后无论传入的参数值是什么1,100, 甚至‘1‘ or ‘1‘‘1‘这个SQL的结构语法树都不会改变数据库只是复用缓存的执行计划将新的参数值填充进去执行。这就从根本上杜绝了因为参数值改变而导致SQL语义发生变化的风险即SQL注入。注意#{}并非简单地给参数加引号。对于字符串它会自动处理引号对于数字则直接传递。这一切都由PreparedStatement的setXxx方法安全完成开发者无需关心。2.2${}直接的字符串拼接文本替换而${}的工作方式则简单粗暴得多字符串替换MyBatis在解析SQL时会直接将${}中的内容通常是一个OGNL表达式最终求值为一个字符串以纯文本的形式“拼接”到SQL语句的对应位置。生成Statement替换完成后MyBatis生成一条完整的、包含具体值的SQL字符串然后通过JDBC的Statement接口直接发送给数据库。数据库编译执行数据库每次接收到这条全新的、完整的SQL字符串都需要对其进行完整的词法分析、语法分析、优化生成执行计划然后执行。例如对于SQLSELECT * FROM user ORDER BY ${orderField}如果orderField的值为“username”那么最终发送给数据库的SQL就是SELECT * FROM user ORDER BY username。如果值是“username; DROP TABLE user; --”恶意输入那么生成的SQL就会包含删除语句这就是典型的SQL注入。两者的核心区别可以用一个表格来清晰对比特性#{}(预编译占位符)${}(字符串替换)处理原理参数化查询使用JDBCPreparedStatement字符串拼接使用JDBCStatementSQL注入风险安全从根本上防止危险存在极高风险性能影响通常更优SQL可被数据库预编译缓存每次都是新SQL需重新编译解析参数处理自动根据类型处理如字符串加引号直接替换不处理类型和引号适用场景绝大多数传入值的场景WHERE条件值INSERT值等动态传入SQL片段表名、列名、排序字段等示例SQLWHERE name #{name}-WHERE name ?ORDER BY ${field}-ORDER BY create_time2.3 一个容易混淆的“例外”很多初学者会困惑为什么在LIKE模糊查询时使用#{keyword}有时会报错或查不出数据!-- 错误示例 -- SELECT * FROM article WHERE title LIKE %#{keyword}%这是因为经过预编译后SQL会变成LIKE ‘%?%‘。数据库会将问号?和它周围的单引号视为一个整体认为你要匹配的字面值就是‘%?%‘这个字符串而不是将?作为参数占位符。这违反了JDBC预编译占位符的语法占位符必须是独立的表达式。正确的写法需要结合${}的拼接功能但必须确保安全!-- 正确但需警惕注入的写法 -- SELECT * FROM article WHERE title LIKE ‘%${keyword}%‘但请注意这是SQL注入的重灾区绝对不能让用户直接控制keyword。安全的做法是在Java代码中对keyword进行严格的过滤和转义或者使用数据库提供的字符串连接函数更推荐!-- 安全写法使用数据库函数MySQL为例 -- SELECT * FROM article WHERE title LIKE CONCAT(‘%‘, #{keyword}, ‘%‘) !-- 安全写法在Java代码中拼接好再传入 -- // Java Service层 String searchPattern “%” filteredKeyword “%”; mapper.selectByTitle(searchPattern);!-- Mapper XML -- SELECT * FROM article WHERE title LIKE #{pattern}3. 实战场景应用与选型指南理解了原理我们来看看在实际开发中如何正确地在不同场景下选择和使用它们。3.1 必须使用#{}的场景默认选择这是黄金法则凡是传入查询条件、插入数据等“值”的地方无脑用#{}。WHERE条件中的值select id“selectUser” resultType“User” SELECT * FROM user WHERE username #{username} !-- 正确 -- AND age #{minAge} !-- 正确 -- AND status IN foreach collection“statusList” item“status” open“(” separator“,” close“)” #{status} !-- 正确即使遍历列表每个值也是用#{} -- /foreach /selectINSERT/UPDATE语句中的值insert id“insertUser” parameterType“User” INSERT INTO user (username, email, age) VALUES (#{username}, #{email}, #{age}) !-- 全部使用#{} -- /insert update id“updateUser” UPDATE user SET email #{newEmail}, !-- 正确 -- age #{newAge} WHERE id #{userId} /update所有传入简单类型或POJO属性的地方这是#{}的主场安全无忧。3.2 谨慎使用${}的场景严格限制${}的使用必须伴随着严格的白名单校验或内部逻辑控制确保其内容绝对安全、可控。动态表名或列名 在一些分表场景如按年月分表order_202401,order_202402或动态字段查询中SQL片段本身需要变化。select id“selectFromDynamicTable” SELECT * FROM ${tableName} WHERE user_id #{userId} /select实操心得这里的${tableName}绝不能来自用户输入。通常是在业务逻辑层根据规则计算出来的例如“order_” yearMonth。必须在代码层面保证其合法性防止出现tableName为“user; DELETE FROM order”这种情况。动态排序字段ORDER BY 文章开头的案例就是这种场景。安全的做法是提供一个有限的可选字段白名单。// Service层代码 public ListUser getUsers(String sortBy) { // 定义允许排序的字段白名单 SetString allowedSortFields new HashSet(Arrays.asList(“create_time”, “username”, “age”)); if (!allowedSortFields.contains(sortBy)) { sortBy “create_time”; // 默认值 } // 可以进一步校验排序方向 return userMapper.selectUsersSorted(sortBy); }!-- Mapper XML -- select id“selectUsersSorted” resultType“User” SELECT * FROM user ORDER BY ${sortField} DESC !-- 经过白名单校验此时${}相对安全 -- /select动态拼接SQL函数或复杂表达式 极少数情况下需要动态调用不同的数据库函数。select id“aggregateData” SELECT ${aggregateFunc}(price) FROM orders WHERE date #{date} /select同样aggregateFunc必须是内部定义的枚举值如“SUM““AVG““COUNT“不能由前端随意传递。3.3 一个高级技巧#{}与${}的混合使用在某些复杂动态SQL中可能需要混合使用。核心原则是值用#{}SQL关键字或片段用经过校验的${}。select id“dynamicSearch” resultType“Book” SELECT * FROM books WHERE 11 if test“title ! null” AND title LIKE CONCAT(‘%‘, #{title}, ‘%‘) !-- 值用#{} -- /if if test“author ! null” AND author #{author} !-- 值用#{} -- /if if test“orderBy ! null and orderBy ! ‘‘“ ORDER BY ${orderBy} !-- SQL片段用经过白名单校验的${} -- /if if test“limit ! null” LIMIT #{limit} !-- 值即使用于LIMIT也用#{} -- /if /select注意LIMIT子句在MySQL中可以使用#{}因为MyBatis会将其作为数值参数正确设置。但在一些早期版本的数据库驱动或特定语法中可能需要${}但务必警惕注入。4. 常见问题排查与避坑实录即使明白了原理在实际开发和排查问题时还是会遇到一些令人困惑的情况。这里记录了几个典型案例和排查思路。4.1 问题一明明用了#{}日志打印的SQL却显示有参数值这是MyBatis日志配置带来的“误会”。我们通常通过配置log4j或logback来打印SQL日志看到的是类似这样的语句 Preparing: SELECT * FROM user WHERE id ? Parameters: 1(Integer)第一行Preparing是发送给数据库的预编译SQL其中的?就是#{}转化来的占位符。第二行Parameters是MyBatis将要设置的实际参数。最终数据库执行的是“预编译SQL参数”的安全形式。而如果你看到日志直接是SELECT * FROM user WHERE id 1那说明你用的可能是${}或者日志框架配置成了打印最终SQL不推荐有安全风险。排查技巧当怀疑SQL注入或参数传递问题时首先检查MyBatis的完整执行日志Preparing和Parameters两部分而不是只看最终拼接的SQL。4.2 问题二IN语句和FOREACH标签结合时到底该用#{}还是${}这是一个高频误区。正确的做法是在foreach标签内部对每一项值使用#{}。!-- 正确示例 -- SELECT * FROM product WHERE category_id IN foreach collection“categoryIds” item“cid” open“(” separator“,” close“)” #{cid} !-- 每个id作为值传入使用#{} -- /foreach最终生成的预编译SQL会是WHERE category_id IN (?, ?, ?)参数是(1, 2, 3)。绝对不能在collection属性上使用${}来直接替换整个列表字符串那会导致严重的安全和语法问题。4.3 问题三使用${}动态传入字段名但字段名包含保留关键字或特殊字符怎么办例如用户表有一个字段叫“order“与SQL关键字ORDER冲突。如果你直接写SELECT ${field} FROM user当field“order“时生成的SQLSELECT order FROM user会报语法错误。解决方案在使用${}进行文本替换后需要手动为字段名或表名添加数据库特定的引号反引号、方括号等。但这会引入数据库兼容性问题。!-- MySQL解决方案不推荐仅作演示 -- SELECT ${field} FROM user更健壮的做法是在业务逻辑层就避免使用关键字作为字段名或者在替换前进行转义处理。这再次说明了为什么${}的使用需要格外小心和额外的处理逻辑。4.4 问题四#{}如何指定参数的数据类型大部分时候MyBatis的TypeHandler会自动处理Java类型和JDBC类型的转换。但有时需要精确控制比如存储一个NUMERIC类型到数据库可以这样指定INSERT INTO account (balance) VALUES (#{amount, jdbcTypeNUMERIC})这在处理一些特定类型如null值不同的数据库对null的jdbcType要求可能不同时非常有用。而对于${}因为它只是文本替换所以不存在“指定类型”的概念它替换进去的就是纯文本字符串。5. 性能与安全影响深度分析5.1 性能层面#{}预编译优势明显。同一条SQL模板带?在第一次执行后其编译好的执行计划会被数据库缓存。后续执行只需传递新的参数值数据库省去了重复解析和优化SQL的开销这对于高频执行的简单查询如根据主键查询性能提升显著。这也是数据库连接池和ORM框架推荐使用参数化查询的重要原因之一。${}字符串拼接每次都是一条全新的SQL字符串数据库必须将其视为一条从未见过的语句执行完整的“解析-优化-编译-执行”流程。在高并发场景下这会消耗更多的数据库CPU和内存资源并挤占SQL缓存空间。结论从性能角度对于值参数始终使用#{}。5.2 安全层面这是#{}和${}最核心的差异点也是面试必考题。${}的SQL注入风险风险极高。攻击者可以构造特殊的输入改变SQL的原始逻辑。常见攻击包括永真条件‘ OR ‘1‘‘1‘使WHERE条件永远成立绕过认证或泄露全部数据。联合查询‘ UNION SELECT username, password FROM users --窃取其他表数据。执行多条语句‘; DROP TABLE users; --破坏数据库需要数据库支持多语句执行且JDBC连接未禁用此功能。布尔盲注/时间盲注通过精心构造的参数根据页面返回差异或响应时间一步步推测出数据库结构信息。 只要用户输入能直接或间接地控制${}中的内容且没有经过极其严格的过滤系统就存在被注入的风险。#{}的安全性它通过预编译机制将数据与指令SQL代码分离。传入的参数无论多么“狡猾”在数据库看来都只是纯粹的“数据值”而不会成为SQL“指令”的一部分。这就好比把一份写好的命令SQL模板和一份数据材料参数分开交给数据库数据库只会用这份材料去填充命令中的空白而不会把材料本身当成新的命令去执行。重要心得不要试图自己写正则表达式或字符串过滤来“净化”用户输入然后放心地使用${}。黑名单永远有漏网之鱼过滤规则也可能被绕过如编码绕过、注释符混淆等。唯一从根本上解决SQL注入的方法就是对所有变量数据使用参数化查询即#{}。6. 在MyBatis Plus及更高阶用法中的体现虽然MyBatis Plus等增强工具在简化单表CRUD但其底层依然遵循MyBatis的核心机制。在Wrapper条件构造器中MyBatis Plus的QueryWrapper、UpdateWrapper等方法中你传入的条件值框架内部最终都会将其转换为#{}预编译参数。你可以放心使用。queryWrapper.eq(“username”, “zhangsan”); // 内部会安全地处理为 username ?在自定义SQL片段中如果你在MyBatis Plus的Select注解或XML中编写自定义SQL#{}和${}的规则完全适用。Select(“SELECT * FROM user WHERE ${ew.sqlSegment}”) // 注意这里ew.sqlSegment包含的是条件表达式片段 ListUser selectList(Param(Constants.WRAPPER) WrapperUser wrapper);上面这个例子比较特殊${ew.sqlSegment}拼接的是由Wrapper动态生成的WHERE后面的条件表达式字符串如name ? AND age ?。虽然这里用了${}但其中的参数值?对应的部分在Wrapper生成时已经做了参数化处理最终执行的仍然是安全的预编译SQL。但这属于框架内部高级用法普通业务代码不要模仿这种直接拼接SQL片段的方式。PageHelper分页插件在使用PageHelper进行物理分页时其会自动在原始SQL后加上LIMIT ?, ?。这两个分页参数也是通过#{}传入的确保了安全性。这也是为什么在MyBatis中分页参数推荐用#{}的原因。7. 配置、日志与调试技巧为了更好地理解和调试#{}与${}的行为合理的配置至关重要。开启完整SQL日志 在application.yml或mybatis-config.xml中配置以便看到Preparing和Parameters日志。# application.yml (Spring Boot) logging: level: com.your.mapper.package: DEBUG # 将你的Mapper接口所在包级别设为DEBUG这会输出前述的预编译SQL和参数列表是分析问题的第一手资料。警惕“打印最终SQL”的配置 有些教程或插件会配置将最终拼接好的、带参数的SQL打印出来。在生产环境务必关闭此功能因为日志里会明文输出所有参数值包括密码等敏感信息违反安全规范。调试时也需谨慎用后即焚。使用MyBatis内置参数在#{}中除了可以指定jdbcType还可以使用_parameter来引用整个参数对象在一些动态标签里很有用。但这属于相对高阶的用法日常使用频率不高。踩过几次坑之后我养成了一个习惯在团队Code Review时只要在XML或注解里看到${}一定会停下来仔细审查它的参数来源是否绝对安全可控。对于#{}和${}的理解已经不仅仅是一个技术知识点更是一种关乎安全与性能的编码习惯和意识。在绝大多数你应该使用#{}的地方如果因为偷懒或无知而使用了${}无异于在系统里埋下了一颗不知道何时会引爆的炸弹。希望本文的详细拆解能帮助你彻底掌握这对占位符写出更安全、更高效的MyBatis代码。