
1. 为什么 JdbcTemplate 调 PostgreSQL 存储过程总在游标上翻车先说清楚这篇要解决什么jdbctemplate 调用 postgresql 存储过程时尤其是存储过程用refcursor返回结果集很多人照着网上文章抄下来跑起来直接抛cursor unnamed portal 1 does not exist。这个报错不是你的 SQL 写错了也不是 PostgreSQL 装坏了而是 JDBC 事务边界和游标生命周期没对齐。适合谁看正在用 Spring JdbcTemplate 维护以存储过程为主的存量系统、又不想为了几个函数硬塞 MyBatis 的 Java 后端。我试过把 MyBatis 套在存储过程密集的项目上结果多了一层 XML 映射参数和游标反而更难调最后又退回 JdbcTemplate 直接CallableStatement。所以这篇不讲 MyBatis只讲 JdbcTemplate 怎么把 PostgreSQL 存储过程调通顺带把几个常见写法勘误一遍。核心结论先摆出来PostgreSQL 的refcursor必须在事务内打开和读取JDBC 默认autoCommittrue每条语句独立提交游标刚 open 就被关掉你再去getObject自然找不到 portal。解决办法就一句con.setAutoCommit(false)但这句话放在哪、什么时候恢复决定了你是踩坑还是跑通。除了游标还有一条更省心的路用row_to_json/array_to_json把多结果集压成字符串返回Java 端拿String再解析完全绕开游标和事务的纠缠。两种方式我都会给完整代码。另外现在很多团队会把模型调用、代码补全这类能力接进开发流程统一走一个 API 通道管理 Key。这篇会在配置骨架部分给出config.toml和settings.json的可复制片段并演示通过 TaoToken 的统一 Key/API 通道完成一次调用验证让存储过程调试和模型辅助编码在同一套配置下跑起来。TaoToken 官网入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end API 地址是 https://taotoken.net/api 后面配置里会用到。先把数据表和存储过程交代清楚不然后面代码没法对照。地区表common_region约 3 万条数据字段包括id、parent_id、name、level、code、pingyin、name_en主键idparent_id和level上有索引。这个表结构不是重点重点是它够简单方便你把注意力放在调用方式上。CREATE TABLE public.common_region ( id int4 DEFAULT nextval(common_region_id_seq::regclass) NOT NULL, parent_id int4 NOT NULL, name varchar(30) COLLATE default NOT NULL, level int2 NOT NULL, code char(6) COLLATE default NOT NULL, pingyin varchar(40) COLLATE default, name_en varchar(60) COLLATE default, CONSTRAINT common_region_pkey PRIMARY KEY (id) ) WITH (OIDSFALSE); CREATE INDEX common_region_id_pk ON public.common_region USING btree (id); CREATE INDEX common_region_parent_id ON public.common_region USING btree (parent_id); CREATE INDEX common_region_region_type ON public.common_region USING btree (level);第一个存储过程用OUT refcursor返回两个游标这是网上文章最常见的写法CREATE OR REPLACE FUNCTION public.sp_test_multi_cursors2( IN id int4, OUT records_cursor_01 refcursor, OUT records_cursor_02 refcursor ) RETURNS record AS $BODY$ declare tmpId integer; begin open records_cursor_01 for select * from common_region limit 3; open records_cursor_02 for select * from common_region limit 2; end; $BODY$ LANGUAGE plpgsql VOLATILE;在数据库客户端里直接select sp_test_multi_cursors2(1);是能出结果的返回两个 portal 名字。但 Java 端用 JdbcTemplate 调如果不关自动提交就会撞上那个经典报错。下一节先讲清楚这个报错怎么来的再给勘误后的正确写法。2. cursor does not exist 报错勘误与 JdbcTemplate 正确调用姿势先复现报错。用最朴素的CallableStatementCreator不碰autoCommitjdbcTemplate.execute(new CallableStatementCreator() { Override public CallableStatement createCallableStatement(Connection con) throws SQLException { String sql { call \sp_test_multi_cursors2\(?,?,?)}; CallableStatement st con.prepareCall(sql); st.setInt(1, 1); st.registerOutParameter(2, Types.REF_CURSOR); st.registerOutParameter(3, Types.REF_CURSOR); return st; } }, new CallableStatementCallbackListRegion() { Override public ListRegion doInCallableStatement(CallableStatement cs) throws SQLException, DataAccessException { cs.execute(); ResultSet rs1 (ResultSet) cs.getObject(2); ResultSet rs2 (ResultSet) cs.getObject(3); // 这里就会抛 PSQLException return new ArrayList(); } });报错长这样org.springframework.jdbc.UncategorizedSQLException: CallableStatementCallback; uncategorized SQLException; SQL state [34000]; error code [0]; ERROR: cursor unnamed portal 1 does not exist; nested exception is org.postgresql.util.PSQLException: ERROR: cursor unnamed portal 1 does not existSQL state34000是invalid_cursor_name。原因在于 PostgreSQL 的refcursor是一个事务级对象open ... for打开的 portal 只在当前事务内有效。JDBC 默认autoCommittruecs.execute()一执行完这条语句所在的事务就提交了portal 随之销毁。你紧接着getObject(2)去取游标数据库那边已经查无此 portal。勘误就一句在创建CallableStatement之前把连接设为手动提交读完游标后再恢复。注意setAutoCommit(false)必须写在prepareCall之前写在之后无效因为语句已经绑定到旧的事务上下文。public void multiCursors2_no_auto_commit() { jdbcTemplate.execute(new CallableStatementCreator() { Override public CallableStatement createCallableStatement(Connection con) throws SQLException { con.setAutoCommit(false); // 关键必须在 prepareCall 之前 String sql { call \sp_test_multi_cursors2\(?,?,?)}; CallableStatement st con.prepareCall(sql); st.setInt(1, 1); st.registerOutParameter(2, Types.REF_CURSOR); st.registerOutParameter(3, Types.REF_CURSOR); return st; } }, new CallableStatementCallbackListRegion() { Override public ListRegion doInCallableStatement(CallableStatement cs) throws SQLException, DataAccessException { ListRegion res new ArrayList(); cs.execute(); ResultSet rs1 (ResultSet) cs.getObject(2); ResultSet rs2 (ResultSet) cs.getObject(3); ArrayListHashMapString, Object mapList1 DataTableHelper.rs2MapList(rs1); ArrayListHashMapString, Object mapList2 DataTableHelper.rs2MapList(rs2); System.out.println(JSONObject.toJSONString(mapList1)); System.out.println(JSONObject.toJSONString(mapList2)); rs1.close(); rs2.close(); cs.getConnection().setAutoCommit(true); // 恢复自动提交避免连接归还池后状态污染 return res; } }); }DataTableHelper.rs2MapList就是把ResultSet转成ArrayListHashMapString,Object的小工具遍历元数据拿列名逐行塞进 map这里不展开。这里有个容易忽略的点连接是从连接池拿的如果你只setAutoCommit(false)不恢复连接还回池里时仍是手动提交状态下一个借到它的请求可能莫名其妙不提交。所以setAutoCommit(true)要放在finally或回调结束前。上面写在rs.close()之后如果中间抛异常就恢复不了生产代码建议用 try-finally 包起来。再补一个勘误registerOutParameter用Types.REF_CURSOR是对的别用Types.OTHER。有些老文章写Types.OTHER在旧驱动上能跑新驱动上取出来可能不是ResultSet。PostgreSQL JDBC 驱动从 9.x 起对REF_CURSOR支持完整直接用标准类型。还有一种写法是把游标参数改成INOUTSQL 里能直接看到数据Java 端调用方式略有不同CREATE OR REPLACE FUNCTION public.sp_test_multi_cursors( IN id int4, INOUT records_cursor_01 refcursor, INOUT records_cursor_02 refcursor ) RETURNS record AS $BODY$ declare tmpId integer; begin open records_cursor_01 for select * from common_region limit 3; open records_cursor_02 for select * from common_region limit 2; end; $BODY$ LANGUAGE plpgsql VOLATILE;Java 端要显式给游标命名SQL 里加::refcursor转换public void multiCursors_enhance() { jdbcTemplate.execute(new CallableStatementCreator() { Override public CallableStatement createCallableStatement(Connection con) throws SQLException { con.setAutoCommit(false); String sql { call \sp_test_multi_cursors\(?,?::refcursor,?::refcursor)}; CallableStatement st con.prepareCall(sql); st.setInt(1, 1); st.setObject(2, crs_001); st.registerOutParameter(2, Types.REF_CURSOR); st.setObject(3, crs_002); st.registerOutParameter(3, Types.REF_CURSOR); return st; } }, new CallableStatementCallbackListRegion() { Override public ListRegion doInCallableStatement(CallableStatement cs) throws SQLException, DataAccessException { cs.execute(); ResultSet rs1 (ResultSet) cs.getObject(2); ResultSet rs2 (ResultSet) cs.getObject(3); System.out.println(JSONObject.toJSONString(DataTableHelper.rs2MapList(rs1))); System.out.println(JSONObject.toJSONString(DataTableHelper.rs2MapList(rs2))); rs1.close(); rs2.close(); cs.getConnection().setAutoCommit(true); return new ArrayList(); } }); }INOUT版本的好处是 SQL 客户端里select * from sp_test_multi_cursors(1, crs_001, crs_002);能直接看到两个结果集调试方便。代价是 Java 端要手动命名游标且依然要处理autoCommit。调用方式游标参数类型是否需 setAutoCommit(false)SQL 客户端可直接查看Java 端取值OUT refcursorOUT是否只返回 portal 名getObject(index)INOUT refcursorINOUT是是getObject(index)setof varchar json无游标否是getResultSet()表格里第三种就是下一节要讲的 JSON 方案它把事务问题彻底绕开。3. 可复制配置骨架config.toml 与 settings.json 接入统一通道存储过程调通之后很多团队会顺手把模型辅助编码接进同一套工程。这里给一份可复制的配置骨架把 Base URL、Key、Model ID 三件套写全方便你在调试存储过程时让模型帮忙读报错、生成映射代码。统一走 TaoToken 的 API 通道地址是 https://taotoken.net/api Key 在控制台申请。先看config.toml适合放在项目根目录或用户配置目录字段名按常见约定写# config.toml [provider] name taotoken base_url https://taotoken.net/api api_key sk-你的TaoTokenKey timeout_seconds 60 [model] id claude-sonnet-4-20250514 max_tokens 4096 temperature 0.2 [logging] level info log_request true再看settings.json适合编辑器插件或 CLI 工具读取结构对应 Base URL、Key、Model ID{ provider: { baseUrl: https://taotoken.net/api, apiKey: sk-你的TaoTokenKey, model: claude-sonnet-4-20250514 }, request: { timeout: 60000, maxTokens: 4096 }, workspace: { projectRoot: ., include: [src/main/java, src/main/resources] } }如果你用的是 Claude Code 这类 CLI配置通常落在~/.claude/settings.json或项目级.claude/settings.json把上面的provider段填进去即可。Codex 系工具读~/.codex/auth.json结构类似{ base_url: https://taotoken.net/api, api_key: sk-你的TaoTokenKey, model: claude-sonnet-4-20250514 }Cline 的 MCP 配置一般写在cline_mcp_settings.json把模型通道指向同一个 Base URL{ mcpServers: { taotoken: { command: npx, args: [-y, taotoken/mcp-server], env: { TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_API_KEY: sk-你的TaoTokenKey, TAOTOKEN_MODEL: claude-sonnet-4-20250514 } } } }三件套对照表方便你核对有没有漏项配置项config.toml 字段settings.json 字段auth.json 字段Base URLprovider.base_urlprovider.baseUrlbase_urlAPI Keyprovider.api_keyprovider.apiKeyapi_keyModel IDmodel.idprovider.modelmodel注意Key 不要硬编码进提交到仓库的文件用环境变量或本地未跟踪的配置文件覆盖。上面写明文只是为了让骨架完整可读。配置好之后用一条 curl 验证通道是否通curl -s https://taotoken.net/api/v1/messages \ -H Content-Type: application/json \ -H x-api-key: sk-你的TaoTokenKey \ -H anthropic-version: 2023-06-01 \ -d { model: claude-sonnet-4-20250514, max_tokens: 256, messages: [ {role: user, content: 用一句话解释 PostgreSQL refcursor 为什么需要事务} ] }返回里能看到content数组和文本结果就说明 Base URL、Key、Model ID 三件套都对。这一步和存储过程本身无关但把通道验证放在同一篇里是因为后面排障时你会需要它来快速确认「是数据库问题还是通道问题」。4. JSON 方案绕开游标与事务的多结果集返回游标方案能跑但每次都要记得setAutoCommit(false)再恢复代码里到处是事务开关维护起来累。更省心的做法是用 PostgreSQL 的row_to_json和array_to_json把结果集直接压成 JSON 字符串Java 端拿String解析完全不用碰游标。先看OUT text版本CREATE OR REPLACE FUNCTION sp_test_multi_json( IN id int4, out json_result_01 text, out json_result_02 text ) RETURNS record AS $BODY$ declare tmpId integer; begin select json_result::text into json_result_01 from ( select array_to_json(array_agg(row_to_json(t))) as json_result from (select * from common_region cr limit 3) t ) middle; select array_to_json(array_agg(row_to_json(t)))::text into json_result_02 from (select * from common_region cr limit 8) t; end; $BODY$ LANGUAGE plpgsql VOLATILE;Java 端调用注意这里不需要setAutoCommit(false)public void multiJson() { jdbcTemplate.execute(new CallableStatementCreator() { Override public CallableStatement createCallableStatement(Connection con) throws SQLException { String sql { call \sp_test_multi_json\(?,?,?)}; CallableStatement st con.prepareCall(sql); st.setInt(1, 1); st.registerOutParameter(2, Types.VARCHAR); st.registerOutParameter(3, Types.VARCHAR); return st; } }, new CallableStatementCallbackListRegion() { Override public ListRegion doInCallableStatement(CallableStatement cs) throws SQLException, DataAccessException { cs.execute(); String json1 cs.getString(2); String json2 cs.getString(3); System.out.println(json1); System.out.println(json2); return new ArrayList(); } }); }OUT text版本在 Java 端很好用但 SQL 客户端里select sp_test_multi_json(1);拿到的是一行两列想单独看某个 JSON 还得手动展开。如果希望 SQL 里也能像结果集一样逐行读改成RETURNS setof varcharCREATE OR REPLACE FUNCTION sp_test_multi_json3(IN id int4) RETURNS setof varchar AS $BODY$ declare tmpId integer; declare json_result_01 varchar; declare json_result_02 varchar; begin select json_result::text into json_result_01 from ( select array_to_json(array_agg(row_to_json(t))) as json_result from (select * from common_region cr limit 3) t ) middle; select array_to_json(array_agg(row_to_json(t)))::text into json_result_02 from (select * from common_region cr limit 8) t; return next json_result_01; return next json_result_02; end; $BODY$ LANGUAGE plpgsql VOLATILE;Java 端用getResultSet()逐行读public void multiJsonEnhance() { jdbcTemplate.execute(new CallableStatementCreator() { Override public CallableStatement createCallableStatement(Connection con) throws SQLException { String sql { call \sp_test_multi_json3\(?)}; // 也可以写成select * from sp_test_multi_json3(?) CallableStatement st con.prepareCall(sql); st.setInt(1, 1); return st; } }, new CallableStatementCallbackListRegion() { Override public ListRegion doInCallableStatement(CallableStatement cs) throws SQLException, DataAccessException { cs.execute(); ResultSet rs cs.getResultSet(); while (rs.next()) { System.out.println(rs.getString(1)); // 列索引从 1 开始 } return new ArrayList(); } }); }两种 JSON 写法的取舍OUT text适合固定返回几个 JSON 块的场景Java 端按索引取setof varchar适合返回块数不固定、想统一遍历的场景SQL 客户端里也能直接select * from sp_test_multi_json3(1);看到多行。提示array_to_json(array_agg(row_to_json(t)))这个嵌套是固定套路row_to_json把每行转对象array_agg聚成数组array_to_json再转成 JSON 文本。少一层都会得到奇怪的结构建议直接抄。到这里两条路线都跑通了。简单多结果集优先 JSON复杂嵌套结果集且必须用游标时再上INOUT refcursor加事务开关。5. 常见报错对照排查401、local proxy failed、reading choices、OAuth这一节把存储过程调试和通道配置里最容易撞的几类报错列出来对照着查。第一类数据库侧。cursor unnamed portal 1 does not existSQL state34000原因和修法前面讲过setAutoCommit(false)写在prepareCall之前。如果加了还是报检查是不是用了连接池且连接被复用setAutoCommit没恢复导致状态错乱。另一个变体是ERROR: cursor crs_001 does not exist出现在INOUT命名游标场景通常是 SQL 里漏了::refcursor转换或者setObject的名字和 SQL 里引用的名字不一致。第二类通道侧。401 Unauthorized先查 Key 有没有填错、有没有多余空格再查 Base URL 是不是写成了https://taotoken.net/api而不是别的路径。local proxy failed一般出现在本机网络配置或工具代理设置上检查工具里的代理开关是否误开把它关掉直连即可。reading choices这类报错通常出现在响应解析阶段多半是请求体格式和接口不匹配比如把 Anthropic 格式发到了 OpenAI 兼容端点或者反过来核对settings.json里的模型 ID 和端点是否配套。OAuth相关报错出现在用 OAuth 流程的工具里检查 token 是否过期、回调地址是否和配置一致必要时重新走一次授权。第三类配置三件套缺失。如果你在 CC Switch、Cline MCP、Codex auth.json 里只填了 Key 没填 Base URL或者只填了 Base URL 没填 Model ID工具会报模型不存在或连接被拒。三件套必须同时存在Base URL 指向 https://taotoken.net/api Key 用控制台申请的Model ID 用通道支持的型号。缺一个都跑不起来。报错关键词出现位置优先排查cursor does not exist数据库setAutoCommit(false) 位置、游标命名401 Unauthorized通道Key 拼写、Base URL 路径local proxy failed本机工具代理开关是否误开reading choices响应解析请求体格式与端点是否配套OAuth授权工具token 有效期、回调地址model not found通道Model ID 是否填写、是否受支持排查顺序建议先确认数据库侧存储过程在客户端能单独跑通再确认通道 curl 能返回结果最后才怀疑 Java 代码。把两层分开验证比在 JdbcTemplate 里同时猜两边问题快得多。6. 把调用验证和通道配置收进同一套工程回到工程实践。存储过程调用和模型通道配置看起来是两件事但在实际项目里经常同时出现你调存储过程报错想让模型帮你读堆栈模型生成的映射代码又要贴回 JdbcTemplate 里验证。把两者收进同一套配置省得来回切工具。具体做法项目根目录放config.toml或.claude/settings.json把 Base URL、Key、Model ID 三件套写全数据库连接配置照旧走 Spring 的dataSource存储过程调用代码按前面的勘误版写游标场景记得事务开关JSON 场景直接取字符串。验证顺序是先用 curl 打一次 https://taotoken.net/api 确认通道通再跑一次multiJsonEnhance确认数据库通两边都通之后再让模型参与生成或重构调用代码。如果你需要长期在编码和 Agent 场景里用模型可以了解 Coding Plan把通道额度固定下来只是偶尔验证模型输出用模型对话即可Key 和接入细节在 API Keys 与接入文档里查。入口统一从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 进按需选对应页面。最后留一个实用习惯把setAutoCommit(false)和恢复写成模板方法别在每个存储过程调用里手写。游标类调用统一走这个模板JSON 类调用走另一个模板代码里就不会再散落事务开关。存储过程本身尽量往setof varchar或OUT text的 JSON 形式靠能不用游标就不用事务问题从源头少一半。