1. 从一次线上事故说起一个“$”引发的血案几年前我还在负责一个电商后台系统。某个深夜运营同学紧急推送了一个促销活动需要根据前端传入的动态字段名比如discount_price或member_price来排序商品列表。为了图快当时一位同事在 MyBatis 的 XML 映射文件中写了这么一句 SQLselect idselectGoodsList resultTypeGoods SELECT * FROM t_goods ORDER BY ${orderByField} DESC /select接口上线后起初一切正常。直到某个“聪明”的用户在请求参数里传入了orderByFieldid; DROP TABLE t_goods; --。是的经典的 SQL 注入。虽然我们的权限控制最终阻止了DROP语句的执行但数据库的异常告警瞬间刷屏整个排序功能瘫痪活动页面一片空白。那次事故让我们团队深刻认识到MyBatis 中那个小小的#和$远不是“一个防注入一个不防”那么简单它关乎着系统的安全基石和性能命脉。在日常开发中#{}和${}这对“孪生兄弟”几乎每天都会用到但很多人对它们的理解可能还停留在表面。面试时也常被问到“#和$的区别”标准答案脱口而出“#是预编译防注入$是字符串拼接有风险”。然而仅仅知道这个结论是远远不够的。你真正需要理解的是在什么场景下为什么必须用$而绝不能用#在什么场景下用了$就等于打开了潘多拉魔盒以及那些看似是$的“合理”使用场景是否存在更优的替代方案这篇文章我们就抛开那些干巴巴的概念结合我踩过的坑和积累的经验深入聊聊 MyBatis 占位符的里里外外。我会带你看看它们的底层实现剖析那些容易混淆的使用场景并分享一些让 SQL 既安全又高效的实战技巧。2. 核心机制拆解#{} 与 ${} 的本质差异要正确使用必先理解其原理。很多人说#{}是预编译${}是字符串替换。这个说法对但不够本质。我们可以从三个层面来深入理解。2.1 编译与执行安全性的根本分野#{}的工作原理预编译占位符当你使用#{}时MyBatis 在底层会创建一个PreparedStatement对象。这个过程可以拆解为两步SQL 解析与编译阶段MyBatis 会将你的 SQL 语句发送给数据库。此时SQL 中的#{}会被替换成一个?也就是参数占位符。数据库会对这条带有?的 SQL 模板进行语法分析、编译和优化生成一个执行计划并缓存起来。例如!-- 你写的 -- SELECT * FROM user WHERE name #{userName} AND status #{status} !-- 发送给数据库的 -- SELECT * FROM user WHERE name ? AND status ?在这个阶段无论#{userName}将来传的值是“张三”还是“admin OR 11”对数据库来说它都只是一个待填充的占位符?不会影响 SQL 语句的结构。参数绑定与执行阶段当真正调用方法传入参数时MyBatis 会通过PreparedStatement.setXxx()方法如setString,setInt将具体的参数值安全地设置到对应的?上。数据库引擎拿到的是已经编译好的执行计划和单独传入的参数值然后直接执行。关键点由于参数值是在编译后才传入并且是以“数据”而非“指令”的形式传入因此它绝对不可能改变原 SQL 语句的语义。用户输入“admin OR 11”最终传入数据库的就是这个完整的字符串它会被当成一个普通的姓名去查询而不会拆解成OR ‘1’‘1’这样的条件。这就是防止 SQL 注入的根本原因。${}的工作原理字符串拼接/替换而${}的行为则简单粗暴得多。它是在 MyBatis 解析 XML 或注解中的 SQL 时进行的一种纯粹的字符串替换。SQL 拼接阶段在 MyBatis 构建最终要发送给数据库的 SQL 字符串时它会直接将${}中的表达式求值然后将结果以字符串的形式“粘贴”到 SQL 语句的对应位置。!-- 你写的假设传入 orderByColumncreate_time -- SELECT * FROM user ORDER BY ${orderByColumn} !-- MyBatis 拼接后的 SQL -- SELECT * FROM user ORDER BY create_time注意这里生成的是一个完整的、静态的 SQL 字符串SELECT * FROM user ORDER BY create_time。直接执行阶段这个完整的 SQL 字符串会被交给数据库数据库将其视为一条全新的 SQL 语句重新进行解析、编译和执行。风险点正因为${}的内容直接成为了 SQL 语句的一部分如果这个内容用户可控比如来自前端传参那么用户就可以注入任何 SQL 片段。这就是我们开头事故的根源。它相当于在代码里做字符串拼接“SELECT * FROM user ORDER BY ” userInput其危险性不言而喻。2.2 参数处理与类型转换的细节#{}的强大不止于防注入它在参数处理上也更智能。自动类型处理#{}会根据 Java 方法的参数类型自动调用相应的setXxx方法。你传一个Date对象它会帮你转换成数据库兼容的格式你传一个Integer它绝不会给你加上单引号。这避免了因类型不匹配导致的语法错误。额外属性支持你可以在#{}内部指定一些属性来精细控制例如#{item, jdbcTypeVARCHAR}明确指定数据库的字段类型在处理可能为null的参数时非常有用可以避免某些数据库因类型推断错误而报错。#{page.start, javaTypeInteger, jdbcTypeNUMERIC}同时指定 Java 类型和 JDBC 类型。#{name, modeOUT}在存储过程调用中指定参数模式。而${}就是纯粹的文本替换不做任何类型转换。如果你传入一个Date对象它会直接调用toString()方法替换进去的结果可能完全不符合数据库的日期格式导致执行失败。2.3 对 SQL 执行计划与缓存的影响这一点常被忽略但对性能有潜在影响。#{}与执行计划缓存由于使用PreparedStatement同一条 SQL 模板SELECT * FROM user WHERE id ?无论参数id是 1 还是 100在数据库看来都是一样的语句。数据库可以缓存这条语句的编译结果执行计划下次遇到同样的模板即使参数值不同就可以跳过编译阶段直接使用缓存的计划并绑定新参数从而提升执行效率。这对于高并发、重复执行的简单查询性能提升显著。${}与执行计划缓存每次替换不同的值都会产生一条全新的 SQL 字符串SELECT * FROM user ORDER BY create_time和SELECT * FROM user ORDER BY name被认为是两条不同的 SQL。数据库无法利用执行计划缓存每次都需要重新进行解析和编译增加了数据库的 CPU 开销。在排序字段、表名动态变化的场景下如果变化频率很高可能会对数据库造成不必要的压力。3. ${} 的“生存空间”不得不用的场景与安全实践既然${}有如此大的安全隐患为什么 MyBatis 还要保留它因为确实存在一些场景#{}无能为力而这些场景往往与 SQL 语句的“结构”有关而非“数据”。3.1 动态表名与列名这是${}最经典且无可替代的用途。当你的逻辑需要根据不同的业务分区、分表或者动态选择查询列时表名和列名是 SQL 的标识符不能作为参数值传入。场景示例按月份分表的日志查询select idselectLogsByMonth resultTypeLog SELECT * FROM log_${month} !-- month 可能是 “202401”、“202402” -- WHERE app_id #{appId} /select这里log_${month}是表名的一部分必须用${}拼接。而#{appId}是查询条件值必须用#{}以保证安全。安全实践在这种场景下绝不能让month这样的参数直接来自用户输入。必须在服务层进行严格的校验和映射。例如从前端接收到“2024-01”在 Service 层校验格式是否正确月份是否在合理范围内甚至通过一个固定的枚举或配置映射来获取真正的表名后缀确保传入${}的内容是完全可控、白名单内的。3.2 ORDER BY 动态排序正如开篇的例子根据用户选择动态排序是一个常见需求。select idselectUsers resultTypeUser SELECT * FROM user WHERE company_id #{companyId} if testorderBy ! null and orderBy ! ORDER BY ${orderBy} ${orderType} !-- orderBy 可能是 “create_time”, orderType 可能是 “DESC” -- /if /select同样排序字段和顺序是 SQL 子句的结构部分无法用#{}实现。#{}会给字段名加上引号变成ORDER BY ‘create_time’这是错误的语法。安全实践这是 SQL 注入的重灾区。必须进行白名单校验。// 在 Service 层或一个专门的校验工具中 private static final SetString ALLOWED_ORDER_FIELDS Set.of(create_time, name, age); private static final SetString ALLOWED_ORDER_TYPES Set.of(ASC, DESC); public void validateOrderParams(String orderBy, String orderType) { if (orderBy ! null !ALLOWED_ORDER_FIELDS.contains(orderBy)) { throw new IllegalArgumentException(Invalid order field: orderBy); } if (orderType ! null !ALLOWED_ORDER_TYPES.contains(orderType)) { throw new IllegalArgumentException(Invalid order type: orderType); } }在调用 Mapper 前先校验参数是否在允许的集合内。绝对不要相信任何前端传入的、用于拼接 SQL 结构的参数。3.3 数据库特定函数或关键字有时需要动态使用一些数据库特有的函数或 SQL 关键字片段。select idselectWithSpecialFunction resultTypemap SELECT ${functionName}(price) AS calculated_price FROM product WHERE id #{productId} /select !-- 或者 -- select idselectWithCase resultTypemap SELECT name, CASE status foreach collectionstatusMap.entrySet() itemvalue indexkey separator WHEN ${key} THEN #{value} /foreach END AS status_text FROM order /select这些场景下${}用于拼接 SQL 语法元素。其安全守则同上确保${}内的内容来源于可信的、服务端可控的配置或经过严格校验的逻辑而非直接的用户输入。4. 超越非此即彼更优雅的动态 SQL 构建策略认识到${}的风险后我们的目标应该是尽量减少它的使用并寻找更安全、更优雅的替代方案。MyBatis 强大的动态 SQL 功能和其他一些技巧可以帮助我们在很多场景下避免使用${}。4.1 利用choose和when替代动态 ORDER BY对于动态排序与其用${}拼接不如用choose标签枚举所有可能性。虽然代码量稍多但绝对安全。select idselectUsersSafely resultTypeUser SELECT * FROM user WHERE company_id #{companyId} ORDER BY choose when testorderBy createTime and orderType DESCcreate_time DESC/when when testorderBy createTime and orderType ASCcreate_time ASC/when when testorderBy name and orderType DESCname DESC/when when testorderBy name and orderType ASCname ASC/when otherwiseid DESC/otherwise !-- 提供默认排序 -- /choose /select这种方法将所有合法的排序组合穷举出来完全避免了字符串拼接从根源上杜绝了注入。适合排序字段相对固定的场景。4.2 使用 MyBatis 提供的 SQL 构建器对于极度复杂、需要动态构建表名、列名、WHERE 条件的场景可以考虑使用 MyBatis 3.x 提供的SQL构建器在 Java 注解中使用SelectProvider等。它提供了一种类型安全的方式来构建 SQL。public String buildDynamicQuery(MapString, Object params) { String tableName (String) params.get(tableName); // 这里 tableName 仍需校验 String dynamicColumn (String) params.get(dynamicColumn); Integer value (Integer) params.get(value); return new SQL() {{ SELECT(*); FROM(tableName); // 注意FROM 的表名仍然需要谨慎处理来源 WHERE(dynamicColumn #{value}); // 这里拼接列名仍有风险最好也做白名单校验 }}.toString(); } // 在 Mapper 接口中 SelectProvider(type YourSqlBuilder.class, method buildDynamicQuery) ListMap selectDynamic(Map params);使用构建器可以让动态逻辑集中在 Java 代码中利用 Java 的条件语句、循环等比 XML 更灵活。但需要警惕的是拼接表名、列名时风险依然存在必须在 Java 代码层做同样的白名单校验。4.3 预编译与 ${} 结合以 LIKE 查询为例一个常见的误区是在模糊查询LIKE中使用${}来拼接%。!-- 错误示范存在注入风险且笨拙 -- select idselectLike resultTypeUser SELECT * FROM user WHERE name LIKE %${name}% /select !-- 稍微好点但仍有风险 -- select idselectLike resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, ${name}, %) /select正确的做法是将通配符作为参数的一部分在 Java 代码中拼接好然后通过#{}安全传入。!-- 正确做法安全 -- select idselectLike resultTypeUser SELECT * FROM user WHERE name LIKE #{namePattern} /select// 在 Service 层 public ListUser findUsersByName(String keyword) { String namePattern % keyword %; // 注意这里keyword本身仍需做防注入过滤如转义但MyBatis的#{}会处理。 return userMapper.selectLike(namePattern); }或者使用数据库的CONCAT函数但参数仍用#{}select idselectLike resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, #{name}, %) /select这样既实现了功能又保证了安全。5. 实战中的深度避坑与性能考量理解了原理和最佳实践在实际项目中我们还会遇到一些更隐蔽的问题。5.1 “#{}” 与 “${}” 在 IN 查询中的陷阱动态IN查询是一个高频需求。错误的使用会导致语法错误或性能问题。错误做法1在#{}中直接传入列表select idselectByIds SELECT * FROM user WHERE id IN (#{ids}) !-- ids 是一个 ListInteger -- /select这会导致 SQL 变成WHERE id IN (‘1,2,3’)数据库会将其视为一个字符串查询结果错误。错误做法2用${}直接拼接select idselectByIds SELECT * FROM user WHERE id IN (${ids}) !-- ids 是 “1,2,3” -- /select这虽然语法正确WHERE id IN (1,2,3)但存在 SQL 注入风险且破坏了预编译缓存每次IN的列表长度不同SQL 就不同。推荐做法使用 MyBatis 的foreach标签select idselectByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} !-- 对每个id使用#{}安全且能利用缓存如果id个数固定 -- /foreach /selectforeach会生成WHERE id IN (?, ?, ?)这样的预编译语句既安全在列表长度固定时还能享受执行计划缓存。如果长度变化频繁数据库可能仍会视为不同SQL但安全性是首要保证。5.2 分页插件的底层与占位符使用如 PageHelper 这类分页插件时其自动生成的分页 SQLLIMIT x, y内部是使用${}拼接的偏移量参数。这是因为LIMIT子句在标准 SQL 中不支持预编译占位符某些数据库如 MySQL 的某驱动版本可能支持LIMIT ?, ?但并非所有驱动和数据库都支持。这意味着如果你手动写分页 SQL应如下select idselectByPage resultTypeUser SELECT * FROM user ORDER BY id LIMIT #{offset}, #{pageSize} /select在旧版本驱动中这可能报错。此时 PageHelper 的做法是内部使用${pageStart}和${pageSize}。你需要信任分页插件它生成的参数offset, pageSize通常是计算后的数字风险相对较低但理论上仍非绝对安全。对于超高安全要求的系统可以考虑在应用层进行结果集限制或使用基于游标的分页如WHERE id #{lastId} LIMIT #{pageSize}。5.3 类型处理器TypeHandler与占位符的联动#{}的智能之处在于它会调用对应的类型处理器TypeHandler。例如你有一个枚举类StatusEnum数据库中存的是数字(0,1)。你可以注册一个自定义的TypeHandler这样在#{status}时MyBatis 会自动将StatusEnum.ACTIVE转换为1传入数据库查询结果也会自动转换回来。而${}完全绕过这个过程。如果你写${status}MyBatis 会直接调用枚举的toString()方法替换进去的可能是“ACTIVE”导致 SQL 错误。教训当参数是复杂对象、枚举或需要特殊格式如 JSON、加密数据时务必使用#{}以利用类型处理器的能力。5.4 日志排查看到的是什么在调试 MyBatis SQL 时我们常看控制台日志。这里有一个重要的区别日志中打印的带有?和参数列表的 SQL是PreparedStatement的表示形式对应#{}的执行。而如果你在日志中看到了一个完整的、参数值已经嵌入的 SQL那可能是某些日志框架或配置将PreparedStatement的 SQL 和参数拼接起来打印了为了可读性。你的 SQL 中使用了${}它本来就是一条完整的 SQL。不要被日志误导。关键是要在代码层面区分清楚你用的是#{}还是${}。6. 设计层面的思考何时该重构以避免 ${}当你发现代码中${}出现得越来越频繁尤其是参数来源于外部输入时就应该从设计层面反思了。场景反思动态表名/列名是否必要分表分库动态表名常源于水平分表如按时间。可以考虑使用成熟的中间件如 ShardingSphere它们能在逻辑上帮你透明地处理路由业务代码无需关心具体表名自然也就不用${}了。动态列查询为了“灵活”而让前端指定返回字段风险极高。更安全的做法是在服务端定义多个明确的查询方法如selectUserBasic,selectUserWithDetail。使用sql片段和include来组合不同的字段集但字段集本身是预定义的。如果确实需要极度灵活可以考虑将查询复杂度转移到专门的搜索服务如 Elasticsearch或者使用ResultHandler在 Java 代码层对结果集进行后过滤但这会牺牲部分性能。原则将“数据”和“指令”SQL结构清晰地分离开。所有来自系统外部的输入都应被视为“数据”必须通过#{}传递。而“指令”部分表名、列名、关键字应尽量在系统内部通过枚举、配置、白名单映射等方式产生。${}只应作为连接内部安全指令和外部安全数据的“最后一道受控的桥梁”而非便捷的字符串拼接工具。回顾开篇的事故最终的修复方案并不是简单地把${}改成#{}因为排序字段语法不支持而是在 Service 层增加了严格的白名单校验。同时我们重新评估了需求为常用的排序字段提供了固定的几个选项在前端展示从根本上减少了不可控参数的产生。占位符的选择本质上是一种安全意识和设计思维的体现。在 MyBatis 的世界里#{}是你的默认选择是你的安全护栏${}是一把必要但危险的手术刀使用时必须戴上“白名单校验”这副无菌手套。理解它们背后的机制能让你写出更健壮、更高效的数据库访问代码也让你的系统在复杂的网络环境中多一份保障。