
1. 项目概述从一次线上事故说起那天下午监控系统突然报警数据库CPU瞬间飙到100%。紧急排查后发现是一条看似普通的MyBatis查询语句引发的全表扫描。问题的根源就出在一个小小的$符号上。开发同学为了图方便在动态排序字段上直接用了ORDER BY ${sortField}而前端传入的参数被恶意拼接最终导致了性能雪崩。这件事让我意识到尽管#和$这两个占位符是MyBatis入门必学的基础但真正能透彻理解其差异、并在生产环境中游刃有余使用的开发者其实并不多。很多人只是模糊地知道“#能防SQL注入$不能”至于背后的原理、各自的最佳实践场景以及那些隐藏的坑往往是在踩过之后才恍然大悟。今天我们就抛开那些教科书式的定义从一个一线开发者的视角深入聊聊MyBatis中#{}和${}这对“孪生兄弟”。我会结合真实的业务场景、源码层面的简单剖析以及我这些年积累下来的实战经验和踩坑记录帮你建立起一套完整、深刻的理解框架。无论你是正在面试准备中被问到“#和$的区别”还是在日常开发中纠结于到底该用哪个这篇文章都能给你清晰、可落地的答案。我们会从最根本的“预编译”与“字符串替换”原理讲起延伸到动态SQL、排序、表名处理等复杂场景的选型策略最后再分享几个能极大提升开发效率和代码安全性的高级技巧与配置。2. 核心原理拆解预编译与字符串替换的本质差异要理解#{}和${}绝不能停留在“一个安全一个不安全”的表面认知。它们的本质区别在于MyBatis处理SQL语句的时机和方式完全不同这直接决定了SQL的执行计划、安全性和适用场景。2.1#{}安全的参数化查询基石当你使用#{}时MyBatis会创建一个PreparedStatement对象。这是JDBC中用于执行预编译SQL语句的接口。关键步骤在于“预编译”SQL解析与编译数据库服务器会先对SQL语句的骨架进行解析和编译生成一个执行计划。例如对于SELECT * FROM user WHERE id ?数据库会知道这是一个在user表上根据id字段进行等值查询的操作并可能决定使用id索引。参数传递那个问号?就是一个占位符。之后程序再将具体的参数值比如123单独传递给这个已编译好的语句。执行数据库将参数值与执行计划结合完成查询。这个过程带来了两大核心优势杜绝SQL注入因为参数值是在SQL结构被编译之后才传入的它永远只被当作“数据”来处理。即使你传入‘1‘ OR ‘1‘‘1‘这样的恶意字符串它也会被当作一个完整的字符串值去匹配id字段而不会被解析成SQL指令的一部分。数据库会去查找id等于这个奇怪字符串的记录显然找不到从而保证了安全。提升性能同一条SQL语句仅参数不同可以被预编译一次然后多次执行。数据库无需每次都对SQL进行完整的语法解析、优化和编译这对于高频执行的查询如根据主键查询能带来可观的性能提升。在MyBatis的XML映射文件中它看起来是这样的select idselectUserById resultTypeUser SELECT * FROM user WHERE id #{userId} /selectMyBatis在底层会将其处理为SELECT * FROM user WHERE id ?并将userId参数安全地设置进去。2.2${}灵活的字符串替换利器而${}的工作方式则简单粗暴得多字符串拼接。在MyBatis解析XML时它会直接将${}中的内容替换为对应的参数值然后拼接到SQL语句中最后才将整条完整的SQL字符串发给数据库。select idselectUserByOrder resultTypeUser SELECT * FROM user ORDER BY ${orderByField} /select如果传入的orderByField是“name“那么最终生成的SQL就是SELECT * FROM user ORDER BY name然后直接交给数据库执行。这种方式的特点非常鲜明灵活性高它可以替换SQL语句中的任何部分不仅仅是WHERE子句中的值还可以是列名、表名、ORDER BY字段等。极高的SQL注入风险正因为是直接拼接如果替换的内容来自不可信的用户输入风险极大。假设上面例子中用户传入“name; DROP TABLE user; --“拼接后的SQL将变成SELECT * FROM user ORDER BY name; DROP TABLE user; --这将导致灾难性后果。无预编译性能优势每次都是全新的SQL语句数据库需要重新解析编译。核心理解你可以把#{}想象成给SQL语句“填空”空位的形状是固定的你只能填规定类型的数据。而${}则是“剪贴替换”你给它一段文本它直接把这文本贴到SQL语句的指定位置至于贴上去的是数据还是指令它不管。2.3 对比表格与底层源码视角为了让区别更直观我们用一个表格来总结特性#{}${}处理方式参数化查询使用PreparedStatement字符串替换使用Statement或PreparedStatement(替换后)安全性高从根本上防止SQL注入低存在SQL注入风险性能高支持预编译同语句可复用执行计划低每次均为全新语句需重新编译参数类型处理自动处理根据参数Java类型设置合适的JDBC类型如String设为VARCHAR原样替换不处理类型可能导致语法错误如字符串缺引号适用场景WHERE条件中的值、INSERT的VALUES、存储过程参数等数据值位置动态表名、列名、ORDER BY排序字段、GROUP BY字段等SQL关键字或标识符位置从MyBatis源码如SqlSourceBuilder等类来看#{}在解析时会被标记为ParameterMapping最终在运行时通过PreparedStatement.setXXX()方法来设值。而${}在解析阶段就被TextSqlNode处理直接通过OGNL表达式求值后替换到原始SQL字符串中。这也是为什么${}无法防止注入的根本原因——它在SQL语句成型前就完成了替换。3. 实战应用场景与选型策略理解了原理我们来看实战中如何选择。记住一个基本原则能用#{}的地方绝对不用${}。${}的使用必须慎之又慎且通常只用于非数据值的替换。3.1 必须使用#{}的场景这是占位符使用的“安全区”和“主战场”。所有传入查询条件的数据值这是最核心的用法。!-- 安全 -- select idselectByCondition resultTypeUser SELECT * FROM user WHERE username #{name} AND age #{minAge} AND create_time BETWEEN #{startTime} AND #{endTime} /select即使参数是Date或BigDecimal等复杂类型#{}也能正确转换。INSERT/UPDATE语句的赋值部分insert idinsertUser parameterTypeUser INSERT INTO user (username, email, age) VALUES (#{username}, #{email}, #{age}) /insert update idupdateUser parameterTypeUser UPDATE user SET email #{email}, age #{age} WHERE id #{id} /update存储过程的输入/输出参数select idcallProcedure statementTypeCALLABLE {call my_procedure(#{param1, modeIN}, #{param2, modeOUT, jdbcTypeVARCHAR})} /select3.2 谨慎使用${}的场景这些场景下${}提供了不可或缺的灵活性但必须配合严格的安全控制。动态排序ORDER BY这是${}最经典的合法使用场景。因为ORDER BY后面跟的是列名或表达式而不是数据值无法使用#{}#{}会给列名加上引号导致语法错误。select idselectUsersWithOrder resultTypeUser SELECT * FROM user if testorderBy ! null and orderBy ! ‘‘ ORDER BY ${orderBy} /if /select致命陷阱与解决方案直接使用${orderBy}如同打开潘多拉魔盒。攻击者可以传入“age; DROP TABLE user --”。必须进行白名单校验// 在Service层或参数拦截器中进行校验 public void validateOrderBy(String orderBy) { ListString allowedFields Arrays.asList(id, username, age, create_time); // 简单校验确保传入的字符串是允许的字段名 // 复杂场景需解析逗号、空格等防止id, (SELECT ...) if (orderBy ! null) { // 这里只是一个简单示例实际需要更严格的解析和校验 String[] parts orderBy.split(\\s); if (!allowedFields.contains(parts[0].toLowerCase())) { throw new IllegalArgumentException(非法的排序字段: orderBy); } } }更安全的做法是前端传递枚举值如“SORT_BY_AGE_DESC”后端映射成安全的数据库列名。动态表名/列名在分表场景如按年月分表user_202301,user_202302或通用Mapper中可能会用到。select idselectFromDynamicTable resultTypeUser SELECT id, name FROM ${tableName} WHERE status #{activeStatus} /select核心安全原则${tableName}的值绝不能来自用户输入必须由后端逻辑根据规则生成如根据用户ID哈希决定表后缀。这是铁律。动态SQL片段拼接特殊场景极少数情况下需要根据条件完全改变SQL结构的一部分。select iddynamicWhere resultTypeUser SELECT * FROM user WHERE 11 if testtype ‘A‘ AND ${dynamicConditionA} /if if testtype ‘B‘ AND ${dynamicConditionB} /if /select警告${dynamicConditionA}这类用法风险极高通常意味着你的数据模型或查询设计可能存在问题。应优先考虑使用MyBatis的动态SQL标签if,choose,where,set来构建条件。如果必须使用确保其值来自可信的、内部定义的常量或经过严格校验和清洗的配置。3.3 模糊查询的经典误区与正确写法这是一个高频踩坑点。很多人想实现LIKE ‘%张%‘查询会错误地尝试!-- 错误写法1直接拼接有注入风险 -- WHERE username LIKE ‘%${name}%‘ !-- 错误写法2使用#{}但语法错误 -- WHERE username LIKE ‘%#{name}%‘ !-- 最终变成 LIKE ‘%?%‘参数无法正确注入 --正确的写法有以下几种在Java代码中拼接好再传参推荐String name “张”; String likePattern “%” name “%”; // 然后将 likePattern 作为参数传入select idselectLike resultTypeUser SELECT * FROM user WHERE username LIKE #{pattern} /select这样既利用了#{}的安全预编译又实现了功能。使用MySQL的CONCAT函数数据库端拼接select idselectLike resultTypeUser SELECT * FROM user WHERE username LIKE CONCAT(‘%‘, #{name}, ‘%‘) /select注意数据库兼容性。使用MyBatis的bind标签select idselectLike resultTypeUser bind namelikePattern value“‘%‘ name ‘%‘ / SELECT * FROM user WHERE username LIKE #{likePattern} /selectbind标签会在当前上下文创建一个变量其值可以在OGNL表达式中计算得出然后再通过#{}安全使用。4. 高级技巧、配置与深度避坑指南掌握了基础用法我们来看看如何用得更好、更稳。这些技巧很多都是我在处理性能问题、排查诡异Bug时总结出来的。4.1#{}的额外属性精细化控制#{}远不止一个参数名那么简单它支持一些非常实用的属性来应对复杂场景。jdbcType指定参数对应的JDBC类型。在处理可能为null的参数时至关重要。当传入的参数为null时MyBatis需要知道对应的JDBC类型否则某些驱动可能报错。!-- 假设 age 可能为 null -- UPDATE user SET age #{age, jdbcTypeINTEGER} WHERE id #{id}常见的jdbcType有VARCHAR,INTEGER,DATE,TIMESTAMP,DECIMAL等。在全局配置中可以设置jdbcTypeForNull为NULL如jdbcTypeForNullNULL来避免为每个可为空的参数都指定。typeHandler指定自定义的类型处理器。用于处理Java类型和JDBC类型之间的特殊转换。!-- 假设有一个将ListString转换为JSON字符串存入数据库的处理器 -- INSERT INTO user (tags) VALUES (#{tags, typeHandlercom.example.JsonArrayTypeHandler})numericScale指定数值类型的小数点后位数。!-- 确保存入的数值精确到两位小数 -- UPDATE account SET balance #{amount, jdbcTypeDECIMAL, numericScale2}4.2 警惕${}的隐式类型问题由于${}是直接替换它不会帮你给字符串值加上引号。这经常导致隐蔽的错误。!-- 假设传入的tableName是“user”status是数字1 -- SELECT * FROM ${tableName} WHERE status ${status} !-- 正确SELECT * FROM user WHERE status 1 -- !-- 假设传入的status是字符串“ACTIVE” -- SELECT * FROM ${tableName} WHERE status ${status} !-- 错误SELECT * FROM user WHERE status ACTIVE (缺少引号) --对于非数值的动态值如果必须用${}你需要自己在SQL中或参数传入前处理好引号但这又增加了复杂性和风险。这再次印证了${}只应用于标识符表名、列名的原则。4.3 结合动态SQL标签的安全实践MyBatis强大的动态SQL标签if,choose,where,set,foreach与#{}是黄金搭档可以安全地构建复杂的查询。select idselectUsers resultTypeUser SELECT * FROM user where if testusername ! null and username ! ‘‘ AND username LIKE CONCAT(‘%‘, #{username}, ‘%‘) /if if testminAge ! null AND age #{minAge} /if if teststatusList ! null and statusList.size 0 AND status IN foreach collectionstatusList itemstatus open“(” separator“,” close“)” #{status} !-- 注意这里用的是#{}安全 -- /foreach /if /where ORDER BY create_time DESC /selectwhere标签会智能地处理掉开头多余的AND或ORforeach标签配合#{}可以安全地生成IN语句避免了手动拼接IN列表的注入风险和语法麻烦。4.4 配置打印SQL与参数强大的调试利器当SQL执行结果不符合预期时查看MyBatis实际执行的SQL语句是排查问题的第一步。这里强烈推荐配置SQL日志打印。标准配置推荐在application.yml或application.properties中配置日志级别。# application.yml logging: level: com.example.mapper: DEBUG # 将你的Mapper接口所在包的级别设为DEBUG或者更精确地控制MyBatis的日志实现logging: level: org.apache.ibatis: INFO com.example.mapper: TRACE # TRACE级别会打印出参数值使用标准日志框架Logback/Log4j2输出格式清晰且能与应用其他日志统一管理。mybatis.configuration.log-impl在MyBatis配置中指定具体的日志实现类如StdOutImpl直接打印到控制台但在生产环境不推荐。注意安全在生产环境务必避免将TRACE或DEBUG级别开放给包含用户敏感信息的Mapper以防参数日志泄露数据。4.5 批量操作中的占位符性能考量在进行批量插入或更新时如果使用#{item.property}在foreach循环中MyBatis会生成一条带有多个占位符的SQL语句如INSERT ... VALUES (?, ?), (?, ?), (?, ?)。这仍然是预编译的性能很好。但要警惕一种情况如果你因为某些原因如极度动态的列不得不使用${}在循环体内拼接值那会生成一条巨大的、每次都不一样的SQL字符串完全无法利用预编译且可能触及数据库或网络包的大小限制。这时必须考虑分批次执行或寻找其他设计方案。5. 常见问题排查与面试要点实录最后分享几个我常被问到或在实际排查中遇到的问题。Q1明明用了#{}为什么日志里看到的SQL还是有参数值而不是问号A这是日志框架的功劳。像p6spy或某些配置了log-impl的驱动会在日志层面将参数值替换回SQL中方便开发者阅读。实际发往数据库的仍然是带占位符的预编译语句。你可以通过抓取网络包或使用数据库自身的日志来验证。Q2${}在ORDER BY里用我做了枚举映射是不是就绝对安全了A大大降低了风险但并非铁板一块。还要防止“SQL注入二级攻击”例如攻击者利用合法字段进行复杂查询导致慢查询拖垮数据库。可以考虑对排序字段进行更严格的格式校验只允许字母、数字、下划线并限制排序字段的长度。Q3传入一个List在foreach里用#{}生成的SQL是怎样的安全吗A假设传入ids[1,2,3]SQL会生成... IN (?, ?, ?)然后分别用1,2,3去设置这三个占位符。这是安全的MyBatis内部处理了列表的展开和参数设置。绝对不要自己拼接成... IN (${ids})那会产生... IN (1,2,3)虽然语法对但失去了预编译优势且如果ids来自不可信源风险极高。Q4#{}可以防止所有的注入吗A#{}可以防止SQL语法层面的注入。但它不能防止业务逻辑层面的问题例如通过#{password}传入的密码如果数据库里存储的就是明文那么查询WHERE password #{input}如果input恰好是某个用户的真实密码依然能查询出来。这属于认证逻辑缺陷不是SQL注入。Q5关于${}和#{}的面试除了区别还常问什么A有经验的面试官可能会追问“什么场景下你不得不使用${}”考察对SQL语法和占位符限制的理解“如果你必须用${}接一个用户输入的排序字段你会怎么设计来保证安全”考察安全意识和解决方案设计能力“#{}是如何处理Date、BigDecimal这些复杂类型的”考察对TypeHandler的了解深度“模糊查询LIKE语句有哪些安全的写法”考察实际编码经验和知识广度理解#{}和${}是写好MyBatis代码的基石。它关乎安全、性能和代码的健壮性。记住那句老话默认总是使用#{}把${}的使用当作一个需要特批和严格审查的例外事件。在每一次写下${}时都问自己一句这个值来自哪里我是否完全信任它有没有更安全的方式替代多这一份警惕就能在代码层面规避掉许多潜在的风险。