笔记——在一次 Statement Closed 报错后,探究背后 MyBatis 执行链
文章目录现场还原先说结论MyBatis 执行一条 XML SQL 时到底做了什么MappedStatement一条 mapper 方法的元信息BoundSql动态 SQL 解析后的结果${} 和 #{} 的区别不只是 SQL 注入${}先拼成完整 SQL#{}交给 JDBC 参数绑定默认的 statementType 是 PREPARED这次为什么会在 setParameters 阶段异常Druid 包装后的 statement 会让问题更隐蔽为什么改成 STATEMENT 能解决useCache 和 flushCache 又在这里做了什么useCachefalseflushCachetrue为什么没有把 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 配置是selectidcountTotalresultTypelonguseCachefalseflushCachetruestatementTypeSTATEMENTSELECT COUNT(*) FROM ${tableName}/select这个改法不是说STATEMENT比PREPARED更好而是说这类动态表名 SQL 本来就没法用#{}绑定表名既然最终 SQL 已经是完整文本并且表名已经在 Service 层白名单校验过用STATEMENT反而更贴合它的执行形态。MyBatis 执行一条 XML SQL 时到底做了什么平时写 mapper XML直觉上像是在写 SQLselectidcountTotalresultTypelongSELECT COUNT(*) FROM ${tableName}/select但 MyBatis 真正执行时中间会拆出几层对象。MappedStatement一条 mapper 方法的元信息MyBatis 启动时会解析 XML。每个select、update、insert、delete最后都会变成一个MappedStatement。它里面保存的不是单纯 SQL 字符串而是一整套执行信息namespace id SQL 类型是 SELECT 还是 UPDATE 参数映射规则 返回值映射规则 缓存配置 statementType 超时时间 fetchSize例如selectidcountTotalresultTypelong最后对应的 statement id 大概是cn.quinlanxie.mapper.ITmpPzSpecCleanMapper.countTotal业务代码调用tmpPzSpecCleanMapper.countTotal(tableName);本质上就是 MyBatis 根据这个 statement id 找到对应的MappedStatement然后进入执行器。BoundSql动态 SQL 解析后的结果XML 里的 SQL 还不是最终 SQL。比如这里用了FROM ${tableName}当参数传入tableNametmp_pz_parse_20260616MyBatis 会把它解析成BoundSqlSELECTCOUNT(*)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最终 SQLFROMtmp_pz_parse_20260616这就是纯文本替换。它适合用在 SQL 结构本身必须动态变化的地方比如动态表名 动态列名 动态排序字段但它必须配合白名单校验。比如这次 Service 层限制表名^tmp_pz_parse_[0-9]{8}$这就把可输入内容限制死了只允许类似tmp_pz_parse_20260616 tmp_pz_parse_20260617这类值。#{}交给 JDBC 参数绑定例如WHERE id #{id}最终 SQLWHEREid?然后 MyBatis 会调用 JDBCpreparedStatement.setLong(1,id);这就是参数绑定。它适合用在值的位置id name status create_time amount但它不能用来绑定表名。下面这种写法是不成立的FROM #{tableName}因为 JDBC 只会把它当成一个字符串值而不是 SQL 标识符。最终类似于FROMtmp_pz_parse_20260616数据库不会把字符串当表名用。所以动态表名只能走${}关键是要把输入校验做好。默认的 statementType 是 PREPAREDMyBatis 的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只要走PreparedStatementMyBatis 就会有这个参数设置阶段。哪怕这次 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然后手动做参数绑定PreparedStatementstatementconnection.prepareStatement(countSql);DefaultParameterHandlerparameterHandlernewDefaultParameterHandler(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就有类似代码StringoriginalSqlSystemMetaObject.forObject(((DruidPooledStatement)statement).getStatement()).getValue(statement).toString();这种写法不是一定有问题但它说明项目里的 SQL 执行链确实不是单纯的 MyBatis 默认链路而是有不少对 statement 的包装、拆包和反射访问。在这种环境下动态表名 SQL 继续走PreparedStatement就更容易撞上生命周期边界。为什么改成 STATEMENT 能解决修改后statementTypeSTATEMENTMyBatis 会使用 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 层限制过batchSize0batchSizeMAX_BATCH_SIZE因此用STATEMENT是合理的。useCache 和 flushCache 又在这里做了什么这次还配了useCachefalse flushCachetrue它们跟 statement closed 不是同一个层面的问题但放在这类动态表名临时表操作里比较稳。useCache“false”表示这个 select 不使用 MyBatis 二级缓存。MyBatis 一级缓存是 SqlSession 级别二级缓存是 namespace 级别。这类清洗任务会反复做countTotal countRemaining selectBatchToClean updateCleanById countRemaining而且表名是动态的tmp_pz_parse_20260616 tmp_pz_parse_20260617这种场景最不希望缓存掺和进来。每次 count 都应该看数据库当前状态。所以useCachefalse意思就是这条查询别读二级缓存。flushCache“true”表示执行这条语句时刷新缓存。正常情况下select的flushCache默认是falseinsert/update/delete默认是true。这里对查询也设成true是为了更明确地告诉 MyBatis这几条临时表清洗 SQL 不要依赖缓存状态尤其是在同一个接口里先 update 再 count宁愿多查一次数据库也不要拿到旧值。为什么没有把 update 也改成 STATEMENT更新语句仍然保留updateidupdateCleanByIdflushCachetrueUPDATE ${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 idStringstatementIdmappedStatement.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 已经是完整文本且没有需要绑定的值参数那么statementTypeSTATEMENT反而可能更简单、更少踩执行链上的坑。这不是鼓励到处用${}和STATEMENT。恰恰相反只有在边界足够清楚的时候才这么做。这次的边界是表名^tmp_pz_parse_[0-9]{8}$ batchSizeService 层限制为正整数且不超过最大批次 查询 SQL没有用户值参数 更新 SQL仍然使用 #{}在这个前提下修复是成立的。

