资讯动态

MyBatis传List调Oracle存储过程:TypeHandler与ARRAY批量插入实战

发布时间:2026/10/17 22:51:54 来源:尧图企业网站定制
简介这份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-01795in 列表最多 1000 项和 SQL 文本过长的错误。更头疼的是这套批量插入挂在 Spring 事务里一旦中间某条失败整个事务回滚但业务上又希望能拿到「哪几条成功、哪几条失败」的细粒度结果MyBatis 原生批处理根本给不了。后来我把思路换了一下不在 Java 层拼 SQL而是把整个 List 通过 MyBatis 的 TypeHandler 封装成 Oracle 的 ARRAY 类型一次性传给存储过程让 PL/SQL 在数据库端循环插入、自己控制 commit 和 rollback最后通过 OUT 参数把执行结果返回给 Java。这套方案跑通之后批量插入的限制基本消失了事务控制也灵活了很多。这篇就把我当时踩过的坑和完整配置拆开讲清楚适合正在被 MyBatis 批量插入折磨、又想用存储过程接管事务的同行参考。2. 原理拆解TypeHandler 如何把 Java List 变成 Oracle ARRAY2.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 调用存储过程必须用statementTypeCALLABLE参数映射推荐用parameterMap虽然官方更推parameterType#{}但传 ARRAY 类型时 parameterMap 更直观。配置如下parameterMap typejava.util.Map id_map parameter propertylist0 jdbcTypeARRAY javaTypejava.util.List modeIN typeHandlercom.zd.util.ListHandler/ parameter propertyresult0 jdbcTypeDECIMAL javaTypejava.lang.Integer modeOUT / /parameterMap select idaddUnitPower statementTypeCALLABLE parameterMap_map ![CDATA[ CALL save_unit_power(?,?) ]] /selectjdbcTypeARRAY告诉 MyBatis 这个参数走 JDBC 数组通道typeHandler指向你自己写的转换类。modeOUT的 result0 不需要 typeHandlerMyBatis 会自动用 IntegerTypeHandler 处理。这里有个细节parameterMap的type写java.util.Map因为我们要同时传 IN 的 list 和接收 OUT 的 result用 Map 装最方便。3.2 ListHandler 的核心逻辑与连接管理ListHandler 继承BaseTypeHandler重点是setNonNullParameter方法。原始代码里每次转换都新建一个DriverManager.getConnection这是个明显的性能坑——每次调用都开新连接连接池形同虚设。我改成从传入的 PreparedStatement 里拿 Connectionpackage 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; ListUnitMenu list (ListUnitMenu) 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, ListUnitMenu 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 值ListUnitMenu list new ArrayList(); String[] menuIds menuTreeIds.split(,); for (String menuId : menuIds) { UnitMenu um new UnitMenu(); um.setMenuId(menuId); um.setUnitId(unitId); list.add(um); } MapString, 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 长度为 0PL/SQL 端认为集合未初始化。解决在 ListHandler 里判断如果 list 为空直接构造一个空 ARRAY 并正常 setArray或者在存储过程开头加if list0 is null or list0.count 0 then result0 : 1; return; end if;做短路处理。我一般两边都做防御性编程。4.4 OUT 参数取出来始终是 null现象存储过程明明返回了 1Java 端paramMap.get(result0)却是 null。原因parameterMap里 OUT 参数的javaType和存储过程实际返回类型不匹配或者 MyBatis 版本对 CALLABLE 的 OUT 回填有 bug。解决确认javaTypejava.lang.Integer和jdbcTypeDECIMAL对应 Oracle 的out int。如果还不行改用parameterType#{}的写法在#{}里指定modeOUT这是 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, jdbcTypeARRAY, modeIN, typeHandlercom.zd.util.ListHandler}, #{result0, jdbcTypeDECIMAL, modeOUT, javaTypejava.lang.Integer}) }) Options(statementType StatementType.CALLABLE) void addUnitPower(MapString, Object paramMap);这样就不用维护单独的 parameterMap 块参数定义和 SQL 在一起改起来不容易漏。注意Options(statementType StatementType.CALLABLE)不能省否则 MyBatis 会当普通查询处理。第二个优化是分片模板。我写了一个通用的分片工具方法任何需要传 List 调存储过程的场景都能复用public static T int callInBatches(ListT data, int batchSize, FunctionListT, Integer caller) { int lastResult 1; for (int i 0; i data.size(); i batchSize) { int end Math.min(i batchSize, data.size()); ListT sub data.subList(i, end); Integer r caller.apply(sub); if (r null || r ! 1) { lastResult -1; break; // 任一批次失败即中断由调用方决定是否补偿 } } return lastResult; }batchSize我一般设 2000caller传入一个 lambda里面封装 Map 组装和 mapper 调用。这样分片逻辑和业务逻辑解耦换一个存储过程只需要改 lambda 里的内容。验证方面我习惯在存储过程里加一行dbms_output.put_line(batch count: || list0.count);然后在 Java 端用CallableStatement注册输出确认每次实际传进去的条数和预期一致。这个习惯帮我抓到过一次分片边界算错导致最后一批数据丢失的问题——subList的toIndex是开区间写i batchSize没问题但如果手滑写成i batchSize - 1最后一条就永远传不进去。从那以后我每次写分片循环都会强制在单元测试里断言「所有分片元素数量之和等于原始 List 大小」这个断言拦住过好几次边界错误。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