在MyBatis中,${}和#{}是两种完全不同的参数占位符,它们的本质区别在于:#{}使用预编译(PreparedStatement)方式,参数会被当作字符串安全处理,自动防SQL注入;而${}是直接字符串拼接,把参数原样塞进SQL语句中,存在严重的SQL注入风险。简单一句话总结——能用#{}的地方绝不用${},只有在动态表名、动态列名、ORDER BY排序字段等必须拼接SQL结构的场景下,才谨慎使用${},并且必须做好参数校验。
很多Java开发者在写MyBatis映射文件时,对这两个符号的使用凭感觉,甚至混着用。这是非常危险的。今天这篇文章,我会把${}和#{}的使用场景、底层原理、安全风险、最佳实践全部讲透,让你以后写SQL映射时心里有数。
一、#{}的底层原理:为什么它能防SQL注入#{}在MyBatis中会被解析为JDBC的PreparedStatement参数占位符(即问号?)。当SQL发送到数据库时,参数和SQL语句是分开传输的,数据库驱动会对参数进行转义和类型检查,从根本上杜绝了SQL注入的可能。
举个例子,假设你写了这样一段映射:
<select id="getUserById" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
当传入id参数为"1 OR 1=1"时,MyBatis生成的实际SQL是:
SELECT * FROM user WHERE id = ?
参数"1 OR 1=1"会被当作一个完整的字符串值传入,数据库只会去查找id等于"1 OR 1=1"这个字符串的记录,而不会把它当作SQL逻辑来执行。这就是#{}的安全机制。
二、${}的底层原理:为什么它有注入风险${}是纯粹的字符串替换。MyBatis在解析映射文件时,会直接把${}中的变量值拼接到SQL字符串中,然后再把整条SQL发送给数据库执行。这意味着如果参数中包含恶意SQL片段,它会被直接执行。
同样的例子,如果你错误地写成:
<select id="getUserById" resultType="User">
SELECT * FROM user WHERE id = ${id}
</select>
当传入id为"1 OR 1=1"时,最终执行的SQL变成:
SELECT * FROM user WHERE id = 1 OR 1=1
这条SQL会返回user表中的所有记录,SQL注入攻击成功。如果攻击者传入更复杂的语句,比如"1; DROP TABLE user;--",后果不堪设想。
三、${}的合法使用场景:哪些情况必须用它虽然${}有风险,但它并非完全不能用。MyBatis的#{}只能替代值(value),不能替代SQL的结构部分。以下场景必须使用${}:
1. 动态表名
当你需要根据业务逻辑切换查询的表时,表名是SQL结构的一部分,不能用#{}。例如:
<select id="getData" resultType="Map">
SELECT * FROM ${tableName} WHERE status = #{status}
</select>
这里tableName必须用${},但status用#{}。注意:tableName的值必须来自可信来源或经过严格白名单校验,绝不能直接接受前端传入的参数。
2. 动态列名
在动态查询条件中,如果排序字段或查询字段是动态的,也需要用${}:
<select id="getList" resultType="User">
SELECT * FROM user ORDER BY ${orderColumn} ${orderDir}
</select>
orderColumn和orderDir都是SQL结构,只能用${}。但这里有一个关键点:你必须在Java代码层面对这些参数做白名单过滤,比如只允许传入"create_time"、"id"等预定义字段名。
3. 动态SQL片段拼接
在使用<if>、<choose>等标签构建复杂SQL时,某些情况下需要拼接SQL关键字或表名:
<select id="search" resultType="User">
SELECT * FROM user
<if test="condition != null">
WHERE ${condition} = #{value}
</if>
</select>
这种写法同样要求condition参数经过严格校验。
四、${}使用时的安全防护策略既然${}在某些场景下不得不用,那就必须建立一套安全防护机制。以下是我总结的几条铁律:
1. 白名单校验是底线
所有传入${}的参数,必须在Java业务层做白名单匹配。比如动态表名,你可以定义一个枚举或常量列表:
private static final Set<String> ALLOWED_TABLES = Set.of("user", "order", "product");
public void validateTableName(String tableName) {
if (!ALLOWED_TABLES.contains(tableName.toLowerCase())) {
throw new IllegalArgumentException("非法表名: " + tableName);
}
}
2. 绝不直接接受前端参数
前端传过来的任何字符串,都不能直接塞进${}。哪怕你觉得"应该没问题",也要做校验。攻击者的手段远比你想象的多。
3. 使用MyBatis的${}时配合@Param注解明确参数来源
在Mapper接口中,使用@Param注解明确每个参数的含义,避免参数混乱导致误用:
List<User> getList(@Param("orderColumn") String orderColumn,
@Param("orderDir") String orderDir,
@Param("status") Integer status);
4. 开启MyBatis的日志监控
在开发和测试环境中,开启MyBatis的SQL日志输出,检查最终生成的SQL是否符合预期。一旦发现异常拼接,立即排查。
五、实际开发中的常见错误和纠正方案我在项目代码审查中,见过太多以下类型的错误:
错误一:LIKE查询中误用${}
有些开发者写LIKE查询时这样写:
<select id="searchUser" resultType="User">
SELECT * FROM user WHERE name LIKE '%${keyword}%'
</select>
这是典型的错误。正确写法应该是:
<select id="searchUser" resultType="User">
SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>
或者在Java层拼接好通配符再传入:
String keyword = "%" + userInput + "%"; mapper.searchUser(keyword);
映射文件中使用#{keyword}即可。
错误二:IN查询中用${}拼接
有些人写IN查询时这样做:
<select id="getByIds" resultType="User">
SELECT * FROM user WHERE id IN (${ids})
</select>
正确做法是用<foreach>标签配合#{}:
<select id="getByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
错误三:ORDER BY中直接用前端参数
前端传一个sort参数直接拼进SQL:
ORDER BY ${sort}
正确做法是在Service层做映射转换:
private String resolveOrderColumn(String sort) {
switch (sort) {
case "time": return "create_time";
case "name": return "user_name";
default: return "id";
}
}
然后映射文件中使用#{orderColumn}或者经过校验后的${orderColumn}。
六、从架构层面杜绝SQL注入的建议除了在MyBatis层面注意${}和#{}的区分,还应该从整体架构上做防护:
1. 参数校验前置:在Controller层就对所有入参做格式校验、长度限制、类型检查,不合规的参数直接拦截。
2. 最小权限原则:数据库连接使用的账号只授予必要的权限,即使发生注入,也能限制破坏范围。
3. 使用ORM框架的安全特性:MyBatis-Plus等增强框架在底层已经对很多场景做了安全处理,合理使用可以减少手写SQL的风险。
4. 定期安全扫描:使用静态代码分析工具(如SonarQube)扫描项目,自动检测SQL注入风险点。
5. 代码Review制度:任何涉及${}的代码变更,必须经过至少一位资深开发的Review,确认参数来源和校验逻辑。
七、总结:一张表搞清楚$和#的区别最后用一张对比表帮你彻底记住:
#{}:预编译参数,安全防注入,用于值的替换(WHERE条件、INSERT值等),是默认首选。
${}:字符串直接拼接,有注入风险,仅用于SQL结构部分(表名、列名、ORDER BY等),使用时必须做白名单校验。
记住一个原则:值用#,结构用$,$必须校验,#可以放心。把这句话刻在脑子里,你写MyBatis时就不会再犯低级的SQL注入错误了。安全不是事后补救,而是在每一行代码中养成习惯。
