【大白话说Java面试题 第131题】【05_Mybatis篇】第1题:MyBatis 中 #{} 和 ${} 的区别?

📌 微服务架构基于Spring Cloud Alibaba的分布式事务处理:Seata AT模式与Sentinel协同实现高并发下数据最终一致性

第1题:MyBatis 中 #{}${} 的区别?

📚 回答:

  • 核心考点#{}${} 的区别不是"预编译 vs 字符串拼接"一句话能说清的。大厂面试中,面试官期望你深入理解 MyBatis 参数解析的完整链路 (OGNL 表达式解析 → SqlSource 构建 → ParameterHandler 参数绑定)、PreparedStatement 预编译的底层机制 (数据库层面如何防御 SQL 注入)、${} 的白名单校验最佳实践 ,以及 TypeHandler 在 #{} 中的类型转换角色。面试官真正想判断的是:你是否能从框架源码、数据库协议、安全工程三个维度,给出体系化的分析。

1. 本质区别:参数占位符 vs 文本替换符
  • 1.1 处理机制对比 MyBatis 对 #{}${} 的处理在解析阶段就分道扬镳:
维度 #{} ${}
解析阶段 替换为 ? 占位符 直接文本替换(OGNL 求值)
SQL 生成 SELECT * FROM users WHERE id = ? SELECT * FROM users WHERE id = 1
执行对象 PreparedStatement Statement
参数绑定 ps.setXxx(index, value) 无,已拼接在 SQL 中
预编译 ✅ 数据库预编译 ❌ 每次重新解析
SQL 注入 ✅ 天然防御 ❌ 存在注入风险
适用场景 所有数据值参数 动态表名、列名、ORDER BY 等
  • 1.2 #{} 的完整处理链路SELECT * FROM users WHERE id = #{userId} 为例:

    1. XML/注解解析
      └─> SqlSourceBuilder 解析 #{userId} → 提取参数名 "userId"

    2. OGNL 表达式求值
      └─> 从参数对象中获取 userId 的值(如 1)

    3. SQL 模板生成
      └─> 替换为 ? → "SELECT * FROM users WHERE id = ?"

    4. PreparedStatement 创建
      └─> connection.prepareStatement(sql)

    5. 参数绑定(ParameterHandler)
      └─> 根据参数类型选择 TypeHandler
      └─> ps.setInt(1, 1) 或 ps.setString(1, "1")

    6. 数据库预编译执行
      └─> 数据库将 SQL 结构和参数值分离处理
      └─> 参数值作为纯数据,不参与语法解析

  • 1.3 ${} 的完整处理链路SELECT * FROM users ORDER BY ${columnName} 为例:

    1. XML/注解解析
      └─> SqlSourceBuilder 解析 ${columnName} → 标记为文本替换

    2. OGNL 表达式求值
      └─> 从参数对象中获取 columnName 的值(如 "name")

    3. SQL 文本拼接
      └─> 直接替换 → "SELECT * FROM users ORDER BY name"

    4. Statement 创建
      └─> connection.createStatement() 或 connection.prepareStatement(sql)

    5. 直接执行
      └─> 数据库将完整 SQL 作为字符串解析
      └─> 替换后的文本参与语法解析,可能被注入

关键差异#{}? 在 JDBC 层面就是占位符,数据库永远将参数视为纯数据${} 替换后的文本直接参与 SQL 语法解析,恶意输入可改变 SQL 结构。


2. #{} 防 SQL 注入的深层原理
  • 2.1 预编译机制的三层防御 #{} 的防注入能力不是 MyBatis 的魔法,而是 JDBC 规范 + 数据库协议 的协同结果:
防御层 机制 作用
JDBC 层 PreparedStatement.setXxx() 参数值通过 API 传入,不拼接 SQL 字符串
数据库协议层 二进制协议传输参数 参数值与 SQL 结构分帧传输,数据库分别解析
数据库引擎层 预编译计划缓存 SQL 结构先编译为执行计划,参数值后绑定,参数永不参与语法解析
java 复制代码
// JDBC 层面的参数绑定(MyBatis 底层调用)
PreparedStatement ps = connection.prepareStatement(
    "SELECT * FROM users WHERE username = ? AND password = ?"
);
ps.setString(1, "admin");           // 参数1:纯数据
ps.setString(2, "' OR '1'='1");    // 参数2:即使包含恶意字符,也是纯数据
// 数据库执行时,? 的位置被替换为转义后的字符串值
// 最终效果等价于:WHERE username = 'admin' AND password = '' OR '1'='1'
  • 2.2 攻击示例:为什么 #{} 能防御注入? 假设用户输入 userId = "1 OR 1=1"
