一句话区别
MyBatis 里 #{} 和 ${} 长得很像,行为完全不同:
#{}会被编译成?,参数通过PreparedStatement传入,值永远只是值;${}在 SQL 拼接阶段就把字符串原样塞进去,参数会变成 SQL 语法的一部分。
前者能防注入,后者是 SQL 注入的入口。这条结论要刻在脑子里:能用 #{} 的地方一律用 #{}。
#{} 能防注入的根本原因,是它把 SQL 的结构和参数分开了:SQL 先被数据库解析、生成执行计划,参数随后作为值传入,无论参数里有什么字符,都不会被当成语法。
${} 则是在 Java 侧先做字符串替换,替换后的整段文本才交给数据库解析。参数里只要带一个单引号,SQL 的结构就可能被改写。它不是“可能不安全”,而是“只要参数可控就一定不安全”。
还有一个容易忽略的差异:#{} 能自动处理类型转换和引号,字符串、日期、数字都能正确绑定;${} 全靠自己拼引号,日期格式、null 值、特殊字符都要手动处理,出错概率高得多。
所以判断标准很简单:这个位置需不需要 SQL 标识符(表名、列名、排序方向)?不需要就用 #{};需要就先过白名单,再用 ${}。
一个注入的反例
下面这段只用于说明原理,不要写进项目:
// ❌ 反例:字符串直接拼进 SQL
@Select("select id, name from user where name = '${name}'")
User selectByName(@Param("name") String name);
正常传 张三,SQL 是 where name = '张三',看起来没问题。但只要传 x' or '1'='1,拼出来的 SQL 就变成:
select id, name from user where name = 'x' or '1'='1'
条件永远成立,整张表的数据都会被查出来。如果这是登录接口,攻击者甚至能绕过密码校验。换回 #{},参数会被当成一个普通字符串,单引号也会被驱动转义,注入无从谈起。
排序为什么必须用 ${}
order by 后面跟的是列名,不是值。写成 order by #{sortBy},编译后是 order by ?,MySQL 会把它当成一个常量字符串,排序直接失效,有的版本还会报语法错误。
这就是 ${} 唯一合理的用武之地:拼表名、列名、排序方向这类SQL 结构。但前提是,拼进去的值必须来自白名单,不能来自用户输入:
private static final List<String> SORT_FIELDS = Arrays.asList("id", "name", "created_at");
public List<User> list(String sortBy) {
if (!SORT_FIELDS.contains(sortBy)) {
throw new IllegalArgumentException("不支持的排序字段: " + sortBy);
}
return userMapper.selectAllOrderBy(sortBy);
}
<select id="selectAllOrderBy" resultType="com.example.demo.entity.User">
select id, name, created_at
from user
order by ${sortBy}
</select>
排序方向也一样处理,只允许 asc 或 desc,或者用枚举映射成固定的 SQL 片段。只要有一个入口能让前端把任意字符串传到 ${},前面所有的 #{} 都白用了。
like 和 in 的正确写法
模糊查询不要写 like '%${name}%',用 concat 配合 #{}:
<select id="selectByName" resultType="com.example.demo.entity.User">
select id, name, created_at
from user
where name like concat('%', #{name}, '%')
</select>
in 查询用 <foreach>,不要用 ${} 把列表拼成字符串:
<select id="selectByIds" resultType="com.example.demo.entity.User">
select id, name, created_at
from user
where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
这样每个 ID 都是一个独立的 ? 占位符,参数个数由集合长度决定,既安全又不会因为某个 ID 带引号导致语法错误。
常见坑
1. 以为 #{} 哪儿都能用
表名、列名、order by、group by 这些位置不能用 #{},只能拼字符串。遇到这类需求,先想有没有别的方案(比如固定几种查询),实在要拼就加白名单。
2. 用 ${} 拼 like
like '%${keyword}%' 既可能注入,又会让 % 参与 SQL 解析。统一改成 concat。
3. 用 ${} 拼 in 列表
in (${ids}) 是另一个高频注入点,尤其当前端传的是字符串拼接后的 ID 列表时。改成 <foreach>,顺便还能避免空集合导致的语法错误。
4. 以为日志里能看到真实参数
#{} 在 SQL 日志里显示成 ?,看不到具体值。有人为了“方便排查”就把 SQL 改成 ${},这是拿安全换便利。需要看参数时调高日志级别、用 MyBatis 的日志实现或专门的 SQL 打印组件。
5. 动态 SQL 里混用两者
<if test="name != null">and name = '${name}'</if> 这种写法在代码评审里应该直接打回。动态条件只控制 SQL 片段是否存在,参数绑定仍然用 #{}。
排查步骤
- 全局搜索
${,逐个确认拼接的值是不是来自用户输入; - 来自前端、Header、URL 的一律改成
#{},确实要拼结构的加白名单; - 打开 SQL 日志,看实际 SQL 里有没有出现单引号包裹的变量;
- 对排序、表名参数做一次手工测试,传
id desc、1=1等异常值,确认被拦截; - 代码评审把“是否使用
${}”列为固定检查项,比事后扫描有效得多。
评论