MyBatis 中 #{} 和 ${} 的区别是什么?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 基础掌握度:面试官不仅仅是想知道你 "用过" 这两个符号,更是想考察你是否理解它们底层的处理机制——预编译 vs 字符串拼接。

  2. 安全意识:这是这道题的核心。考察你是否具备 SQL 注入防范意识,以及能否在开发中做出正确选择。

  3. 实战经验:看你能否说出 ${} 的合理使用场景(动态表名、动态排序等),而不是一刀切地说 "永远别用 ${}"。

核心答案

#{}${} 的核心区别在于 SQL 参数的替换方式不同

对比维度#{}${}
替换方式预编译处理,使用 ? 占位符字符串拼接,直接替换到 SQL 中
SQL 注入安全,自动转义危险,存在注入风险
性能预编译 SQL 可缓存复用每次生成不同的 SQL,无法复用
适用场景传参数值(WHERE 条件等)动态表名、列名、ORDER BY 等
类型处理自动处理类型(加引号等)原样拼接,不处理类型

一句话结论:能用 #{} 就用 #{},只有动态表名、动态列名、ORDER BY 等场景才用 ${}

深度解析

一、底层机制对比

上图中可以看到两种方式处理流程的关键差异:

  • #{} 的处理流程:MyBatis 在解析 SQL 时,会将 #{id} 替换为 ? 占位符,然后通过 PreparedStatement.setInt() 等方法设置参数值。数据库先编译 SQL 结构,再绑定参数,参数值不会参与 SQL 编译,因此不可能改变 SQL 的语法结构。

  • ${} 的处理流程:MyBatis 在解析 SQL 时,直接将 ${id} 替换为参数的字符串值,拼接到 SQL 中。参数值参与了 SQL 编译,恶意输入可以改变 SQL 的语法结构,导致 SQL 注入。

二、SQL 注入实战演示

来看一个典型的 SQL 注入场景:

<!-- 危险!使用 ${} 拼接用户名 -->
<select id="findByUsername" resultType="User">
    SELECT * FROM t_user WHERE username = '${username}'
</select>

假设恶意用户输入:username = "admin' OR '1'='1"

最终执行的 SQL 变成:

-- 原本只想查一个用户,结果变成了查全部用户!
SELECT * FROM t_user WHERE username = 'admin' OR '1'='1'

同样的场景,使用 #{}

<!-- 安全!使用 #{} 预编译 -->
<select id="findByUsername" resultType="User">
    SELECT * FROM t_user WHERE username = #{username}
</select>

不管用户输入什么,最终 SQL 结构始终是:

-- 参数值只是 ? 的绑定值,无法改变 SQL 结构
SELECT * FROM t_user WHERE username = ?
-- 参数绑定值:admin' OR '1'='1(整个字符串作为一个值)

三、${} 的合理使用场景

${} 并非一无是处,以下场景必须用 ${},因为 ? 占位符不能用在 SQL 的结构部分:

场景一:动态表名

<!-- 根据月份动态选择分表 -->
<select id="findByMonth" resultType="Order">
    SELECT * FROM t_order_${month} WHERE status = #{status}
</select>
<!-- 调用:month = "202601" → 查询 t_order_202601 表 -->

场景二:动态列名

<!-- 根据用户选择动态排序字段 -->
<select id="findList" resultType="User">
    SELECT id, username, email FROM t_user
    ORDER BY ${orderColumn} ${orderDir}
</select>
<!-- 调用:orderColumn = "created_time", orderDir = "DESC" -->

场景三:LIKE 查询

<!-- LIKE 查询不能直接用 #{} 包裹 %,需要用 CONCAT 函数 -->
<select id="searchByUsername" resultType="User">
    SELECT * FROM t_user
    WHERE username LIKE CONCAT('%', #{keyword}, '%')
</select>

注意:LIKE 查询推荐用 CONCAT + #{} 的方式,而不是 '%' + ${keyword} + '%'

四、${} 的安全防范

当必须使用 ${} 时,一定要在 Java 代码层做白名单校验

// 动态排序:白名单校验列名和方向
private static final Set<String> ALLOWED_COLUMNS = Set.of(
    "id", "username", "created_time", "status"
);
private static final Set<String> ALLOWED_DIRS = Set.of("ASC", "DESC");

public List<User> findList(String orderColumn, String orderDir) {
    // 白名单校验,防止注入
    if (!ALLOWED_COLUMNS.contains(orderColumn)) {
        throw new IllegalArgumentException("非法排序字段");
    }
    if (!ALLOWED_DIRS.contains(orderDir.toUpperCase())) {
        throw new IllegalArgumentException("非法排序方向");
    }
    return userMapper.findList(orderColumn, orderDir);
}

面试高频追问

  1. 追问一:为什么 #{} 可以防止 SQL 注入?

    • 因为 #{} 使用 PreparedStatement 的参数绑定机制,参数值不参与 SQL 编译,数据库将参数值作为纯数据处理,无法改变 SQL 的语法结构。
  2. 追问二LIKE 查询怎么用 #{}

    • 使用 LIKE CONCAT('%', #{keyword}, '%'),而不是 '%' + ${keyword} + '%'
  3. 追问三:MyBatis 的 #{} 底层是怎么实现的?

    • MyBatis 在解析 Mapper XML 时,将 #{} 替换为 ?,并记录参数映射信息。执行时通过 PreparedStatement.setXxx() 方法设置参数值,底层依赖 JDBC 的 PreparedStatement 机制。

常见面试变体

  • 变体一:"MyBatis 如何防止 SQL 注入?"
  • 变体二:"#{}${} 分别在什么场景下使用?"
  • 变体三:"为什么 #{} 不能用在 ORDER BY 后面?"

记忆口诀

# 号安全占位问,$ 号直接拼字符串——#{}? 占位符(安全),${} 是字符串拼接(危险),能用 # 就别用 $

总结

#{} 使用 PreparedStatement 预编译处理,参数通过 ? 占位符绑定,安全防注入${} 直接字符串拼接到 SQL 中,存在 SQL 注入风险。日常开发中优先使用 #{},只有在动态表名、动态列名、ORDER BY 等无法使用占位符的场景才用 ${},并且必须在代码层做白名单校验。