1. 为什么 MyBatis 调 Oracle 存储过程返回游标总踩坑
很多同学第一次在 MyBatis 里调 Oracle 存储过程返回SYS_REFCURSOR,都会遇到一个尴尬局面:存储过程在 PL/SQL Developer 里跑得好好的,一放到 Java 里就报ORA-17004: 列类型无效或者ORA-01000: 超出打开游标的最大数,再不然就是ResultSet拿到了但rs.next()永远返回 false。核心原因在于 Oracle 的游标是输出参数,不是普通查询结果集,MyBatis 需要靠statementType="CALLABLE"加jdbcType=CURSOR才能正确注册出参类型。
这篇内容聚焦的场景很具体:Oracle 端已经写好一个返回SYS_REFCURSOR的存储过程,Java 端用 MyBatis 调用,最终把游标里的数据映射成List<Map<String, Object>>。适合谁看?适合正在做老系统对接、报表导出、数据同步的 Java 后端,尤其是那些表结构经常变、不想为每个存储过程写一个实体类的项目。List<Map>的好处就是字段随游标走,不用改 Java 代码。
我会把整条链路拆开:Oracle 包和过程怎么定义、Mapper XML 怎么写、Java 怎么调、怎么验证跑通、报错怎么排查。中间还会顺带说清楚resultMap和resultType=map在游标场景下到底该选哪个。最后给一个统一 Key 通道的接入方式,方便你在本地或测试环境快速验证,不用来回改配置文件。
先说结论:游标映射到List<Map>最稳的写法是手动遍历 ResultSet,而不是指望 MyBatis 自动映射。原因后面会展开,但你可以先记住这个判断。
2. TaoToken 统一 Key 通道前置准备
在动手写 Mapper 之前,先把调用通道理顺。很多团队本地开发时数据库连接、模型调用、密钥管理是散的,改一个环境要动好几处配置。我习惯用一个统一的 Key 通道来收敛这些入口,TaoToken 就是干这个的:它提供一个统一的 API 入口和 Key 管理,模型对话、编码计划、控制台、API Keys 都在同一套体系里。
你需要先拿到一个可用的 Key。打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册后进入控制台,在 API Keys 页面创建一个 Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Keys 页面是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建时建议按环境命名,比如dev-mybatis-oracle,方便后面排查是哪个环境在用。
API 的基础地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接用于代码里的base_url。如果你只是想先验证模型通道是否通,可以用模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 发一条消息试试。如果你在做长期编码或 Agent 类项目,Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 里有套餐说明。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,遇到参数不确定时优先查这里。
这里要强调一点:TaoToken 是统一 Key 通道,不是数据库代理,也不是让你绕过任何合规限制的工具。它的作用是让你在多个模型/服务之间用一套 Key 和入口,减少配置漂移。数据库连接本身还是走你本地的 JDBC,两者互不干扰。把 Key 准备好之后,我们进入正题。
3. 可复制配置:Oracle 包、Mapper XML 与 Java 调用
这一节是全文的核心,所有代码都可以直接复制。我按 Oracle 端、Mapper 端、Java 端三段来写,每段都给出完整片段。
3.1 Oracle 端:定义包和返回游标的过程
先在 Oracle 里建一个包,声明一个返回SYS_REFCURSOR的过程。注意游标类型用SYS_REFCURSOR,这是 Oracle 内置的弱类型游标,适合字段不固定的场景。
CREATE OR REPLACE PACKAGE PKG_TEST AS PROCEDURE P_TEST( P_DEPT_NO IN VARCHAR2, V_CURSOR OUT SYS_REFCURSOR ); END PKG_TEST; / CREATE OR REPLACE PACKAGE BODY PKG_TEST AS PROCEDURE P_TEST( P_DEPT_NO IN VARCHAR2, V_CURSOR OUT SYS_REFCURSOR ) IS BEGIN OPEN V_CURSOR FOR SELECT EMP_NO, EMP_NAME, SALARY, HIRE_DATE FROM EMP WHERE DEPT_NO = P_DEPT_NO ORDER BY EMP_NO; END P_TEST; END PKG_TEST; /建完后在 PL/SQL Developer 里先自测一下,确认游标能打开:
DECLARE V_CUR SYS_REFCURSOR; V_NO VARCHAR2(20); V_NM VARCHAR2(50); BEGIN PKG_TEST.P_TEST('D001', V_CUR); LOOP FETCH V_CUR INTO V_NO, V_NM; EXIT WHEN V_CUR%NOTFOUND; DBMS_OUTPUT.PUT_LINE(V_NO || ' - ' || V_NM); END LOOP; CLOSE V_CUR; END; /如果这一步就报错,先别往下走,问题在 Oracle 端。常见的是包体没编译通过,或者EMP表字段名对不上。
3.2 Mapper XML:statementType=CALLABLE 与 jdbcType=CURSOR
Mapper 接口先定义方法,参数用Map承载,因为游标是出参,需要从同一个 Map 里取回:
public interface TestMapper { void testP(Map<String, Object> param); }Mapper XML 的关键就三处:statementType="CALLABLE"、mode=OUT、jdbcType=CURSOR。缺一个都会出问题。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.TestMapper"> <select id="testP" statementType="CALLABLE" parameterType="map"> {call PKG_TEST.P_TEST( #{pDeptNo, mode=IN, jdbcType=VARCHAR}, #{vCursor, mode=OUT, jdbcType=CURSOR, javaType=java.sql.ResultSet, resultMap=empMap} )} </select> <resultMap id="empMap" type="java.util.Map"> <result column="EMP_NO" property="empNo"/> <result column="EMP_NAME" property="empName"/> <result column="SALARY" property="salary"/> <result column="HIRE_DATE" property="hireDate"/> </resultMap> </mapper>这里有个取舍要讲清楚:resultMap和resultType=map在游标场景下行为不一样。如果你写resultMap,MyBatis 会尝试按你定义的列名映射,列名对不上就丢字段;如果你写resultType="map",MyBatis 会用数据库返回的列名做 key,字段随游标走。对于List<Map>这种需求,推荐用resultType="map",因为你要的就是动态字段。但注意,resultType=map在部分 MyBatis 版本里对游标出参支持不稳定,所以更稳的做法是不依赖自动映射,手动遍历 ResultSet,也就是下一节的写法。
3.3 Java 调用:手动遍历 ResultSet 映射成 List
这是最稳的一段。不要指望 MyBatis 把游标自动塞进返回值,而是从入参 Map 里把ResultSet取出来自己遍历。
@Service public class TestService { @Autowired private TestMapper testMapper; public List<Map<String, Object>> queryByDept(String deptNo) { Map<String, Object> param = new HashMap<>(); param.put("pDeptNo", deptNo); testMapper.testP(param); List<Map<String, Object>> list = new ArrayList<>(); ResultSet rs = (ResultSet) param.get("vCursor"); if (rs == null) { return list; } try { ResultSetMetaData meta = rs.getMetaData(); int columnCount = meta.getColumnCount(); while (rs.next()) { Map<String, Object> row = new LinkedHashMap<>(); for (int i = 1; i <= columnCount; i++) { String key = meta.getColumnLabel(i); Object val = rs.getObject(i); row.put(key, val); } list.add(row); } } catch (SQLException e) { throw new RuntimeException("读取游标失败", e); } finally { try { rs.close(); } catch (SQLException ignore) { } } return list; } }几个细节值得说。第一,用LinkedHashMap而不是HashMap,保证字段顺序和游标列顺序一致,前端展示时不会乱。第二,用getColumnLabel(i)而不是getColumnName(i),因为前者会返回别名,后者在某些驱动下返回的是原始列名。第三,rs.close()一定要放在finally,否则游标不释放,跑几次就ORA-01000。
如果你确实想用 MyBatis 自动映射,可以把 XML 里的resultMap换成resultType="map",然后 Java 端直接接收返回值:
<select id="testP" statementType="CALLABLE" parameterType="map" resultType="map"> {call PKG_TEST.P_TEST( #{pDeptNo, mode=IN, jdbcType=VARCHAR}, #{vCursor, mode=OUT, jdbcType=CURSOR, javaType=java.sql.ResultSet, resultMap="empMap"} )} </select>但实测下来,这种写法在不同 MyBatis 版本上表现不一致,有的版本能返回 List,有的返回空。所以生产环境我还是推荐手动遍历。
4. 验证请求与成功结果
配置写完后,怎么确认真的跑通了?我一般分三步验证。
第一步,写一个单元测试直接调 Service:
@RunWith(SpringRunner.class) @SpringBootTest public class TestServiceTest { @Autowired private TestService testService; @Test public void testQueryByDept() { List<Map<String, Object>> list = testService.queryByDept("D001"); System.out.println("size = " + list.size()); for (Map<String, Object> row : list) { System.out.println(row); } } }第二步,看控制台输出。成功的话你会看到类似:
size = 3 {EMP_NO=1001, EMP_NAME=张三, SALARY=12000, HIRE_DATE=2021-03-15} {EMP_NO=1002, EMP_NAME=李四, SALARY=9800, HIRE_DATE=2022-07-01} {EMP_NO=1003, EMP_NAME=王五, SALARY=15000, HIRE_DATE=2020-11-20}第三步,确认游标被释放。可以在 Oracle 里查一下当前打开的游标数:
SELECT COUNT(*) FROM V$OPEN_CURSOR WHERE USER_NAME = 'YOUR_USER';跑几次测试后这个数字不应该持续增长。如果一直涨,说明rs.close()没生效,或者 MyBatis 的SqlSession没关。
如果你用的是 TaoToken 的统一 Key 通道来管理模型调用,验证方式类似:在模型对话页面发一条消息,确认返回正常,说明 Key 和通道没问题。数据库这条链路和模型通道是独立的,两边都通才算环境就绪。
5. 本篇常见错排查清单
这一节按真实报错来,每条都给出原因和改法。
ORA-17004: 列类型无效。这是最常见的。原因通常是jdbcType=CURSOR没写,或者写成了jdbcType=OTHER。Oracle 的游标必须显式声明jdbcType=CURSOR,并且javaType=java.sql.ResultSet。检查 Mapper XML 里出参那一行,三个属性缺一不可。
ORA-01000: 超出打开游标的最大数。游标没关。检查 Java 代码里rs.close()是否在finally块,以及SqlSession是否被正确关闭。如果你用的是 Spring 管理的事务,确认方法上有@Transactional,否则连接可能不释放。
local proxy failed / 401。这类报错通常出现在你通过统一 Key 通道调模型时,Key 无效或环境变量没读到。检查base_url是否写成 https://taotoken.net/api ,Key 是否从 API Keys 页面正确复制,有没有多余空格。401 就是鉴权失败,别去改数据库配置。
reading choices 报错。这是模型返回结构解析失败,一般出现在你用统一通道调对话接口时。确认请求体里的model字段和文档一致,响应解析按choices[0].message.content取。接入文档里有完整示例。
OAuth 相关报错。如果你在配 Claude Code 或类似工具,OAuth 回调地址要和控制台里登记的一致。ClaudeCodeAnthropic 的接入说明在文档里有专门章节,按步骤走就行。
ResultSet 拿到但 rs.next() 返回 false。两种可能:一是存储过程里游标没 OPEN,二是你在 Java 里提前把游标读了一次。确认 Oracle 端OPEN V_CURSOR FOR执行了,Java 端只读一次。
字段名全是大写或带下划线。Oracle 默认返回大写列名,List<Map>的 key 就是大写。如果前端要小驼峰,在遍历时用meta.getColumnLabel(i).toLowerCase()转换,或者用resultMap显式映射。
CC Switch / Cline MCP / Codex auth.json 配置。如果你在用这些工具,记住三件套:Base URL 填 https://taotoken.net/api ,Key 填你创建的 Key,Model ID 按文档里的模型名填。三者缺一,工具就连不上。
6. 统一 Key 通道接入与后续建议
把数据库链路跑通之后,如果你还想把模型调用也收敛到同一套 Key 体系,可以按下面的路径操作。先到 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 确认 Key 有效,再到接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 查对应工具的配置格式。如果你只是临时验证模型是否可用,模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 最快。长期做编码或 Agent 项目的话,Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 有更合适的方案。
回到 MyBatis 这条链路,最后给几个实用建议。第一,游标出参的 Map key 命名要固定,比如统一用vCursor,别这次叫cursor下次叫outCursor,否则维护时容易找不到。第二,List<Map>虽然灵活,但字段类型全是Object,前端拿到的日期可能是Timestamp,建议在遍历时按meta.getColumnTypeName(i)做一次类型归一化。第三,如果存储过程返回的游标字段特别多,手动遍历的性能瓶颈在getObject,可以考虑用rs.getObject(i, Class)指定类型,减少装箱开销。
我试过在同一个项目里混用resultMap和手动遍历,最后统一成手动遍历,因为排查问题时能直接打断点看 ResultSet,比猜 MyBatis 映射规则快得多。你可以先按本文的代码跑通,再根据自己项目的字段稳定性决定要不要换成自动映射。