MyBatis中#与$占位符的本质区别、安全风险与正确使用场景
1. 项目概述从一次线上事故说起那天下午报警信息像潮水一样涌来核心业务接口的响应时间从几十毫秒飙升到十几秒数据库监控显示CPU瞬间打满。紧急回滚代码后我们定位到问题源头一个看似无害的MyBatis动态SQL片段开发同学为了图方便在ORDER BY子句里使用了${sortField}。这本意是想根据前端传的字段名动态排序结果被别有用心的人传入了(SELECT SLEEP(10))。是的这就是一次典型的SQL注入攻击而元凶就是对$占位符的误用。这件事让我意识到即便是在MyBatis这样成熟的框架里对#和$这两个基础符号的理解深浅直接关系到系统的安全与稳定。这不是一个简单的语法选择题而是涉及预编译、参数化、SQL注入防御、性能优化乃至框架设计哲学的核心议题。今天我们就抛开那些枯燥的文档结合我踩过的坑和积累的经验把这两个占位符掰开揉碎了讲清楚。2. 核心原理#与$的本质区别要理解怎么用必须先明白它们是什么。很多资料会告诉你#是预编译$是字符串替换但这句话背后的技术细节和深远影响才是关键。2.1#占位符安全卫士与性能基石当你写下SELECT * FROM user WHERE id #{userId}时MyBatis在背后为你做了一系列复杂而精妙的工作。它并不是简单地把#{userId}替换成你传入的数字5。真正的过程分为两步第一步SQL解析与参数化。MyBatis会首先将这条SQL语句发送给数据库驱动此时语句是SELECT * FROM user WHERE id ?。那个问号?就是一个参数占位符。数据库的SQL引擎会对其进行编译和优化生成一个执行计划。这个计划是高度可复用的特别是当你的查询结构不变只是参数值变化时比如根据不同的ID查询用户数据库无需重复进行昂贵的解析和优化操作。第二步参数安全设置。当真正执行时MyBatis会通过PreparedStatement的setXXX()方法例如setInt(1, 5)将具体的参数值5安全地传递给数据库。这个过程是类型安全的并且最重要的是传入的值会被数据库驱动严格地视为数据而非SQL代码的一部分。即使你传入的值是“1 OR 11”到了数据库那里它就是一个普通的字符串最终执行的SQL等价于SELECT * FROM user WHERE id ‘1 OR 11’这通常会因为类型不匹配或找不到记录而返回空结果绝不会改变SQL语义。注意#{}对于字符串参数会自动添加单引号。对于数值、日期等类型也会做相应的类型处理。你永远不需要、也不应该在#{}内部手动添加引号。2.2$占位符字符串拼接的双刃剑相比之下${}的行为就“直白”得多。对于SELECT * FROM user ORDER BY ${sortField}MyBatis在SQL执行前会直接进行字符串替换。如果你传入的sortField是“name”那么最终生成的SQL就是SELECT * FROM user ORDER BY name然后这条完整的SQL语句才会被发送给数据库执行。这里没有参数化没有预编译就是纯粹的字符串拼接。这意味着无法防止SQL注入如果sortField来自用户输入且未被严格过滤攻击者传入“name; DROP TABLE user --”拼接后的SQL将包含恶意指令。无法利用预编译缓存每次${}内容变化对于数据库来说都是一条全新的SQL语句ORDER BY name和ORDER BY age被认为是两条不同的SQL需要重新解析、优化增加了数据库的负担。需要手动处理类型和引号如果你要拼接的是一个字符串值比如表名你需要自己在SQL片段或传入参数中加上引号例如WHERE name ‘${name}’。这很容易出错。2.3 对比表格一目了然的本质差异为了更清晰地对比我将核心区别总结如下表特性维度#{}(参数占位符)${}(字符串替换)处理方式预编译PreparedStatement参数化查询静态文本替换字符串拼接安全性高从根本上防止SQL注入低存在SQL注入风险需手动过滤性能高可利用数据库预编译执行计划缓存低每次替换都可能生成新SQL无法利用缓存引号处理自动根据参数类型添加如字符串加单引号需手动处理容易遗漏或出错适用场景绝大多数情况特别是WHERE条件中的值极少数情况如动态表名、列名、SQL关键字数据库日志看到的是带?的SQL和独立的参数列表看到的是完整的、拼接好的SQL语句3. 正确使用场景与实战解析理解了原理我们就能在正确的场景下使用正确的工具。我的原则是能用#{}的绝对不用${}。${}的使用需要充分的理由和严格的安全措施。3.1#{}的绝对主力场景这是你每天都会用到的涵盖了95%以上的需求。场景一WHERE条件中的值传递这是最经典、最安全的用法。无论参数是基本类型、POJO对象还是Map#{}都能完美处理。select idselectUser resultTypeUser SELECT * FROM user WHERE username #{username} AND status #{status} /selectMyBatis会自动处理username字符串和status数值的类型和引号。场景二INSERT/UPDATE语句中的值设置insert idinsertUser parameterTypeUser INSERT INTO user (name, email, age) VALUES (#{name}, #{email}, #{age}) /insert同样安全可靠无需担心注入问题。场景三复杂类型与OGNL表达式#{}内部支持强大的OGNL表达式可以访问复杂对象的属性。select idselectByExample parameterTypemap SELECT * FROM product WHERE category_id #{filter.categoryId} AND price BETWEEN #{filter.minPrice} AND #{filter.maxPrice} AND name LIKE CONCAT(‘%’, #{filter.keyword}, ‘%’) /select这里的filter是传入Map中的一个对象#{}可以优雅地导航到其内部属性。3.2${}的谨慎使用场景使用${}时你必须像对待用户输入一样对待传入的参数假设它可能是恶意的。场景一动态指定表名或列名这是${}最正当的用途之一。因为表名和列名是SQL的标识符不能使用参数占位符?。select idselectFromDynamicTable resultTypemap SELECT * FROM ${tableName} WHERE valid 1 /select重要安全实践${tableName}的值绝不能直接来自用户输入。必须在代码层进行白名单校验。例如你的系统只允许查询user_2024、user_2025这几张表那么传入的参数必须先与这个白名单比对合法后才传入MyBatis。场景二动态排序字段ORDER BY文章开头事故的根源。正确的做法不是完全不用而是受控地使用。select idselectUsers resultTypeUser SELECT * FROM user WHERE dept_id #{deptId} ORDER BY ${sortField} ${sortOrder} /select安全加固方案白名单校验在Service层对sortField进行校验。只允许排序“id”,“name”,“create_time”等已知的安全字段。枚举限制让前端传递枚举值如“NAME_ASC”后端解析为安全的“name ASC”。默认值兜底当参数不合法或为空时使用一个安全的默认排序如ORDER BY id DESC。场景三拼接SQL函数或特殊表达式极少用有时需要动态选择SQL函数。select idaggregateData resultTypemap SELECT ${aggFunc}(price) AS total FROM orders WHERE date #{targetDate} /selectaggFunc可能是‘SUM’,‘AVG’,‘COUNT’。同样必须进行白名单校验。3.3 模糊查询的经典误区与正确姿势这是一个高频误区。很多人想实现LIKE ‘%keyword%’会错误地写成WHERE name LIKE ‘%#{keyword}%’ !-- 语法错误#{}不能放在引号内拼接 --或者危险地写成WHERE name LIKE ‘%${keyword}%’ !-- SQL注入风险 --正确且安全的做法有以下几种方案一在Java代码中拼接好再传参推荐String searchName “%” keyword “%”; mapper.selectByName(searchName);select idselectByName” WHERE name LIKE #{searchName} /select这是最清晰、最安全的方式逻辑一目了然。方案二使用SQL的字符串连接函数如MySQL的CONCATWHERE name LIKE CONCAT(‘%’, #{keyword}, ‘%’)这种方式数据库兼容性需要注意但同样安全。方案三使用MyBatis的bind标签select idselectUser” bind name“pattern” value“‘%’ keyword ‘%’” / SELECT * FROM user WHERE name LIKE #{pattern} /selectbind创建了一个新的变量pattern其值在MyBatis内部完成拼接然后安全地通过#{}传入SQL。这避免了在Java代码中处理保持了Mapper的完整性。4. 高级话题与底层机制探究当你深入使用MyBatis尤其是在处理复杂动态SQL或追求极致性能时会对这两个占位符有更深的理解。4.1 动态SQL标签if,foreach内的使用在if、choose、foreach等标签内#{}和${}的规则不变但上下文更复杂。在foreach中遍历集合必须用#{}select idselectByIds” SELECT * FROM user WHERE id IN foreach collection“idList” item“id” open“(” separator“,” close“)” #{id} !-- 正确每个id都被安全地参数化 -- /foreach /select绝对不能使用${id}否则如果idList包含“1,2,3) OR 11 --”后果不堪设想。在if中动态选择列名或表名谨慎使用${}select idselectDynamic” SELECT if test“includeSensitive true” id, name, ${sensitiveColumn} /if if test“includeSensitive false” id, name /if FROM user /select这里的${sensitiveColumn}例如“email”或“phone”同样需要白名单控制。4.2 与resultMap、parameterType的关联#{}中的属性名必须与parameterType指定类型的属性名或Map的key严格匹配。MyBatis通过OGNL表达式来解析。如果传入的是多个参数且未使用Param注解则可以使用#{arg0},#{arg1}或#{param1},#{param2}来访问。但最佳实践是使用Param注解明确参数名。User selectByCond(Param(“username”) String name, Param(“status”) Integer state);WHERE username #{username} AND status #{status}4.3 源码层面的简要窥探理解原理有助于调试。在MyBatis源码中以SqlSourceBuilder等类为核心处理#{}和${}的路径完全不同对于#{}解析器会将其替换为?同时记录下对应的参数映射信息属性名、类型处理器等保存在ParameterMapping列表中。后续由PreparedStatementHandler负责设置参数。对于${}解析器会直接调用OGNL表达式引擎计算${}内的表达式将得到的字符串直接拼接到原始的SQL文本中生成一个静态的、最终的SQL字符串StaticSqlSource。这也是为什么开启MyBatis的SQL日志后你会看到两种不同的输出格式。配置mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl后使用#{}打印的SQL带?参数另外列出。 Preparing: SELECT * FROM user WHERE id ? Parameters: 5(Integer)使用${}打印的就是完整的SQL。 Preparing: SELECT * FROM user ORDER BY name Parameters:5. 常见“坑点”与性能优化实践5.1 那些年我踩过的坑IN语句的误用试图在#{}中直接传入一个逗号分隔的字符串“1,2,3”结果SQL变成WHERE id IN (‘1,2,3’)只查一条记录。正确做法是使用foreach标签。${}用于批量插入时的性能灾难有人为了“方便”用${}拼接一个巨大的VALUES列表。这会导致一条长达几万行的SQL发送给数据库解析耗时极长且可能超过数据库包大小限制。批量插入应使用foreach配合#{}或者使用ExecutorType.BATCH模式。类型处理器混淆在#{}中可以指定typeHandler如#{age, javaTypeInteger, jdbcTypeNUMERIC}。但在${}中指定是无效的因为它是直接替换。分页参数的安全问题早期有些分页插件通过${}拼接limit ${offset}, ${size}。如果offset和size来自前端且未校验可能导致深分页性能问题甚至恶意消耗资源。应使用#{}并在后端对分页参数做合理化限制如size最大不超过100。5.2 性能优化相关心得优先使用#{}以利用数据库缓存对于高频查询如根据主键查询、根据固定条件查询使用#{}能让数据库缓存相同的执行计划显著提升性能。而${}每次都可能生成不同的SQL文本导致缓存失效。动态表名场景的折中方案如果业务上确实需要查询很多动态表如按日期分表且表名无法白名单化可以考虑在应用层使用数据库连接池配合不同的SqlSession每个SqlSession使用固定的表名Mapper避免在SQL层面进行字符串替换。监控与告警可以在代码审查环节加入规则扫描Mapper XML文件中${}的使用并重点审查。在预发环境可以通过拦截器Interceptor对包含${}的SQL进行日志记录或告警做到事前预防和事中发现。6. 面试精要如何清晰表达你的理解如果你在面试中被问到这个问题可以按以下逻辑层次清晰地阐述这能体现你的深度一句话概括本质“#{}是参数占位符实现预编译安全${}是字符串替换直接拼接SQL文本不安全。”展开核心区别从处理机制预编译vs拼接、安全性防注入vs风险、性能缓存友好vs不友好、使用场景值vs标识符/关键字四个维度对比。举例说明正确与错误用法用WHERE条件值、ORDER BY动态排序、LIKE模糊查询、动态表名这几个典型场景举例。深入原理简要提到MyBatis底层处理方式的不同ParameterMappingvsStaticSqlSource以及数据库日志的差异。总结最佳实践强调“默认使用#{}”使用${}时必须进行严格的白名单校验并给出模糊查询、批量操作等常见问题的安全解决方案。记住框架提供的每一个特性都有其设计初衷和适用边界。#和$的选择是MyBatis留给开发者在灵活性与安全性之间的一道选择题。答对了你的应用坚如磐石答错了可能就是下一个故障的导火索。希望我分享的这些经验和教训能帮你做出每一次正确的选择。