相关新闻

猫抓Cat-Catch完全指南:5分钟从零开始掌握浏览器资源嗅探

猫抓Cat-Catch完全指南:5分钟从零开始掌握浏览器资源嗅探

猫抓Cat-Catch完全指南:5分钟从零开始掌握浏览器资源嗅探 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 还在为网页上的精彩视频无法保…

2026/6/18 12:29:04阅读更多 →
终极视频加速神器:如何用Chrome扩展提升3倍观看效率?

终极视频加速神器:如何用Chrome扩展提升3倍观看效率?

终极视频加速神器:如何用Chrome扩展提升3倍观看效率? 【免费下载链接】videospeed HTML5 video speed controller (for Google Chrome) 项目地址: https://gitcode.com/gh_mirrors/vi/videospeed 你是否厌倦了视频平台的固定播放速度?…

2026/6/18 12:29:04阅读更多 →
百度网盘批量转存工具:告别手动操作,3分钟处理100个分享链接

百度网盘批量转存工具:告别手动操作,3分钟处理100个分享链接

百度网盘批量转存工具:告别手动操作,3分钟处理100个分享链接 【免费下载链接】BaiduPanFilesTransfers 百度网盘批量转存、分享和检测工具 项目地址: https://gitcode.com/gh_mirrors/ba/BaiduPanFilesTransfers 还在为同事发来的几十个百度网盘学…

2026/6/18 12:29:04阅读更多 →
终极宝可梦合法性解决方案:PKHeX自动合规插件完全指南

终极宝可梦合法性解决方案:PKHeX自动合规插件完全指南

终极宝可梦合法性解决方案:PKHeX自动合规插件完全指南 【免费下载链接】PKHeX-Plugins Plugins for PKHeX 项目地址: https://gitcode.com/gh_mirrors/pk/PKHeX-Plugins 在宝可梦游戏世界中,数据合规性是每位训练家都可能面临的挑战。无论是参与线…

2026/6/18 13:44:46阅读更多 →
Grok 4 Heavy深度解析:多智能体协同如何重构AI工程实践

Grok 4 Heavy深度解析:多智能体协同如何重构AI工程实践

