动态 SQL 的坑几乎都是运行时才炸:<if> 条件写松了拼出空 WHERE,foreach 超参数上限直接报 ORA-01795,ResultMap 里 column 拼错一个字母,字段静默变 null——不报错、不告警,下游取空值时才被发现。
下面把九种标签的判据、where/set 的剥离逻辑、OGNL 必须背下来的写法,以及 ResultMap 的四种映射形态摊开。
1. 九个标签与选择判据
| 标签 | 解决什么 | 什么时候用 |
|---|---|---|
<if> |
单条件拼接 | 可选筛选 |
<choose>/<when>/<otherwise> |
多选一,首个命中即停 | 互斥分支(排序、状态机) |
<where> |
拼 WHERE 并剥掉开头多余 AND/OR |
多条件动态查询首选 |
<set> |
拼 SET 并剥掉尾部多余逗号 |
动态更新 |
<trim> |
自定义前后缀与剥离字符 | where/set 表达不了时 |
<foreach> |
集合展开 | IN (...)、批量 values |
<bind> |
表达式绑成变量 | 拼 like 的 %、复用计算结果 |
<sql>/<include> |
片段复用 | 列清单、公共条件 |
<selectKey> |
插入前后取主键 | 序列、非自增主键 |
选择顺序一句话:能用 <where> 就别用 <trim>,能用 <trim> 就别手拼 WHERE 1=1——后者不报错,但日志难读。
<select id="findOrders" resultMap="orderMap">
SELECT id, user_id, status, created_at FROM orders
<where>
<if test="userId != null">AND user_id = #{userId}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="begin != null">AND created_at >= #{begin}</if>
<if test="end != null">AND created_at < #{end}</if>
</where>
ORDER BY id DESC
</select>
2. where / set 的剥离机制
<where> 内部是一个 TrimSqlNode:前缀 WHERE、前缀覆盖 AND |OR 。逻辑是只有内部有内容才加前缀,加了前缀才剥掉紧跟着的那一小段。
<set> 同理:后缀 SET、后缀覆盖 ,,只剥末尾一个逗号,SET a=1,, 依旧语法错。
<trim> 是通用形式:
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="userId != null">AND user_id = #{userId}</if>
</trim>
<trim prefix="SET" suffixOverrides=",">
<if test="name != null">name = #{name},</if>
</trim>
两个易错点:prefixOverrides 大小写敏感,写 and 匹配不到 AND;分隔符里的尾空格有意义("AND |OR " 是"AND+空格""OR+空格"两个候选),漏掉就剥不干净。
3. <if test> 的 OGNL 陷阱
test 是 OGNL 表达式,不是 Java:
| 写法 | 结果 | 正确写法 |
|---|---|---|
status != null |
空串 "" 也通过,拼出 status = '' |
status != null and status != '' |
flag == 'Y' |
'Y' 被当 char,与 String 比较恒假 |
flag == "Y"(XML 里写 ")或 .equals |
a != null && b != null |
XML 里 & 要转义 |
用 and/or,或写 && |
type == 1 |
字面量是 Integer/Long,String 字段恒假 |
type == '1' 或先转型 |
单字符最阴:"Y".equals('Y') 为 false,条件恒假,SQL 里那段条件永远不出现——查询范围悄悄变大。另外 test 里不要调方法,@java.lang.Integer@parseInt(...) 这类静态调用会被 OGNL 安全限制拦掉。
4. foreach 的三属性与 IN 上限
<select id="findByIds" resultType="Order">
SELECT * FROM orders WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
| 属性 | 作用 | 常见错误 |
|---|---|---|
collection |
遍历对象 | 单参数没加 @Param 时靠猜 list/array |
item |
循环变量名 | 与外层 #{} 重名,取错值 |
separator/open/close |
分隔与括号 | 忘写 open/close,拼出 IN 1,2,3 |
collection 的解析顺序:先看 @Param 名字,再看 ParamNameResolver 给单参数集合塞的 list/array/collection 键。所以 findByIds(List<Long> ids) 要写 collection="list",加了 @Param("ids") 才能写 "ids"。
IN 上限是硬约束:Oracle 列表不超过 1000 项(ORA-01795),PostgreSQL 绑定参数上限 65535,MySQL 受 max_allowed_packet 与语句长度限制。超阈值要分片查询再合并,这一步放 Service,别指望 <foreach> 兜住。
5. ResultMap:什么时候必须写
resultType 靠列名到属性的默认规则自动映射;resultMap 是显式声明。四种情况必须写:
| 场景 | 为什么 resultType 不行 |
|---|---|
| 列名与属性名不一致且无全局驼峰 | 自动映射找不到属性,字段 null |
| 一对一/一对多关联 | 需要 association/collection 组织层级 |
需要 typeHandler |
只能在 <result> 上指定 |
join 出同名列(两个 id、name) |
自动映射互相覆盖 |
<resultMap id="orderMap" type="com.example.Order">
<id column="id" property="id"/>
<result column="user_id" property="userId"/>
<result column="created_at" property="createdAt"
typeHandler="com.example.InstantTypeHandler"/>
<association property="user" javaType="User"
column="user_id" select="findUserById"/>
<collection property="items" ofType="OrderItem"
column="id" select="findItemsByOrderId"/>
</resultMap>
驼峰转换有三个配置位,优先级从高到低:<resultMap> 的显式 column(不参与转换)→ mybatis-config.xml 的 mapUnderscoreToCamelCase → Spring Boot 的 mybatis.configuration.map-underscore-to-camel-case。多模块项目最常见的坑是 A 模块配在 SqlSessionFactoryBean、B 模块用 XML,两处不一致,同一实体在不同 Mapper 里一个能映射一个不能。
column 与 property 拼错不会报错:显式声明覆盖了自动映射,属性留 null。定位靠日志——logging.level.com.example.mapper=debug 打出结果集列名再对照。
6. 嵌套映射:嵌套查询 vs 嵌套结果
| 方式 | 写法 | SQL 次数 | 适用 |
|---|---|---|---|
| 嵌套查询 | select="findItemsByOrderId" |
1 + N | 关联数据少、可复用语句、开懒加载 |
| 嵌套结果 | 单条 join + <collection> 内联 <result> |
1 | 一对多、数据量可控 |
<resultMap id="orderWithItems" type="Order">
<id column="order_id" property="id"/>
<result column="order_status" property="status"/>
<collection property="items" ofType="OrderItem">
<id column="item_id" property="id"/>
<result column="item_name" property="name"/>
</collection>
</resultMap>
<collection> 里的 <id> 是分组键:DefaultResultSetHandler 靠它判断这行是否同一父对象。缺 <id> 或 join 后 id 有重复值,collection 会被合并成一个对象或数量错乱——这就是"一对多 join 后行数变多、对象数变少"的根因。
N+1 可以用 fetchType="lazy" 缓解,但懒加载在 session 关闭后抛 LazyInitializationException;真解法是改嵌套结果,或先查父 id 集合、再一次 IN 查子表在 Service 里分组。
7. discriminator 与 typeHandler
<resultMap id="vehicleMap" type="Vehicle">
<id column="id" property="id"/>
<discriminator javaType="string" column="vehicle_type">
<case value="CAR" resultMap="carMap"/>
<case value="TRUCK" resultMap="truckMap"/>
</discriminator>
</resultMap>
discriminator 按列值切换映射,适合单表继承。限制明确:判据列必须在同一行结果集里,所以只能配嵌套结果;<case> 的 resultMap 要显式继承父映射公共字段,否则公共列丢失。
typeHandler 做 Java 与 JDBC 类型转换,继承 BaseTypeHandler,实现 setNonNullParameter 与三参的 getNullableResult:
@MappedTypes(Instant.class)
@MappedJdbcTypes(JdbcType.TIMESTAMP)
public class InstantTypeHandler extends BaseTypeHandler<Instant> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
Instant p, JdbcType t) throws SQLException {
ps.setTimestamp(i, Timestamp.from(p));
}
@Override
public Instant getNullableResult(ResultSet rs, String col) throws SQLException {
Timestamp ts = rs.getTimestamp(col);
return ts == null ? null : ts.toInstant();
}
}
注册有三处:<typeHandlers> 全局包扫描、<result> 单个指定、#{} 里的 typeHandler=。全局注册时 javaType/jdbcType 都要能推出来,否则遇到 NULL 会落到 UnknownTypeHandler 猜错类型。
8. useGeneratedKeys 回填
<insert id="insert" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
INSERT INTO orders (user_id, status) VALUES (#{userId}, #{status})
</insert>
keyProperty 指定生成值写回参数的哪个属性(支持 info.id 嵌套),keyColumn 只在 PostgreSQL 这类要显式指定返回列时有用。非自增主键用 <selectKey>,order="BEFORE" 先取值再插入,AFTER 则插入后取。
批量插入能否回填取决于驱动与 rewriteBatchedStatements 等参数,MySQL 可回填、Oracle 不行,按驱动版本核对。
9. 排错:六种静默失败
| 现象 | 原因 | 处理 |
|---|---|---|
WHERE 后无内容、语法错 |
所有 <if> 不成立,<where> 仍出前缀 |
加兜底条件 |
| 条件恒不生效、范围变大 | status != null 放过空串 |
补 and status != '' |
flag == 'Y' 永远为假 |
单引号解析成 char |
双引号或 .equals |
字段全 null 无异常 |
column 拼错、驼峰配置不一致 |
Mapper 包 debug 日志核对列名 |
| 一对多数量偏少 | <collection> 缺 <id> 或分组键重复 |
补 <id>,确认 id 唯一 |
IN 查询报错 |
列表超数据库上限 | 分片,阈值按版本核对 |
10. 边界
| 需求 | 动态 SQL | 更合适的位置 |
|---|---|---|
| 十几个可选筛选 | 是 | — |
| 排序字段拼接 | 否(${} 有注入面) |
服务端白名单映射 |
| 多表复杂聚合 | 勉强 | 宽表或专用语句 |
| 分页 | 不必手写 | 统一交给分页插件 |
底线:${} 只用于结构(表名、列名、排序方向)且来自白名单,值一律 #{}。动态 SQL 也要有复杂度上限——三层以上的 <choose> 嵌套,说明该拆成多条语句在 Service 里组合。
11. 自测
<where>剥掉多余AND的前提条件是什么?test="status != null"为什么会放过空字符串?test="flag == 'Y'"恒为假的原因是什么?collection="list"这个键名是谁放进去的?<collection>里<id>缺失会导致什么后果?useGeneratedKeys在批量插入时能否回填,取决于什么?