MyBatis 动态 SQL 与 TypeHandler:Java 值如何变成可执行语句
动态 SQL 最危险的地方不是 XML 难看,而是最终 SQL 和参数映射直到运行时才确定。一个空条件可能变成全表更新,一个 null 可能因 JdbcType 不明确而被驱动拒绝,一个枚举重命名也可能让历史行无法读取。需要追到 BoundSql 才能看见真正执行的契约。
BoundSql:动态 SQL 的最终落点
场景说明:XML 里的 <if>、<where>、<foreach> 只是模板;数据库真正看到的是 BoundSql 里的最终 SQL 和参数列表。慢 SQL 排查、分页插件、SQL 注入审查,都应该落到 BoundSql。
MyBatis 默认的 XML 动态 SQL 由 XmlLanguageDriver 处理。静态 SQL 通常会变成较简单的 SqlSource;只要出现动态标签或 ${},就会在执行时根据参数重新计算 SQL。#{} 会变成 JDBC 占位符和 ParameterMapping,${} 会直接替换成文本。
BoundSql 和参数绑定的关系可以按下面这条链路排查:
这张图排障时很好用:Parameter 'xxx' not found 先看 ParamNameResolver,null 绑定报错先看 JdbcType 和 TypeHandler,SQL 注入审查直接看用户输入有没有绕过 ParameterMapping 进入 ${}。设计评审时要求关键列表查询把 BoundSql.sql、参数列表和排序白名单都能讲清楚,否则后面分页插件、慢 SQL 日志和数据库执行计划很容易对不上。
直接做法,用这三条规则审查动态 SQL:
SQL 结构变化:交给 <if>、<choose>、<where>、<set>、<foreach>。
参数值绑定:默认用 #{},让 JDBC PreparedStatement 绑定。
标识符拼接:只有表名、列名、排序方向这类无法用 #{} 的位置,才允许白名单后的 ${}。拿排序举例,前端传的是枚举,不是 SQL 片段:
public String resolveSortClause(OrderSort sort) {
if (sort == null) {
return "created_at desc, id desc";
}
return switch (sort) {
case CREATED_ASC -> "created_at asc, id asc";
case CREATED_DESC -> "created_at desc, id desc";
case AMOUNT_DESC -> "amount desc, id desc";
};
}XML 里再使用白名单后的值:
order by ${sortClause}验证结果,拿 BoundSql 打印最终 SQL,并确认:
普通用户输入是否都变成了 ?。
排序字段是否只来自枚举白名单。
空集合、空字符串、空时间范围是否不会变成全表查询。常见坑:
@Param 名称和 XML 里使用的名称不一致,test 表达式一直为 false。<where> 可以去掉开头的 and,但不能替你决定“没有任何条件时是否允许查询”。foreach nullable="true" 只能处理标签输出问题,不能替代业务上的集合大小限制。
like #{keyword} 不会自动加 %,要在 Java 层或 <bind> 明确生成。
生产建议:动态 SQL 越灵活,越要限制入参形态。核心列表查询要把高频条件、排序、分页方式写成清楚的参数对象,不要让一个 XML 同时服务后台列表、导出、定时任务和外部接口。
ParamNameResolver:XML 里的名字从哪里来
场景说明:很多 BindingException: Parameter 'xxx' not found 看起来像 XML 写错了,其实是 Mapper 方法参数名和运行时参数名不一致。MyBatis 会通过 ParamNameResolver 把 Java 方法参数解析成可供 OGNL 使用的名字:单参数对象可以直接取属性,多参数默认会有 param1、param2 这类兜底名;显式写了 @Param 后,XML 应优先使用注解名。
直接做法是:核心 Mapper 多参数一律显式 @Param,列表查询优先封装参数对象。这样 XML 的参数名就不会依赖编译器是否保留 -parameters,也不会因为方法签名调整而悄悄漂移。
Optional<OrderDetailRow> findDetailForCustomer(@Param("orderNo") String orderNo,
@Param("customerId") Long customerId);对应 XML:
<select id="findDetailForCustomer" resultMap="OrderDetailMap">
select id, order_no, customer_id, status, amount
from biz_order
where order_no = #{orderNo}
and customer_id = #{customerId}
</select>验证方式很简单:写一个最小集成测试,让 Mapper 方法真的执行一次,不要只靠 IDE 跳转。异常里如果出现 Available parameters are [arg1, arg0, param1, param2],基本就能定位到参数名解析问题。
常见坑:
多参数不写 @Param,XML 里却使用业务名。单个 List 参数在 XML 里误用成业务名,实际要看是否被解析成 list、collection 或注解名。参数对象里嵌套字段过深,test 表达式可读性很差,后续维护者不敢改。
生产建议:Mapper 方法签名属于数据访问契约。高频 SQL 的参数对象要独立建类,并用单元测试覆盖空值、空集合、极限分页和非法排序,别让参数名问题等到线上才从日志里冒出来。
TypeHandler、JdbcType 和枚举映射
场景说明:字段明明有值,查出来却不对;枚举上线后老数据解析失败;传 null 时某些驱动报 JDBC 类型不明确。这类问题通常不在 SQL 文本,而在 TypeHandler、JdbcType 和数据库字段类型的边界上。
MyBatis 参数绑定和结果读取都会经过 TypeHandlerRegistry。常规类型有内置处理器;枚举默认可用 EnumTypeHandler,按枚举名读写;如果数据库存的是数字码或业务码,就要显式写自定义 TypeHandler,不要靠 ordinal()。
@MappedTypes(OrderStatus.class)
@MappedJdbcTypes(JdbcType.INTEGER)
public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
OrderStatus parameter, JdbcType jdbcType) throws SQLException {
ps.setInt(i, parameter.code());
}
@Override
public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
int code = rs.getInt(columnName);
return rs.wasNull() ? null : OrderStatus.fromCode(code);
}
@Override
public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
int code = rs.getInt(columnIndex);
return rs.wasNull() ? null : OrderStatus.fromCode(code);
}
@Override
public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
int code = cs.getInt(columnIndex);
return cs.wasNull() ? null : OrderStatus.fromCode(code);
}
}配置示例:
mybatis:
type-handlers-package: com.example.infra.mybatis.type
configuration:
jdbc-type-for-null: 'NULL'验证方式:至少测三类值,正常枚举、未知码、null。未知码不要静默映射成默认状态,应该明确抛异常或进入兼容分支。
TypeHandler 的选择规则也要讲清。参数绑定时 Java 类型和 JDBC 类型通常都比较明确;结果映射时 Java 属性类型明确,但 JDBC 类型经常是 null。如果你只写了 @MappedJdbcTypes(JdbcType.INTEGER),又没有让它包含 null 场景,某些 ResultMap 读取可能不会按你想的方式选择这个处理器。更稳的做法是:
<resultMap id="OrderResultMap" type="com.example.order.Order">
<result column="status"
property="status"
jdbcType="INTEGER"
typeHandler="com.example.infra.mybatis.type.OrderStatusTypeHandler"/>
</resultMap>排查顺序:
看 mybatis.type-handlers-package 是否真的扫描到类。看处理器上的 @MappedTypes、@MappedJdbcTypes 是否覆盖读写两边。看 XML 的 resultMap、#{status,jdbcType=INTEGER,typeHandler=...} 是否显式指定。
写一条坏值数据,确认读取时走的是自定义处理器,而不是默认枚举名处理器。
生产建议:全局扫描适合统一约定,关键状态字段建议在 resultMap 或参数占位符里显式指定。这样升级 MyBatis、换驱动或字段类型调整时,映射边界不会藏在自动选择规则里。
常见坑:
枚举使用 ordinal(),后续新增枚举导致历史数据语义错位。数据库字段是 tinyint,Java 侧按字符串枚举名处理,查询和写入都不稳定。jdbcTypeForNull 在不同驱动上的行为不一致,核心写入最好在 XML 里给关键字段显式 jdbcType。
生产建议:枚举入库要先定“存名字、存业务码、还是存数字码”。状态、类型、渠道这类字段必须有兼容策略和坏值告警,否则一次枚举重构就可能变成数据事故。
动态 SQL:能用,但别写成条件黑洞
场景说明:动态 SQL 最容易从“可选条件”滑成“万能查询”。一开始只是状态可选,后来权限、关键字、时间、标签、排序、分页全塞进一个 XML。它能跑,但调优时很难知道哪条组合是真正高频。
直接做法,普通可选条件用 <where>,更新用 <set>,集合用 <foreach>,模糊查询用 <bind>,排序字段用白名单在 Java 层转换。
<select id="listForBackoffice" resultMap="OrderListRowMap">
select id, order_no, status, amount, created_at
from biz_order
<where>
tenant_id = #{tenantId}
<if test="ownerId != null">
and owner_id = #{ownerId}
</if>
<if test="status != null and status != ''">
and status = #{status}
</if>
<if test="createdFrom != null">
and created_at >= #{createdFrom}
</if>
<if test="createdTo != null">
and created_at < #{createdTo}
</if>
<if test="keyword != null and keyword != ''">
<bind name="kw" value="'%' + keyword + '%'"/>
and order_no like #{kw}
</if>
</where>
order by created_at desc, id desc
limit #{limit} offset #{offset}
</select>如果要做 IN 查询,先在 Service 层限制集合大小和空集合行为。
if (ids == null || ids.isEmpty()) {
return List.of();
}
if (ids.size() > 1000) {
throw new IllegalArgumentException("ids too many");
}
return orderMapper.listByIds(ids);<select id="listByIds" resultMap="OrderListRowMap">
select id, order_no, status, amount, created_at
from biz_order
where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>排序不要直接把前端传入拼进 SQL。${} 是字符串替换,不是参数绑定;它只适合已经白名单化的字段、表名、排序方向。
public enum OrderSort {
CREATED_DESC("created_at desc, id desc"),
AMOUNT_DESC("amount desc, id desc");
private final String clause;
OrderSort(String clause) {
this.clause = clause;
}
public String clause() {
return clause;
}
}order by ${sortClause}验证结果,把日志里的最终 SQL 拿到数据库里跑执行计划。
EXPLAIN
select id, order_no, status, amount, created_at
from biz_order
where tenant_id = 1001
and status = 'PAID'
and created_at >= '<EVENT_TIME>'
order by created_at desc, id desc
limit 20 offset 0;常见坑:
if test="name != null" 没判断空字符串,导致 like '%%'。foreach 空集合拼出非法 SQL,或者直接跳过条件查全表。${} 拼接用户输入,形成注入风险。
XML 里混入太多业务判断,Service 失去边界。
生产建议:动态 SQL 只处理 SQL 形态,不处理业务决策。是否允许某个状态、是否能查看某个租户、排序字段是否合法,先在 Service 层判断,再把干净参数交给 Mapper。
用两个状态模型拆掉框架错觉
下面两个 Java 17 程序只保留本篇最关键的状态与分支。它们不连接真实数据库,因此不能证明驱动或数据库的厂商行为;它们用来证明调用方必须维持的不变量,真实集成测试再负责验证 SQL、锁和网络。
javac --release 17 -Xlint:all -Werror examples/backend-development/data-access/mybatis-dynamic-typehandler/DynamicSqlDemo.java examples/backend-development/data-access/mybatis-dynamic-typehandler/TypeHandlerRoundTripDemo.java
java -cp examples/backend-development/data-access/mybatis-dynamic-typehandler DynamicSqlDemo
java -cp examples/backend-development/data-access/mybatis-dynamic-typehandler TypeHandlerRoundTripDemosql=SELECT * FROM orders WHERE tenant_id = ? AND status = ? parameters=[north, PAID]
java=PAID jdbc=VARCHAR:PAID roundTrip=true nullJdbcType=VARCHAR输出的价值在于固定中间状态,而不是展示 API 能运行。修改实现后,如果资源没有复位、冲突被误报为成功、缓存跨越了更新边界或调用身份发生变化,模型应先失败,随后真实数据库测试再给出厂商级证据。
