前言

在 Java 开发中,SQL 注入是一个永远不能忽视的安全问题。每一个有经验的开发者都知道,用户输入的数据永远不能被信任——它可能包含恶意代码,试图绕过你的安全检查,直接操作你的数据库。

MyBatis 作为一款优秀的 ORM 框架,在设计之初就充分考虑了 SQL 注入的防护。默认情况下,MyBatis 会使用预编译的方式处理参数,并设置 PreparedStatement 参数,同时进行必要的安全检查和字符转义。

但在实际开发中,我们经常会在 Mapper 文件中看到两种变量引用方式:#{}${}。它们看起来很像,但背后的运行机制却截然不同。理解它们的区别,是每一个 MyBatis 使用者的必修课。

MyBatis参数绑定机制

PreparedStatement 预编译(#{} 方式)

1. Mapper 配置

在 MyBatis 中,默认使用预编译方式。以下是一个典型的 Mapper 配置:

1
2
3
<select id="getList" resultType="Map">
select * from A where age = #{age} ORDER BY #{age}
</select>

注意:这里 ORDER BY #{age} 的写法是有问题的。预编译参数不能用于 SQL 的结构部分(如 ORDER BY、GROUP BY、表名、列名等),只能用于值的部分。下面会详细解释。

2. SQL 执行过程

1
select * from A where age = #{age}

当参数 age 传入值为 5 时:

  1. MyBatis 首先将 SQL 解析为预编译语句:select * from A where age = ?
  2. 然后通过 PreparedStatementsetXxx() 方法将值 5 绑定到占位符 ?
  3. 最后在 SQL 的执行中进行值的替换

3. 为什么预编译能防止 SQL 注入?

预编译的核心原理是:SQL 语句的结构和执行计划的编译,与参数的绑定是两个完全独立的阶段。

1
2
3
4
5
阶段一:SQL 编译
select * from A where age = ? -- 数据库编译执行计划,? 只是一个占位符

阶段二:参数绑定
将值 5 绑定到 ? 的位置 -- 数据库只把 5 当作一个值,不会重新解析 SQL 结构

即使用户传入的值为 "5; DROP TABLE A; --",数据库也会把它当作一个完整的字符串值来处理,而不是将其中的 DROP TABLE 识别为 SQL 命令。这就是预编译防止 SQL 注入的根本原因。

4. 预编译的优势

  • 安全性:从根本上杜绝了 SQL 注入攻击。
  • 性能:预编译语句的执行计划可以被数据库缓存,重复执行时可以直接复用,减少编译开销。
  • 类型安全PreparedStatement 会根据参数的 Java 类型自动选择合适的 setXxx() 方法,避免类型转换错误。

Statement 直接拼接(${} 方式)

1. Mapper 配置

如果需要在 Mapper 中使用 ${} 方式,通常需要配合 statementType="STATEMENT" 使用:

1
2
3
<select id="getList" resultType="Map" statementType="STATEMENT">
select * from A where age = ${age} ORDER BY ${age}
</select>

2. SQL 执行过程

1
select * from B where age = ${age}

当参数 age 传入值为 5 时:

  • MyBatis 直接将值拼接到 SQL 字符串中
  • 解析后执行的 SQL:select * from B where age = 5

3. ${} 的危险性

${} 方式的本质是字符串拼接,它不会进行任何参数化处理。这意味着:

  • 如果用户传入 "5; DROP TABLE A; --",最终执行的 SQL 就会变成:
    1
    select * from B where age = 5; DROP TABLE A; --
  • 你的数据库可能瞬间被清空。

SQL注入攻击示例

4. ${} 的合理使用场景

虽然 ${} 存在安全风险,但它并非一无是处。以下场景中,${} 是合理甚至必要的选择:

场景一:动态排序字段

1
2
3
<select id="getList" resultType="Map">
select * from users ORDER BY ${orderByColumn} ${sortDirection}
</select>

这里的 orderByColumnsortDirection 是 SQL 的结构部分,不能用 #{} 替代。但必须在代码层面对这两个参数进行严格的白名单校验。

场景二:动态表名

1
2
3
<select id="getData" resultType="Map">
select * from ${tableName} where id = #{id}
</select>

场景三:LIKE 模糊查询(部分场景)

1
2
3
<select id="searchUsers" resultType="Map">
select * from users where name LIKE '%${keyword}%'
</select>

注意:LIKE 查询其实有更好的写法:LIKE CONCAT('%', #{keyword}, '%'),推荐使用这种预编译的方式。

#{} 与 ${} 的详细对比

对比维度 #{} ${}
处理方式 PreparedStatement 预编译 Statement 直接拼接
SQL 注入防护 是(参数与 SQL 分离) 否(直接拼接)
执行性能 更好(执行计划可缓存) 较差(每次重新编译)
适用场景 参数值(WHERE 条件中的值) SQL 结构部分(表名、列名、ORDER BY)
字符串处理 自动加引号 原样拼接
类型安全 是(自动类型转换)

一个生动的例子

假设我们有一个查询用户的方法:

1
2
3
4
<!-- 使用 #{} - 安全 -->
<select id="getUser" resultType="User">
select * from users where name = #{name}
</select>

name 传入 "zhangsan" 时,执行的 SQL 是:

1
select * from users where name = 'zhangsan'
1
2
3
4
<!-- 使用 ${} - 危险 -->
<select id="getUser" resultType="User">
select * from users where name = ${name}
</select>

name 传入 "zhangsan" 时,执行的 SQL 是:

1
select * from users where name = zhangsan

注意这里没有引号!如果 zhangsan 不是一个合法的列名或变量名,SQL 就会报错。如果用户恶意传入 "1=1 OR 1=1",执行的 SQL 就变成了:

1
select * from users where name = 1=1 OR 1=1

这会返回所有用户的数据——这就是经典的 SQL 注入攻击。

最佳实践与安全建议

经过大量的实战经验,我总结了以下最佳实践:

1. 默认使用 #{}

能使用 #{} 的情况下就坚决不要使用 ${} 这是铁律,没有例外。

2. 必须使用 ${} 时做好白名单校验

如果确实需要使用 ${}(比如动态排序),必须在代码层面做好严格的参数校验:

1
2
3
4
5
6
7
8
9
10
public List<User> getUsers(String orderBy, String direction) {
// 白名单校验
if (!Arrays.asList("name", "age", "create_time").contains(orderBy)) {
throw new IllegalArgumentException("Invalid order by column: " + orderBy);
}
if (!Arrays.asList("ASC", "DESC").contains(direction)) {
throw new IllegalArgumentException("Invalid sort direction: " + direction);
}
return userMapper.getList(orderBy, direction);
}

3. 使用 MyBatis 的 标签

对于需要动态拼接 SQL 的场景,优先使用 MyBatis 的动态 SQL 标签,而不是 ${}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<select id="searchUsers" resultType="User">
select * from users
<where>
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
<if test="age != null">
and age = #{age}
</if>
</where>
<choose>
<when test="orderBy == 'name'">
order by name
</when>
<when test="orderBy == 'age'">
order by age
</when>
<otherwise>
order by id
</otherwise>
</choose>
</select>

4. 开启 SQL 日志进行审计

在开发和测试环境中,开启 MyBatis 的 SQL 日志,方便排查问题和进行安全审计:

1
<setting name="logImpl" value="STDOUT_LOGGING"/>

总结

回到最初的那句话:SQL 存在注入的风险,MyBatis 默认使用预编译的方式并进行了必要的安全检查和字符转义。

  1. ${} 方式会引发 SQL 注入的问题,同时会影响 SQL 语句的预编译,所以从安全性和性能的角度出发,能使用 #{} 的情况下就不要使用 ${}
  2. 可能需要直接插入一个不做任何修改的字符串到 SQL 语句中时(如动态表名、动态排序),使用 ${} 语法更加灵活方便,但是不能有外部 SQL 的进入,防止 SQL 注入。

一句话记住核心区别#{} 是”值替换”,${} 是”字符串拼接”。前者安全,后者灵活但有风险。在安全面前,灵活性永远要让步。

MyBatis安全最佳实践