MyBatis中#与$占位符详解:从SQL注入到源码解析的实战指南
1. 项目概述从一次线上SQL注入事故说起去年我处理过一个线上事故一个查询接口突然返回了大量本不该出现的数据排查后发现是开发同学在MyBatis的动态SQL里用错了符号把#写成了$导致用户输入的参数直接被拼接进了SQL语句引发了SQL注入。这个看似简单的符号选择背后是MyBatis处理SQL参数化查询的核心机制也是区分安全与风险、高效与低效的关键。#和$这两个占位符几乎每个使用MyBatis的开发者都会遇到但真正能说清楚它们底层原理、适用场景以及潜在陷阱的人可能并不多。今天我就结合自己踩过的坑和源码调试的经验来彻底聊聊MyBatis中这对“孪生兄弟”的理解与使用。无论你是刚接触MyBatis的新手还是已经用过一段时间但对其原理感到模糊的开发者这篇文章都会带你从现象到本质不仅告诉你“怎么用”更会深入解释“为什么这么用”。我们会从一次模拟的SQL注入演示开始逐步拆解预编译与字符串拼接的差异分析它们在不同场景如动态表名、排序字段、IN查询下的最佳实践最后再深入到MyBatis源码层面看看这两个符号究竟是如何被解析和执行的。理解透了这些你就能在编写MyBatis XML映射文件时做出更安全、更高效的选择。2. 核心概念拆解预编译与字符串拼接的本质区别要理解#和$绝对不能停留在“一个能防注入一个不能”的表面认知。它们的根本区别在于MyBatis向数据库发送SQL语句的两种完全不同的方式参数化查询预编译和静态语句拼接。2.1#占位符安全的参数化查询当你使用#{}时你是在告诉MyBatis“这里有一个参数请把它当作一个‘值’来处理而不是SQL的一部分。” MyBatis会创建一个PreparedStatement对象。这个过程可以分解为以下几步SQL解析与编译数据库驱动首先会收到一个带问号?的SQL模板。例如SELECT * FROM user WHERE id ?。数据库会对这个SQL语句的语法、语义进行检查并生成一个执行计划。这个编译过程只发生一次后续即使传入不同的参数值也无需再次编译。参数传递当程序执行时具体的参数值比如id5会通过单独的通道传递给这个已经编译好的PreparedStatement。执行数据库将接收到的参数值“填入”预编译好的执行计划中然后执行。关键优势绝对防止SQL注入因为参数值始终被当作数据而非代码处理。即使用户输入1 OR 11到了数据库那里它就是一个完整的字符串值最终执行的SQL类似于SELECT * FROM user WHERE id 1 OR 11这只会去寻找id字段等于这个奇怪字符串的记录而不会改变SQL结构。性能提升对于同一条SQL语句仅参数不同的多次执行数据库只需在第一次编译后续直接使用缓存好的执行计划大大减少了开销。自动类型处理MyBatis会根据参数对象的类型自动调用PreparedStatement的相应set方法如setString,setInt你无需手动处理类型转换和引号。注意#{}内部可以指定一些参数属性例如#{id, jdbcTypeINTEGER}来显式指定数据库类型这在某些数据库如Oracle处理NULL值时非常有用可以避免“无效的列类型”错误。2.2$占位符直接的字符串替换拼接而当你使用${}时你是在对MyBatis说“把这里的内容原封不动地替换到SQL字符串里。” MyBatis会创建一个Statement对象。它的工作流程简单粗暴字符串拼接在MyBatis层面它会直接获取${}中表达式的结果一个字符串然后像做字符串替换一样将这个结果拼接到最终的SQL字符串中。发送完整SQL数据库驱动收到的是一个完整的、拼接好的SQL语句。例如如果tableName变量的值是”user”那么最终生成的SQL就是SELECT * FROM user WHERE id 5。编译与执行数据库每次收到这条完整的SQL都需要对其进行完整的解析、编译生成执行计划然后执行。核心风险与场景SQL注入漏洞这是最大的风险。如果${}中的值来自不可信的用户输入攻击者就可以通过输入特殊字符来改变SQL的语义。例如order by ${sortField}如果sortField被传入”id; DROP TABLE user --“后果不堪设想。性能损耗每次执行都是全新的SQL语句数据库无法复用执行计划在高并发场景下会消耗更多的CPU资源进行编译。必须手动处理你需要自己确保替换进去的字符串是合法的SQL片段。例如如果参数是字符串类型你需要手动在XML或代码里加上单引号WHERE name ‘${name}’。这很容易出错比如忘记引号导致语法错误或者多加了引号。一个简单的对比表格特性#{}(参数占位符)${}(字符串替换)处理方式参数化查询使用PreparedStatement字符串拼接使用Statement安全性高从根本上防止SQL注入低存在SQL注入风险性能高支持预编译执行计划可复用低每次需重新编译SQL引号处理自动添加根据参数类型决定需手动添加易出错适用场景绝大多数情况传入WHERE条件值极少数情况动态传入SQL关键字、表名、列名底层原理setXxx()方法设置参数字符串直接替换3. 实战场景深度解析何时用#何时敢用$理解了原理我们来看实战。我始终坚持一个原则默认永远使用#{}仅在无法用#{}实现功能且能完全掌控${}内容来源时才考虑使用${}。3.1 必须使用#{}的场景占99%这是你日常开发中最频繁使用的场景所有传入WHERE子句作为条件值的部分都必须使用#{}。!-- 查找特定用户 -- select idselectUserById resultTypeUser SELECT * FROM user WHERE id #{userId} !-- userId是数值自动处理 -- /select !-- 根据姓名模糊查询 -- select idselectUserByName resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, #{name}, %) !-- 使用CONCAT函数安全拼接模糊查询 -- /select !-- 多条件查询 -- select idselectUserByCondition resultTypeUser SELECT * FROM user WHERE 11 if testname ! null and name ! AND name #{name} /if if testage ! null AND age #{age} /if /select实操心得对于模糊查询LIKE不要在Java代码里拼接好%再传进来如”%” name “%”这样会破坏参数化查询的语义。更推荐在XML中使用数据库的字符串连接函数如MySQL的CONCAT或者使用MyBatis的bind标签它能在动态SQL上下文中创建一个变量但最终仍以参数化方式传递。select idselectUserByName resultTypeUser bind namepattern value% name % / SELECT * FROM user WHERE name LIKE #{pattern} /select3.2 谨慎使用${}的场景占1%只有在动态指定SQL语句的组成部分而非值时才可能用到${}。并且必须确保参数值不是来自前端用户直接输入而是来自后端可控的枚举、配置或经过严格校验的白名单。场景一动态表名或列名当你的数据表需要根据业务规则进行分表如按年份user_2023,user_2024查询时需要动态指定表名。select idselectByYear resultTypeUser SELECT * FROM user_${year} WHERE status #{status} /select重要警告这里的${year}必须确保是后端逻辑计算出来的如当前年份或者是从一个固定的枚举值中获取。绝对不允许从前端请求参数中直接获取并拼接。场景二动态排序字段(ORDER BY)用户选择不同的列进行排序这是一个经典难题。ORDER BY后面跟的是列名或表达式不能使用#{}因为它会加上引号变成ORDER BY ‘name’语法错误。select idselectUsers resultTypeUser SELECT * FROM user WHERE company_id #{companyId} ORDER BY ${sortField} ${sortOrder} /select避坑技巧这是高风险操作必须进行白名单校验。在Service层或一个专门的拦截器/工具类中对传入的sortField和sortOrder进行校验。// 一个简单的白名单校验示例 public class SqlInjectionChecker { private static final SetString ALLOWED_SORT_FIELDS Set.of(“id”, “name”, “create_time”); private static final SetString ALLOWED_ORDERS Set.of(“ASC”, “DESC”); public static String safeSortField(String field) { if (!ALLOWED_SORT_FIELDS.contains(field)) { throw new IllegalArgumentException(“Invalid sort field: ” field); } return field; } // 同理校验 sortOrder }在XML中使用时可以结合OGNL表达式调用静态方法需配置或者更简单地在Service层调用校验方法后再传入Mapper。场景三IN查询的动态参数个数这是一个常见的误区。很多人想用${}来拼接IN语句如id IN (${ids})其中ids是”1,2,3”。这极其危险正确的做法是使用MyBatis的foreach标签动态生成多个#{}占位符。select idselectUsersInIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionidList itemid open( separator, close) #{id} /foreach /select这样生成的SQL是id IN (?, ?, ?)然后通过PreparedStatement安全地设置三个参数值。既安全又能利用预编译优势。4. 源码层面剖析MyBatis如何解析#和$要真正吃透不妨跟着我一起简单看看MyBatis的源码。这能让你理解为什么会有这样的行为差异。核心的解析过程发生在SqlSource构建和参数绑定阶段。MyBatis在启动时会解析所有的Mapper XML文件。解析#{}和${}的主要类是TextSqlNode和MixedSqlNode用于动态SQL以及最终的ParameterMappingTokenHandler。对于${}的处理 在TextSqlNode中会使用GenericTokenParser来查找${和}之间的内容。找到后它会调用OGNLObject-Graph Navigation Language表达式解析器去计算这个表达式在当前参数上下文中的值。然后直接进行字符串替换将这个值替换到原始的SQL文本中。所以最终交给Statement执行的就是一个完整的、拼接好的SQL字符串。对于#{}的处理 同样会使用GenericTokenParser查找#{和}。但处理方式完全不同。它不会立即替换而是创建一个ParameterMapping对象记录这个占位符的位置、属性的名称、类型处理器TypeHandler等信息。这个ParameterMapping会被加入一个列表。最终MyBatis会生成一个带?的SQL字符串BoundSql.getSql()以及这个参数映射列表。执行阶段 当真正执行查询时例如DefaultParameterHandlerMyBatis会遍历参数映射列表为每一个ParameterMapping找到对应的参数值并通过对应的TypeHandler调用PreparedStatement.setXxx()方法将值安全地设置进去。一个简单的类比${}就像你在Word文档里使用“查找并替换”功能把${name}直接替换成张三然后打印出一份完整的文稿。#{}就像你准备了一份填空题试卷模板“我的名字是 ____。”然后你有一份独立的答案纸上面写着“张三”。阅卷人数据库会根据模板和答案纸来判卷。答案纸上的内容永远不会改变模板本身的结构。5. 常见问题排查与高阶技巧在实际开发和排查问题时关于占位符的困惑不少我整理了几个典型问题和解决方案。5.1 问题一为什么有时候#{}传值会报错场景你写了一个查询SELECT * FROM table WHERE column #{value}当value为null时可能一切正常但当value为””空字符串或某些特殊字符串时数据库报语法错误或查不到数据。排查思路开启MyBatis日志这是最重要的第一步。在配置文件中设置日志级别为DEBUG可以打印出执行时的SQL和参数。# application.yml (Spring Boot) logging: level: com.xxx.mapper: DEBUG查看日志你会发现#{}被替换成了?并附带了参数值。这能帮你确认最终发给数据库的SQL到底是什么样子。检查类型处理器MyBatis使用TypeHandler在Java类型和JDBC类型间转换。如果字段是VARCHAR你传入一个IntegerMyBatis会尝试转换但可能不符合预期。确保传入的参数类型与字段类型匹配或在#{}中显式指定jdbcType。空字符串与NULL数据库对NULL和空字符串””的处理不同。WHERE name ”和WHERE name IS NULL是两种不同的条件。如果你的业务逻辑中空字符串有意义需要确保数据库里存的就是空字符串而不是NULL。5.2 问题二${}导致的分页查询性能问题场景在实现动态排序的分页查询时如果使用了${sortField}并且这个字段没有索引或者排序方向经常变化可能会导致数据库无法有效利用缓存执行计划每次都要进行全表扫描和排序性能极差。优化建议索引优化为常用的排序字段建立索引。减少动态性如果排序字段有限如仅按时间、金额、姓名排序可以考虑使用CASE WHEN语句来避免${}。虽然SQL会变长但它是参数化的执行计划可能更稳定。ORDER BY CASE #{sortField} WHEN ‘create_time’ THEN create_time WHEN ‘amount’ THEN amount ELSE id END ${sortOrder} !-- sortOrder (ASC/DESC) 仍需白名单校验 --应用层排序对于数据量不是特别大的情况可以考虑在数据库中按主键或时间索引拉取数据后在应用层内存中进行排序。这需要权衡网络传输、内存消耗和数据库压力。5.3 问题三Like查询与#{}的配合如前所述直接LIKE #{pattern}如果pattern不包含%就不是模糊查询。有几种安全做法XML中使用CONCAT推荐数据库函数通用。使用bind标签MyBatis特性灵活。在Java代码中拼接好%但需注意如果这么做请确保拼接逻辑是安全的不会引入额外的%或_这两个是LIKE的通配符导致查询范围扩大。如果用户输入本身可能包含%你需要进行转义。在MySQL中可以使用ESCAPE关键字如LIKE ‘%’ REPLACE(#{input}, ‘%’, ‘\%’) ‘%’ ESCAPE ‘\’。5.4 高阶技巧自定义TypeHandler处理复杂#{}场景有时你希望存入数据库或从数据库查询时对某个字段进行特殊的格式处理如将JSON字符串与Java对象互转。你可以为这个字段编写自定义的TypeHandler并在#{}中指定它。例如你有一个User对象的attributes字段在Java中是MapString, Object在数据库中是JSON字符串。编写一个JsonTypeHandler实现TypeHandlerMapString, Object接口。在MyBatis配置中注册这个处理器或者在使用它的字段上通过MappedTypes和MappedJdbcTypes注解指定。在XML中你可以直接使用#{attributes}MyBatis会自动调用你的JsonTypeHandler进行序列化和反序列化。这展示了#{}的强大扩展性——它不仅仅是简单的值传递而是通过类型处理器与你的业务逻辑深度集成。6. 安全规范与代码审查要点在团队协作中必须将${}的使用纳入严格的代码审查流程。以下是我团队内的审查清单禁用扫描在项目中配置静态代码分析工具如SonarQube、Checkstyle添加规则对Mapper XML中出现的${进行高优先级告警要求开发者必须给出合理解释。白名单强制校验任何使用${}的地方必须能看到对应的白名单校验代码。审查时重点检查校验逻辑是否严密白名单集合是否固定或来自可信配置源。参数溯源审查${}中参数的来源。它必须是后端硬编码的常量或枚举。从配置文件如application.yml中读取的、受控的配置项。经过多层业务逻辑处理、完全排除了用户输入可能性的计算结果。替代方案评估在审查时主动询问开发者是否考虑过使用#{}的替代方案例如动态表名是否可以通过数据库分区或视图来规避动态排序是否可以通过有限的CASE WHEN来实现日志与监控对于允许使用${}的少数接口要在日志中详细记录拼接前的原始值和拼接后的SQL片段注意脱敏便于事后审计和问题追踪。7. 总结与个人实践心得回顾这十多年的使用经验MyBatis的#和$之争本质上是一场安全、性能与灵活性之间的权衡。我的个人实践心得非常明确把#{}当作默认的、唯一的选择。在动手写任何SQL片段之前先问自己“这里能不能用#{}” 99%的情况下答案都是“能”。对于动态表名、排序这些看似必须用${}的场景也要多思考一层这个需求是否可以通过更好的架构设计来避免比如分表是否可以用数据库中间件动态排序是否可以用前端可控的有限排序选项来替代把${}的使用视为一个需要特批的“危险操作”。每次使用它都意味着你引入了一个潜在的安全风险点。你必须为这个风险点配备全套的“安全措施”严格的白名单校验、清晰的参数溯源、详细的日志记录。在你的团队里甚至可以设立一个规则任何Mapper XML文件中新增${}都需要在代码评审中经过资深工程师或技术负责人的 explicit approval。最后理解其底层原理至关重要。知道#{}最终变成了PreparedStatement的?而${}是简单的字符串替换这能让你在遇到诡异问题时有清晰的排查方向——去查看MyBatis打印出来的真实SQL日志。掌握了这些你不仅能写出安全的MyBatis代码更能深刻理解Java数据库编程中“预编译”这一核心概念这份理解会延伸到你对JPA、Hibernate乃至任何数据库访问框架的学习和使用中。