📌 微服务架构 :基于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}为例:-
XML/注解解析
└─> SqlSourceBuilder 解析 #{userId} → 提取参数名 "userId" -
OGNL 表达式求值
└─> 从参数对象中获取 userId 的值(如 1) -
SQL 模板生成
└─> 替换为 ? → "SELECT * FROM users WHERE id = ?" -
PreparedStatement 创建
└─> connection.prepareStatement(sql) -
参数绑定(ParameterHandler)
└─> 根据参数类型选择 TypeHandler
└─> ps.setInt(1, 1) 或 ps.setString(1, "1") -
数据库预编译执行
└─> 数据库将 SQL 结构和参数值分离处理
└─> 参数值作为纯数据,不参与语法解析
-
-
1.3
${}的完整处理链路 以SELECT * FROM users ORDER BY ${columnName}为例:-
XML/注解解析
└─> SqlSourceBuilder 解析 ${columnName} → 标记为文本替换 -
OGNL 表达式求值
└─> 从参数对象中获取 columnName 的值(如 "name") -
SQL 文本拼接
└─> 直接替换 → "SELECT * FROM users ORDER BY name" -
Statement 创建
└─> connection.createStatement() 或 connection.prepareStatement(sql) -
直接执行
└─> 数据库将完整 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 解析的时机和方式:
#{}是参数占位符 :MyBatis 解析时将其替换为?,生成PreparedStatement,通过ParameterHandler调用ps.setXxx()绑定参数。参数值在数据库协议层 作为纯数据传输,永不参与 SQL 语法解析,因此天然防御 SQL 注入。${}是文本替换符 :MyBatis 解析时通过 OGNL 表达式求值,直接将变量值拼接到 SQL 字符串中。替换后的文本直接参与 SQL 语法解析,恶意输入可改变 SQL 结构,导致注入。
从源码角度看,
#{}在SqlSourceBuilder中被解析为ParameterMapping,最终走PreparedStatementHandler;${}在TextSqlNode中通过 OGNL 求值后拼接,走SimpleStatementHandler(或仍用 PreparedStatement 但 SQL 已拼接完成)。" -
追问 2:"为什么
#{}能防止 SQL 注入?请从数据库协议层面解释。"高分回答:
"
#{}防注入的核心在于 JDBC 预编译协议的三层分离:- JDBC API 层 :
PreparedStatement.setString(1, value)将参数值通过 API 传入,而非字符串拼接。 - 数据库协议层 :参数值与 SQL 模板通过二进制协议分帧传输,数据库分别解析。SQL 结构先编译为执行计划,参数值后绑定。
- 数据库引擎层 :参数值作为纯数据 填充到执行计划中,无论内容如何(即使包含
' OR '1'='1),都被视为字符串字面值,不会解析为 SQL 关键字或逻辑运算符。
这类似于函数调用的参数传递:SQL 结构是函数体,参数值是实参,实参永远不会被当作代码执行。"
- JDBC API 层 :
-
追问 3:"
${}在什么场景下必须使用?如何安全使用?"高分回答:
"
${}只在参数值必须参与 SQL 语法结构时使用,典型场景包括:- 动态表名 :分库分表时
SELECT * FROM ${tableName} - 动态列名 :
ORDER BY ${column}、GROUP BY ${column} - 动态函数 :
SELECT ${func}(column) FROM table
安全使用的唯一模式是白名单校验:
- 绝对禁止直接传入用户输入
- 使用
Set<String>定义允许的取值集合 - 校验不通过直接抛异常
- 分库分表场景应通过路由算法计算表名,用户无感知
错误示范是用正则校验(如
[a-zA-Z_]+),正则容易被绕过,白名单才是最安全的。" - 动态表名 :分库分表时
-
追问 4:"MyBatis 的 TypeHandler 在
#{}中起什么作用?"高分回答:
"
TypeHandler是 Java 类型与 JDBC 类型之间的转换桥梁。当使用#{}时:- 入参阶段 :
ParameterHandler根据参数类型从TypeHandlerRegistry中获取对应的TypeHandler,调用setParameter()将 Java 对象转换为 JDBC 可接受的类型(如String→VARCHAR,Enum→VARCHAR)。 - 出参阶段 :
ResultSetHandler根据列的 JDBC 类型获取TypeHandler,调用getResult()将数据库值转换为 Java 对象。
例如,自定义枚举处理器可以将
StatusEnum存储为数据库的code(如 0/1/2)而非name,实现更紧凑的存储。MyBatis-Plus 还提供了 JSON 类型处理器(JacksonTypeHandler),可直接将 Java 对象映射为数据库 JSON 字段。" - 入参阶段 :
-
追问 5:"如果项目里已经大量使用了
${},如何排查和修复 SQL 注入风险?"高分回答:
"排查和修复应分三步走:
- 静态扫描 :使用 SonarQube、Checkmarx 或自定义脚本扫描所有 Mapper XML 和注解中的
${}使用,重点关注 WHERE、AND、OR 条件中的危险用法。 - 分类处理 :
- 数据值参数 (如 WHERE id = ${id}):全部替换为
#{} - 动态表名/列名:添加白名单校验
- ORDER BY/GROUP BY:添加白名单 + 方向校验(ASC/DESC)
- 数据值参数 (如 WHERE id = ${id}):全部替换为
- 运行时防护 :
- 启用 MyBatis 日志,审计所有生成的 SQL
- 在数据库层启用 SQL 防火墙(如 MySQL 的 query_rewrite、阿里云 DMS 的 SQL 审核)
- 对敏感接口做渗透测试验证
修复优先级:WHERE 条件 > LIKE 条件 > IN 子句 > ORDER BY > 动态表名。"
- 静态扫描 :使用 SonarQube、Checkmarx 或自定义脚本扫描所有 Mapper XML 和注解中的
-
追问 6:"MyBatis-Plus 的
@TableField和#{}有什么关系?"高分回答:
"MyBatis-Plus 的
@TableField注解在底层仍然依赖 MyBatis 的#{}机制,但做了更高层的封装:- 字段映射 :
@TableField("user_name")指定数据库列名,MP 在生成 SQL 时自动处理列名与属性名的映射,生成的仍然是#{}占位符。 - 类型处理器 :
@TableField(typeHandler = JacksonTypeHandler.class)指定字段的类型转换器,入参时通过TypeHandler将 Java 对象转为 JSON 字符串,出参时反向转换。 - 条件构造器 :
QueryWrapper的eq()、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 审计和渗透测试。
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