拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MyBatis中#{}与${}的深度解析:从SQL注入到动态SQL安全实践

1. 从一次线上事故说起一个“$”引发的血案几年前我还在负责一个电商后台系统当时有个需求是根据用户在前端勾选的商品ID列表批量查询商品详情。一个刚入职不久的同事为了图省事在MyBatis的Mapper XML里写了这么一句SQLselect idbatchQueryProducts resultTypeProduct SELECT * FROM product WHERE id IN (${ids}) /select参数ids是从前端传过来的用逗号拼接好的字符串比如1001,1002,1003。在开发环境一切正常。上线后某个“热心”用户在前端输入了这样的ID列表1001) OR 11; --。于是这条SQL在数据库里实际执行时变成了SELECT * FROM product WHERE id IN (1001) OR 11; --后面的--注释掉了原本可能存在的其他查询条件。OR 11这个永真条件导致这条语句查询出了product表中的所有数据。这还不是最糟的如果参数是1001); DROP TABLE product; --后果不堪设想。虽然那次只是导致了数据全量泄露的性能雪崩没有造成数据丢失但也足以让我们团队惊出一身冷汗。问题的根源就在于那个看似无害的美元符号——${}。这个经历让我深刻意识到在MyBatis中#{}和${}这两个占位符绝不仅仅是“一个能防注入一个不能”那么简单。它们是两种截然不同的SQL构建哲学理解其底层机制和适用场景是写出安全、高效、可维护的MyBatis代码的基石。今天我们就抛开那些面试八股文从实战和源码的角度彻底拆解这对“孪生兄弟”。2. 核心机制拆解#{}与${}的本质区别很多人把#{}叫做“预编译占位符”把${}叫做“字符串替换”。这个说法对但不够本质。我们可以从SQL语句的生命周期来看待它们。2.1 #{}安全的参数化查询构建者当你使用#{}时MyBatis在底层做的事情可以类比为“填空题”。1. SQL解析与预处理阶段MyBatis会首先拿到你写的SQL模板例如SELECT * FROM user WHERE name #{name} AND age #{age}在这个阶段MyBatis会创建一个PreparedStatement对象并将上面的SQL发送给数据库。注意此时发送的SQL中的#{name}和#{age}已经被替换成了问号?。也就是说数据库收到的是一条“不完整”的、带有占位符的SQL语句SELECT * FROM user WHERE name ? AND age ?数据库会对其进行编译、解析生成执行计划。这个过程叫做“预编译”。2. 参数设置阶段当真正要执行这条SQL时MyBatis才会将具体的参数值比如name“张三”,age18通过PreparedStatement.setXxx()方法安全地设置到对应的问号位置。对于字符串“张三”会调用setString(1, “张三”)数据库驱动会确保它被当作一个完整的字符串值处理即便它里面包含单引号‘也会被转义如‘’。对于数字18会调用setInt(2, 18)。为什么能防止SQL注入因为攻击者精心构造的恶意字符串如‘ OR ‘1’‘1在第一步就已经被当作一个完整的值传递给了setString方法。这个值最终在数据库看来就是查询name字段是否等于这个奇怪的字符串而不会被拆解成SQL语法的一部分。它永远无法逃逸出“值”的范畴去影响SQL结构。实操心得99%的查询条件、插入值、更新值都应该使用#{}。这是铁律。它带来的不仅是安全还有性能优势——同一条带#{}的SQL模板数据库只需编译一次后续传入不同参数可以复用执行计划。2.2 ${}直接的SQL字符串拼接工而${}的行为则粗暴直接得多它做的是“字符串粘贴”。1. 完整的字符串替换在MyBatis创建Statement通常是PreparedStatement但参数处理方式不同之前它就会进行字符串替换。还是那个例子SELECT * FROM user WHERE name ${name} AND age ${age}如果传入name“张三”,age18MyBatis在内部会直接生成最终的SQL字符串SELECT * FROM user WHERE name 张三 AND age 18然后将这条完整的、拼接好的SQL语句发送给数据库。2. 与数据库的交互数据库收到的是完整的SQL因此每次执行都需要进行完整的解析、编译、生成执行计划。即便两次执行的SQL只是参数值不同数据库也会当作两条全新的SQL来处理。SQL注入的根源如果name参数来自不可信源且被恶意传入张三 OR 11那么拼接后的SQL就变成了SELECT * FROM user WHERE name 张三 OR 11 AND age 18‘1’‘1’这个永真条件被作为SQL语法的一部分成功“注入”了进去。如果参数是数字类型的age传入18 OR 11拼接后是age 18 OR 11同样会导致注入。核心禁忌绝对禁止将用户输入、请求参数等外部动态数据直接放入${}中。这等同于敞开大门邀请黑客。上述的电商案例就是血的教训。3. ${}的正确打开方式动态SQL的“结构”补丁既然${}这么危险为什么MyBatis还要保留它因为有些场景#{}确实无能为力。这些场景的共同点是需要动态改变的不是SQL的“参数值”而是SQL的“结构本身”比如表名、列名、排序字段等。这些部分在数据库预编译机制中是不允许使用参数化占位符?的。3.1 经典应用场景与实战代码场景一动态表名/列名常见于分表场景假设用户数据按月分表表名为user_202501user_202502。查询时需要动态指定表名。select idselectByMonth resultTypeUser SELECT id, name FROM user_${month} WHERE status #{status} /select这里${month}替换的是表名的一部分而#{status}是查询条件值。必须确保month参数来自系统内部可靠的逻辑计算如从当前日期计算得出而非前端直接传递。场景二动态排序ORDER BY用户可以选择按不同字段升序/降序排序。select idselectUsers resultTypeUser SELECT * FROM user WHERE company_id #{companyId} ORDER BY ${orderByField} ${orderByDirection} /selectorderByField如create_time和orderByDirectionASC/DESC需要拼接进SQL子句。安全做法是在后端代码中对前端传入的排序字段和方向进行白名单校验。// 服务层代码示例 public ListUser getUsers(String inputField, String inputDirection) { // 定义允许排序的字段白名单 SetString allowedFields new HashSet(Arrays.asList(id, name, create_time, age)); // 定义允许的排序方向 SetString allowedDirections new HashSet(Arrays.asList(ASC, DESC)); String safeField allowedFields.contains(inputField) ? inputField : id; String safeDirection allowedDirections.contains(inputDirection.toUpperCase()) ? inputDirection.toUpperCase() : ASC; return userMapper.selectUsers(safeField, safeDirection); }场景三动态拼接SQL函数或关键字例如在PostgreSQL中使用ON CONFLICT ... DO UPDATE SET语句时需要动态指定冲突判断列。insert idupsertUser INSERT INTO user (id, name, email) VALUES (#{id}, #{name}, #{email}) ON CONFLICT (${conflictColumn}) DO UPDATE SET name EXCLUDED.name, email EXCLUDED.email /insert${conflictColumn}需要是列名如(email)或(id)。同样conflictColumn必须在后端可控范围内。3.2 安全使用守则与底层原理使用${}时你必须时刻保持警惕遵循以下原则白名单校验是生命线所有用于${}的变量必须在内层逻辑中进行严格的枚举值或正则匹配校验确保其内容完全符合预期。就像给${}这个“危险工具”加了一个安全锁。数据源必须可信理想情况下${}中的值应来自系统内部生成如根据日期生成的表名后缀、配置中心读取的固定值、或经过严密业务逻辑处理后的结果。永远不要信任直接来自HTTP请求体、URL参数、Cookie或任何用户输入的数据。最小化使用范围即使在一个SQL片段中也应只对必须使用${}的部分进行拼接其他部分坚持使用#{}。如上文分表查询的例子WHERE条件依然用#{}。从MyBatis源码org.apache.ibatis.scripting.xmltags.TextSqlNode等来看${}的处理发生在SqlSource构建的早期。它调用GenericTokenParser进行简单的文本替换不涉及任何参数类型处理或转义。这种设计初衷就是为了灵活性但也把安全责任完全交给了开发者。4. 进阶在动态SQL标签中如何抉择MyBatis强大的动态SQL标签if,choose,foreach等让SQL编写更加灵活。在这些标签内部#{}和${}的选择同样需要遵循上述原则但有一些细微差别。4.1foreach标签遍历集合的最佳实践这是最常用的场景之一根据ID列表查询。select idselectByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionidList itemid open( separator, close) #{id} /foreach /select这里必须使用#{id}。foreach标签会循环生成多个?占位符MyBatis会为每个id值调用一次PreparedStatement.setXxx()最终生成类似WHERE id IN (?, ?, ?)的安全语句。如果错误地使用${id}则会导致字符串直接拼接引发注入或语法错误。4.2if标签条件判断中的参数传递在条件判断中拼接条件值部分永远用#{}。select idselectByCondition resultTypeUser SELECT * FROM user WHERE 11 if testname ! null and name ! AND name #{name} /if if testminAge ! null AND age #{minAge} /if if testorderBy ! null ORDER BY ${orderBy} !-- 这里如果是动态排序仍需用${}并确保orderBy安全 -- /if /select注意test表达式中的name、minAge是直接引用传入的参数对象属性这里不需要加#{}或${}。只有在SQL语句体内部需要将参数值传递给数据库时才使用占位符。4.3bind标签连接两种占位符的桥梁有时我们需要对参数进行处理后再用于查询或者构建一个复杂的${}场景。bind标签非常有用。 例如模糊查询时我们希望在参数两侧加上%但又想用安全的#{}select idselectByLikeName resultTypeUser bind namepattern value% name %/ SELECT * FROM user WHERE name LIKE #{pattern} /selectbind创建了一个新的变量pattern其值在内存中计算‘%’ name ‘%’然后将这个计算后的值通过#{pattern}安全地传递给SQL。这比直接在SQL中写LIKE ‘%${name}%’要安全得多。再比如动态构造一个${}使用的安全值select idselectFromDynamicTable resultTypeUser !-- 根据type值安全地映射到具体的表名后缀 -- bind nametableSuffix valuetype 1 ? active : history/ SELECT * FROM user_${tableSuffix} WHERE id #{id} /select通过bind标签我们将业务逻辑根据type判断表后缀放在了XML中计算确保了${tableSuffix}的值是可控的、安全的。5. 性能、日志与排查那些容易被忽略的细节选择#{}还是${}不仅影响安全也直接影响性能和调试体验。5.1 性能考量预编译 vs. 硬解析#{}预编译高性能首选。同一条SQL模板SELECT * FROM user WHERE id ?在第一次执行时数据库会进行“硬解析”语法语义检查、权限校验、生成执行计划等开销较大。但之后只要传入不同的参数值数据库会直接使用缓存的执行计划进行“软解析”性能极高。这对于高并发、模板化查询如根据主键查询的场景至关重要。${}字符串拼接每次拼接出不同的SQL字符串SELECT * FROM user WHERE id 1SELECT * FROM user WHERE id 2数据库都会将其视为全新的SQL语句每次都需要硬解析。在并发量高时会显著增加数据库的CPU开销和共享池Shared Pool的压力可能引发库缓存Library Cache争用甚至导致“ORA-04031”之类的错误。实战建议对于QPS每秒查询率较高的核心查询接口务必使用#{}。仅在表名、列名等非值部分的动态化这种低频操作中审慎使用${}。5.2 日志与调试看到的 vs. 执行的这是很多开发者在联调或排查问题时遇到的困惑。在MyBatis日志或一些监控工具中你可能会看到两种不同的SQL。使用#{}时日志中打印的通常是带有?的SQL和具体的参数列表 Preparing: SELECT * FROM user WHERE name ? AND age ? Parameters: 张三(String), 18(Integer)这是PreparedStatement的日志格式。你需要将参数值“脑补”到?的位置。有些插件或配置如mybatis.configuration.log-impl配合StdOutImpl可以输出完整SQL但那是插件在本地模拟拼接的结果并非真正发给数据库的语句。使用${}时日志中打印的就是最终发往数据库的完整SQL Preparing: SELECT * FROM user WHERE name 张三 AND age 18 Parameters:一目了然方便直接拷贝到数据库客户端执行。但这恰恰是风险所在——如果参数里有敏感信息也会被明文打印出来。务必注意生产环境日志脱敏。5.3 常见问题排查指南问题1为什么我的IN查询用#{}报语法错误错误写法WHERE id IN (#{ids}) !-- ids是1,2,3的字符串 --这会生成WHERE id IN (‘1,2,3’)数据库会认为你在用id和字符串‘1,2,3’比较。正确做法是使用foreach标签如4.1节所示。问题2LIKE模糊查询到底该怎么写不安全写法LIKE ‘%${keyword}%’安全但错误的写法LIKE ‘%#{keyword}%’这会把‘%#{keyword}%’整体当作一个字符串值去匹配安全且正确的写法使用bind标签见4.3节。在Java代码中拼接好模式串再传入String pattern “%” keyword “%”;然后Mapper接口参数用Param(“pattern”) String patternXML中用LIKE #{pattern}。使用数据库特定的连接函数兼容性差不推荐如MySQL的CONCAT(‘%’, #{keyword}, ‘%’)。问题3${}传入数字还需要加引号吗不需要。${}是直接替换。如果列是字符串类型如VARCHAR你需要自己在XML中加上引号WHERE name ‘${name}’。如果列是数字类型如INT则不能加引号WHERE age ${minAge}。而#{}是自动类型识别的MyBatis会根据参数对象的属性类型决定在设置参数时是否添加引号你无需关心。6. 框架演进与最佳实践总结随着MyBatis-Plus等增强工具的出现很多动态SQL的编写可以通过Lambda表达式或Wrapper在Java代码中完成这在一定程度上减少了直接编写${}的机会提升了安全性。但理解其底层原理对于阅读复杂遗留代码、进行深度性能优化或解决极端场景问题仍然是不可或缺的。回顾开篇的事故我们现在可以清晰地总结出MyBatis占位符的“军规”默认使用#{}将其作为所有参数传递的首选和默认方式。这是防止SQL注入的防火墙。严格限制${}的使用范围仅将其用于动态指定SQL关键字、表名、列名等非数据值部分。并在使用前心中默念三遍“参数是否绝对安全可控”。建立安全审查机制在Code Review中将Mapper XML中的${}出现作为重点审查项。可以使用静态代码分析工具如SonarQube设置规则对包含${}且非特定模式如ORDER BY ${}的SQL进行告警。参数校验前置对于不得不使用${}的场景校验逻辑必须放在Java业务代码中进行严格的白名单过滤绝不放任任何未经处理的用户输入流入${}。说到底#{}和${}的区别是“数据”与“代码”的边界问题。SQL注入的本质就是把用户输入的“数据”错误地当成了“代码”来执行。#{}严格捍卫了这条边界而${}则要求开发者自己成为边界的守护者。在软件安全的战场上选择#{}就是选择站在坚固的城墙之后而使用${}则意味着你必须亲自握紧剑与盾并且时刻保持清醒。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门