MyBatis 动态 SQL 原理
候选人小孙在面试美团P6时,面试官看了看他的项目经验,写了这么一段代码:
@Select("<script>" +
"SELECT * FROM user " +
"<where>" +
" <if test='name != null'>AND name = #{name}</if>" +
" <if test='age != null'>AND age = #{age}</if>" +
"</where>" +
"</script>")
List<User> findByCondition(User user);
面试官问:"这个 <where> 标签的作用是什么?如果我传入的 user 对象 name 为 null,age 不为 null,最终生成的 SQL 是什么?"
小孙说:"where 标签会自动加 where 关键字,age 不为 null 时会生成 SELECT * FROM user WHERE age = ?。"
面试官追问:"那如果 name 和 age 都不为 null 呢?"
小孙说:"SELECT * FROM user WHERE name = ? AND age = ?。"
面试官继续追问:"如果只有 name 为 null 呢?"
小孙愣了一下:"那就不应该有 where 了吧...但实际生成的可能是 SELECT * FROM user AND age = ??"
面试官笑了:"这个场景下 MyBatis 会怎么处理?"
小孙彻底卡住了。
【面试官心理】
这道题我用来筛选"会用"和"理解原理"的候选人。90% 的候选人知道 <where> 标签会自动处理 AND/OR,但只有 20% 的人知道它底层是一个 TrimSqlNode,知道 IfSqlNode 和 WhereSqlNode 的解析顺序和树形结构的更少。这道题能答到源码层面的,是真正理解 MyBatis 动态 SQL 的。
一、动态 SQL 的核心体系
MyBatis 的动态 SQL 核心是 SqlNode 树形结构。每个动态标签对应一个 SqlNode,解析后形成一棵 AST,遍历这棵树就能得到最终的 SQL。
graph TD
A[MixedSqlNode<br/>根节点] --> B[IfSqlNode<br/>test="name != null"]
A --> C[IfSqlNode<br/>test="age != null"]
A --> D[WhereSqlNode<br/>包装子节点]
D --> E[MixedSqlNode]
E --> F[IfSqlNode<br/>test="status != null"]
F --> G[TrimSqlNode<br/>prefix="WHERE"<br/>prefixOverrides="AND|OR"]
1.1 SqlNode 接口体系 🔴
public interface SqlNode {
boolean apply(DynamicContext context);
}
// 所有动态标签都实现 SqlNode
public class IfSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String test; // OGNL 表达式
private final SqlNode contents; // 子节点
@Override
public boolean apply(DynamicContext context) {
// 关键:用 OGNL 评估表达式
if (evaluator.evaluateBoolean(test, context.getBindings())) {
return contents.apply(context);
}
return true; // 条件不满足,不追加内容,但继续处理后续节点
}
}
public class WhereSqlNode extends TrimSqlNode {
// WhereSqlNode 本质上是一个特殊的 TrimSqlNode
// prefix = "WHERE", prefixOverrides = "AND|OR|\\s+AND|\\s+OR"
// suffixOverrides 默认为空
}
public class SetSqlNode extends TrimSqlNode {
// SetSqlNode 本质上也是一个 TrimSqlNode
// prefix = "SET", prefixOverrides 为空
// suffix = ",", suffixOverrides = ","
}
1.2 DynamicSqlSource vs RawSqlSource 🔴
// 动态 SQL 源(包含 <if>/<where>/<foreach> 等标签)
public class DynamicSqlSource implements SqlSource {
private final SqlNode rootSqlNode;
@Override
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(parameterObject);
// 遍历 SqlNode 树,拼接 SQL
rootSqlNode.apply(context);
// 处理 ${} 替换和 #{} 占位符
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
SqlSource sqlSource = sqlSourceParser.parse(
context.getSql(),
parameterObject.getClass(),
context.getBindings()
);
return sqlSource.getBoundSql(parameterObject);
}
}
// 静态 SQL 源(没有动态标签)
public class RawSqlSource implements SqlSource {
private final SqlSource sqlSource;
@Override
public BoundSql getBoundSql(Object parameterObject) {
return sqlSource.getBoundSql(parameterObject);
}
}
💡
MyBatis 在 XML 解析阶段就会判断:Mapper 方法的 SQL 是否包含动态标签。如果不包含,直接创建 RawSqlSource,性能更好。如果包含,创建 DynamicSqlSource,每次执行时都要解析。
二、OGNL 表达式求值 🔴
动态标签的 test 属性使用 OGNL 表达式,MyBatis 用 ExpressionEvaluator 来求值:
public class ExpressionEvaluator {
public boolean evaluateBoolean(String expression, Object parameter) {
// OGNL 表达式评估
Object value = OgnlCache.getValue(expression, parameter);
// 关键:MyBatis 的 truthy 规则
// null、Boolean.FALSE、Number.ZERO、CharSequence.EMPTY、Collection.EMPTY、Map.EMPTY 都是 false
return Boolean.TRUE.equals(value) || (value instanceof Number && ((Number) value).doubleValue() != 0);
}
}
2.1 test 表达式的常见问题
// 问题1:test 中的空字符串判断
// <if test="name != ''">
// 注意:test="name != null" 不会自动排除空字符串!
// 空字符串 != null 为 true,会进入 if 块
// 问题2:字符串的 length 判断
// <if test="name != null and name.length > 0">
// 更好的写法:
// <if test="name != null and !name.isEmpty()">
// 问题3:集合的非空判断
// <if test="ids != null and ids.size > 0">
// 或
// <if test="ids != null and !ids.isEmpty()">
// 或(最简洁)
// <if test="ids != null and ids">
// OGNL 中非空集合自动转为 true
2.2 parameterObject 的绑定
// DynamicContext 的 bindings 包含两个键
public class DynamicContext {
public static final String PARAMETER_OBJECT_KEY = "param1"; // 单参数
// 如果是 @Param 注解,会注册多个键
public void bind(String name, Object value) {
bindings.put(name, value); // 可以手动注册额外变量
}
}
// 在 OGNL 中可以直接引用
// <if test="id == param1.id"> -- 单参数直接用字段
// <if test="id == _parameter.id"> -- _parameter 是 param1 的别名
// <if test="id == @com.xxx.MyEnum@A"> -- 调用静态方法
三、核心标签深度解析 🔴
3.1 Where 标签 — 智能 trim
// WhereSqlNode 本质上就是 TrimSqlNode
public class WhereSqlNode extends TrimSqlNode {
public WhereSqlNode(Configuration configuration, SqlNode contents) {
super(configuration, contents, "WHERE", "AND|OR|\\s+AND|\\s+OR",
null, null); // suffix 和 suffixOverrides 都是 null
}
}
// TrimSqlNode 的 apply 逻辑
public class TrimSqlNode implements SqlNode {
private final SqlNode contents;
private final String prefix; // 追加前缀
private final String suffix; // 追加后缀
private final List<String> prefixOverrides; // 要去掉的前缀模式
private final List<String> suffixOverrides; // 要去掉的后缀模式
@Override
public boolean apply(DynamicContext context) {
contents.apply(context); // 先让子节点处理
String sql = context.getSql();
// 去掉多余的前缀(关键!)
sql = applyOverrides(sql, prefixOverrides);
if (sql.length() > 0) {
sql = (prefix == null ? "" : prefix + " ") + sql;
}
// 去掉多余的后缀
sql = applyOverrides(sql, suffixOverrides);
if (sql.length() > 0) {
sql = sql + (suffix == null ? "" : " " + suffix);
}
context.setSql(sql);
return true;
}
private String applyOverrides(String sql, List<String> overrides) {
for (String pattern : overrides) {
// 使用正则去掉匹配的前缀
sql = sql.replaceFirst("(?i)" + pattern + " ", "");
}
return sql;
}
}
Where 标签的工作流程:
// 输入 SQL(子节点拼接后的结果)
String input = " AND name = #{name} AND age = #{age} ";
// 第一步:去掉 AND/OR 前缀
sql = "name = #{name} AND age = #{age}";
// 第二步:追加 WHERE 前缀
sql = "WHERE name = #{name} AND age = #{age}";
// 最终结果
sql = "WHERE name = #{name} AND age = #{age}";
⚠️
注意:Where 标签只会去掉 SQL 开头的 AND/OR。如果 AND/OR 出现在中间(比如子查询的 WHERE 条件中),不会被去掉。这是很多候选人踩的坑。
3.2 ForEach 标签 — 批量处理
<select id="findByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
// ForEachSqlNode.apply
public class ForEachSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String collectionExpr; // 表达式,如 "ids"
private final SqlNode contents; // 子节点
private final String open, close, separator;
@Override
public boolean apply(DynamicContext context) {
// 求值 collectionExpr,得到集合
Iterable<?> iterable = evaluator.evaluateIterable(collectionExpr,
context.getBindings());
if (!iterable.iterator().hasNext()) {
// 集合为空,直接返回,不追加任何内容
return true;
}
boolean first = true;
context.appendSql(open); // 追加 "("
for (Object item : iterable) {
if (!first) {
context.appendSql(separator); // 追加 ","
}
// 关键:为每个元素创建新的 binding
context.bind("item", item); // item 可以直接在子节点中使用
contents.apply(context);
first = false;
}
context.appendSql(close); // 追加 ")"
return true;
}
}
常见翻车点:
// 翻车1:collection 属性写错
// <foreach collection="idList"> // 错!应该是 ids,不是 idList
// 实际上,如果参数是 List,MyBatis 会自动推断 collection 名
// 但如果是数组或自定义对象,需要显式指定
// 翻车2:空集合导致语法错误
List<Long> ids = new ArrayList<>();
// 如果 ids 为空,生成的 SQL 是 "WHERE id IN ()" —— 语法错误!
// ForEachSqlNode 会判断集合为空时直接返回,不追加内容
// 但如果 where/in 块中有其他内容,可能导致问题
// 翻车3:item 的作用域
// item 只在 foreach 标签内部可用,不能在外部使用
3.3 Set 标签 — 智能更新
<update id="updateUser">
UPDATE user
<set>
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
<if test="age != null">age = #{age},</if>
</set>
WHERE id = #{id}
</update>
// SetSqlNode 本质上也是 TrimSqlNode
public class SetSqlNode extends TrimSqlNode {
public SetSqlNode(Configuration configuration, SqlNode contents) {
// suffixOverrides = "," —— 自动去掉末尾的逗号
super(configuration, contents, "SET", null, null, ",");
}
}
Set 标签的工作流程:
// 输入(子节点拼接)
String input = "name = #{name}, email = #{email}, age = #{age},";
// 第一步:去掉末尾的逗号
sql = "name = #{name}, email = #{email}, age = #{age}";
// 第二步:追加 SET 前缀
sql = "SET name = #{name}, email = #{email}, age = #{age}";
// 最终结果
sql = "SET name = #{name}, email = #{email}, age = #{age} WHERE id = #{id}";
💡
关键:Set 标签的 suffixOverrides 是 ,,意思是把 SQL 末尾的逗号去掉。但它不会去掉中间的逗号。如果所有字段都不为 null,最后一个逗号会被去掉;如果只有部分字段不为 null,中间的逗号也会保留(因为逗号不在末尾了)。
3.4 Trim 标签 — 通用智能处理
Trim 是 Where 和 Set 的通用版本:
<!-- 去掉开头多余的 AND/OR,加上 WHERE -->
<trim prefix="WHERE" prefixOverrides="AND|OR">
<if test='name != null'>AND name = #{name}</if>
<if test='age != null'>AND age = #{age}</if>
</trim>
<!-- 去掉末尾多余的逗号,加上 SET -->
<trim prefix="SET" suffixOverrides=",">
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
</trim>
四、Choose/When/Otherwise — 条件分支
<select id="findByCondition" resultType="User">
SELECT * FROM user
<where>
<choose>
<when test='type == "VIP"'>
AND level >= 3
</when>
<when test='type == "NEW"'>
AND create_time > DATE_SUB(NOW(), INTERVAL 7 DAY)
</when>
<otherwise>
AND status = 'ACTIVE'
</otherwise>
</choose>
</where>
</select>
// ChooseSqlNode:类似于 switch-case
public class ChooseSqlNode implements SqlNode {
private final SqlNode defaultSqlNode;
private final List<SqlNode> ifSqlNodes;
@Override
public boolean apply(DynamicContext context) {
// 按顺序评估每个 when 的条件
for (SqlNode sqlNode : ifSqlNodes) {
if (sqlNode.apply(context)) {
return true; // 第一个匹配的 when 完成后就返回
}
}
// 所有 when 都不匹配,执行 otherwise
if (defaultSqlNode != null) {
return defaultSqlNode.apply(context);
}
return true;
}
}
五、❌ 错误示范
翻车点一:不知道 Where 标签底层是 TrimSqlNode
候选人原话:"Where 标签就是自动加 WHERE 关键字..."
实际上 Where 标签会去掉 SQL 开头的 AND|OR,但不会去掉中间的。如果 SQL 是 WHERE name = #{name} OR age = #{age},Where 标签不会处理中间的 OR。
翻车点二:OGNL 表达式判断字符串为空
<!-- 错误写法:不会排除空字符串 -->
<if test="name != null">AND name = #{name}</if>
<!-- 正确写法:同时判断 null 和空字符串 -->
<if test="name != null and name != ''">AND name = #{name}</if>
<!-- 更简洁的写法:使用 length 或 isEmpty -->
<if test="name != null and !name.isEmpty()">AND name = #{name}</if>
翻车点三:foreach 的 collection 属性搞混
// 错误:参数是数组,但 collection 写的是单数形式
@Select("<script>SELECT * FROM user WHERE id IN " +
"<foreach collection='id' item='i' open='(' separator=',' close=')'>" +
"#{i}</foreach></script>")
List<User> findByIds(Long[] ids);
// 正确:数组用 array,List 用 list,或显式指定
@Select("<script>SELECT * FROM user WHERE id IN " +
"<foreach collection='array' item='i' open='(' separator=',' close=')'>" +
"#{i}</foreach></script>")
List<User> findByIds(Long[] ids);
// 或使用 @Param 注解明确命名
@Select("<script>SELECT * FROM user WHERE id IN " +
"<foreach collection='ids' item='i' open='(' separator=',' close=')'>" +
"#{i}</foreach></script>")
List<User> findByIds(@Param("ids") Long[] ids);
翻车点四:set 标签后仍有多余逗号
<!-- 如果只有 email 不为 null,生成的 SQL 是 -->
<!-- UPDATE user SET email = #{email}, WHERE id = #{id} —— 语法错误! -->
<!-- 实际上 SetSqlNode 会自动去掉末尾的逗号 -->
<!-- 所以结果是 -->
<!-- UPDATE user SET email = #{email} WHERE id = #{id} -->
<!-- 但如果 where/set 标签中的条件都不满足,会生成 -->
<!-- UPDATE user SET WHERE id = #{id} —— 语法错误! -->
六、标准回答
P5 级别:能说出标签作用
MyBatis 的动态 SQL 标签包括 if、where、foreach、set、choose 等。if 标签用于条件判断,where 标签用于自动处理 WHERE 关键字和多余的 AND/OR,foreach 用于批量处理,set 用于 UPDATE 语句中自动处理逗号。
P6 级别:能讲清 SqlNode 树形结构和 OGNL 表达式
每个动态标签对应一个 SqlNode,解析后形成树形结构。遍历这棵树,每个 SqlNode 的 apply 方法决定是否将自己的 SQL 片段追加到上下文中。Where 标签本质上是 TrimSqlNode,prefix = "WHERE",prefixOverrides = "AND|OR|\s+AND|\s+OR",自动去掉开头的 AND/OR 并追加 WHERE。ForEach 标签会对集合迭代,每次迭代都会绑定 item 到上下文中供子节点使用。OGNL 表达式在 test 属性中求值,遵循 MyBatis 的 truthy 规则:null、false、零值、空字符串、空集合都视为 false。
【面试官心理】
P6 能答出 SqlNode 树形结构和 TrimSqlNode 的 prefixOverrides 机制,已经超过了 80% 的候选人。我通常会追问:"foreach 标签中,如果 collection 为空集合会怎样?" 能答出"不会生成任何内容,避免了 IN () 的语法错误"的,说明真的看过源码。
P7 级别:能从解析性能角度分析
动态 SQL 的解析发生在两次:构建时和运行时。构建时解析 SqlNode 树(这一步在 XML 解析时完成),运行时遍历 SqlNode 树拼接 SQL。对于没有动态标签的 SQL,MyBatis 直接创建 RawSqlSource,跳过运行时解析,性能更好。生产中如果动态 SQL 非常复杂,可以考虑预编译 SQL 片段或使用 MyBatis-Plus 的 Lambda 查询来提升可维护性。
七、追问升级 🟡
追问1:$ 和 # 的区别是什么?
#{} 使用 PreparedStatement 的占位符 ?,参数以安全的方式设置,防止 SQL 注入
${} 是直接字符串替换,存在 SQL 注入风险,但可以用于动态表名、列名等场景
追问2:Bind 标签有什么用?
<!-- Bind 标签用于创建 OGNL 表达式中可用的变量 -->
<select id="findByName" resultType="User">
<bind name="pattern" value="'%' + name + '%'"/>
SELECT * FROM user WHERE name LIKE #{pattern}
</select>
追问3:Trim 和 Where/Set 哪个更通用?
Trim 是最通用的,Where 和 Set 都是 Trim 的特例:
- Where = Trim(prefix="WHERE", prefixOverrides="AND|OR|\s+AND|\s+OR")
- Set = Trim(prefix="SET", suffixOverrides=",")
【面试官心理】
如果候选人能说出"动态 SQL 的解析是一次性的,SqlNode 树在 Mapper 初始化时就构建好了,运行时只需要遍历"的,说明对 MyBatis 的生命周期有全局理解。这是 P7 级别的加分项。