我刚开始学 MyBatis 的时候,在 XML 里写 SQL,发现有两种传参的写法:#{xxx} 和 ${xxx}。当时心想,不就是两种写法嘛,哪个顺手用哪个。直到有一次代码审查,mentor 指着我写的 ${userInput} 说"你这个有 SQL 注入风险",我才意识到这俩根本不是一回事。
先从一段代码说起
假设你在写一个登录查询,要根据用户名查用户信息。新手可能会写出这样的 XML:
xml
<select id="getUserByName" resultType="User">
SELECT * FROM t_user WHERE username = '${username}'
</select>
看起来没毛病对吧?传入 zhangsan,拼出来的 SQL 就是:
sql
SELECT * FROM t_user WHERE username = 'zhangsan'
很正常。但如果有人传的不是 zhangsan,而是这样一串东西呢:
' OR '1'='1
那拼出来的 SQL 就变成了:
sql
SELECT * FROM t_user WHERE username = '' OR '1'='1'
'1'='1' 永远成立,这个 WHERE 条件形同虚设,整张表的用户数据全被查出来了。这就是 SQL 注入,一种老掉牙但至今管用的攻击方式。
换成 #{username},就不会有这个问题。为什么?下面细说。
#{} 和 ${} 到底有什么区别
这俩的区别,一句话就能说清楚:
#{}:MyBatis 会先把你的 SQL 里的#{}换成?,然后把参数值单独交给 JDBC 去设置。参数是参数,SQL 是 SQL,它俩不搅在一起。${}:MyBatis 直接把你的参数值拼进 SQL 字符串里,拼完了才交给数据库。参数变成了 SQL 的一部分。
用上面的例子来看。如果你写的是:
xml
SELECT * FROM t_user WHERE username = #{username}
MyBatis 的处理过程是:
- 把
#{username}替换成?,生成SELECT * FROM t_user WHERE username = ? - 这条带问号的 SQL 先发给数据库做预编译,数据库把它解析好、执行计划也缓存好
- 真正执行的时候,再调用
PreparedStatement.setString(1, "zhangsan")把参数值填进去
就算这时候有人传了 ' OR '1'='1,它也只是作为"用户名"这个数据的值传进去,数据库会把它当成一个普通的字符串来处理。最终查的是"用户名等于 ' OR '1'='1 的这个人",而不是把注入代码当成 SQL 来执行。
如果你写的是 ${username},那 MyBatis 的处理过程就是:
- 直接拿
' OR '1'='1替换掉${username} - 拼出来的整条 SQL 发给数据库执行
- 数据库看到的就是被篡改过的 SQL,该查的查了,不该查的也查了
换个角度理解:装修工 vs 印刷工
打个不太严谨但好懂的比方。
用 #{} 就像你去印刷厂印请柬。请柬的模板是固定的:"诚邀 ___ 先生/女士光临",空白处留好。你来填名字的时候,工作人员用钢笔在空白处写上"张三"。你名字再奇怪------比如叫"张三 OR 全体",它也只是写在空白处的一个名字,不会跑到请柬正文里去。
用 ${} 就像是你在 Word 里写请柬,然后直接把客人的名字敲进正文里。这时候如果客人名字里带了格式代码或者宏命令,整个文档就可能被搞乱。
这个比方不精确,但大概就是那么个意思:#{} 把数据和指令分开了,${} 没分开。
底层到底发生了什么
如果你对 JDBC 有点了解,这件事其实更清楚。
#{} 走的是 PreparedStatement 这条路。JDBC 里大概是这样的:
java
String sql = "SELECT * FROM t_user WHERE username = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, username); // 参数在这里设置
ResultSet rs = ps.executeQuery();
关键就在 setString。这个方法会把参数值做转义处理,比如参数里的单引号会被转成 \',这样它就不可能逃逸出来改变 SQL 的语义了。
${} 走的是 Statement 这条路。JDBC 里大概是这样的:
java
String sql = "SELECT * FROM t_user WHERE username = '" + username + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
注意这里 SQL 字符串是拼接出来的,username 是什么它就是什么,没有任何转义。如果 username 包含 SQL 关键字,数据库就会把它当指令执行。
从性能上说,#{} 也更优。因为预编译的 SQL 带问号,结构是固定的,数据库可以把它的解析结果和执行计划缓存起来,下次同样的 SQL 模板直接复用。${} 每次拼出来的 SQL 字符串都可能不一样,数据库每次都要重新解析。
什么时候该用 ${}
读到这里你可能会想:那 ${} 这么危险,干脆永远别用算了。
实际上 ${} 有它不可替代的场景。因为 ? 占位符只能出现在"值"的位置,不能出现在 SQL 语法结构的位置。比如表名、列名、排序方向这些,? 是放不上去的。
举个例子,你要做一个动态排序的功能,用户可以选择按"创建时间"或"点击量"排序:
xml
<select id="listByOrder" resultType="Content">
SELECT * FROM t_content
ORDER BY ${sortField} ${sortDirection}
</select>
这里不能用 #{}。如果写成 ORDER BY #{sortField} #{sortDirection},MyBatis 会把它变成 ORDER BY ? ?,然后传两个字符串进去。数据库不认识这种写法,会直接报错------因为 ORDER BY 后面跟的是列名,不是字符串值。
类似的场景还有:
- 动态表名:
SELECT * FROM ${tableName} - 动态列名:
SELECT ${columnName} FROM t_user LIKE拼接(虽然能用CONCAT('%', #{keyword}, '%')规避,但有些写法还是会用到${})
但这里有一个铁律:用 ${} 的时候,参数绝对不能直接来自前端用户输入。 必须在代码层做白名单校验。比如排序字段,你可以这样处理:
java
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("created_time", "click_count", "update_time");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public List<Content> listByOrder(String sortField, String sortDirection) {
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
throw new IllegalArgumentException("非法的排序字段: " + sortField);
}
if (sortDirection == null || !ALLOWED_DIRECTIONS.contains(sortDirection.toUpperCase())) {
throw new IllegalArgumentException("非法的排序方向: " + sortDirection);
}
return mapper.listByOrder(sortField, sortDirection.toUpperCase());
}
就是说,前端传什么进来,先在 Service 层过一遍白名单,只放行你预期的值。不在白名单里的,直接抛异常。这个时候 ${} 接到的参数实际上是你代码里硬编码的安全值,而不是用户的原始输入。
我当时踩的坑
讲一个我自己真实犯过的错。
项目里有一个动态查询的接口,前端传一个 type 参数过来,后端拼到 SQL 的 WHERE 条件里。我图省事,XML 里直接写了 ${type}。
代码跑了两个月,一点问题没有。因为前端传的都是 "A" 或者 "B" 这种预期的值,加上我们这个系统只有内部在用,我就没太在意。
后来有一天,一个同事在浏览器地址栏里手贱改了参数,把 type 的值改成了 ' OR 1=1 -- 。调用接口,数据库里所有的记录全返回了。虽然没造成什么实际损失,但这事被 leader 在会上拿来当反面案例讲了一遍。
事后我复盘,这个问题的根源不是"有人乱传参数",而是我把用户输入直接拼进了 SQL 。如果当时用 #{type},就算参数再奇怪,也只是查"type 字段等于 ' OR 1=1 -- 的记录",查不到东西而已,不会把整张表翻出来。
从那以后我的原则就很明确了:只要是传递数据值,就用 #{}。只有在你百分之百确定这个地方不能放 ? 占位符,而且参数已经过严格校验的时候,才用 ${}。
一张表总结
| 对比维度 | #{} |
${} |
|---|---|---|
| 处理方式 | 替换为 ? 占位符,再通过 PreparedStatement 赋值 |
直接字符串拼接进 SQL |
| SQL 注入风险 | 无,参数被安全转义 | 有,参数直接参与 SQL 构建 |
| 预编译缓存 | 支持,同模板 SQL 可复用执行计划 | 不支持,每次拼接都是新 SQL |
| 适用场景 | WHERE 条件值、INSERT/UPDATE 字段值,即所有"数据值"的位置 | 表名、列名、排序字段等 SQL 语法结构的位置 |
| 使用前提 | 无特殊前提,默认首选 | 参数必须经过白名单校验,绝不能直接接收前端输入 |
如果你刚学 MyBatis,记住一件事就够了:写 SQL 的时候,先写 #{}。如果报错了,想想是不是写在了"不能放问号"的位置(比如表名、列名),是的话再考虑换成 ${},同时别忘了做白名单校验。
就这样。