1. Oracle 存储过程返回 sys_refcursor 时 Mybatis 映射对象集合到底卡在哪
先说清楚要解决的是什么问题。Oracle 存储过程没有像 MySQL 那样直接SELECT返回结果集的能力,它输出多行数据的标准做法是开一个SYS_REFCURSOR游标作为 OUT 参数,调用方拿到游标后再逐行取。Mybatis 作为持久层框架,默认擅长处理SELECT语句到实体的映射,但遇到「存储过程 + 游标 OUT 参数」这种组合,配置项就变得很讲究:statementType要改成CALLABLE,游标参数要声明jdbcType=CURSOR和javaType=ResultSet,还得额外挂一个resultMap告诉 Mybatis 游标里的列该往哪个实体类塞。这三者缺一个,运行时就给你抛异常或者返回空集合。
这个场景适合谁?适合正在做 Oracle 老系统维护、权限菜单树加载、报表数据导出这类功能的 Java 后端。典型症状是:存储过程在 PL/SQL Developer 里跑得好好的,游标能打开能看到数据,但一进 Mybatis 就变成null,或者报Invalid column type、ORA-01000之类的错。很多人第一次配的时候会把游标参数写成普通 OUT 参数,结果 Mybatis 试图把ResultSet当字符串处理,直接崩。
我先把整条链路拆开:Oracle 端定义一个带SYS_REFCURSOROUT 参数的存储过程;Java 端 Dao 方法接收一个Map(因为存储过程有多个 IN/OUT 参数,用 Map 承载最灵活);Mybatis XML 里用{call ...}语法调用,游标参数绑定resultMap;Service 层从 Map 里取出游标映射后的List,注意不能直接赋值引用,要用clear()+addAll()回填到调用方传入的集合里。这条链路里最容易翻车的是两个点:一是 XML 里resultMap=cursorMap这个属性漏写,导致 Mybatis 不知道游标列映射到哪个实体;二是 Service 层用menus = (List)map.get("menus")直接赋值,改变了局部变量引用,调用方手里的集合还是空的。
下面按「前置准备 → 可复制配置 → 验证 → 排错」的顺序走一遍,每一步都给能直接粘贴的代码。你照着配,目标是让游标里的行变成List<FrmMenu>,字段对得上、空值不炸。
2. 接入前把 TaoToken 的 Key 和模型 ID 准备好
这一节讲前置。虽然本篇核心是 Mybatis 配置,但你在调试过程中大概率需要让 AI 帮你读报错、补全 resultMap 字段、解释jdbcType=CURSOR的行为,这时候有一个稳定的模型调用入口会省很多来回。我用 TaoToken 的 API 做这类辅助排查,它的接口兼容 OpenAI 风格,配起来不折腾。
第一步,打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册账号,进控制台。第二步,在控制台左侧找到 API Keys 菜单,新建一个 Key,复制出来存好——这个 Key 只显示一次,丢了只能重建。第三步,确认你要用的模型 ID,比如做代码补全和报错解释,选一个上下文够长的对话模型即可,模型 ID 在文档的模型列表里能查到。
拿到三件套之后,你可以在本地用 curl 先验证 Key 是否可用:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer 你的API_KEY" \ -d '{ "model": "你的模型ID", "messages": [{"role": "user", "content": "解释 Mybatis jdbcType=CURSOR 的作用"}] }'返回里如果有choices[0].message.content,说明 Key 和模型 ID 都对。这一步别跳过,很多人后面调 Mybatis 报错,结果发现是 Key 填错或者模型 ID 写了个不存在的,白折腾半小时。
如果你打算长期做这类代码排查和 Agent 辅助开发,可以看下 Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,按套餐走比单次调用划算。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,里面有各语言的示例。API Keys 管理页是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,重建 Key 也在这里。想直接在网页里对话验证模型效果,用模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 就行。
这里提醒一句:TaoToken 是模型调用入口,不是数据库中间件,它不参与你 Oracle 存储过程的执行链路。它的作用是当你遇到ORA-报错或者 Mybatis 映射异常时,把堆栈贴给模型让它帮你定位。数据库连接、JDBC 驱动、连接池这些还是走你项目原有的配置。
3. Mybatis 调用 sys_refcursor 的完整 XML 与 Java 配置片段
这一节是核心,给可直接复制的配置。先看 Oracle 存储过程签名,假设长这样:
CREATE OR REPLACE PROCEDURE selectRoleMenu( cdbhs OUT SYS_REFCURSOR, jsjb IN VARCHAR2, sjjs IN VARCHAR2, M_RES OUT INTEGER, M_ERR OUT VARCHAR2 ) IS BEGIN OPEN cdbhs FOR SELECT mlbh, cdbh, cdmc, ymdz, cdtp FROM frm_menu WHERE jsjb = selectRoleMenu.jsjb; M_RES := 1; M_ERR := 'OK'; END;注意游标是第一个 OUT 参数,后面跟两个 IN 参数和两个 OUT 参数。Mybatis 调用时参数顺序必须和存储过程声明一致。
Dao 接口方法接收 Map:
void selectUserMenu(@Param("map") Map<String, Object> map);Mybatis XML 配置,这是最关键的一段:
<resultMap type="com.zlkj.frm.bean.FrmMenu" id="cursorMap"> <result property="mlbh" column="MLBH" jdbcType="VARCHAR"/> <result property="cdbh" column="CDBH" jdbcType="VARCHAR"/> <result property="cdmc" column="CDMC" jdbcType="VARCHAR"/> <result property="ymdz" column="YMDZ" jdbcType="VARCHAR"/> <result property="cdtp" column="CDTP" jdbcType="VARCHAR"/> </resultMap> <select id="selectUserMenu" statementType="CALLABLE" parameterType="map"> {call FRM_DATA_PKG.selectUserMenu( #{map.menus, mode=OUT, jdbcType=CURSOR, javaType=java.sql.ResultSet, resultMap=cursorMap}, #{map.yhdh, mode=IN, jdbcType=VARCHAR}, #{map.code, mode=OUT, jdbcType=INTEGER}, #{map.mess, mode=OUT, jdbcType=VARCHAR} )} </select>几个参数逐个解释。statementType="CALLABLE"告诉 Mybatis 这是存储过程调用,不是普通查询,必须写。游标参数那行mode=OUT表示输出,jdbcType=CURSOR是 Oracle 游标的 JDBC 类型,javaType=java.sql.ResultSet指定 Java 侧接收类型,resultMap=cursorMap把游标结果集和实体映射绑起来——这一项漏了,Mybatis 拿到游标也不知道该 new 什么类,返回的集合要么是null要么是Object数组。M_RES和M_ERR是普通 OUT 参数,jdbcType分别用INTEGER和VARCHAR。
Service 层回填集合,这里有个坑必须避开:
public void selectUserMenu(List<FrmMenu> menus, FrmSysUser user) { Map<String, Object> map = new HashMap<>(); map.put("menus", new ArrayList<FrmMenu>()); map.put("yhdh", user.getYhdh()); map.put("code", null); map.put("mess", null); frmMenuDao.selectUserMenu(map); List<FrmMenu> result = (List<FrmMenu>) map.get("menus"); if (result != null) { menus.clear(); menus.addAll(result); } }为什么不能写menus = (List)map.get("menus")?因为 Java 是值传递,menus这个形参重新赋值只改变了方法内的局部引用,调用方传进来的那个 List 对象还是空的。正确做法是clear()清空原集合再addAll()把游标结果灌进去,这样调用方手里的集合才有数据。这个坑我在老项目里见过不止一次,表现是「存储过程明明返回了 5 条,Service 里打印有数据,但 Controller 拿到的 List 是空的」。
如果你用 Spring Boot,数据源配置里记得把 Oracle 驱动和连接池配好,application.yml大致这样:
spring: datasource: url: jdbc:oracle:thin:@//127.0.0.1:1521/ORCL username: your_user password: your_pwd driver-class-name: oracle.jdbc.OracleDriverMybatis 的 mapper 扫描路径和 XML 位置按你项目实际配,这里不展开。配置齐了之后,游标到List<FrmMenu>的映射链路就通了。
4. 用最小 Demo 验证游标映射与空值处理是否一次跑通
配完不能直接上生产,先写个最小可运行 Demo 验证。我一般分三步:先验证存储过程本身能开游标,再验证 Mybatis 能拿到集合,最后验证空值和字段类型。
第一步,在数据库客户端里直接调存储过程,确认游标有数据:
DECLARE v_cur SYS_REFCURSOR; v_res INTEGER; v_err VARCHAR2(200); BEGIN selectRoleMenu(v_cur, '01', '00', v_res, v_err); DBMS_OUTPUT.PUT_LINE('res=' || v_res || ', err=' || v_err); -- 游标内容在客户端结果集里看 END;如果这一步游标打不开,问题在存储过程,不在 Mybatis,先修 SQL。
第二步,写一个 JUnit 测试直接调 Dao:
@RunWith(SpringRunner.class) @SpringBootTest public class FrmMenuDaoTest { @Autowired private FrmMenuDao frmMenuDao; @Test public void testSelectUserMenu() { Map<String, Object> map = new HashMap<>(); map.put("menus", new ArrayList<FrmMenu>()); map.put("yhdh", "admin"); map.put("code", null); map.put("mess", null); frmMenuDao.selectUserMenu(map); List<FrmMenu> list = (List<FrmMenu>) map.get("menus"); System.out.println("size=" + (list == null ? "null" : list.size())); System.out.println("code=" + map.get("code") + ", mess=" + map.get("mess")); if (list != null && !list.isEmpty()) { FrmMenu first = list.get(0); System.out.println("first.mlbh=" + first.getMlbh() + ", cdmc=" + first.getCdmc()); } } }跑通的标准是:size大于 0,first.mlbh和first.cdmc有值,code和mess是存储过程里赋的值。如果size=0但数据库里有数据,八成是resultMap的column和游标 SELECT 的列名大小写对不上——Oracle 默认列名大写,你resultMap里写小写mlbh在某些驱动版本下匹配不到,统一写大写最稳。
第三步,验证空值处理。把存储过程里某条记录的cdtp置为 NULL,再跑一次,确认实体里cdtp是null而不是抛NullPointerException。resultMap里jdbcType="VARCHAR"已经声明了类型,Mybatis 遇到 NULL 会正常塞null。如果你在实体里用了基本类型int接可能为 NULL 的列,会报Cannot assign null to primitive,把实体字段改成包装类型Integer即可。
再补一个边界:游标返回 0 行时,map.get("menus")拿到的是空ArrayList还是null?取决于驱动,实测 Oracle 驱动一般给空集合,但保险起见 Service 里判null再clear()+addAll()。验证完这三步,游标到对象集合的映射就算一次跑通了。
5. 报错对照:401、local proxy failed、reading choices、OAuth 怎么排
这一节把常见报错和真实堆栈对上,方便你对号入座。
ORA-01000: maximum open cursors exceeded——游标没关。Mybatis 调用存储过程时游标由框架管理,但如果你在存储过程里手动OPEN了多个游标没CLOSE,或者连接池复用连接时游标泄漏,就会累积。排查:查v$open_cursor看哪个 session 游标数暴涨,存储过程里确保每个OPEN都有对应CLOSE,连接池的maxOpenCursors适当调大。
Invalid column type: 1111——jdbcType写错。Oracle 游标的 JDBC 类型码是-10(OracleTypes.CURSOR),Mybatis 里写jdbcType=CURSOR即可,别写成OTHER或REF。如果报这个错,检查游标参数那行是不是漏了jdbcType=CURSOR。
java.sql.SQLException: 无效的列类型——和上面同源,多半是javaType没写java.sql.ResultSet,Mybatis 试图用默认类型处理游标。
reading choices类报错——这是你在用 TaoToken 辅助排查时,模型返回体解析失败。检查请求体里model字段是否拼错、messages是否是数组、Authorization头是否带了Bearer前缀。用 curl 复现一次,看返回的error.message具体说什么。
401 Unauthorized——Key 无效或过期。去 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 确认 Key 状态,重建一个再试。注意 Key 前后别带空格。
local proxy failed——本地网络到 API 端点的连接没通。检查你的出口网络、DNS 解析、是否有本地防火墙拦截。这个报错和 Mybatis 无关,是模型调用链路的问题,换个网络环境或稍后重试。
OAuth相关报错——如果你用的是需要 OAuth 授权的客户端(比如某些 IDE 插件),token 过期会报这个。重新走一遍授权流程,或者改用 API Key 方式直连。
Cannot assign null to primitive——实体字段用了int/long接可能为 NULL 的列,改成Integer/Long。
Result Maps collection does not contain value for cursorMap——resultMap的 id 和 XML 里引用的名字不一致,或者resultMap定义在另一个 namespace 下没加全限定名。检查<resultMap id="cursorMap">和resultMap=cursorMap是否完全一致。
排错顺序建议:先看数据库客户端能不能跑通存储过程,再看 Mybatis 日志里实际执行的{call ...}语句,最后看返回 Map 里各 key 的值。Mybatis 开logging.level.your.mapper.package=DEBUG能看到参数绑定详情。
6. 后续调试与模型辅助的入口
配置跑通之后,日常维护中还会遇到字段增减、游标列类型变化、多数据源切换这些情况。我的习惯是把 Mybatis 的 SQL 日志级别常开在开发环境,每次改resultMap后跑一遍第 4 节的 Demo 测试,确认集合 size 和首条记录字段都对。存储过程那边如果加了新列,resultMap里补一行<result property="新字段" column="新列名" jdbcType="..."/>就行,不用动 Java 代码。
遇到看不懂的堆栈,把完整异常贴给模型让它帮你定位,比翻文档快。模型调用入口用 API https://taotoken.net/api ,Key 在控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 管理,接入示例看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 。Claude Code 用户走 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 这个入口配 Base URL 和 Key。长期做代码辅助就上 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,按量走单次调用用模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 验证效果。
最后留一个实操细节:resultMap里column的大小写跟着游标 SELECT 的列名走,Oracle 默认大写,你写大写最保险;如果用了别名带引号的小写列名,column也要对应小写。这个细节不注意,表现就是集合有 size 但字段全是null,排查起来很费时间。