
文章目录现场还原先说结论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仍然使用 #{}在这个前提下修复是成立的。