MyBatis 中 #{} 和 ${} 的区别详解
前言
在 MyBatis 框架中,#{} 和 ${} 是两种常见的参数占位符,它们虽然看起来相似,但在底层实现、安全性以及适用场景上有着本质的区别。正确理解二者的差异,对于写出安全、高效、可维护的 MyBatis SQL 语句至关重要。
本文将深入剖析 #{} 和 ${} 的工作原理,并通过大量代码示例帮助你在实际开发中做出正确的选择。
一、核心区别一览
| 对比维度 | #{}(井号占位符) |
${}(美元占位符) |
|---|---|---|
| 底层实现 | JDBC PreparedStatement 参数占位符 ? |
直接字符串拼接替换 |
| 预编译 | 支持预编译,数据库缓存执行计划 | 不参与预编译 |
| SQL 注入 | 安全,自动过滤特殊字符 | 危险,存在注入风险 |
| 适用场景 | 绝大多数传参场景 | 动态表名、列名、ORDER BY 等 |
| 性能 | 高(预编译复用) | 低(每次重新编译) |
| 数据类型 | 自动处理类型映射 | 原样拼接,需手动处理 |
二、#{} — 预编译参数占位符
2.1 工作原理
#{} 会被 MyBatis 解析为 JDBC 的 PreparedStatement 参数占位符 ?,并由数据库驱动完成参数设置和类型转换。
示例:
<select id="selectUserById" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
MyBatis 生成的 JDBC 代码等价于:
String sql = "SELECT * FROM user WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setInt(1, 100); // 自动设置参数并处理类型
ResultSet rs = ps.executeQuery();
2.2 特性分析
✅ 自动类型处理
MyBatis 会根据 Java 类型自动调用合适的 setXXX() 方法。例如,传入 Integer 调用 setInt(),传入 String 调用 setString()。
<select id="findByName" resultType="User">
SELECT * FROM user WHERE name = #{name}
</select>
传入 String 类型时,自动添加单引号包围,生成的 SQL 为:
SELECT * FROM user WHERE name = '张三'
✅ SQL 注入防护
由于参数值是通过 setXXX() 方法安全地设置到预编译语句中,而非直接拼接到 SQL 字符串中,因此可以彻底杜绝 SQL 注入攻击。
攻击者输入 ' OR 1=1 -- 时:
<!-- 安全!不会造成注入 -->
<select id="login" resultType="User">
SELECT * FROM user WHERE username = #{username} AND password = #{password}
</select>
最终的 SQL 为:
SELECT * FROM user WHERE username = ? AND password = ?
参数值 ' OR 1=1 -- 会被当作一个普通的字符串匹配值,而不会被解析为 SQL 逻辑。
✅ 支持预编译优化
相同 SQL 模板只需编译一次,后续执行直接复用执行计划,大幅提升数据库性能。
2.3 复杂用法
实体类属性引用
<insert id="insertUser">
INSERT INTO user (name, age, email)
VALUES (#{name}, #{age}, #{email})
</insert>
对象嵌套属性
<select id="selectByDept" resultType="User">
SELECT * FROM user WHERE department.id = #{dept.id}
</select>
自定义类型处理器
<insert id="addUser">
INSERT INTO user (create_time)
VALUES (#{createTime, jdbcType=TIMESTAMP})
</insert>
三、${} — 字符串替换占位符
3.1 工作原理
${} 会被 MyBatis 直接替换为参数的字符串值,本质是纯字符串拼接,不经过预编译。
示例:
<select id="selectUserByTable" resultType="User">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
当传入 tableName = "user" 时,生成的 SQL 为:
SELECT * FROM user WHERE id = ?
3.2 特性分析
⚠️ 无类型处理
${} 直接进行字符串替换,不会添加引号,也不会进行类型转换。
<select id="findByDynamicColumn" resultType="User">
SELECT * FROM user ORDER BY ${column} DESC
</select>
传入 column = "age",生成:
SELECT * FROM user ORDER BY age DESC
如果传入 column = "name",生成:
SELECT * FROM user ORDER BY name DESC
⚠️ 无预编译,性能略低
每次传入不同的参数值,都会生成全新的 SQL 语句,数据库无法复用执行计划。
🔴 SQL 注入高危风险
由于是直接拼接,攻击者可以构造恶意输入篡改 SQL 语义。
危险示例:
<!-- 🔴 极度危险!永远不要这样使用! -->
<select id="dangerousLogin" resultType="User">
SELECT * FROM user WHERE username = '${username}' AND password = '${password}'
</select>
攻击者输入 username = "' OR '1'='1" 和 password = "' OR '1'='1",则生成:
SELECT * FROM user WHERE username = '' OR '1'='1' AND password = '' OR '1'='1'
所有用户数据全部泄露!
四、何时使用 #{} vs ${}
4.1 始终优先使用 #{}
适合使用 #{} 的场景:
| 场景 | 示例 |
|---|---|
| WHERE 条件值 | WHERE id = #{id} |
| INSERT 值 | VALUES (#{name}, #{age}) |
| UPDATE 设置值 | SET name = #{name} |
| LIKE 模糊查询(配合 CONCAT) | LIKE CONCAT('%', #{keyword}, '%') |
| IN 列表(配合 MyBatis 动态 SQL) | IN <foreach collection="list" item="item" separator=",">#{item}</foreach> |
4.2 谨慎使用 ${}
仅在以下少数场景中必须使用 ${}:
| 场景 | 示例 | 注意事项 |
|---|---|---|
| 动态表名 | SELECT * FROM ${tableName} |
严格校验表名白名单 |
| 动态列名 | ORDER BY ${sortColumn} |
只接受预期列名 |
| 动态数据库名 | USE ${database} |
限制可选值范围 |
| SQL 函数/关键字 | SELECT ${function}(column) |
严格控制输入来源 |
4.3 关键原则
能不用
${}就不用${};能用#{}的地方绝不用${}。
如果必须在 ${} 的场景下传参,请务必:
- 对输入做严格的白名单校验
- 禁止直接接受用户原始输入
- 使用枚举或常量限制可选值范围
五、综合实战示例
5.1 安全的用户查询
<mapper namespace="com.example.mapper.UserMapper">
<!-- ✅ 安全:使用 #{} -->
<select id="findById" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
<!-- ✅ 安全:模糊查询用 CONCAT + #{} -->
<select id="searchByName" resultType="User">
SELECT * FROM user
WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>
<!-- ✅ 安全:IN 查询配合 foreach -->
<select id="findByIds" resultType="User">
SELECT * FROM user
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<!-- ❌ 危险:直接拼接 LIKE -->
<!-- <select id="wrongSearch" resultType="User">
SELECT * FROM user WHERE name LIKE '%${keyword}%'
</select> -->
</mapper>
5.2 动态排序(必须用 ${},但需校验)
<mapper namespace="com.example.mapper.UserMapper">
<select id="getSortedUsers" resultType="User">
SELECT * FROM user
ORDER BY ${sortColumn} ${sortOrder}
</select>
</mapper>
Java 调用端进行白名单校验:
public List<User> getSortedUsers(String sortColumn, String sortOrder) {
// 白名单校验
List<String> allowedColumns = Arrays.asList("id", "name", "age", "create_time");
List<String> allowedOrders = Arrays.asList("ASC", "DESC");
if (!allowedColumns.contains(sortColumn)) {
throw new IllegalArgumentException("非法的排序列: " + sortColumn);
}
if (!allowedOrders.contains(sortOrder.toUpperCase())) {
throw new IllegalArgumentException("非法的排序方式: " + sortOrder);
}
return userMapper.getSortedUsers(sortColumn, sortOrder);
}
5.3 动态表名(必须用 ${},严格校验)
<mapper namespace="com.example.mapper.LogMapper">
<!-- 按月份分表查询 -->
<select id="selectByMonth" resultType="Log">
SELECT * FROM ${tableName}
WHERE create_date BETWEEN #{startDate} AND #{endDate}
</select>
</mapper>
安全处理:
public List<Log> selectByMonth(String month) {
// 只允许符合 log_2024_01 格式的表名
String tableName = "log_" + month;
if (!tableName.matches("^log_\\d{4}_\\d{2}$")) {
throw new IllegalArgumentException("非法的表名格式");
}
return logMapper.selectByMonth(tableName, "2024-01-01", "2024-01-31");
}
5.4 完整的 Mapper 配置对比
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.BookMapper">
<!-- ========== #{} 安全用法 ========== -->
<select id="findById" resultType="Book">
SELECT * FROM book WHERE id = #{id}
</select>
<select id="findByPriceRange" resultType="Book">
SELECT * FROM book
WHERE price BETWEEN #{min} AND #{max}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO book (title, author, price)
VALUES (#{title}, #{author}, #{price})
</insert>
<update id="updatePrice">
UPDATE book SET price = #{price}
WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM book WHERE id = #{id}
</delete>
<!-- ========== 必须用 ${} 的场景 ========== -->
<select id="selectByDynamicField" resultType="Book">
SELECT * FROM book
WHERE ${fieldName} = #{value}
</select>
<select id="selectWithDynamicSort" resultType="Book">
SELECT * FROM book
WHERE price > #{price}
ORDER BY ${sortColumn} ${sortDirection}
</select>
</mapper>
六、常见误区与陷阱
误区 1:${} 性能更好
事实: 正相反。#{} 支持预编译,相同 SQL 模板只编译一次,性能更高。${} 每次拼接新 SQL 都需要重新编译、生成执行计划。
误区 2:表名用 #{} 也可以
事实: #{} 会自动添加引号,导致 SQL 语法错误。
<!-- ❌ 错误!表名不能带引号 -->
<select id="badExample">
SELECT * FROM #{tableName}
</select>
生成:
-- 语法错误!
SELECT * FROM 'user'
误区 3:用户输入经过编码就可以用 ${}
事实: 编码和转义不能完全防范 SQL 注入。白名单校验才是正确做法。永远不要对用户输入直接使用 ${}。
误区 4:${} 在 MyBatis 3.5+ 已废弃
事实: ${} 并未废弃,在动态 SQL 元素(表名、列名等)的场景中仍是唯一选择。只是要极其谨慎地使用。
七、总结
| 对比维度 | #{} |
${} |
|---|---|---|
| 本质 | 预编译参数占位符 | 字符串直接替换 |
| 生成方式 | ? 占位 + 参数设置 |
直接拼接到 SQL |
| SQL 注入 | ✅ 安全防护 | 🔴 极度危险 |
| 预编译 | ✅ 支持 | ❌ 不支持 |
| 自动引号 | ✅ 自动添加 | ❌ 需手动处理 |
| 类型处理 | ✅ 自动映射 | ❌ 原样替换 |
| 适用场景 | 绝大多数传参 | 表名/列名/排序等动态元数据 |
| 推荐程度 | ⭐⭐⭐⭐⭐ 优先使用 | ⚠️ 严格限制使用 |
最佳实践口诀
传值用井号,安全又可靠;
元数据用美元,白名单校验好。
预编译防注入,性能还更高;
拼接字符串,小心别翻车。
希望本文能帮助你深入理解 MyBatis 中 #{} 和 ${} 的区别,在项目中写出更安全、更高效的 SQL 语句。