xml 复制代码
<!-- ✅ 安全:使用 #{} -->
<select id="selectUser" resultType="User">
    SELECT * FROM users WHERE id = #{userId}
</select>
<!-- 最终 SQL:SELECT * FROM users WHERE id = ? -->
<!-- 参数绑定:ps.setString(1, "1 OR 1=1") -->
<!-- 数据库将 "1 OR 1=1" 作为完整字符串值处理,不会解析 OR 逻辑 -->

<!-- ❌ 危险:使用 ${} -->
<select id="selectUserUnsafe" resultType="User">
    SELECT * FROM users WHERE id = ${userId}
</select>
<!-- 最终 SQL:SELECT * FROM users WHERE id = 1 OR 1=1 -->
<!-- 数据库解析为:WHERE (id = 1) OR (1=1) → 条件恒真,返回所有数据 -->
  • 2.3 特殊场景:#{} 的自动类型处理 MyBatis 的 ParameterHandler 会根据参数类型自动选择 TypeHandler
Java 类型 默认 TypeHandler JDBC 设置方法
String StringTypeHandler ps.setString()
Integer IntegerTypeHandler ps.setInt()
Date DateTypeHandler ps.setTimestamp()
Enum EnumTypeHandler ps.setString()(默认存 name)
自定义对象 自定义 TypeHandler 开发者实现
java 复制代码
// 自定义 TypeHandler 示例:将 Java 枚举映射为数据库存储
@MappedTypes(StatusEnum.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class StatusEnumTypeHandler extends BaseTypeHandler<StatusEnum> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, 
                                     StatusEnum parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, parameter.getCode());  // 存储枚举的 code 而非 name
    }
    // ... getResult 方法
}

3. ${} 的安全使用规范
  • 3.1 ${} 的唯一合法场景 ${} 只应在参数值必须参与 SQL 语法结构 时使用,且绝对不能直接传入用户输入
