Java调用Oracle存储过程处理Record参数:JDBC与STRUCT实战指南
做Java和Oracle联调的时候最让人头疼的往往不是SQL写得多复杂而是Java类型系统和数据库类型系统之间的映射问题。我最近就被一个带Record参数的存储过程折腾了一整天——不是SQL写错也不是逻辑不对纯粹就是JDBC怎么都绑不上这个Record类型的入参和出参。网上资料零零散散Oracle官方文档又写得云里雾里最后是靠翻驱动源码加反复摸索才把整条链路跑通。这篇东西专门写给正在跟Oracle存储过程搏斗的Java工程师。你会搞清楚为什么JDBC不能直接绑定PL/SQL的Record类型、怎么用STRUCT对象完成Record的传入和传出、以及存储过程返回Record集合时该用什么姿势接收。整个过程有完整可跑的代码示例也有我踩过的那些坑照着做基本能避掉90%的雷。1. 先说结论Java调Oracle Record到底卡在哪1.1 一个老坑JDBC无法直接绑定PL/SQL包内Record为了把问题说清楚我先还原一下经典的翻车现场。很多业务系统里DBA会把一组相关的字段封装成一个Record类型存过程直接拿这个类型当入参或出参。比如这样的定义CREATE OR REPLACE PACKAGE EMP_PKG AS TYPE EMP_RECORD IS RECORD ( EMP_ID NUMBER(10), EMP_NAME VARCHAR2(100), SALARY NUMBER(10,2) ); PROCEDURE SAVE_EMP(p_emp IN EMP_RECORD); PROCEDURE GET_EMP(p_emp_id IN NUMBER, p_emp OUT EMP_RECORD); END EMP_PKG;这种写法的好处是传参非常方便一次把员工的多个字段打包带走不用写一长串参数列表。但问题就出在Java这边——你用JDBC的CallableStatement根本没法直接声明“我要传一个PL/SQL Record进去”。// 这种写法一定会翻车 CallableStatement cstmt conn.prepareCall({call EMP_PKG.SAVE_EMP(?)}); cstmt.setObject(1, ???); // 该传个什么东西JDBC根本不认识PL/SQL RECORD原因其实不复杂JDBC是面向SQL标准的它只认SQL层面的类型。而PL/SQL的Record类型是PL/SQL引擎的私有类型它不存在于SQL引擎的类型系统里。你把一个Record传给数据库驱动根本不知道该怎么把它序列化到网络协议里。这时候Oracle会直接给你一个ORA-03115: 不支持的网络数据类型或表示法或者更常见的ORA-06550: PLS-00306: 调用时参数数量或类型错误。我一直认为“类型系统对不上”是这个需求最大的坎而不是存储过程本身的逻辑。只有先把这个坎过了后面所有代码写起来才顺。1.2 破局的三种思路与选型逻辑既然JDBC不认包内RECORD那思路就清晰了要么让数据库的类型变成JDBC认得的要么绕开Record类型走别的通道。我实践下来有三条路可以走。方案一把Record改成数据库对象类型Oracle里除了PL/SQL包内的RECORD还有一种更“正规”的类型叫对象类型用CREATE TYPE AS OBJECT创建。这种类型是数据库级别的SQL引擎和JDBC驱动都能识别。改成对象类型之后Java可以用oracle.sql.STRUCT来映射它。这是我最推荐的做法改动可控Java侧的代码也很直观而且不仅能传一个Record还能传Record的集合。唯一的要求是你要有创建类型的权限以及存储过程里如果有包内RECORD的引用需要同步改。方案二存储过程外再加一层包装用游标返回结果如果Record类型是历史包袱动不了或者你没有改类型的权限那可以绕道走SYS_REFCURSOR。思路是在原有逻辑外面包一个过程把Record里的字段查出来放到游标里返回。Java侧用OracleTypes.CURSOR接收然后当ResultSet用。这个方案的优点是彻底避开Record类型缺点是只能用来“返回数据”没办法做到把Record作为入参传进去。如果业务上有“传一个完整Record进去做更新”的需求这条路就走不通。方案三退化成普通参数列表最土的办法但也是最不容易出错的存储过程签名改成多个普通参数Java侧一个个传。比如SAVE_EMP(p_id IN NUMBER, p_name IN VARCHAR2, p_salary IN NUMBER)。Record类型可以不删改成内部拼装。我见过不少老系统就是这么干的。好处是零风险坏处是失去了Record类型带来的封装性。如果是一次两次调用我觉得完全可以接受但如果是高频复杂场景还是建议方案一。一句话总结选型逻辑能改对象类型就改对象类型这是最贴近原生Record用法的路子改不了就用游标读出来实在不行就参数拆开传。下面我就按方案一为主线把完整实操过程写出来。2. 环境准备与Oracle侧的类型设计2.1 建表与对象类型的正确姿势动手写Java之前先把数据库侧的类型和存储过程准备好。我先建一张员工信息表然后创建一个跟Record字段完全对齐的对象类型。-- 1. 建基础业务表 CREATE TABLE EMP_INFO ( EMP_ID NUMBER(10) PRIMARY KEY, EMP_NAME VARCHAR2(100), SALARY NUMBER(10,2) ); -- 2. 创建对象类型数据库级JDBC可识别 CREATE OR REPLACE TYPE EMP_RECORD AS OBJECT ( EMP_ID NUMBER(10), EMP_NAME VARCHAR2(100), SALARY NUMBER(10,2) );这里有个非常容易踩的坑如果你原来的系统里已经存在同名的包内RECORD比如EMP_PKG.EMP_RECORD那CREATE TYPE EMP_RECORD不会有问题因为包内的类型和数据库对象类型不在同一个命名空间。但你的存储过程代码里如果写的是EMP_PKG.EMP_RECORD就得注意别搞混了。更推荐的做法是给对象类型起一个和包内Record高度一致的名字但语义上别冲突。我实际项目里通常会直接让对象类型顶替掉包内Record的角色包里的类型定义改成别名指向对象类型CREATE OR REPLACE PACKAGE EMP_PKG AS -- 直接复用数据库对象类型作为Record的正式定义 SUBTYPE EMP_RECORD IS EMP_RECORD; -- 这里语法上不推荐同名实际企业里一般直接弃用包内定义 PROCEDURE SAVE_EMP(p_emp IN EMP_RECORD); PROCEDURE GET_EMP(p_emp_id IN NUMBER, p_emp OUT EMP_RECORD); END EMP_PKG;如果包已经存在且改起来风险大那我建议直接新建一个包或者在原有包内把Record定义换成对象类型引用。这个要看你们系统的耦合程度灵活处理。2.2 存储过程定义与兼容性检查类型建好之后存储过程就可以基于对象类型来定义了。注意此时存储过程的参数类型是数据库级对象类型JDBC能识别这是整个方案能跑通的前提条件。-- 入参保存一条员工记录 CREATE OR REPLACE PROCEDURE SAVE_EMP_RECORD(p_emp IN EMP_RECORD) IS BEGIN INSERT INTO EMP_INFO(EMP_ID, EMP_NAME, SALARY) VALUES (p_emp.EMP_ID, p_emp.EMP_NAME, p_emp.SALARY); COMMIT; END SAVE_EMP_RECORD; -- 出参根据ID返回一条员工记录 CREATE OR REPLACE PROCEDURE GET_EMP_RECORD(p_emp_id IN NUMBER, p_emp OUT EMP_RECORD) IS BEGIN SELECT EMP_ID, EMP_NAME, SALARY INTO p_emp.EMP_ID, p_emp.EMP_NAME, p_emp.SALARY FROM EMP_INFO WHERE EMP_ID p_emp_id; END GET_EMP_RECORD;这里有几个细节值得你注意。第一对象类型定义里的属性顺序和存储过程里取的字段顺序必须完全一致因为Java侧映射时是按位置取属性的不是按名字。如果你在类型里先写了EMP_NAME后写EMP_IDJava侧组装数据时也得跟着这个顺序来。第二如果你的Oracle版本是10g、11g这种老版本对象类型的创建语法完全一样没问题。但如果要用STRUCT调用的JDBC驱动建议至少用ojdbc6及以上版本。我手里这套例子用 ojdbc8 Oracle 19c 实测通过企业里常见的 ojdbc8 11g 组合也没问题。第三出参过程在查询不到记录时会抛NO_DATA_FOUND异常这个一般由存储过程的异常处理来兜底。实际项目里我会在过程里加一个EXCEPTION WHEN NO_DATA_FOUND THEN p_emp : NULL;避免Java侧收到异常。具体是否要这样处理看业务需求。3. Java侧调用实现剖析3.1 驱动依赖与StructDescriptor核心机制Java侧的依赖其实很简单就是指明的Oracle官方JDBC驱动。Maven坐标如下dependency groupIdcom.oracle.database.jdbc/groupId artifactIdojdbc8/artifactId version19.8.0.0/version /dependency如果你们公司用的是老项目可能引的是ojdbc6或ojdbc14那也没关系。驱动的oracle.sql.STRUCT和oracle.sql.StructDescriptor这两个类老早就有了稳定得很。核心机制我用人话解释一下。StructDescriptor相当于一个“类型描述器”你告诉它“我要操作的数据库对象类型叫EMP_RECORD”它就会去数据库的数据字典里把EMP_RECORD的结构加载进来包括有几个属性、每个属性是什么类型。然后你把它和连接对象、属性值数组一起交给STRUCT构造函数STRUCT就是你在Java代码里创建的一个“对象类型的实例”。它内部会按照StructDescriptor描述的布局把属性值打包成Oracle数据库能识别的格式。最后通过CallableStatement.setObject()把这个STRUCT塞给存储过程。这里我建议你别用setStruct()虽然它也能用但我实测下来不如setObject()稳定而且setStruct()在某些老版本驱动上有兼容性问题。setObject()走的是通用的对象映射对于STRUCT来说反而更稳。3.2 把Record作为入参传进存储过程直接看代码。下面这个方法演示了如何把一条员工记录封装成STRUCT传给SAVE_EMP_RECORD存储过程import oracle.sql.STRUCT; import oracle.sql.StructDescriptor; import java.math.BigDecimal; import java.sql.CallableStatement; import java.sql.Connection; import java.sql.SQLException; public class OracleRecordCaller { public void saveEmpRecord(Connection conn, Integer empId, String empName, BigDecimal salary) throws SQLException { // 1. 创建类型描述器注意类型名要用数据库里的大写形式 StructDescriptor sd StructDescriptor.createDescriptor(EMP_RECORD, conn); // 2. 组装属性数组顺序必须和对象类型定义的属性顺序一致 Object[] attrs new Object[] { empId, empName, salary }; // 3. 创建STRUCT对象 STRUCT empStruct new STRUCT(sd, conn, attrs); // 4. 调用存储过程 String sql {call SAVE_EMP_RECORD(?)}; try (CallableStatement cstmt conn.prepareCall(sql)) { cstmt.setObject(1, empStruct); cstmt.execute(); } } }这段代码最关键的三个点我逐个拆开说。一是StructDescriptor.createDescriptor的第二个参数必须传Connection对象因为驱动需要连上数据库查询数据字典。传null的话驱动会默认用一个全局连接池来查某些环境会失效。二是属性数组的顺序和类型。顺序不对数据就错位类型不匹配比如对象类型属性是NUMBER你传了字符串Oracle会报无效数字类的错误。我一般建议NUMBER用BigDecimal或Integer传VARCHAR2用StringDATE用java.sql.Timestamp。至于Integer还是BigDecimal看字段精度整数型用Integer够用有小数就用BigDecimal。三是调用语句用{call SAVE_EMP_RECORD(?)}这比用匿名SQL块BEGIN SAVE_EMP_RECORD(?); END;更规范也更容易排查问题。不过有些特殊场景下匿名SQL块能绕开驱动对返回值的限制后面常见问题里我会提到。3.3 接收存储过程返回的Record出参出参比入参稍微绕一点。你需要在调用前注册出参类型告诉驱动“第2个参数返回的是数据库对象类型EMP_RECORD”然后再把返回的STRUCT对象转成属性数组取出来。public Object[] getEmpRecord(Connection conn, Integer empId) throws SQLException { String sql {call GET_EMP_RECORD(?, ?)}; try (CallableStatement cstmt conn.prepareCall(sql)) { // 1. 设置入参 cstmt.setInt(1, empId); // 2. 注册出参类型是STRUCT类型名是EMP_RECORD cstmt.registerOutParameter(2, java.sql.Types.STRUCT, EMP_RECORD); // 3. 执行 cstmt.execute(); // 4. 拿到STRUCT对象 STRUCT empStruct (STRUCT) cstmt.getObject(2); // 5. 转换为属性数组按对象类型定义的顺序取值 return empStruct.getAttributes(); } }调用之后getAttributes()返回的Object数组里每个元素对应对象类型的一个属性。以我前面定义的EMP_RECORD为例返回数组的下标0是EMP_ID下标1是EMP_NAME下标2是SALARY。这里要特别强调一下registerOutParameter的写法。很多同学写的是cstmt.registerOutParameter(2, java.sql.Types.STRUCT);结果运行时直接报ORA-03115或Invalid column type。原因是少了第三个参数——类型名。JDBC驱动需要知道这个STRUCT具体对应的数据库对象类型叫什么才能从数据字典里加载结构。实在记不住的话可以换成Oracle驱动自己的常量cstmt.registerOutParameter(2, oracle.jdbc.OracleTypes.STRUCT, EMP_RECORD);实测效果一样看团队编码习惯选一种就行。3.4 再进一步用游标返回Record集合单个Record能传能取了很多业务还要求一次返回多条记录也就是Record的集合。这里就回到了我前面提到的方案二。对象类型虽然可以做嵌套表/数组集合类型但Java侧处理起来复杂度会上去。我的习惯是当需要返回多条记录时直接在存储过程里查成SYS_REFCURSORJava侧用游标接收既简单又直观。存储过程定义如下CREATE OR REPLACE PROCEDURE GET_EMP_LIST( p_cur OUT SYS_REFCURSOR ) IS BEGIN OPEN p_cur FOR SELECT EMP_ID, EMP_NAME, SALARY FROM EMP_INFO; END GET_EMP_LIST;Java侧调用public ListEmpInfo getEmpList(Connection conn) throws SQLException { String sql {call GET_EMP_LIST(?)}; ListEmpInfo list new ArrayList(); try (CallableStatement cstmt conn.prepareCall(sql)) { cstmt.registerOutParameter(1, oracle.jdbc.OracleTypes.CURSOR); cstmt.execute(); try (ResultSet rs (ResultSet) cstmt.getObject(1)) { while (rs.next()) { EmpInfo info new EmpInfo(); info.setEmpId(rs.getInt(EMP_ID)); info.setEmpName(rs.getString(EMP_NAME)); info.setSalary(rs.getBigDecimal(SALARY)); list.add(info); } } } return list; }这种做法的底层思路是存储过程内部负责把数据准备好无论你内部怎么用Record对外暴露的出口统一用游标。Java只认游标不认Record集合。这样既绕开了类型映射的麻烦又不需要改包内已有的大量Record逻辑。如果你确实需要返回对象类型数组也可以定义TYPE EMP_RECORD_TAB IS TABLE OF EMP_RECORD然后用createArrayOf或OracleConnection.createARRAY来接收。但那个API写起来比较繁琐而且性能并不比游标好我很少推荐。4. 常见问题与排查技巧实录4.1 高频报错速查表把这段时间踩过的坑和网上高频出现的问题整理成了表格你遇到报错可以先对号入座。报错信息原因分析解决办法ORA-06550: PLS-00306: 调用时参数数量或类型错误存储过程参数是包内RECORD类型JDBC不认识改为对象类型或用游标/拆参方案ORA-03115: 不支持的网络数据类型或表示法驱动试图绑定PL/SQL RECORD老版本驱动尤其常见升级ojdbc版本同时确认类型是CREATE TYPE AS OBJECTjava.sql.SQLException: Invalid column type: 1111registerOutParameter没有指定类型名驱动不知道STRUCT结构补上第三个参数registerOutParameter(2, Types.STRUCT, EMP_RECORD)java.lang.ClassCastException: oracle.sql.STRUCT cannot be cast to ...出参注册类型与实际类型不一致或驱动返回的不是STRUCT检查存储过程出参类型和参数位置打印INFO级别日志确认返回类型ORA-00902: invalid datatype创建对象类型时属性类型写错或用了PL/SQL专属类型确认所有属性都是SQL标准类型NUMBER/VARCHAR2/DATE等收到的属性数组全是nullStructDescriptor加载了类型但STRUCT属性数组没有与类型结构对齐检查Java侧属性数组顺序和CREATE TYPE定义逐一对齐4.2 几个容易翻车的细节排查完大方向再补充几个我实际工程里经常被坑到的小细节。这些不写进官方文档但能让你少走很多弯路。第一关于类型名的大小写。Oracle的数据字典里对象类型名默认是大写存储的。你写StructDescriptor.createDescriptor(emp_record, conn)驱动大概率会自动转成大写去找但我还是建议代码里直接写大写EMP_RECORD。别看这是个懒人习惯一旦遇到特殊字符或带双引号创建的混合大小写类型大小写不对就会直接找不到类型。第二关于NULL值的处理。如果对象类型的某个属性是NULLSTRUCT属性数组里对应位置也要放null不要放空字符串或0。比如员工有可能没有薪资那attrs就应该是new Object[]{empId, empName, null}。放空字符串在VARCHAR2字段上会变成空串而不是NULL语义有差异后续在数据库做IS NULL判断时会出问题。第三驱动版本的隐藏坑。我遇到过项目里用的是ojdbc6但存储过程里用了新版本的SYS_REFCURSOR特性结果驱动不认。排查半天才发现是老驱动不支持。这种问题最隐蔽因为报错信息五花八门。建议能升就升企业环境里至少用到ojdbc8。虽然驱动是官方统一维护的但在不同Oracle版本之间STRUCT的行为偶尔有细微差异你手头测试环境的11g和同事环境的19c可能表现不同遇到诡异问题先确认两边的驱动版本号是否一致。第四事务控制和连接池的坑。存储过程里如果带了COMMIT那它走的就是数据库自己控制的事务边界如果不带COMMITJava侧的事务边界就生效。我建议存储过程里不要写COMMIT把事务控制权统一交给Java的Service层这样方便在多个存储过程调用之间做统一回滚。连接池用的是Druid或HikariCP都没关系STRUCT是每次调用创建的不涉及连接状态残留这点比较省心。第五如果存储过程有重载比如同名过程一个有Record入参一个没有{call PROC(?)}这种写法有时会让驱动和数据库对参数类型理解不一致。遇到这种情况我一般会用匿名SQL块显式指定String sql BEGIN SAVE_EMP_RECORD(:1); END;; CallableStatement cstmt conn.prepareCall(sql); cstmt.setObject(1, empStruct); cstmt.execute();实测这样能绕开一部分重载解析的歧义代价是SQL语句可读性稍差但作为兜底方案很管用。最后再分享一个小技巧排查这类问题最有效的办法不是看报错信息反复猜而是打开JDBC的SQL日志。用-Doracle.jdbc.Tracetrue启动JVM驱动会把发送到数据库的协议数据打到日志里你能看到STRUCT的属性序列化过程。这对于定位“到底是我Java侧传错了还是数据库侧没接住”非常有帮助。我的经验是90%的Record调用问题最终都被日志揪出了“属性顺序”或“类型名大小写”这两个元凶。调完这些基本就能在项目里顺畅地使用Oracle存储过程加Record参数了。如果你们系统里还有大量的包内RECORD历史代码我的建议是优先用游标方案过渡新代码一律走对象类型加STRUCT。这套路子在多次企业项目里都验证过稳定性和可维护性都很好。