
1. 报错现场Cursor 流式查询在 executeForCursor 撞上 closeOnCompletion1.1 报错栈里真正有用的三行用 Druid 处理 Cursor 流式查询时项目在MybatisMapperMethod.executeForCursor处直接抛java.sql.SQLFeatureNotSupportedException这种报错通常和 SQL 无关。TaoToken 只是 Codex 的兼容通道不负责连接池行为但排查这类版本问题我会先把 Key 从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_content 拿到手再让 Codex 顺着报错栈读 Druid 源码。整套走下来问题最终集中在 Druid 的一个 JDBC 4.1 可选特性上。日志去掉 Spring AOP 的几百行后关键信息只有三段Caused by: java.sql.SQLFeatureNotSupportedException at com.alibaba.druid.pool.DruidPooledStatement.closeOnCompletion(DruidPooledStatement.java:871) ... at org.apache.ibatis.executor.SimpleExecutor.doQueryCursor(SimpleExecutor.java:75)SimpleExecutor.doQueryCursor执行完handler.queryCursor(stmt)后紧接着调用了stmt.closeOnCompletion()。这个调用被 MyBatis 的日志代理PreparedStatementLogger.invoke转发到 Druid 的DruidPooledStatement结果老版本 Druid 对这个方法完全不买账直接抛异常。closeOnCompletion是 JDBC 4.1 为“结果集耗尽后自动关闭 Statement”设计的钩子。MyBatis 的 Cursor 采用流式遍历遍历完结果集再让 Statement 自动关闭正好命中这个特性。但 Druid 1.1.13 对这个方法的实现比较敷衍于是流式查询连第一个next()都到不了。1.2 这是连接池版本问题不是 Mapper 写法问题这个异常真正让人头疼的地方在于发生时机。它不是连接获取失败不是 SQL 执行失败而是 Cursor 已经构造好、MyBatis 准备优雅收尾的那一刻炸出来。很多团队看到SQLFeatureNotSupportedException会先怀疑数据库驱动不支持游标或者 MyBatis-Plus 的Cursor用法不对实际上只要看到DruidPooledStatement这七个字基本就能把怀疑对象锁定在 Druid 版本上。当时项目里druid-spring-boot-starter锁的是 1.1.13。它和 MyBatis-Plus 的BaseMapper配合时普通selectList不受影响因为那种查询不需要调用closeOnCompletion只有返回CursorT的方法会走到这条路径。所以问题很隐蔽往往在大数据量导出、分批处理的场景里突然冒出来还容易被误判成“结果集太大导致驱动不支持”。2. 排查思路从 MyBatis 源码一路读到 Druid 连接池2.1 SimpleExecutor 为什么要先拿 Cursor 再关 Statement既然报错栈指向SimpleExecutor.doQueryCursor那就先看这个方法。MyBatis 在这里的逻辑可以拆成四步用newStatementHandler创建语句处理器调用prepareStatement准备一个 JDBCStatement调用handler.queryCursor(stmt)得到CursorE对象立刻执行stmt.closeOnCompletion()再把 Cursor 返回给调用方。第 4 步是 MyBatis 的一个设计约定你应用层慢慢遍历 Cursor遍历完后驱动会自动关闭 Statement这样连接不会被提前回收也不至于因为忘记finally { cursor.close(); }而导致连接泄漏。但这个约定依赖底层 JDBC 驱动的“自觉”。Druid 作为连接池对外暴露的是DruidPooledStatementMyBatis 拿到的其实是经过 JDBC 动态代理的包装对象。如果 Druid 不支持closeOnCompletion问题就集中在第 4 步爆发后面的 Mapper 调用链再长都是陪跑。2.2 PreparedStatementLogger 只是忠实的传话筒MyBatis 开启日志后PreparedStatement会通过java.lang.reflect.Proxy做一层代理由PreparedStatementLogger.invoke统一拦截。这个类对方法的处理分成几类executeQuery、executeUpdate等执行方法会打印参数setString、setInt等绑定参数的方法会记录列信息getResultSet、getUpdateCount会包装返回值其他方法一律method.invoke(statement, params)原样转发。closeOnCompletion就落在“其他”这一类。所以PreparedStatementLogger.invoke看到异常后只是通过ExceptionUtil.unwrapThrowable把最底层的异常解包出来没有做任何额外处理。这说明问题不在 MyBatis 的日志代理而在被代理的statement对象本身。从排障角度讲看到这种“MyBatis 代理层原样抛出”的模式就应该直接放弃在 Mapper 层找原因转而对比连接池实现。这也是为什么后来我会选择用 Codex 来做源码对比而不是继续人肉翻 JAR 包。3. 用 TaoToken 配通 Codex再让 AI 帮我读源码3.1 准备材料在模型广场创建 API Key到了这一步单纯靠肉眼读源码也能定位但效率不高。我的做法是让 Codex 帮忙把DruidPooledStatement在旧版和新版之间的实现差异列出来。要用 Codex先得准备一把能访问模型的 API Key。打开 TaoToken注册登录后进控制台创建 Key。注意 Key 的占位符是YOUR_API_KEY不要填成控制台页面里的示例字符。TaoToken 的模型广场会展示当前可用的模型 ID你需要记下自己准备用的那一个后面填进 Codex 配置里。这样做的价值在于官方额度分散在多把 Key 里时切来切去很容易把模型搞混。这个统一接入通道允许在一个控制台里看到多个模型的调用情况Codex 这类 OpenAI 兼容工具只需要改一个 base_url 就能共用密钥省去为某个模型单独充值的麻烦。3.2 Codex 配置~/.codex/config.toml 里加一个 providerCodex CLI 的配置放在~/.codex/config.toml建议先备份再改。下面是一份可用的配置model YOUR_MODEL_ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY字段说明model以模型广场当时列表为准填你要用的模型 IDmodel_provider指定使用下面定义的这个 providerbase_url固定填https://taotoken.net/api末尾不要加/v1env_keyCodex 会读取这个环境变量的值作为 API Key。写完后在当前 shell 导出环境变量再启动export TAOTOKEN_API_KEYYOUR_API_KEY codex如果你已经在用其他 provider不用删旧配置[model_providers.taotoken]是独立的一段Codex 支持多个 provider 并存。以后想切回官方模型只要把model_provider改回去就行。3.3 给 Codex 喂什么信息它才能快速定位 closeOnCompletion配好 Codex 之后不要只说一句“帮我看下报错”。我通常会把下面三样东西一次性贴过去完整异常栈里Caused by附近的位置特别是DruidPooledStatement.closeOnCompletion那一行Mapper 接口中返回CursorT的方法签名pom.xml里 Druid 和 MyBatis-Plus 的版本号。然后直接问为什么 Cursor 流式查询会抛出SQLFeatureNotSupportedExceptionCodex 会先解释 MyBatis 的Cursor需要 Statement 实现closeOnCompletion接着会建议去对比 Druid 1.1.13 和 1.2.19 的源码差异。它给出的判断基于你贴进来的信息不会连接到生产库执行任何操作。后续要不要升级依赖、怎么改 pom仍然由你在本地决定和验证。4. 关键差异Druid 两个版本的 closeOnCompletion 实现4.1 1.1.13 版本的实现直接抛异常从 1.1.13 的源码中可以看到DruidPooledStatement.closeOnCompletion的实现非常直白public void closeOnCompletion() throws SQLException { throw new SQLFeatureNotSupportedException(); }这不是一个严格意义上的 bug而是 Druid 在当时选择不实现 JDBC 4.1 的可选特性。问题在于 MyBatis 不区分“可选”和“必须”直接把closeOnCompletion当成每个 Statement 都应支持的能力来用。两个框架的默认行为撞到一起流式查询就废了。4.2 1.2.19 版本的实现透传给底层 Statement到了 1.2.19Druid 改成了标准写法public void closeOnCompletion() throws SQLException { this.stmt.closeOnCompletion(); }这里的stmt是 Druid 连接池内部持有的真实 JDBC Statement。Druid 用这种方式把“是否支持自动关闭”的决定权交还给了真正的数据库驱动。如果你的数据库驱动不支持closeOnCompletion异常会从驱动层抛出至少能明确下一步要看驱动文档而不是在连接池里反复查。4.3 升级依赖pom 里的改动用 Codex 确认差异之后改动反而很小。把druid-spring-boot-starter的版本从 1.1.13 升到 1.2.19 即可dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.19/version /dependency升级后建议做两类验证一类是原有的selectList、selectPage等普通查询确认连接池行为没有回归另一类是CursorT流式查询确认closeOnCompletion不再抛异常。如果项目里配置了 Druid 的拦截器或自定义 Filter也一并跑一遍因为连接池从旧版本跨到新版本时部分内部状态可能有变化。5. 验证流式查询恢复顺带解决更多 Cursor 问题5.1 升级后如何判断问题真的解决升级完依赖重新编译启动直接调用之前出错的 Mapper 方法。如果日志里不再出现SQLFeatureNotSupportedException并且你能正常用cursor.forEach遍历到全部记录说明 Druid 已经把closeOnCompletion委托给了底层 Statement。这里有一个容易忽视的点遍历完 Cursor 后要确保 Cursor 被关闭。1.2.19 虽然实现了自动关闭语义但如果你写了break提前跳出循环连接依然可能占住不放。建议代码里保留try (CursorBomDtl cursor mapper.queryBomDtl(...))这种写法把兜底主动权掌握在自己手里。验证通过后回到 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_content 控制台看一下刚才 Codex 的请求记录确认模型 ID、Token 数量和计费都能对上。这样既能确认 TaoToken 通道正常也不会在月底发现一笔来源不明的消费。5.2 用同一把 Key 继续排查其他流式查询异常这次排障留下的不只是 pom 里一个版本号还有一套可复用的动作遇到 Cursor 流式查询问题先看Caused by最下层是哪个类抛的再决定是升级连接池还是换驱动。Codex 接入 TaoToken 后这类问题可以直接丢给它做静态分析。你只需要把报错栈、框架版本和 Mapper 方法签名贴进去它会试着给出排查路径。需要注意的是Codex 或任何 AI 工具都不应该被描述成“帮你连接生产库执行 SQL 诊断”。它更适合生成 SQL、解释报错、对比源码真正在数据库客户端里跑那条 SQL或者在生产环境执行诊断命令必须由你本人在本地完成再把结果贴回对话。这样既符合安全习惯也能避免故障现场被误操作二次破坏。如果接下来想把 Codex 长期当作 Druid、MyBatis-Plus 这类框架问题的辅助排查工具可以在 模型对话 里先验证一下模型选择是否顺畅要长时间写代码、频繁调用再看 Coding Plan 的套餐是否更划算。新增或轮换 API Key 就回 控制台 API Keys 操作三步之内能完成不用重装 Codex也不用改 base_url。