# 面经手册 · 第32篇《MyBatis #{} 和 ${} 区别是什么?从SQL注入到预编译深度解析》
作者:小傅哥
博客:https://bugstack.cn (opens new window)
沉淀、分享、成长,让自己和他人都能有所收获!😄
# 一、前言
面试场上,#{} 和 ${} 的区别,几乎是 MyBatis 八股文的必考题。
但大多数候选人只能背出"#{} 防注入、${} 不防注入"就草草收场。面试官追问一句:为什么 #{} 能防注入?底层是怎么处理的?模糊查询 like 该怎么写?${} 在什么场景下非用不可?——大多数人就卡住了。
本文从 JDBC 预编译原理出发,结合 MyBatis 源码,把 #{} 和 ${} 的区别彻底讲透。不只是面试能答上来,更要知道为什么。
# 二、面试题
谢飞机,小记!,最近突击了 MyBatis 八股文,信心满满来面试。
面试官:谢飞机,MyBatis 中 #{} 和 ${} 的区别是什么?
谢飞机:#{} 是预编译占位符,会替换成 ?,可以防止 SQL 注入;${} 是字符串拼接,直接把值拼到 SQL 里,有注入风险。
面试官:嗯,那为什么 #{} 能防注入?底层原理是什么?
谢飞机:因为用的是 PreparedStatement... 预编译嘛...
面试官:预编译为什么就能防注入?给个具体的例子说明。
谢飞机:嗯... 就是... 参数化查询?
面试官:好吧。那模糊查询 like 语句用 #{} 怎么写?用 ${} 有什么问题?
谢飞机:like 用... concat?
面试官:${} 在什么场景下必须用,不能用 #{}?
谢飞机:... 表名?列名?
面试官:对,但为什么 #{} 不能用在表名列名上?你清楚吗?
谢飞机:我... 再见!ヾ( ̄▽ ̄)
# 三、JDBC 预编译基础
在讲 MyBatis 的 #{} 和 ${} 之前,必须先搞清楚 JDBC 的预编译机制。这是理解两者区别的根基。
# 1. Statement vs PreparedStatement
// 方式一:Statement — 字符串拼接,有 SQL 注入风险
Statement stmt = conn.createStatement();
String sql = "SELECT * FROM user WHERE name = '" + name + "'";
ResultSet rs = stmt.executeQuery(sql);
// 方式二:PreparedStatement — 预编译 + 参数化,防注入
String sql = "SELECT * FROM user WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, name);
ResultSet rs = ps.executeQuery();
2
3
4
5
6
7
8
9
10
关键区别:
| 对比项 | Statement | PreparedStatement |
|---|---|---|
| SQL 构建 | 字符串拼接 | 占位符 ? + 参数设置 |
| 编译时机 | 每次执行都编译 | 预编译一次,可复用 |
| SQL 注入 | ❌ 有风险 | ✅ 安全 |
| 性能 | 重复 SQL 效率低 | 预编译缓存,效率高 |
# 2. PreparedStatement 为什么能防注入?
来看一个经典的 SQL 注入案例:
// 用户输入:name = "admin' OR '1'='1"
// Statement 拼接后的 SQL:
// SELECT * FROM user WHERE name = 'admin' OR '1'='1'
// 结果:查出所有用户数据!
// PreparedStatement 处理方式:
// SQL 模板:SELECT * FROM user WHERE name = ?
// 参数设置:setString(1, "admin' OR '1'='1")
// 实际执行:SELECT * FROM user WHERE name = 'admin\' OR \'1\'=\'1'
// 结果:把整个输入当作一个字符串值来匹配,注入失效
2
3
4
5
6
7
8
9
10
11
核心原理:PreparedStatement 在设置参数值时,会对特殊字符(如单引号 ')进行转义处理。数据库引擎在编译 SQL 模板时就已经确定了语法结构(SELECT、WHERE、= 等关键字和逻辑关系已固定),参数值不会改变 SQL 的语法结构,因此注入的恶意 SQL 片段只会被当作普通字符串数据处理。
# 3. 预编译的两个阶段
阶段一:编译(Compile)
SQL 模板发送给数据库 → 数据库解析语法 → 生成执行计划
此时 ? 只是占位,不参与语法解析
阶段二:执行(Execute)
将参数值填充到 ? 位置 → 执行已编译的执行计划
参数值只作为数据,不会被解析为 SQL 语法
2
3
4
5
6
7
一句话总结:预编译之所以能防注入,是因为语法解析和参数填充是分离的,参数永远无法改变已编译的 SQL 语法结构。
# 四、MyBatis #{} 源码解析
# 1. #{} 的处理过程
MyBatis 在解析 SQL 映射时,会经历以下步骤:
XML 映射文件中的 SQL
↓ SqlSource 解析
GenericTokenParser 解析 `#{}` 标记
↓ 替换为 ?
ParameterMappingTokenHandler 记录参数映射
↓ 生成 ParameterMapping 列表
最终 SQL:SELECT * FROM user WHERE name = ?
2
3
4
5
6
7
# 2. 核心源码追踪
步骤一:SQL 解析入口
// org.apache.ibatis.builder.SqlSourceBuilder
public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(
configuration, parameterType, additionalParameters);
GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
String sql = parser.parse(originalSql);
return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
2
3
4
5
6
7
8
GenericTokenParser 负责识别 #{ 和 } 之间的内容,提取后交给 ParameterMappingTokenHandler 处理。
步骤二:Token 处理器
// org.apache.ibatis.builder.SqlSourceBuilder.ParameterMappingTokenHandler
private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
@Override
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
return "?"; // ← 关键:将 `#{}` 替换为 ?
}
}
2
3
4
5
6
7
8
9
关键发现:handleToken 方法直接返回 "?",这就是 #{} 被替换为占位符 ? 的地方。同时,参数的元信息(Java 类型、JDBC 类型、模式等)被保存到 ParameterMapping 列表中,供后续参数设置使用。
步骤三:参数设置
// org.apache.ibatis.scripting.defaults.DefaultParameterHandler
@Override
public void setParameters(PreparedStatement ps) {
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
String propertyName = parameterMapping.getProperty();
Object value = metaObject.getValue(propertyName);
TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
// 通过 TypeHandler 将参数安全地设置到 PreparedStatement
typeHandler.setParameter(ps, i + 1, value, jdbcType);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
每个 #{} 参数都通过对应的 TypeHandler 调用 PreparedStatement.setXxx() 方法安全设置,参数值不会参与 SQL 语法解析。
# 3. #{} 支持的属性
#{property,javaType=int,jdbcType=NUMERIC,mode=IN,resultMap=...,typeHandler=...,numericScale=2}
| 属性 | 说明 |
|---|---|
| javaType | 参数的 Java 类型 |
| jdbcType | 参数的 JDBC 类型 |
| mode | 参数模式:IN / OUT / INOUT |
| typeHandler | 指定类型处理器 |
| numericScale | 小数位数 |
| resultMap | 结果映射(OUT 模式) |
# 五、MyBatis ${} 源码解析
# 1. ${} 的处理过程
XML 映射文件中的 SQL
↓ SqlSource 解析
GenericTokenParser 解析 `${}` 标记
↓ 直接替换为字符串值
TextSqlSource / DynamicSqlSource
↓ 无 ParameterMapping 记录
最终 SQL:SELECT * FROM user WHERE name = '张三' ← 直接拼接
2
3
4
5
6
7
# 2. 核心源码追踪
步骤一:动态 SQL 解析
${} 的解析发生在动态 SQL 处理阶段(早于 #{} 的解析):
// org.apache.ibatis.scripting.xmltags.TextSqlNode
public class TextSqlNode implements SqlNode {
private final String text;
public TextSqlNode(String text) {
this.text = text;
}
@Override
public boolean apply(DynamicContext context) {
// 使用 ${ } 解析器处理文本
GenericTokenParser parser = new GenericTokenParser("${", "}",
new BindingTokenParser(context, false));
context.appendSql(parser.parse(text));
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
步骤二:Token 处理器
// org.apache.ibatis.scripting.xmltags.TextSqlNode.BindingTokenParser
private static class BindingTokenParser implements TokenHandler {
@Override
public String handleToken(String content) {
Object value = OgnlCache.getValue(content, context.getBindings());
// 直接将值 toString() 拼接到 SQL 中!
return value == null ? "" : value.toString();
}
}
2
3
4
5
6
7
8
9
10
关键区别:${} 的 handleToken 方法直接返回值的字符串形式,没有替换为 ?,没有 ParameterMapping,没有 TypeHandler。值被原样拼接到 SQL 字符串中。
# 3. ${} 的风险验证
<!-- Mapper XML -->
<select id="findUser" resultType="User">
SELECT * FROM user WHERE name = '${name}'
</select>
2
3
4
// 调用:name = "admin' OR '1'='1"
userMapper.findUser("admin' OR '1'='1");
// 最终执行的 SQL:
// SELECT * FROM user WHERE name = 'admin' OR '1'='1'
// 注入成功!
2
3
4
5
6
# 六、核心对比总结
# 1. 一图看懂区别
`#{}` 处理链路:
SQL: SELECT * FROM user WHERE name = #{name}
→ 解析:识别 #{name}
→ 替换:SELECT * FROM user WHERE name = ?
→ 参数设置:PreparedStatement.setString(1, "张三")
→ 执行:安全
`${}` 处理链路:
SQL: SELECT * FROM user WHERE name = '${name}'
→ 解析:识别 ${name}
→ 替换:SELECT * FROM user WHERE name = '张三'
→ 无参数设置:直接执行拼接后的 SQL
→ 执行:有注入风险
2
3
4
5
6
7
8
9
10
11
12
13
# 2. 全面对比表
| 对比项 | #{} | ${} |
|---|---|---|
| 本质 | 预编译占位符 | 字符串拼接替换 |
| 替换结果 | ? | 实际值的字符串 |
| SQL 注入 | ✅ 安全(参数化查询) | ❌ 有风险(原样拼接) |
| 参数类型处理 | TypeHandler 自动转换 | 无处理,直接 toString() |
| 编译时机 | 先编译 SQL 模板,再设参数 | SQL 编译前就已拼接完成 |
| 性能 | 预编译缓存可复用 | 每次需要重新编译 |
| 适用场景 | WHERE 条件值、INSERT 值等 | 表名、列名、ORDER BY 等 |
| 加引号 | 自动(由 JDBC 处理) | 不加,需手动加引号 |
# 3. 处理顺序
MyBatis SQL 解析顺序:
1. 先解析 `${}` → 字符串替换(DynamicSqlSource 阶段)
2. 再解析 `#{}` → 替换为 ?(SqlSourceBuilder 阶段)
2
3
这意味着 ${} 替换后的内容,如果恰好包含 #{} 标记,还会被二次解析。这也可能导致额外的安全风险。
# 七、高频面试场景
# 1. 模糊查询 like 怎么写?
❌ 错误写法:
<!-- 直接拼接 `${}`,有注入风险 -->
SELECT * FROM user WHERE name LIKE '%${name}%'
<!-- `#{}` 放在 '' 内,会被当成普通字符串 -->
SELECT * FROM user WHERE name LIKE '%#{name}%'
<!-- 实际执行:LIKE '%?' — ? 不会被替换,直接报错 -->
2
3
4
5
6
✅ 正确写法一:CONCAT 函数
<select id="findUserByName" resultType="User">
SELECT * FROM user WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
2
3
✅ 正确写法二:bind 标签
<select id="findUserByName" resultType="User">
<bind name="pattern" value="'%' + name + '%'" />
SELECT * FROM user WHERE name LIKE #{pattern}
</select>
2
3
4
✅ 正确写法三:Java 层拼接
// Service 层
String pattern = "%" + name + "%";
userMapper.findUserByName(pattern);
2
3
<select id="findUserByName" resultType="User">
SELECT * FROM user WHERE name LIKE #{pattern}
</select>
2
3
# 2. ${} 必须使用的场景
场景一:动态表名
<!-- `#{}` 替换为 ?,但表名位置不能放 ? -->
<!-- SELECT * FROM ? — 语法错误! -->
<select id="findByTable" resultType="User">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
2
3
4
5
为什么 #{} 不能用在表名?因为 PreparedStatement 的 ? 占位符只能用于值的位置(WHERE 条件、INSERT 值等),不能用于标识符的位置(表名、列名、关键字等)。数据库在编译 SQL 时需要知道表名才能生成执行计划,? 无法满足这个要求。
场景二:动态列名(ORDER BY)
<select id="findUsers" resultType="User">
SELECT * FROM user ORDER BY ${column} ${order}
</select>
2
3
场景三:动态 SQL 关键字
<select id="findUsers" resultType="User">
SELECT * FROM user ${whereClause}
</select>
2
3
# 3. ${} 的安全使用
使用 ${} 时,必须在 Java 层做白名单校验:
// 白名单校验
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "name", "age", "create_time");
public List<User> findUsers(String column, String order) {
if (!ALLOWED_COLUMNS.contains(column)) {
throw new IllegalArgumentException("非法排序列: " + column);
}
if (!"ASC".equalsIgnoreCase(order) && !"DESC".equalsIgnoreCase(order)) {
throw new IllegalArgumentException("非法排序方向: " + order);
}
return userMapper.findUsers(column, order);
}
2
3
4
5
6
7
8
9
10
11
12
原则:${} 的值必须来自程序可控的白名单,永远不要直接接收用户输入。
# 4. IN 查询的 #{} 用法
<!-- foreach + `#{}`,安全处理 IN 列表 -->
<select id="findByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
2
3
4
5
6
7
每个 IN 列表项都会生成一个 ? 占位符,通过 PreparedStatement 安全设值。
# 八、常见面试追问
# Q1:#{} 和 ${} 哪个先解析?
${} 先解析。MyBatis 的 SQL 解析分两个阶段:动态 SQL 阶段先处理 ${}(TextSqlNode),然后 SqlSourceBuilder 阶段再处理 #{}。
# Q2:#{} 替换为 ? 后,参数是怎么设置到 PreparedStatement 的?
通过 DefaultParameterHandler.setParameters() 方法,遍历 ParameterMapping 列表,调用每个参数对应的 TypeHandler.setParameter(),最终调用 PreparedStatement.setXxx() 完成参数绑定。
# Q3:${} 拼接的值会被二次处理吗?
会。${} 替换发生在 #{} 替换之前,所以 ${} 替换后的内容如果包含 #{} 标记,还会被二次解析。这也是一种潜在的安全风险。
# Q4:为什么 PreparedStatement 的 ? 不能用在表名位置?
因为数据库在编译 SQL 时需要确定查询的表结构(列信息、索引等)来生成执行计划。? 是参数占位符,数据库无法对一个未知的表生成执行计划。表名属于 SQL 的结构部分(标识符),不是数据部分(值)。
# Q5:MyBatis 默认用 #{} 还是 ${}?
MyBatis 不强制默认,但在 XML 映射中,只要能用 #{} 的地方都应该用 #{},只有在表名、列名等标识符位置才需要用 ${}。
# 九、总结
记住三个核心要点:
1. `#{}` 是预编译占位符 → 替换为 ? → PreparedStatement.setXxx() → 安全
`${}` 是字符串拼接 → 直接替换为值的字符串 → 无参数化 → 有注入风险
2. 预编译防注入的原理 = 语法解析与参数填充分离
SQL 模板先编译确定语法结构,参数值只作为数据,无法改变语法
3. `${}` 只在标识符位置(表名、列名、ORDER BY)使用
使用时必须做白名单校验,永远不直接接收用户输入
2
3
4
5
6
7
8
9
10
面试回答模板:
#{}是预编译占位符,MyBatis 在解析时会将其替换为?,通过 PreparedStatement 的setXxx()方法设置参数值。由于 SQL 模板在编译阶段就已经确定了语法结构,参数值只作为数据处理,无法改变 SQL 的语义,因此能有效防止 SQL 注入。
${}是字符串拼接,MyBatis 在解析时会直接将值替换为字符串拼接到 SQL 中,不经过参数化处理,因此存在 SQL 注入风险。在使用上,条件值应该使用
#{};只有在表名、列名、ORDER BY 等标识符位置,因为 PreparedStatement 的 ? 不能用于标识符,才需要使用${},但必须做白名单校验。

