简介:这份PDF资料面向使用Mybatis与Oracle进行持久层开发的中高级Java工程师,聚焦于如何将List类型参数传递给Oracle存储过程以实现批量插入数据这一常见难题。资源共1个PDF文件,压缩包约61KB,内容围绕TypeHandler机制展开,讲解如何把Java中的List封装为Oracle的ARRAY类型并传入存储过程。资料完整覆盖建表、创建Oracle对象类型与数组类型、编写存储过程、配置Mybatis的parameterMap与自定义ListHandler、Server端调用等关键环节,并给出可参考的代码示例与排错思路。相比Mybatis常规批量插入,该方案可摆脱不能实时返回主键、foreach循环集合长度受限等约束,同时便于在存储过程中灵活控制事务回滚。目前已有1961人学习,适合需要处理Oracle批量写入与事务控制的开发者参考借鉴。
1. 从一次批量插入翻车说起:MyBatis 传 List 调 Oracle 存储过程到底解决什么
去年做某权限管理系统时,我遇到一个很典型的场景:给单位批量分配菜单权限,前端传过来一个逗号分隔的 menuIds 字符串,后端要拆成 List 批量写进关联表。一开始用 MyBatis 的foreach标签拼批量 insert,数据量小的时候一切正常,等到某个单位一次性勾了上千个菜单,问题就来了——foreach拼出来的 SQL 长度直接顶到 Oracle 的极限,报 ORA-01795(in 列表最多 1000 项)和 SQL 文本过长的错误。更头疼的是,这套批量插入挂在 Spring 事务里,一旦中间某条失败,整个事务回滚,但业务上又希望能拿到「哪几条成功、哪几条失败」的细粒度结果,MyBatis 原生批处理根本给不了。
后来我把思路换了一下:不在 Java 层拼 SQL,而是把整个 List 通过 MyBatis 的 TypeHandler 封装成 Oracle 的 ARRAY 类型,一次性传给存储过程,让 PL/SQL 在数据库端循环插入、自己控制 commit 和 rollback,最后通过 OUT 参数把执行结果返回给 Java。这套方案跑通之后,批量插入的限制基本消失了,事务控制也灵活了很多。这篇就把我当时踩过的坑和完整配置拆开讲清楚,适合正在被 MyBatis 批量插入折磨、又想用存储过程接管事务的同行参考。
2. 原理拆解:TypeHandler 如何把 Java List 变成 Oracle ARRAY
2.1 为什么不能直接传 List,非要绕 TypeHandler 这一圈
JDBC 规范里,PreparedStatement 的setObject只认基本类型、字符串、日期和数组,它不认识java.util.List。而 Oracle 的存储过程参数如果是自定义的 table of object 类型,JDBC 层必须用java.sql.Array接口来传递,这个接口的实现类在 Oracle 驱动里就是oracle.sql.ARRAY。所以中间必须有一个「翻译官」,把 List 里的每个 Java 对象拆成字段数组,再包装成 STRUCT,最后组成 ARRAY。MyBatis 提供的BaseTypeHandler就是干这个的——它在setNonNullParameter方法里给你一个PreparedStatement和参数下标,你负责把 List 转成 ARRAY 塞进去。
这里有个容易被忽略的点:MyBatis 的 TypeHandler 默认只处理单值参数,当你的参数是 List 且要作为存储过程的 IN 参数时,必须显式在parameterMap里指定typeHandler,否则 MyBatis 会尝试用默认的 UnknownTypeHandler,直接抛类型转换异常。这也是很多人卡在「参数传不进去」这一步的根本原因。
2.2 Oracle 端的对象类型和数组类型怎么建
Oracle 不允许直接把 Java 的 List 映射成表类型,必须先定义两个数据库对象:一个 OBJECT 类型描述单行结构,一个 TABLE 类型描述整个集合。以单位菜单权限为例,建表语句和类型定义如下:
-- 业务表:单位与菜单的关联 create table ZD_UNIT_MENU ( unit_id VARCHAR2(32), menu_id VARCHAR2(32) ); -- 单行对象类型,字段名要和 Java 对象属性对应 CREATE OR REPLACE TYPE unit_menu_obj AS OBJECT( unitId VARCHAR2(32), menuId VARCHAR2(32) ); -- 集合类型,基于上面的对象类型 CREATE OR REPLACE TYPE unit_menu_table AS table OF unit_menu_obj;注意unit_menu_obj里的字段名unitId、menuId是大小写敏感的,后面 Java 端构造 STRUCT 时传参顺序必须和这里一致,否则会出现字段错位——插进去的 unit_id 变成了 menu_id,这种 bug 排查起来非常费劲。我一般建议字段名全用大写或者全用驼峰,团队内统一,别混着来。
2.3 存储过程里循环插入与事务控制
存储过程接收unit_menu_table类型的 IN 参数,用FOR i IN 1 .. list0.count LOOP遍历,逐条执行 insert。这里用execute immediate动态拼 SQL 是一种写法,但更稳妥的做法是直接静态 insert,避免拼接字符串带来的注入风险和性能损耗。下面是我后来改过的版本:
create or replace procedure save_unit_power( list0 in unit_menu_table, result0 out int ) as begin FOR i IN 1 .. list0.count LOOP insert into ZD_UNIT_MENU(UNIT_ID, MENU_ID) values(list0(i).unitId, list0(i).menuId); end loop; commit; result0 := 1; -- 成功返回 1 EXCEPTION WHEN OTHERS THEN result0 := -1; -- 失败返回 -1 ROLLBACK; end save_unit_power;result0是 OUT 参数,Java 端调用后可以从 Map 里取出来判断执行结果。这里的事务边界完全在存储过程内部,Java 层的 Spring 事务不会介入,所以如果你有跨存储过程的业务一致性要求,得在 Java 层再包一层补偿逻辑,别指望数据库端帮你兜底。
3. 完整落地:从 parameterMap 配置到 ListHandler 实现
3.1 mapper.xml 里的 parameterMap 与 CALLABLE 语句
MyBatis 调用存储过程必须用statementType="CALLABLE",参数映射推荐用parameterMap(虽然官方更推parameterType+#{},但传 ARRAY 类型时 parameterMap 更直观)。配置如下:
<parameterMap type="java.util.Map" id="_map"> <parameter property="list0" jdbcType="ARRAY" javaType="java.util.List" mode="IN" typeHandler="com.zd.util.ListHandler"/> <parameter property="result0" jdbcType="DECIMAL" javaType="java.lang.Integer" mode="OUT" /> </parameterMap> <select id="addUnitPower" statementType="CALLABLE" parameterMap="_map"> <![CDATA[ CALL save_unit_power(?,?) ]]> </select>jdbcType="ARRAY"告诉 MyBatis 这个参数走 JDBC 数组通道,typeHandler指向你自己写的转换类。mode="OUT"的 result0 不需要 typeHandler,MyBatis 会自动用 IntegerTypeHandler 处理。这里有个细节:parameterMap的type写java.util.Map,因为我们要同时传 IN 的 list 和接收 OUT 的 result,用 Map 装最方便。
3.2 ListHandler 的核心逻辑与连接管理
ListHandler 继承BaseTypeHandler,重点是setNonNullParameter方法。原始代码里每次转换都新建一个DriverManager.getConnection,这是个明显的性能坑——每次调用都开新连接,连接池形同虚设。我改成从传入的 PreparedStatement 里拿 Connection:
package com.zd.util; import java.sql.CallableStatement; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; import oracle.sql.ARRAY; import oracle.sql.ArrayDescriptor; import oracle.sql.STRUCT; import oracle.sql.StructDescriptor; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import com.zd.model.system.UnitMenu; public class ListHandler extends BaseTypeHandler { @Override public void setNonNullParameter(PreparedStatement ps, int i, Object o, JdbcType jdbcType) throws SQLException { if (o == null) return; List<UnitMenu> list = (List<UnitMenu>) o; // 复用当前 PreparedStatement 的连接,避免重复建连 Connection conn = ps.getConnection(); try { ARRAY array = getArray(conn, "UNIT_MENU_OBJ", "UNIT_MENU_TABLE", list); ps.setArray(i, array); } catch (Exception e) { throw new SQLException("List 转 ARRAY 失败", e); } } private ARRAY getArray(Connection con, String oracleObj, String oracleList, List<UnitMenu> listData) throws Exception { ArrayDescriptor desc = ArrayDescriptor.createDescriptor(oracleList, con); STRUCT[] structs = new STRUCT[listData.size()]; StructDescriptor structDesc = new StructDescriptor(oracleObj, con); for (int i = 0; i < listData.size(); i++) { Object[] result = { listData.get(i).getUnitId(), listData.get(i).getMenuId() }; structs[i] = new STRUCT(structDesc, con, result); } return new ARRAY(desc, con, structs); } @Override public Object getNullableResult(ResultSet rs, String columnName) throws SQLException { return null; } @Override public Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return null; } }ArrayDescriptor.createDescriptor的第一个参数是数据库里的 TABLE 类型名,第二个是连接;StructDescriptor对应 OBJECT 类型名。构造STRUCT时传入的Object[]顺序必须和 OBJECT 类型定义完全一致。这里还有个血泪经验:Oracle 驱动对类型名大小写敏感,UNIT_MENU_OBJ写成小写会报「无效的名称模式」,必须大写。
3.3 Service 层调用与 OUT 参数回读
Service 层把 List 和占位 result 放进同一个 Map,调用 mapper 方法后从 Map 里取 OUT 值:
List<UnitMenu> list = new ArrayList<>(); String[] menuIds = menuTreeIds.split(","); for (String menuId : menuIds) { UnitMenu um = new UnitMenu(); um.setMenuId(menuId); um.setUnitId(unitId); list.add(um); } Map<String, Object> paramMap = new HashMap<>(); paramMap.put("list0", list); paramMap.put("result0", null); // 占位,OUT 参数会回填 unitMenuMapper.addUnitPower(paramMap); Integer result = (Integer) paramMap.get("result0"); if (result != null && result == 1) { // 执行成功 } else { // 执行失败,走补偿逻辑 }注意result0初始值给 null 就行,MyBatis 执行完 CALLABLE 语句后会把 OUT 参数写回这个 Map。如果你给的是空字符串"",某些 MyBatis 版本会尝试做类型转换,反而报错。这个细节当时卡了我半天,日志里只看到类型转换异常,完全没提示是 OUT 参数初始值的问题。
4. 避坑排查:这五个坑我替你踩过了
4.1 报「ORA-03115: 不支持的网络数据类型或表示形式」
现象:调用存储过程时抛 ORA-03115,堆栈指向setArray。原因:JDBC 连接没有注册 Oracle 的 ARRAY 类型,或者驱动版本和数据库版本不匹配。常见于用 ojdbc14 连 11g 以上数据库。解决:换成 ojdbc6 或 ojdbc8,并在连接 URL 或数据源配置里确认没有禁用 Oracle 扩展类型。如果用的是 Druid 连接池,检查connectionProperties里没有把oracle.jdbc.mapDateToTimestamp之类的开关配错。
4.2 字符串字段插入后变成乱码或问号
现象:unit_id 和 menu_id 插进去全是???。原因:构造 STRUCT 时字符集不匹配,Oracle 驱动需要orai18n.jar来做字符转换,缺这个包时中文和特殊字符会丢。解决:把orai18n.jar加进 classpath,版本和 ojdbc 保持一致。这个包很小,但缺了就是不行,属于典型的「不知道就永远查不出来」的坑。
4.3 List 为空时存储过程报 ORA-06531
现象:传空 List 进去,存储过程在list0.count处抛 ORA-06531(引用未初始化的集合)。原因:空 List 构造出的 ARRAY 长度为 0,PL/SQL 端认为集合未初始化。解决:在 ListHandler 里判断,如果 list 为空,直接构造一个空 ARRAY 并正常 setArray;或者在存储过程开头加if list0 is null or list0.count = 0 then result0 := 1; return; end if;做短路处理。我一般两边都做,防御性编程。
4.4 OUT 参数取出来始终是 null
现象:存储过程明明返回了 1,Java 端paramMap.get("result0")却是 null。原因:parameterMap里 OUT 参数的javaType和存储过程实际返回类型不匹配,或者 MyBatis 版本对 CALLABLE 的 OUT 回填有 bug。解决:确认javaType="java.lang.Integer"和jdbcType="DECIMAL"对应 Oracle 的out int。如果还不行,改用parameterType+#{}的写法,在#{}里指定mode=OUT,这是 MyBatis 3.4.6 之后更稳的方式。
4.5 批量数据量大时内存溢出
现象:一次传几万条数据,Java 端 OOM。原因:ListHandler 一次性构造了所有 STRUCT 对象,内存占用是数据量的数倍。解决:在 Service 层做分片,比如每 2000 条调一次存储过程,循环调用。存储过程本身是逐条 insert 的,分片不会影响事务语义,反而能降低单次内存峰值。分片大小根据你的 JVM 堆和单条数据大小调,我一般从 1000 开始试。
5. 进阶技巧:用注解简化配置与批量分片模板
跑通之后我又做了一轮优化,把重复代码抽掉。第一个优化是用 MyBatis 注解替代 XML 里的 parameterMap,减少配置散落。在 Mapper 接口上直接写:
@Select("{ CALL save_unit_power(#{list0, jdbcType=ARRAY, mode=IN, typeHandler=com.zd.util.ListHandler}, #{result0, jdbcType=DECIMAL, mode=OUT, javaType=java.lang.Integer}) }") @Options(statementType = StatementType.CALLABLE) void addUnitPower(Map<String, Object> paramMap);这样就不用维护单独的 parameterMap 块,参数定义和 SQL 在一起,改起来不容易漏。注意@Options(statementType = StatementType.CALLABLE)不能省,否则 MyBatis 会当普通查询处理。
第二个优化是分片模板。我写了一个通用的分片工具方法,任何需要传 List 调存储过程的场景都能复用:
public static <T> int callInBatches(List<T> data, int batchSize, Function<List<T>, Integer> caller) { int lastResult = 1; for (int i = 0; i < data.size(); i += batchSize) { int end = Math.min(i + batchSize, data.size()); List<T> sub = data.subList(i, end); Integer r = caller.apply(sub); if (r == null || r != 1) { lastResult = -1; break; // 任一批次失败即中断,由调用方决定是否补偿 } } return lastResult; }batchSize我一般设 2000,caller传入一个 lambda,里面封装 Map 组装和 mapper 调用。这样分片逻辑和业务逻辑解耦,换一个存储过程只需要改 lambda 里的内容。
验证方面,我习惯在存储过程里加一行dbms_output.put_line('batch count: ' || list0.count);,然后在 Java 端用CallableStatement注册输出,确认每次实际传进去的条数和预期一致。这个习惯帮我抓到过一次分片边界算错导致最后一批数据丢失的问题——subList的toIndex是开区间,写i + batchSize没问题,但如果手滑写成i + batchSize - 1,最后一条就永远传不进去。
从那以后我每次写分片循环,都会强制在单元测试里断言「所有分片元素数量之和等于原始 List 大小」,这个断言拦住过好几次边界错误。希望帮到你。
本文还有配套的精品资源,点击获取