合法场景 示例 说明
动态表名 SELECT * FROM ${tableName} 分库分表场景
动态列名 SELECT ${columns} FROM users 动态查询字段
ORDER BY 字段 ORDER BY ${sortColumn} 动态排序
GROUP BY 字段 GROUP BY ${groupColumn} 动态分组
数据库函数 SELECT ${func}(column) FROM users 动态函数调用
  • 3.2 白名单校验: 使用的唯一安全模式 ∗ ∗ 任何来自用户输入的参数,在使用 ' {} 使用的唯一安全模式** 任何来自用户输入的参数,在使用 ` 使用的唯一安全模式∗∗任何来自用户输入的参数,在使用'{}` 前必须经过白名单校验**:
java 复制代码
// ❌ 致命错误:直接传入用户输入
@Select("SELECT * FROM users ORDER BY ${columnName}")
List<User> selectOrderBy(@Param("columnName") String columnName);
// 用户传入 columnName = "name; DROP TABLE users; --" → SQL 注入!

// ✅ 正确:白名单校验
public class SqlSafeUtil {
    private static final Set<String> ALLOWED_COLUMNS = 
        Set.of("id", "username", "email", "created_time", "status");

    public static String validateColumn(String column) {
        if (!ALLOWED_COLUMNS.contains(column)) {
            throw new IllegalArgumentException("Invalid column: " + column);
        }
        return column;
    }
}

// Mapper 调用
@Select("SELECT * FROM users ORDER BY ${validatedColumn}")
List<User> selectOrderBy(@Param("validatedColumn") String validatedColumn);

// Service 层
public List<User> getUsers(String sortColumn) {
    String safeColumn = SqlSafeUtil.validateColumn(sortColumn);
    return userMapper.selectOrderBy(safeColumn);
}
  • 3.3 分库分表场景的安全实践 分库分表是 ${} 最常见的合法场景,但必须配合路由规则而非直接用户输入:
java 复制代码
// ❌ 错误:用户传入表名
@Select("SELECT * FROM ${tableName} WHERE user_id = #{userId}")
User selectByTable(@Param("tableName") String tableName, @Param("userId") Long userId);

// ✅ 正确:通过路由算法计算表名,用户无感知
public User getUser(Long userId) {
    String tableName = shardingRouter.getTableName("user", userId);  // user_001, user_002...
    return userMapper.selectByTable(tableName, userId);
}
  • 3.4 正则校验的局限性 不要用正则表达式校验 ${} 参数,容易被绕过:
java 复制代码
// ❌ 错误:正则可被绕过
if (!columnName.matches("[a-zA-Z_]+")) {  // 允许下划线,但 "name;DROP" 不匹配?
    // 实际上 "name" 匹配通过,但 "name
;DROP" 可能绕过
}

// ✅ 正确:严格白名单
private static final Set<String> ALLOWED = Set.of("id", "name", "age");

4. 源码级解析:MyBatis 如何处理 #{}${}
  • 4.1 SqlSource 构建阶段 MyBatis 启动时解析 Mapper XML,构建 SqlSource
java 复制代码
// XMLScriptBuilder 解析 #{}
protected MixedSqlNode parseDynamicTags(XNode node) {
    List<SqlNode> contents = new ArrayList<>();
    NodeList children = node.getNode().getChildNodes();
    for (int i = 0; i < children.getLength(); i++) {
        XNode child = node.newXNode(children.item(i));
        if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE 
                || child.getNode().getNodeType() == Node.TEXT_NODE) {
            String data = child.getStringBody("");
            TextSqlNode textSqlNode = new TextSqlNode(data);
            // 检测是否包含 ${} 动态文本
            if (textSqlNode.isDynamic()) {
                contents.add(textSqlNode);  // ${} → 动态 SQL,运行时文本替换
            } else {
                contents.add(new StaticTextSqlNode(data));  // 纯文本
            }
        }
    }
    return new MixedSqlNode(contents);
}
  • 4.2 #{} 的 ParameterMapping 构建 #{} 被解析为 ParameterMapping,记录参数名、类型、处理器:
java 复制代码
// SqlSourceBuilder 处理 #{}
public SqlSource parse(String originalSql, Class<?> parameterType) {
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler();
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql = parser.parse(originalSql);  // 替换为 ?
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
  • 4.3 ${} 的 OGNL 求值 ${} 通过 TextSqlNode 在运行时进行 OGNL 表达式求值:
java 复制代码
// TextSqlNode 处理 ${}
public boolean apply(DynamicContext context) {
    GenericTokenParser parser = new GenericTokenParser("${", "}", 
        content -> OgnlCache.getValue(content, context.getBindings()));
    context.appendSql(parser.parse(text));
    return true;
}

5. 生产环境避坑指南
  • 5.1 严禁在 WHERE 条件中使用 ${} 这是最危险的误用,直接导致 SQL 注入:
xml 复制代码
<!-- ❌ 致命错误 -->
<select id="selectUser" resultType="User">
    SELECT * FROM users WHERE username = ${username}
</select>

<!-- ✅ 正确 -->
<select id="selectUser" resultType="User">
    SELECT * FROM users WHERE username = #{username}
</select>
  • 5.2 LIKE 模糊查询的正确写法 LIKE 配合 #{} 时,通配符应在 Java 代码中拼接:
java 复制代码
// ❌ 错误:试图在 SQL 中拼接 %
SELECT * FROM users WHERE name LIKE '%#{name}%'
// 实际生成:LIKE '%'name'%' → 语法错误!

// ✅ 正确:Java 中拼接通配符
String keyword = "%" + name + "%";
mapper.selectByName(keyword);

// XML
<select id="selectByName" resultType="User">
    SELECT * FROM users WHERE name LIKE #{keyword}
</select>
  • 5.3 IN 子句的正确写法 动态 IN 应使用 <foreach> 而非 ${}
xml 复制代码
<!-- ❌ 错误:${} 拼接 IN 列表 -->
SELECT * FROM users WHERE id IN (${ids})

<!-- ✅ 正确:foreach + #{} -->
<select id="selectByIds" resultType="User">
    SELECT * FROM users WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
  • 5.4 动态 ORDER BY 的白名单校验 排序字段和方向都必须校验:
java 复制代码
public class OrderByUtil {
    private static final Set<String> ALLOWED_COLUMNS = 
        Set.of("id", "created_time", "updated_time");
    private static final Set<String> ALLOWED_DIRECTIONS = 
        Set.of("ASC", "DESC");

    public static String validate(String column, String direction) {
        if (!ALLOWED_COLUMNS.contains(column)) {
            throw new IllegalArgumentException("Invalid sort column");
        }
        String dir = direction.toUpperCase();
        if (!ALLOWED_DIRECTIONS.contains(dir)) {
            throw new IllegalArgumentException("Invalid sort direction");
        }
        return column + " " + dir;
    }
}
  • 5.5 全局安全扫描 使用 SonarQube、Checkmarx 等工具扫描 ${} 的使用:
xml 复制代码
<!-- SonarQube 规则:检测 Mapper 中危险的 ${} 使用 -->
<rule>
    <key>mybatis-sql-injection</key>
    <name>MyBatis SQL Injection Risk</name>
    <description>
        Detect ${} usage in WHERE, AND, OR conditions without whitelist validation
    </description>
</rule>
  • 5.6 日志脱敏 生产环境开启 SQL 日志时,注意敏感参数脱敏:
yaml 复制代码
# application.yml
mybatis:
  configuration:
    log-impl: SLF4J
    # 开启参数日志,但密码字段需脱敏
logging:
  level:
    com.example.mapper: DEBUG  # 打印 SQL

6. 面试官追问与高分回答模板
  • 追问 1:"#{}${} 的本质区别是什么?"

    低分回答 :"#{} 是预编译,${} 是字符串拼接。"(太浅,没有触及源码和协议层)

    高分回答

    "两者的本质区别在于参数参与 SQL 解析的时机和方式

    1. #{} 是参数占位符 :MyBatis 解析时将其替换为 ?,生成 PreparedStatement,通过 ParameterHandler 调用 ps.setXxx() 绑定参数。参数值在数据库协议层 作为纯数据传输,永不参与 SQL 语法解析,因此天然防御 SQL 注入。
    2. ${} 是文本替换符 :MyBatis 解析时通过 OGNL 表达式求值,直接将变量值拼接到 SQL 字符串中。替换后的文本直接参与 SQL 语法解析,恶意输入可改变 SQL 结构,导致注入。

    从源码角度看,#{}SqlSourceBuilder 中被解析为 ParameterMapping,最终走 PreparedStatementHandler${}TextSqlNode 中通过 OGNL 求值后拼接,走 SimpleStatementHandler(或仍用 PreparedStatement 但 SQL 已拼接完成)。"

  • 追问 2:"为什么 #{} 能防止 SQL 注入?请从数据库协议层面解释。"

    高分回答

    "#{} 防注入的核心在于 JDBC 预编译协议的三层分离

    1. JDBC API 层PreparedStatement.setString(1, value) 将参数值通过 API 传入,而非字符串拼接。
    2. 数据库协议层 :参数值与 SQL 模板通过二进制协议分帧传输,数据库分别解析。SQL 结构先编译为执行计划,参数值后绑定。
    3. 数据库引擎层 :参数值作为纯数据 填充到执行计划中,无论内容如何(即使包含 ' OR '1'='1),都被视为字符串字面值,不会解析为 SQL 关键字或逻辑运算符。

    这类似于函数调用的参数传递:SQL 结构是函数体,参数值是实参,实参永远不会被当作代码执行。"

  • 追问 3:"${} 在什么场景下必须使用?如何安全使用?"

    高分回答

    "${} 只在参数值必须参与 SQL 语法结构时使用,典型场景包括:

    1. 动态表名 :分库分表时 SELECT * FROM ${tableName}
    2. 动态列名ORDER BY ${column}GROUP BY ${column}
    3. 动态函数SELECT ${func}(column) FROM table

    安全使用的唯一模式是白名单校验

    • 绝对禁止直接传入用户输入
    • 使用 Set<String> 定义允许的取值集合
    • 校验不通过直接抛异常
    • 分库分表场景应通过路由算法计算表名,用户无感知

    错误示范是用正则校验(如 [a-zA-Z_]+),正则容易被绕过,白名单才是最安全的。"

  • 追问 4:"MyBatis 的 TypeHandler 在 #{} 中起什么作用?"

    高分回答

    "TypeHandler 是 Java 类型与 JDBC 类型之间的转换桥梁。当使用 #{} 时:

    1. 入参阶段ParameterHandler 根据参数类型从 TypeHandlerRegistry 中获取对应的 TypeHandler,调用 setParameter() 将 Java 对象转换为 JDBC 可接受的类型(如 StringVARCHAREnumVARCHAR)。
    2. 出参阶段ResultSetHandler 根据列的 JDBC 类型获取 TypeHandler,调用 getResult() 将数据库值转换为 Java 对象。

    例如,自定义枚举处理器可以将 StatusEnum 存储为数据库的 code(如 0/1/2)而非 name,实现更紧凑的存储。MyBatis-Plus 还提供了 JSON 类型处理器(JacksonTypeHandler),可直接将 Java 对象映射为数据库 JSON 字段。"

  • 追问 5:"如果项目里已经大量使用了 ${},如何排查和修复 SQL 注入风险?"

    高分回答

    "排查和修复应分三步走:

    1. 静态扫描 :使用 SonarQube、Checkmarx 或自定义脚本扫描所有 Mapper XML 和注解中的 ${} 使用,重点关注 WHERE、AND、OR 条件中的危险用法。
    2. 分类处理
      • 数据值参数 (如 WHERE id = ${id}):全部替换为 #{}
      • 动态表名/列名:添加白名单校验
      • ORDER BY/GROUP BY:添加白名单 + 方向校验(ASC/DESC)
    3. 运行时防护
      • 启用 MyBatis 日志,审计所有生成的 SQL
      • 在数据库层启用 SQL 防火墙(如 MySQL 的 query_rewrite、阿里云 DMS 的 SQL 审核)
      • 对敏感接口做渗透测试验证

    修复优先级:WHERE 条件 > LIKE 条件 > IN 子句 > ORDER BY > 动态表名。"

  • 追问 6:"MyBatis-Plus 的 @TableField#{} 有什么关系?"

    高分回答

    "MyBatis-Plus 的 @TableField 注解在底层仍然依赖 MyBatis 的 #{} 机制,但做了更高层的封装:

    1. 字段映射@TableField("user_name") 指定数据库列名,MP 在生成 SQL 时自动处理列名与属性名的映射,生成的仍然是 #{} 占位符。
    2. 类型处理器@TableField(typeHandler = JacksonTypeHandler.class) 指定字段的类型转换器,入参时通过 TypeHandler 将 Java 对象转为 JSON 字符串,出参时反向转换。
    3. 条件构造器QueryWrappereq()like() 等方法底层自动使用 #{},避免手写 XML 时的误用风险。

    但需要注意:MP 的 apply()last() 等方法允许直接拼接 SQL 片段,使用时仍需警惕注入风险。"


7. 方案选型速查表
场景 推荐方案 核心理由
WHERE 条件参数 #{} 预编译防注入,性能最优
INSERT/UPDATE 值 #{} 类型安全,自动转换
LIKE 模糊查询 #{} + Java 拼接 % 避免 SQL 注入
IN 子句 <foreach> + #{} 动态列表,安全高效
动态 ORDER BY ${} + 白名单校验 列名必须参与语法
动态 GROUP BY ${} + 白名单校验 同上
分库分表表名 ${} + 路由算法 用户无感知,内部计算
动态查询列 ${} + 白名单校验 列名必须参与语法
枚举字段存储 #{} + 自定义 TypeHandler 控制存储格式
JSON 字段映射 #{} + JacksonTypeHandler 自动序列化/反序列化

💡 面试官想要的满分总结

#{}${} 的区别不是"预编译 vs 拼接"这么简单,而是参数在 SQL 生命周期中的参与方式不同#{} 通过 PreparedStatement 将参数作为纯数据绑定,在数据库协议层与 SQL 结构分离传输,参数永不参与语法解析 ,因此天然免疫 SQL 注入;${} 通过 OGNL 表达式在 MyBatis 层面直接文本替换,替换后的字符串直接参与 SQL 语法解析,恶意输入可改变 SQL 逻辑结构。

工程实践上,#{} 是 99% 场景的默认选择#{} 的防注入能力来自 JDBC 规范 + 数据库预编译协议的协同,而非 MyBatis 的魔法。${} 只在动态表名、列名、ORDER BY 等必须参与语法的场景使用,且必须通过白名单校验,绝对禁止直接传入用户输入。

最后记住:没有绝对安全的框架,只有规范使用的开发者 。即使使用 #{},也要注意 LIKE 的 % 拼接位置、IN 子句的 <foreach> 用法,以及生产环境的 SQL 审计和渗透测试。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