当前位置: 首页 > news >正文

笔记——在一次 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_20260616

SQL 本身很朴素,甚至没有?占位符:

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>

这个改法不是说STATEMENTPREPARED更好,而是说这类动态表名 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_20260616

BoundSql可以理解为“这一次调用真正要执行的 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”

表示执行这条语句时刷新缓存。

正常情况下,selectflushCache默认是falseinsert/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:仍然使用 #{}

在这个前提下,修复是成立的。

http://www.jsqmd.com/news/1035770/

相关文章:

  • 2026六盘水焊缝探伤检测权威机构排行 TOP 本地高频选择,无损检测 + UT+RT+PT 检测 附电话地址 - 中安检测集团
  • Windows系统replres.rll文件解析:资源本地化与故障排查指南
  • 终极指南:5个高效技巧解决ComfyUI-SUPIR模型加载失败问题
  • 2026龙岩焊缝探伤检测权威机构排行 TOP 本地高频选择,无损检测 + UT+RT+PT 检测 附电话地址 - 中安检测集团
  • 2026 年灯光窗帘一体智能开关推荐:优智者多功能强 - 思溯深度专栏
  • MCP44XX数字电位器硬件集成:从I2C驱动到PCB布局的工程实践
  • 2026年GEO代理加盟全解析:轻资产创业首选源头厂家杭州爱搜索项目 - 品牌报告
  • 2026开封焊缝探伤检测权威机构排行 TOP 本地高频选择,无损检测 + UT+RT+PT 检测 附电话地址 - 中安检测集团
  • 帧延迟视角下的机器视觉:传输速度、接口选择与持续优化 为什么cxp cof bitrate在持续进化
  • [PWNHUB 公开赛 2018]傻 fufu 的工作日
  • 抖音批量下载神器:从零开始构建你的视频资源库
  • 2026年陕西律师选型指南:西安股权纠纷、建工合同、财税合规深度横评 - 企业名录优选推荐
  • SQLyog社区版:免费高效的MySQL数据库管理终极指南
  • 2026年贵州刺梨原汁与功能饮品代工供应商精选指南:从源头工厂直达全国市场 - 优质企业观察收录
  • 3DS原生GBA游戏终极方案:open_agb_firm完整指南
  • 昆山兴运建设工程有限公司:化工厂/厂房/超市拆除回收专业服务商 - 品牌推荐官
  • QQ音乐格式转换终极指南:3步解锁你的音乐自由
  • 浏览器端AI图像标注工具:make-sense.ai如何解决计算机视觉项目的数据标注痛点
  • 2026克拉玛依焊缝探伤检测权威机构排行 TOP 本地高频选择,无损检测 + UT+RT+PT 检测 附电话地址 - 中安检测集团
  • 2025终极指南:如何用openpilot开源系统让普通汽车秒变智能座驾
  • WP 多余插件拖慢页面速度,精简提速同时修复 Core Web Vitals
  • 四川无尘洁净车间建造者-四川华川洁净公司 - 华川洁净
  • 【JAVA毕设源码分享】基于springboot的特色水果商城系统(程序+文档+代码讲解+一条龙定制)
  • 2026上海品质团建服务商排行TOP6 - 陀螺团建
  • RimWorld模组管理的终极解决方案:RimSort完整指南
  • 2026常州包包回收避坑指南:正规门店全域免费上门 - 薛定谔的梨花猫
  • 2026年贵州刺梨原汁生产与全国招商加盟供应商深度指南 - 优质企业观察收录
  • 如何让旧手柄在PC游戏中完美工作?XOutput终极转换指南
  • 深度解析:LegacyUpdate如何修复Windows Update错误代码80072EFE的技术原理
  • MOSFET驱动器选型与应用实战:以MCP14E9/10/11为例解析高速开关驱动设计