1. 项目概述:一场被“延迟一小时”的AI发布会,到底在卖什么?昨天晚上,我关掉手头三个并行跑着的模型微调任务,特意腾出整块时间蹲守马斯克那场“跳票”已久的直播。不是因为迷信他,而是过去五年里&#xff…

2026/6/18 13:44:46阅读更多 →
WeakAuras自动更新终极指南:3分钟告别手动复制粘贴的完整解决方案

WeakAuras自动更新终极指南:3分钟告别手动复制粘贴的完整解决方案

WeakAuras自动更新终极指南:3分钟告别手动复制粘贴的完整解决方案 【免费下载链接】WeakAuras-Companion A cross-platform application built to provide the missing link between Wago.io and World of Warcraft 项目地址: https://gitcode.com/gh_mirrors/we/…

2026/6/18 13:44:46阅读更多 →
MCP+Claude Code实战:从零搭建你的自动化编程助手

MCP+Claude Code实战:从零搭建你的自动化编程助手

前言:为什么你现在必须关注MCPClaude Code 上个月我做了一个实验:把一个180K行的Spring Boot单体代码库接入Claude Code,让它做一次全量架构分析。结果Claude给出了一份比我们技术Lead更细的依赖关系图,还发现了三处我们自己没注…

2026/6/18 13:44:46阅读更多 →
Windows安卓子系统终极指南:WSABuilds完整安装与优化教程

Windows安卓子系统终极指南:WSABuilds完整安装与优化教程

Windows安卓子系统终极指南:WSABuilds完整安装与优化教程 【免费下载链接】WSABuilds Run Windows Subsystem For Android on your Windows 10 and Windows 11 PC using prebuilt binaries with Google Play Store (MindTheGapps) and/or Magisk or KernelSU (root …

2026/6/18 13:44:46阅读更多 →
Claude Code上下文智能监控与自动处理完整指南

Claude Code上下文智能监控与自动处理完整指南

Claude Code上下文智能监控与自动处理完整指南 对话太多导致上下文溢出?教你实时监控Token余量,构建7层递进式防御体系,让长会话永不"失忆" 目录 一、问题背景:为什么上下文管理如此重要二、Claude Code上下文机制深度…

2026/6/18 13:39:45阅读更多 →
ZigBee HA智能家居开发实战:从集群模型到NXP JN516x代码实现

ZigBee HA智能家居开发实战:从集群模型到NXP JN516x代码实现

1. ZigBee HA:智能家居的“通用语言”与开发基石如果你正在或计划踏入智能家居设备开发领域,尤其是基于ZigBee协议,那么“ZigBee Home Automation”这个名词你一定不陌生。它不仅仅是ZigBee联盟定义的一套应用层规范,更是确保不同…

2026/6/18 0:00:24阅读更多 →
Java毕设选题推荐:基于 Spring Boot 的个人随笔博客运维管理系统的设计与实现 基于 Spring Boot 的用户原创博客分享社区【附源码、mysql、文档、调试+代码讲解+全bao等】

Java毕设选题推荐:基于 Spring Boot 的个人随笔博客运维管理系统的设计与实现 基于 Spring Boot 的用户原创博客分享社区【附源码、mysql、文档、调试+代码讲解+全bao等】

博主介绍:✌️码农一枚 ,专注于大学生项目实战开发、讲解和毕业🚢文撰写修改等。全栈领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围:&am…

2026/6/18 0:00:24阅读更多 →
JN517x嵌入式开发实战:看门狗、脉冲计数器与I2C接口的深度解析与避坑指南

JN517x嵌入式开发实战:看门狗、脉冲计数器与I2C接口的深度解析与避坑指南

1. 项目概述在嵌入式开发领域,尤其是基于NXP JN517x这类无线微控制器的项目中,系统稳定性和与外设的可靠交互是两大核心挑战。前者关乎产品能否在无人值守的复杂环境中长期运行,后者则决定了设备能否准确感知世界并与其他芯片“对话”。JN517…

2026/6/18 0:00:24阅读更多 →