笔记——在一次 Statement Closed 报错后,探究背后 MyBatis 执行链
文章目录
- 现场还原
- 先说结论
- MyBatis 执行一条 XML SQL 时到底做了什么
- MappedStatement:一条 mapper 方法的元信息
- BoundSql:动态 SQL 解析后的结果
- `${}` 和 `#{}` 的区别,不只是 SQL 注入
- `${}`:先拼成完整 SQL
- `#{}`:交给 JDBC 参数绑定
- 默认的 statementType 是 PREPARED
- 这次为什么会在 setParameters 阶段异常
- Druid 包装后的 statement 会让问题更隐蔽
- 为什么改成 STATEMENT 能解决
- useCache 和 flushCache 又在这里做了什么
- useCache="false"
- flushCache="true"
- 为什么没有把 update 也改成 STATEMENT
- 一个更根本的方向:少让插件碰到这类 SQL
- 复盘一下这个问题的判断路径
- 一些经验判断和必要事项
现场还原
这个问题最开始看起来有点反直觉。
日志里报的是:
Error querying database. Cause: java.sql.SQLException: No operations allowed after statement closed. The error occurred while setting parameters SQL: SELECT COUNT(*) FROM tmp_pz_parse_20260616SQL 本身很朴素,甚至没有?占位符:
SELECTCOUNT(*)FROMtmp_pz_parse_20260616如果只盯着这条 SQL,很容易想不通:既然没有参数,为什么错误会发生在setting parameters?为什么 statement 已经关闭了,MyBatis 还会去设置参数?
这篇笔记就顺着这个问题,把 MyBatis 执行一条 mapper SQL 的关键机制捋一遍。
先说结论
这次问题不是COUNT(*)语法错,也不是临时表名不存在。
更准确地说,它是一个执行链问题:
动态表名 SQL -> 默认走 PreparedStatement -> MyBatis 仍然进入参数设置流程 -> 项目中的连接池 / 拦截器 / statement 包装对象参与执行 -> 某个 PreparedStatement 生命周期已经结束 -> 后续仍触发 setParameters -> No operations allowed after statement closed所以最后的修复方向是:对这几条已经经过严格校验、且没有真实绑定参数的动态表名查询,改成普通Statement执行,避开PreparedStatement的参数绑定阶段。
对应 mapper 配置是:
<selectid="countTotal"resultType="long"useCache="false"flushCache="true"statementType="STATEMENT">SELECT COUNT(*) FROM ${tableName}</select>这个改法不是说STATEMENT比PREPARED更好,而是说这类动态表名 SQL 本来就没法用#{}绑定表名,既然最终 SQL 已经是完整文本,并且表名已经在 Service 层白名单校验过,用STATEMENT反而更贴合它的执行形态。
MyBatis 执行一条 XML SQL 时到底做了什么
平时写 mapper XML,直觉上像是在写 SQL:
<selectid="countTotal"resultType="long">SELECT COUNT(*) FROM ${tableName}</select>但 MyBatis 真正执行时,中间会拆出几层对象。
MappedStatement:一条 mapper 方法的元信息
MyBatis 启动时会解析 XML。每个<select>、<update>、<insert>、<delete>最后都会变成一个MappedStatement。
它里面保存的不是单纯 SQL 字符串,而是一整套执行信息:
namespace + id SQL 类型,是 SELECT 还是 UPDATE 参数映射规则 返回值映射规则 缓存配置 statementType 超时时间 fetchSize例如:
<selectid="countTotal"resultType="long">最后对应的 statement id 大概是:
cn.quinlanxie.mapper.ITmpPzSpecCleanMapper.countTotal业务代码调用:
tmpPzSpecCleanMapper.countTotal(tableName);本质上就是 MyBatis 根据这个 statement id 找到对应的MappedStatement,然后进入执行器。
BoundSql:动态 SQL 解析后的结果
XML 里的 SQL 还不是最终 SQL。
比如这里用了:
FROM ${tableName}当参数传入:
tableName="tmp_pz_parse_20260616"MyBatis 会把它解析成BoundSql:
SELECTCOUNT(*)FROMtmp_pz_parse_20260616BoundSql可以理解为“这一次调用真正要执行的 SQL”。
它通常包含两部分:
最终 SQL 文本 参数映射 parameterMappings如果 XML 是这样:
WHERE id = #{id}解析后 SQL 会变成:
WHEREid=?同时parameterMappings里会记录这个?对应的是id。
但如果 XML 是这样:
FROM ${tableName}解析后 SQL 直接变成:
FROMtmp_pz_parse_20260616它不会产生?,也就没有真正需要 JDBC 绑定的参数。
这也是这次问题让人别扭的地方:最终 SQL 没有?,但默认执行流程还是PreparedStatement那套流程。
${}和#{}的区别,不只是 SQL 注入
很多人讲${}和#{},会直接总结成:
${} 有 SQL 注入风险 #{} 可以防 SQL 注入这个说法没错,但还不够。
它们真正的区别是:
${} 是文本替换 #{} 是参数绑定${}:先拼成完整 SQL
例如:
FROM ${tableName}传入:
tmp_pz_parse_20260616最终 SQL:
FROMtmp_pz_parse_20260616这就是纯文本替换。
它适合用在 SQL 结构本身必须动态变化的地方,比如:
动态表名 动态列名 动态排序字段但它必须配合白名单校验。
比如这次 Service 层限制表名:
^tmp_pz_parse_[0-9]{8}$这就把可输入内容限制死了,只允许类似:
tmp_pz_parse_20260616 tmp_pz_parse_20260617这类值。
#{}:交给 JDBC 参数绑定
例如:
WHERE id = #{id}最终 SQL:
WHEREid=?然后 MyBatis 会调用 JDBC:
preparedStatement.setLong(1,id);这就是参数绑定。
它适合用在值的位置:
id name status create_time amount但它不能用来绑定表名。
下面这种写法是不成立的:
FROM #{tableName}因为 JDBC 只会把它当成一个字符串值,而不是 SQL 标识符。最终类似于:
FROM'tmp_pz_parse_20260616'数据库不会把字符串当表名用。
所以动态表名只能走${},关键是要把输入校验做好。
默认的 statementType 是 PREPARED
MyBatis 的statementType有三个常见值:
STATEMENT PREPARED CALLABLE默认是:
PREPARED也就是使用 JDBC 的PreparedStatement。
平时大多数 SQL 都应该用默认值,因为它有几个好处:
支持参数绑定 减少 SQL 注入风险 数据库可以复用执行计划 和 MyBatis 参数映射机制配合最好默认流程大概是:
Executor -> StatementHandler.prepare(connection) -> connection.prepareStatement(sql) -> ParameterHandler.setParameters(preparedStatement) -> StatementHandler.query/update -> ResultSetHandler 处理结果 -> close statement这条链里有一个很关键的步骤:
ParameterHandler.setParameters只要走PreparedStatement,MyBatis 就会有这个参数设置阶段。
哪怕这次 SQL 最终没有?,执行链仍然是围绕PreparedStatement建起来的。
这次为什么会在 setParameters 阶段异常
从日志看,异常发生在:
The error occurred while setting parameters而不是:
Error executing SQL Error mapping results这说明数据库还没真正开始执行,问题出在 MyBatis 准备 statement 的过程中。
正常情况下,即使没有参数,setParameters也不会有事。它最多发现没有 parameter mappings,然后什么都不做。
但这个项目里执行链并不只有 MyBatis 自己。
还有几个参与者:
Druid 连接池 MyBatis-Plus 自定义 StatisticsInterceptor 自定义 UpdateDataInterceptor MyBatis 插件代理链其中比较值得注意的是StatisticsInterceptor。
它拦截的是:
StatementHandler.prepare(Connection.class,Integer.class)也就是说,它插在 statement 准备阶段。
插件内部还会自己构造统计 SQL,然后手动做参数绑定:
PreparedStatementstatement=connection.prepareStatement(countSql);DefaultParameterHandlerparameterHandler=newDefaultParameterHandler(mappedStatement,boundSql.getParameterObject(),boundSql);parameterHandler.setParameters(statement);这类代码在普通分页查询里可能没事,但遇到动态 SQL、动态表名、分页参数改写、无参数 SQL 时,风险会变高。
因为它复用了原始的:
MappedStatement BoundSql parameterObject同时又自己创建了另一个PreparedStatement。
如果 SQL 被插件改过、参数映射列表被删改过、statement 被 Druid 包装过,或者某个分支提前关闭了 statement,后面再进入setParameters,就可能拿到一个生命周期已经结束的 statement。
这时 MySQL JDBC driver 会直接抛:
No operations allowed after statement closed.所以这次报错不是说 SQL 文本需要参数却没设置,而是说“参数设置阶段拿到的 JDBC statement 已经不能用了”。
Druid 包装后的 statement 会让问题更隐蔽
项目里使用的是 Druid。
Druid 不会把原生 JDBC 对象直接裸露出来,它会包一层,比如:
DruidPooledConnection DruidPooledPreparedStatement DruidPooledStatement这些包装对象会做连接池管理、监控、慢 SQL 记录、防火墙检查、statement 缓存等事情。这本来是正常的。
但问题是,MyBatis 插件如果通过反射去拿底层 statement,或者自己在 prepare 阶段额外创建 statement,就很容易出现“上层对象还在,底层真实 statement 已经 close”的情况。
项目里的UpdateDataInterceptor就有类似代码:
StringoriginalSql=SystemMetaObject.forObject(((DruidPooledStatement)statement).getStatement()).getValue("statement").toString();这种写法不是一定有问题,但它说明项目里的 SQL 执行链确实不是单纯的 MyBatis 默认链路,而是有不少对 statement 的包装、拆包和反射访问。
在这种环境下,动态表名 SQL 继续走PreparedStatement,就更容易撞上生命周期边界。
为什么改成 STATEMENT 能解决
修改后:
statementType="STATEMENT"MyBatis 会使用 JDBC 的普通Statement。
执行形态变成:
Executor -> StatementHandler.prepare(connection) -> connection.createStatement() -> StatementHandler.query -> statement.execute(sql)关键差异是:不再有PreparedStatement的参数绑定阶段。
也就是不会再走:
parameterHandler.setParameters(preparedStatement);而这次异常正好发生在这个阶段。
所以STATEMENT的作用不是“修复数据库”,而是让这类已经完整拼好的 SQL 不再走不必要的参数绑定链路。
对于这三类 SQL 尤其合适:
SELECT COUNT(*) FROM ${tableName} SELECT COUNT(*) FROM ${tableName} WHERE short_spec_clean IS NULL SELECT id, short_spec_raw FROM ${tableName} WHERE short_spec_clean IS NULL ORDER BY id LIMIT ${batchSize}这些 SQL 的动态部分都不是值参数,而是 SQL 结构的一部分。
表名必须拼进去。
LIMIT数字也已经在 Service 层限制过:
batchSize>0batchSize<=MAX_BATCH_SIZE因此用STATEMENT是合理的。
useCache 和 flushCache 又在这里做了什么
这次还配了:
useCache="false" flushCache="true"它们跟 statement closed 不是同一个层面的问题,但放在这类动态表名临时表操作里比较稳。
useCache=“false”
表示这个 select 不使用 MyBatis 二级缓存。
MyBatis 一级缓存是 SqlSession 级别,二级缓存是 namespace 级别。
这类清洗任务会反复做:
countTotal countRemaining selectBatchToClean updateCleanById countRemaining而且表名是动态的:
tmp_pz_parse_20260616 tmp_pz_parse_20260617这种场景最不希望缓存掺和进来。每次 count 都应该看数据库当前状态。
所以:
useCache="false"意思就是:这条查询别读二级缓存。
flushCache=“true”
表示执行这条语句时刷新缓存。
正常情况下,select的flushCache默认是false,insert/update/delete默认是true。
这里对查询也设成true,是为了更明确地告诉 MyBatis:
这几条临时表清洗 SQL 不要依赖缓存状态尤其是在同一个接口里先 update 再 count,宁愿多查一次数据库,也不要拿到旧值。
为什么没有把 update 也改成 STATEMENT
更新语句仍然保留:
<updateid="updateCleanById"flushCache="true">UPDATE ${tableName} SET short_spec_clean = #{shortSpecClean} WHERE id = #{id}</update>这里不能轻易改成STATEMENT。
原因是shortSpecClean是清洗后的字符串,虽然它来自原始规格字段,不是用户直接输入,但它仍然是一个“值”。
值就应该用#{}绑定。
例如清洗结果里如果出现特殊字符、空字符串、null 处理、类型转换,交给PreparedStatement更稳。
所以这次修复只动了查询:
动态表名 count 动态表名 select batch没有把 update 一起改掉。
这是一个边界:
SQL 结构动态,用 ${},前提是白名单校验 SQL 值动态,用 #{}一个更根本的方向:少让插件碰到这类 SQL
这次用STATEMENT是针对当前报错的直接修复。
如果要把这块做得更干净,可以考虑再加一层保护:让统计类插件跳过临时表清洗 mapper。
例如在StatisticsInterceptor里判断 statement id:
StringstatementId=mappedStatement.getId();if(statementId.startsWith("cn.quinlanxie.mapper.ITmpPzSpecCleanMapper.")){returninvocation.proceed();}因为这个清洗接口不是普通分页列表,也不需要页面 footer 统计。
它是一个批处理工具接口。
让分页统计插件参与进来,收益很低,风险更高。
不过这个改法影响的是公共插件,范围比 mapper XML 更大,需要更小心测试。
当前先在 mapper 层把这几条 SQL 的执行方式收窄,是更克制的处理。
复盘一下这个问题的判断路径
这类问题不能只看最后一条 SQL。
当看到:
The error occurred while setting parameters第一反应应该是去看 MyBatis 执行链,而不是只看 SQL 语法。
排查顺序可以是:
1. 这条 SQL 有没有 #{} 2. 最终 SQL 有没有 ? 3. mapper 有没有动态表名、动态列名、动态排序 4. statementType 是不是默认 PREPARED 5. 项目里有没有 MyBatis 拦截器 6. 拦截器有没有手动 prepareStatement / setParameters 7. 连接池有没有 statement 包装或缓存 8. 是否存在批处理、循环调用、同线程上下文残留这次刚好几个条件都碰上了:
动态表名 临时表批处理 默认 PreparedStatement Druid statement 包装 自定义 MyBatis 插件 异常发生在 setParameters所以最后才会出现看似奇怪的现象:
一条没有参数的 COUNT SQL,却在设置参数阶段报 statement closed一些经验判断和必要事项
大多数业务 SQL,继续用默认PREPARED,也就是#{}参数绑定。
但如果遇到下面这种 SQL:
表名动态 列名动态 排序字段动态 分表名动态 临时表名动态就要意识到:这些部分不是 JDBC 参数,它们是 SQL 结构。
这时要做两件事:
第一,输入必须白名单校验 第二,确认是否真的需要 PreparedStatement如果最终 SQL 已经是完整文本,且没有需要绑定的值参数,那么statementType="STATEMENT"反而可能更简单、更少踩执行链上的坑。
这不是鼓励到处用${}和STATEMENT。
恰恰相反,只有在边界足够清楚的时候才这么做。
这次的边界是:
表名:^tmp_pz_parse_[0-9]{8}$ batchSize:Service 层限制为正整数且不超过最大批次 查询 SQL:没有用户值参数 更新 SQL:仍然使用 #{}在这个前提下,修复是成立的。
