菜单

Administrator
发布于 2026-05-18 / 1 阅读
0
0

MyBatis 中 #{} 和 ${} 的区别

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 关键原则

能不用 ${} 就不用 ${};能用 #{} 的地方绝不用 ${}

如果必须在 ${} 的场景下传参,请务必:

  1. 对输入做严格的白名单校验
  2. 禁止直接接受用户原始输入
  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 语句。


评论