MyBatis(一):SQL 安全、参数绑定与动态 SQL
MyBatis(一):SQL 安全、参数绑定与动态 SQL
导语:MyBatis 的第一层考点是「SQL 安全」——
#{}与${}的区别能背,但能讲清"预编译为什么能防注入"、${}到底什么时候非用不可、怎么把它用安全,才算过关。第二层是「参数绑定」——多参、自增主键、jdbcType/TypeHandler这些"一用就出错"的细节。第三层是「动态 SQL」——foreach/where/set/trim/bind/sql-include的用法与底层SqlNode原理,共 13 题。
一、#{} 与 ${}:SQL 注入防护
1. #{} 和 ${} 有什么区别?
答: 一句话——#{} 是"参数占位符"(预编译),${} 是"文本替换"(字符串拼接)。
| 维度 | #{} | ${} |
|---|---|---|
| 处理阶段 | SQL 解析时替换为 ? 占位符,运行时由 ParameterHandler 绑定 | 在动态 SQL 拼接阶段(进入数据库前)直接文本替换 |
| 底层实现 | PreparedStatement + setXxx() | 等价于 JDBC Statement 字符串拼接 |
| 参数类型处理 | 走 TypeHandler,可指定 javaType/jdbcType/typeHandler | 纯字符串拼接,类型由数据库隐式推断 |
| 是否加引号 | 由数据库/JDBC 决定(字符串会自动处理) | 不加引号,需要引号要自己写 '${}' |
| 安全性 | 安全(防 SQL 注入) | 有注入风险 |
| 典型场景 | 所有参数值 | SQL 元结构:表名、列名、ORDER BY 字段、动态 schema |
// #{name} 的最终效果(安全:预编译 + 参数绑定)
select * from user where name = ? ;
ps.setString(1, name);
// ${name} 的最终效果(危险:直接拼进 SQL 文本)
select * from user where name = '张三' ;结论:能用
#{}就绝不用${};${}只用于"拼的是 SQL 结构而不是值"的场景,且必须做白名单校验(见第 3 题)。
两个容易忽略的细节(加分点):
${}与#{}都支持 OGNL 表达式:#{user.name}、${user.table}、#{list[0].id}都合法,区别只在"绑定成?"还是"拼成文本"。#{}的完整写法:#{property,javaType=int,jdbcType=NUMERIC,typeHandler=XxxHandler}——当参数为null或需要特殊 JDBC 类型时,jdbcType是必须的(见第 7 题)。
2. 为什么 #{} 能防止 SQL 注入,而 ${} 不能?
答: 注入的根源是用户输入被当成了 SQL 代码。两者在"输入有没有机会变成语法"这一点上根本不同:
#{}(预编译):SQL 的结构在数据库侧先编译定型(select * from user where name = ?),参数通过独立的协议通道后传,只被当作"值"。即使传入1' OR '1'='1,它也只是"一个名字叫1' OR '1'='1的字符串",比较结果为空而已,无法改变 SQL 语义。${}(字符串替换):MyBatis 在解析阶段就把值拼进了 SQL 文本,恶意输入会改写 SQL 结构:
-- 模板:select * from user where name = '${name}'
-- 输入:' OR '1'='1
select * from user where name = '' OR '1'='1' -- 条件恒真 → 全表泄露必须说清的三个认知(面试官爱追问):
- 防注入的是"预编译 + 参数绑定"机制,而不是"转义"——参数从不参与 SQL 语法解析,转义只是附带效果;
#{}并非绝对安全,${}也并非一定危险——危险与否取决于"输入是否可控"。若${}的值来自服务端固定枚举,风险为零;ORDER BY这类"结构位"天生无法用?——因为?只能占"值"的位置,不能占标识符的位置,这就是${}存在的唯一理由。
一句话:
#{}让输入永远是"数据",${}让输入有机会成为"代码"。
3. ${} 在什么场景下必须使用?如何安全地使用?
答:
必须用 ${} 的场景(都是"SQL 元结构"):
| 场景 | 示例 | 说明 |
|---|---|---|
| 动态表名 / schema | select * from ${tableName} | 分表场景(如按月分表 order_202608) |
| 动态列名 | select ${columns} from ... | 按需返回字段(一般用 resultMap 更稳) |
| 动态排序字段 | order by ${sortField} ${sortOrder} | 最典型,也最常被拿来考注入 |
| SQL 片段的关键字 | 如动态 ASC/DESC | 同上 |
| 复用片段传参 | <include> 中的 ${alias} | 见第 12 题 |
安全用法(面试必答的"三道防线"):
- 白名单/枚举映射(最关键):绝不做"用户传什么就拼什么",而是把用户输入映射到服务端固定集合:
// ✅ 正确:用户传 "price",服务端映射到固定列
private static final Map<String, String> SORT_MAP = Map.of(
"price", "price", "time", "create_time", "sales", "sale_count");
String column = SORT_MAP.get(sortField); // 非法值得到 null
if (column == null) throw new IllegalArgumentException("非法排序字段");<!-- ✅ 再用 choose 兜一层(即使 Service 漏校验也不会注入) -->
<choose>
<when test="sortField == 'price'">order by price</when>
<when test="sortField == 'time'">order by create_time</when>
<otherwise>order by id</otherwise>
</choose>- 格式强校验:如用正则约束
^[A-Za-z_][A-Za-z0-9_]*$,拒绝空格、括号、引号、分号等一切"能构造表达式"的字符; - 最小权限:数据库账号不做 DDL、限制可用库表,即使被注入也降低影响面。
反面案例:
order by ${sortField}被传入(case when (select substr(password,1,1) from user limit 1)='a' then 1 else 2 end)即可做布尔盲注——这就是"${}不只有or 1=1这一种玩法"。
4. 模糊查询 like 怎么写才安全?
答: #{} 不能直接写在引号里('%#{name}%' 这种写法在 MyBatis 中不会被当成占位符替换,会变成字面量),所以有三种正确写法:
方式一:CONCAT 函数(最推荐,标准 SQL)
<select id="likeName" resultType="User">
select * from user where name like concat('%', #{name}, '%')
</select>方式二:<bind> 标签(数据库无关性最好)
<select id="likeName" resultType="User">
<bind name="pattern" value="'%' + name + '%'"/>
select * from user where name like #{pattern}
</select>方式三:Java 侧拼好再传入(最直观)
String keyword = "%" + input.trim() + "%";
mapper.likeName(keyword); // XML 里写 like #{name}三个必须提醒的点:
- 不要用
like '%${name}%':不仅可能注入,还会因为不加引号导致 SQL 语法错误; - Oracle 用
||拼接('%' || #{name} || '%'),或在 Java/<bind>里统一处理(这也是<bind>的价值); %与_通配符的转义:#{}只防注入、不转义通配符。用户输入100%会变成"以 100 开头",需要业务上做转义(ESCAPE子句或 Java 侧替换)。
二、参数绑定与主键回填
5. Mapper 方法传递多个参数有哪几种方式?
答:
| 方式 | 写法 | XML 引用 | 评价 |
|---|---|---|---|
@Param 注解 | find(@Param("name") String n, @Param("age") Integer a) | #{name}、#{age} | 最推荐,语义清晰 |
| 封装成 POJO / Map | find(UserQuery query) | #{name}(属性名)、#{mapKey} | 参数多时推荐(可复用查询对象) |
| 顺序占位 | find(String n, Integer a) | #{param1}、#{param2} | 可读性差,不推荐 |
| 真实参数名 | 同上,且编译保留了参数名 | #{n}、#{a} | 依赖编译信息,不要依赖 |
底层机制(这才是加分项):ParamNameResolver
- 单参数且不是集合/数组:不封装,直接以该对象作为参数(所以
find(User u)可以直接#{name}取属性); - 多参数,或单参数是集合/数组:封装为
ParamMap,key 包括:@Param指定的名字(优先级最高);param1、param2…(按位置生成的通用名);- 单参集合还会额外放入
list/array/collection三个 key(wrapToMapIfCollection)。
- 因此集合参数既可用
#{list},也能用#{param1}、#{collection}——但混用极易出错,统一用@Param最稳。
// ✅ 推荐写法
List<User> find(@Param("ids") List<Long> ids, @Param("status") Integer status);
// XML:collection="ids"(显式命名,不受集合类型影响)
<foreach collection="ids" item="id" open="(" close=")" separator=",">#{id}</foreach>一句话:单参直接取属性,多参封
ParamMap;@Param是唯一不踩坑的写法。
6. 如何获取自增主键?useGeneratedKeys 和 selectKey 有什么区别?
答:
方式一:useGeneratedKeys(推荐,MySQL/SQL Server 支持)
<insert id="insert" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
insert into user(name, age) values(#{name}, #{age})
</insert>执行后 MyBatis 会把数据库生成的自增主键回填到入参对象的 id 属性(底层用 Statement.getGeneratedKeys()),业务代码直接读 user.getId() 即可。
方式二:selectKey(适合 Oracle 序列、或不支持自增主键的库)
<insert id="insert">
<selectKey keyProperty="id" resultType="long" order="BEFORE">
select seq_user.nextval from dual
</selectKey>
insert into user(id, name) values(#{id}, #{name})
</insert>两者对比:
| 维度 | useGeneratedKeys | selectKey |
|---|---|---|
| 主键来源 | 数据库自增(AUTO_INCREMENT/IDENTITY) | 额外执行一条 SQL(序列/函数) |
| 执行时机 | 与 insert 一体,靠 JDBC getGeneratedKeys | 由 order 决定:BEFORE(先取主键再插入)/AFTER |
| 适用数据库 | MySQL、SQL Server | Oracle(序列)、任意数据库 |
| 性能 | 好(无额外往返) | 多一次 SQL 往返 |
两个坑:
- 批处理(
ExecutorType.BATCH)下拿不到主键——因为addBatch()尚未执行,必须commit()之后才有值(见《MyBatis(二)》); keyColumn在部分数据库(如 PostgreSQL)必须显式指定,否则回填失败。
7. 参数为 null 时报错是怎么回事?jdbcType 和 TypeHandler 有什么用?
答: 这是只写 #{} 在新环境突然报错的经典原因。
现象:insert into user(name) values(#{name}),当 name = null 时,Oracle 报 无效的列类型: 1111(Invalid column type)。
原因:#{} 最终会调用 JDBC 的 setNull(index, jdbcType),setNull 必须知道 JDBC 类型。MyBatis 在参数为 null 时无法推断类型,只能使用全局配置 jdbcTypeForNull,默认值是 OTHER。MySQL 驱动对 OTHER 宽容,而 Oracle 等驱动直接报错。
解决方案(三选一):
<!-- ① 单个字段指定 jdbcType(最精确) -->
insert into user(name) values(#{name,jdbcType=VARCHAR})
<!-- ② 全局兜底(mybatis-config.xml / Spring 配置) -->
<settings><setting name="jdbcTypeForNull" value="NULL"/></settings>
<!-- ③ 自定义 TypeHandler 统一处理 -->
<typeHandlers><typeHandler handler="com.x.NullSafeStringHandler" javaType="String"/></typeHandlers>TypeHandler 的作用与选择机制:
- 作用:负责 Java 类型 ↔ JDBC 类型的双向转换(
setParameter()写参、getResult()读结果); - 内置实现:
StringTypeHandler、IntegerTypeHandler、DateTypeHandler、EnumTypeHandler(按name())、EnumOrdinalTypeHandler(按ordinal())、BooleanTypeHandler等; - 选择机制:MyBatis 启动时把所有 TypeHandler 注册进
TypeHandlerRegistry,运行时按 (javaType, jdbcType) 匹配;jdbcType为 null 时按javaType匹配; - 自定义:继承
BaseTypeHandler<T>,在 Mapper 中通过#{prop,typeHandler=XxxTypeHandler}或全局typeHandlers注册。
一句话:
null报错的根因是"数据库不知道这个 null 是什么类型",指定jdbcType或把jdbcTypeForNull设为NULL即可解决。
三、动态 SQL
8. 动态 SQL 有什么作用?有哪些常用标签?
答: 动态 SQL 让在 XML 里按参数条件拼 SQL 成为可能,避免在 Java 里用 StringBuilder 手工拼接(既丑又容易漏空格/逗号)。
常用标签(10 个,建议按功能分组记忆):
| 分组 | 标签 | 作用 |
|---|---|---|
| 条件 | if | 单条件判断 |
choose / when / otherwise | 多分支(只命中第一个成立的分支,类似 switch) | |
| 拼接治理 | where | 自动加 WHERE 并去掉开头多余的 AND/OR |
set | 自动加 SET 并去掉结尾多余的逗号 | |
trim | 通用裁剪(prefix/prefixOverrides/suffix/suffixOverrides) | |
| 遍历与变量 | foreach | 遍历集合拼 SQL(IN、批量插入) |
bind | 用 OGNL 定义一个变量供后续引用 | |
| 复用 | sql + include | SQL 片段抽取与引用(支持传参) |
底层原理(加分点,面试常用来区分"用过"与"懂"):
MyBatis 把 <select> 解析成一棵 SqlNode 组合树,运行时递归调用 apply() 拼出最终 SQL:
| 标签 | 对应 SqlNode |
|---|---|
if | IfSqlNode |
choose/when/otherwise | ChooseSqlNode |
foreach | ForEachSqlNode |
where | WhereSqlNode(继承 TrimSqlNode) |
set | SetSqlNode(继承 TrimSqlNode) |
trim | TrimSqlNode |
bind | VarDeclSqlNode |
条件判断与取值全部由 OGNL 表达式完成(如 test="name != null and name != ''");含 #{}/${} 的节点就是 TextSqlNode / MixedSqlNode,最终产出 DynamicSqlSource 或 RawSqlSource(不含 ${} 且静态的 SQL 会在启动时就被编译成 StaticSqlSource,性能更好)。
追问"
if和choose的区别":if是多个条件可同时成立(会全部拼接);choose是多选一(第一个成立的生效,其余忽略)。凡是"互斥分支"(如排序字段)用choose。
9. foreach 标签的作用和关键属性?
答: 用于遍历集合/数组拼接 SQL,典型场景是 IN 查询与批量插入。
六个属性:
| 属性 | 必填 | 说明 |
|---|---|---|
collection | ✅ | 要遍历的集合,取值规则见第 10 题 |
item | 每个元素的别名(#{item} 引用) | |
index | 遍历下标(List 为序号,Map 为 key) | |
open / close | 拼接的起始/结束符号,如 ( ) | |
separator | 元素间的分隔符,如 , |
<!-- IN 查询 -->
<select id="byIds" resultType="User">
select * from user where id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</select>
<!-- 批量插入:一次 SQL 插多行(MySQL 推荐) -->
<insert id="batchInsert">
insert into user(name, age) values
<foreach collection="list" item="u" separator=",">
(#{u.name}, #{u.age})
</foreach>
</insert>三个实践注意点:
IN元素过多要当心:会生成超长 SQL(如 1 万个 id),既可能触及 SQL 长度限制,也无法复用执行计划。建议分批(如每批 500~1000),或改用临时表/JOIN;IN为空集合时:foreach会拼出in ()→ SQL 语法错误。必须在外层用<if test="ids != null and ids.size() > 0">兜住,或补一个恒假条件(面试常考的细节);index在 Map 上是 key,在 List 上是序号——用index当#{index}传参时要看清集合类型。
10. foreach 的 collection 属性在不同传参下怎么写?
答: 这是实际开发最常报 Parameter 'xxx' not found 的地方。规则如下:
| Mapper 方法签名 | collection 取值 |
|---|---|
find(List<Long> ids) | list(或 collection) |
find(Long[] ids) | array(或 collection) |
find(@Param("ids") List<Long> ids) | ids(@Param 优先级最高,推荐) |
find(UserQuery q),q 里有 ids 字段 | ids(对象属性名) |
find(Map<String,Object> map) | map 中对应的 key |
find(List<Long> ids, Integer status) | 多参:@Param 名,或 list/param1(极易踩坑,务必 @Param) |
记忆口诀:单参集合用 list/array/collection,其他一律用 @Param 显式命名。
追问"为什么单参 List 有时
list能用、有时要用collection?"——ParamNameResolver对集合/数组参数会同时放入list(List)、array(数组)、collection(都放)三个 key,所以三者都能命中;但一旦是多参数,集合就会被包进ParamMap,此时只有param1/@Param名有效——这就是"同样的 XML,加了第二个参数就报错"的原因。
11. where / set / trim 标签分别解决什么问题?
答: 三者解决的都是"动态拼接后多出连接符"的问题:
| 标签 | 等价 trim 写法 | 解决的问题 |
|---|---|---|
<where> | prefix="WHERE" + prefixOverrides="AND | OR | AND\n | OR\n ..." | 条件全空时不输出 WHERE;首个条件前多余 AND/OR 被去掉 |
<set> | prefix="SET" + suffixOverrides="," | 去掉 update 列表末尾多余的逗号;全空时不会输出 SET |
<trim> | 自定 prefix/suffix/prefixOverrides/suffixOverrides | 通用的前后裁剪(where/set 的父类能力) |
<select id="search" resultType="User">
select * from user
<where>
<if test="name != null and name != ''">and name = #{name}</if>
<if test="age != null">and age = #{age}</if>
</where>
</select>
<update id="updateSelective">
update user
<set>
<if test="name != null">name = #{name},</if>
<if test="age != null">age = #{age},</if>
</set>
where id = #{id}
</update>三个易错点:
<where>只去掉"第一个"多余的连接符,中间条件写错(如name = #{name} and)它不会救你;where 1=1是旧写法:能用<where>就用,但在索引优化/执行计划上看1=1无影响,只是写法不优雅;<set>全条件为空时会拼出空SET→ 语法错误,业务上应先校验"至少有一个待更新字段"。
12. bind 标签与 sql/include 标签分别有什么用?OGNL 怎么用?
答:
(1)<bind>:在 SQL 里定义变量
<select id="likeName" resultType="User">
<bind name="pattern" value="'%' + name + '%'"/>
select * from user where name like #{pattern}
</select>- 常用于统一模糊查询的拼接方式(屏蔽 MySQL
concat/ Oracle||的差异); - 也可做参数转换,如
<bind name="offset" value="(pageNum - 1) * pageSize"/>; - 注意:
<bind>的值是 OGNL 表达式,字符串拼接用+,函数调用用@类@方法()。
(2)<sql> + <include>:SQL 片段复用
<sql id="userColumns">id, name, age, create_time</sql>
<sql id="queryByTable">
select ${columns} from ${table} where status = 1
</sql>
<select id="list" resultType="User">
select <include refid="userColumns"/> from user where id = #{id}
</select>
<!-- include 支持 property 传参(用 ${} 引用,不会预编译) -->
<include refid="queryByTable">
<property name="columns" value="id, name"/>
<property name="table" value="user"/>
</include>${}在<include>中用于替换片段里的占位,不支持#{},所以传入值仍要白名单校验;- 好处是统一列名/条件,改一处生效全站(比
resultMap更偏"SQL 文本复用")。
(3)OGNL 在 MyBatis 中的高频用法
| 场景 | 写法 |
|---|---|
| 非空判断 | test="name != null and name != ''" |
| 集合判断 | test="ids != null and ids.size() > 0" |
| 取参数对象 | #{_parameter.name}(单参 Map/对象时可用) |
| 调用静态方法 | test="@java.util.Objects@nonNull(name)" |
| 比较字符 | 用 'x'.equals(name) 或 name == 'x'(单字符要小心,OGNL 会把 'x' 当 char 比较 → 建议 name == 'x'.toString()) |
| XML 特殊字符 | <、> 在 test="" 中需写成 <、> |
高频坑:
<if test="status == '1'">——若status是 String,OGNL 的==对单字符字面量会按 char 解释,导致"相等却不成立"。统一写status == '1'.toString()或"1".equals(status)。
13. 实体属性名与数据库字段名不一致怎么办?
答: 三种方式,按"改动范围从小到大"排列:
| 方式 | 做法 | 适用 |
|---|---|---|
| SQL 别名 | select user_name as userName from ... | 单条 SQL 的临时处理 |
| 全局驼峰映射(最常用) | mapUnderscoreToCamelCase=true(Spring Boot 下为 mybatis.configuration.map-underscore-to-camel-case=true) | 字段遵循 user_name → userName 规范 |
resultMap 显式映射(最稳) | <resultMap id="userMap" type="User"><result column="user_name" property="userName"/></resultMap> | 字段名不规范、需复杂映射、多表 join |
三个延伸点:
- 优先级:
resultMap显式配置 > 自动映射(autoMappingBehavior,默认PARTIAL,即"无嵌套结果时自动映射";FULL会连嵌套结果一起自动映射,风险更高); - 驼峰映射对"结果映射"生效、对"参数"不生效:
#{userName}取的是 Java 属性,与列名无关; - 字段多且规范时,用
resultMap的<id>明确主键(影响嵌套查询/缓存的CacheKey与去重),仍值得写。
一句话:规范命名 +
mapUnderscoreToCamelCase覆盖 90% 场景;复杂映射必须resultMap。
