MyBatis传List调Oracle存储过程批量插入实战
简介这份资源面向使用 Mybatis 与 Oracle 的 Java 后端开发者聚焦于如何将 List 类型参数传入 Oracle 存储过程以实现批量插入数据解决 Mybatis 批处理难以实时返回主键、foreach 循环集合长度受限、事务回滚不便等痛点。资源以 PDF 形式呈现压缩包内共 1 个文件大小约 61KB内容围绕数据库表与 Oracle 对象类型、数组类型的建立、存储过程编写、Mybatis parameterMap 与 TypeHandler 配置以及服务端调用方式展开并给出 ListHandler 将 Java List 转换为 Oracle ARRAY 的关键实现思路。目前已有 1961 人学习适合需要掌握 Mybatis 调用存储过程传集合参数、灵活控制事务回滚的中高级开发者参考可帮助读者理解参数封装与批量插入的完整链路并据此排查实际项目中的传参报错问题。1. 批量写库卡在存储过程那一步MyBatis 传 List 调 Oracle 到底难在哪单条insert循环一万次数据库连接池被打满接口超时报警——这是很多做批量数据落库的开发者都遇到过的场景。把循环改成一次调用 Oracle 存储过程、把 List 当参数整体传进去是常见的优化方向。但真动手时你会发现MyBatis 的#{}对 List 并不友好Oracle 的存储过程也不认识 Java 的ArrayList中间隔着一层类型系统的鸿沟。这篇笔记讲的就是怎么把这道鸿沟填上从 Oracle 自定义类型定义、MyBatis 的TypeHandler编写到 Mapper XML 里statementTypeCALLABLE的写法再到批量提交时的参数上限和事务边界。适合正在做批量入库优化、或者被ORA-06550、invalid column type这类报错卡住的开发者。下面按我实际落地的顺序拆开讲。2. 先搞清楚 Oracle 侧的接收容器自定义 Type 和 Array 怎么建2.1 为什么不能直接把 Java List 塞进存储过程Oracle 存储过程的参数类型是数据库层面的类型Java 的ListT在 JDBC 层面没有直接对应物。你写#{list}传一个ArrayListMyBatis 默认会尝试用UnknownTypeHandler去猜结果通常是invalid column type或者参数个数不匹配。正确的思路是在 Oracle 里定义一个OBJECT TYPE描述单行结构再定义一个TABLE TYPE也就是嵌套表描述多行集合存储过程的入参用这个TABLE TYPE。Java 侧则通过 JDBC 的ARRAY或者 MyBatis 的TypeHandler把 List 映射成这个嵌套表。这个设计的好处是一次网络往返传一批数据存储过程内部用FORALL或普通循环批量写性能比逐条 JDBC 调用高一个量级。代价是类型定义要提前在数据库里建好不能像临时表那样随用随建。2.2 建 TYPE 的 SQL 和三个容易忽略的细节假设要批量插入的是用户订单数据单行结构包含订单号、用户 ID、金额三个字段。先建 OBJECT TYPE-- 单行结构字段类型要和目标表列类型对齐 CREATE OR REPLACE TYPE T_ORDER_ROW AS OBJECT ( order_no VARCHAR2(64), user_id NUMBER(18), amount NUMBER(12,2) ); / -- 嵌套表类型元素是上面的 OBJECT CREATE OR REPLACE TYPE T_ORDER_TAB AS TABLE OF T_ORDER_ROW; /这里有三个细节容易翻车。第一VARCHAR2的长度要和目标表列一致或更长短了会在存储过程内部赋值时抛ORA-06502。第二NUMBER的精度如果不写默认是NUMBER无精度和表列的NUMBER(12,2)在隐式转换时可能产生精度丢失建议显式对齐。第三CREATE OR REPLACE TYPE如果被其他 TYPE 或存储过程引用替换时会报ORA-02303需要先删依赖或加FORCE选项——生产环境改 TYPE 要格外小心我一般先在测试库验证。2.3 存储过程怎么写入参、循环和异常处理存储过程接收T_ORDER_TAB内部遍历插入CREATE OR REPLACE PROCEDURE P_BATCH_INSERT_ORDER ( p_orders IN T_ORDER_TAB, p_result OUT NUMBER ) AS BEGIN -- 入参为空直接返回避免空集合遍历报错 IF p_orders IS NULL OR p_orders.COUNT 0 THEN p_result : 0; RETURN; END IF; FORALL i IN 1 .. p_orders.COUNT INSERT INTO T_ORDER ( order_no, user_id, amount, create_time ) VALUES ( p_orders(i).order_no, p_orders(i).user_id, p_orders(i).amount, SYSDATE ); p_result : SQL%ROWCOUNT; COMMIT; EXCEPTION WHEN OTHERS THEN ROLLBACK; p_result : -1; RAISE; END; /FORALL比普通FOR循环快因为它把多次 INSERT 批量发给 SQL 引擎减少了 PL/SQL 和 SQL 引擎之间的上下文切换。SQL%ROWCOUNT拿到实际插入行数通过OUT参数回传给 Java 侧做校验。异常处理里先ROLLBACK再RAISE保证失败时数据不半提交——这个顺序不能反先 RAISE 的话 ROLLBACK 就不会执行了。注意FORALL在集合元素为 NULL 时会跳过该行而不报错如果业务上不允许 NULL需要在存储过程里显式判断p_orders(i).order_no IS NULL并抛异常。3. MyBatis 侧怎么把 List 映射成 Oracle 嵌套表3.1 TypeHandler 是绕不开的一环MyBatis 内置的ArrayTypeHandler能处理java.sql.Array但它要求你传的已经是Connection.createArrayOf创建的对象而 Mapper 方法签名里通常是ListOrderRow。所以常见做法是自定义一个TypeHandler在setParameter里把 List 转成java.sql.Array在getResult里做反向转换如果存储过程有 OUT 的数组参数。下面是一个可复用的TypeHandler骨架// 泛型 T 是单行对象类型这里用 OrderRow 示意 MappedTypes(List.class) MappedJdbcTypes(JdbcType.ARRAY) public class OracleListTypeHandler extends BaseTypeHandlerListOrderRow { // Oracle 嵌套表的类型名必须和数据库里建的一致 private static final String ORACLE_TYPE_NAME T_ORDER_TAB; Override public void setNonNullParameter(PreparedStatement ps, int i, ListOrderRow parameter, JdbcType jdbcType) throws SQLException { Connection conn ps.getConnection(); // 把 List 转成 Object[]每个元素是 oracle.sql.STRUCT 或 java.sql.Struct Object[] elements new Object[parameter.size()]; for (int idx 0; idx parameter.size(); idx) { OrderRow row parameter.get(idx); // 用 Connection.createStruct 构造单行结构 elements[idx] conn.createStruct( T_ORDER_ROW, new Object[]{row.getOrderNo(), row.getUserId(), row.getAmount()} ); } // 用 createArrayOf 构造嵌套表 Array array conn.createArrayOf(ORACLE_TYPE_NAME, elements); ps.setArray(i, array); } Override public ListOrderRow getNullableResult(ResultSet rs, String columnName) throws SQLException { // 如果存储过程有 OUT 数组参数在这里反解 Array array rs.getArray(columnName); return array null ? null : convertArray(array); } // 其余 getNullableResult 重载省略逻辑类似 private ListOrderRow convertArray(Array array) throws SQLException { Object[] elements (Object[]) array.getArray(); ListOrderRow result new ArrayList(elements.length); for (Object elem : elements) { Object[] attrs ((Struct) elem).getAttributes(); OrderRow row new OrderRow(); row.setOrderNo((String) attrs[0]); row.setUserId(((Number) attrs[1]).longValue()); row.setAmount(new BigDecimal(attrs[2].toString())); result.add(row); } return result; } }关键点在conn.createStruct和conn.createArrayOf这两步。createStruct的第一个参数是 OBJECT TYPE 名第二个是属性数组顺序必须和 TYPE 定义一致。createArrayOf的第一个参数是 TABLE TYPE 名第二个是元素数组。这两个方法都依赖 JDBC 驱动对 Oracle 类型的支持用 ojdbc8 及以上版本比较稳。3.2 Mapper XML 里 statementType 和 mode 的写法Mapper 接口方法签名// 注意 Param 里的名字要和 XML 中 #{orders, typeHandler...} 对应 int batchInsertOrders(Param(orders) ListOrderRow orders, Param(result) MapString, Object result);XML 映射select idbatchInsertOrders statementTypeCALLABLE parameterTypemap {call P_BATCH_INSERT_ORDER( #{orders, modeIN, jdbcTypeARRAY, typeHandlercom.example.handler.OracleListTypeHandler}, #{result, modeOUT, jdbcTypeNUMERIC} )} /select这里有几个必须注意的点。第一调用存储过程用select标签而不是insert因为statementTypeCALLABLE配合{call ...}语法时MyBatis 走的是CallableStatement用select才能正确拿到 OUT 参数。第二modeIN和modeOUT必须显式写不写的话 MyBatis 默认按 IN 处理OUT 参数拿不到值。第三typeHandler要写全限定类名或者在 MyBatis 配置里注册后简写。第四OUT 参数用Map接收时key 就是#{result}里的result调用后从map.get(result)取值。3.3 调用层代码和事务边界Service 层调用Service public class OrderService { Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public int batchInsert(ListOrderRow orders) { // 分批每批 500 条避免单次 ARRAY 过大 int batchSize 500; int total 0; for (int i 0; i orders.size(); i batchSize) { int end Math.min(i batchSize, orders.size()); ListOrderRow sub orders.subList(i, end); MapString, Object result new HashMap(); orderMapper.batchInsertOrders(sub, result); Object cnt result.get(result); if (cnt null || ((Number) cnt).intValue() 0) { throw new RuntimeException(批量插入失败批次起始 i); } total ((Number) cnt).intValue(); } return total; } }分批的原因后面避坑章节会细说这里先记住 500 这个经验值。Transactional保证整批要么全成功要么全回滚但要注意存储过程内部已经COMMIT了外层事务对已提交部分无能为力——这是 Oracle 存储过程和 Spring 事务配合时的经典矛盾。我的做法是存储过程内部不COMMIT把提交权交给 Spring但这样又要求存储过程调用必须在事务内。两种方案各有取舍下面避坑章节展开。4. 参数上限、类型不匹配和事务那些坑4.1 ORA-06550 和 invalid column type 的排查顺序现象调用时报ORA-06550: 第 1 行, 第 7 列: PLS-00306: 调用 P_BATCH_INSERT_ORDER 时参数个数或类型错误或者 JDBC 层直接抛invalid column type。原因通常有三个一是 TYPE 名拼写和数据库里不一致Oracle 默认大写Java 里写小写会找不到二是createStruct的属性个数和 OBJECT TYPE 定义不匹配三是createArrayOf的 TYPE 名不是 TABLE TYPE 而是 OBJECT TYPE。解决先在数据库里执行SELECT * FROM USER_TYPES WHERE TYPE_NAME T_ORDER_TAB确认类型存在且TYpecode是COLLECTION。然后在TypeHandler里打印elements.length和每个Struct的属性个数和 TYPE 定义逐一对齐。最后确认createArrayOf传的是T_ORDER_TAB而不是T_ORDER_ROW。4.2 单次 ARRAY 别超过 1000 条现象批量 5000 条时偶尔成功偶尔报ORA-01460: 未实现或不合理的转换请求或者直接 OOM。原因Oracle 对单个 ARRAY 的大小有隐式限制具体阈值和 JDBC 驱动版本、java.sql.Array实现有关但经验上超过 1000 条就容易出问题。另外createStruct会为每行创建一个对象5000 行就是 5000 个 Struct 对象堆内存压力不小。解决分批每批 500 条。这个值不是拍脑袋是我在几个项目里试出来的平衡点——太小则网络往返次数多太大则内存和驱动层容易出问题。分批逻辑放在 Service 层如上面代码所示。4.3 存储过程 COMMIT 和 Spring 事务的冲突现象Service 方法抛异常期望整批回滚但数据库里已经有一部分数据了。原因存储过程内部写了COMMIT这个提交是数据库层面的不受 Spring 事务管理。Spring 的Transactional只能回滚它自己发起的事务对存储过程内部的提交无能为力。解决两种方案。方案 A存储过程内部不COMMIT由 Spring 统一提交缺点是存储过程单独调用时比如运维手动执行不会自动提交需要调用方自己处理。方案 B存储过程内部COMMITService 层不做事务控制改为在应用层做补偿——失败时记录失败批次人工或定时任务重试。我一般选方案 A因为事务边界清晰出问题好排查。4.4 OUT 参数拿不到值的两种常见写法错误现象map.get(result)返回 null。原因一XML 里modeOUT没写MyBatis 按 IN 处理自然不会回填。原因二jdbcType和存储过程 OUT 参数类型不匹配比如存储过程是NUMBERXML 里写了jdbcTypeVARCHAR驱动层转换失败但不报错只是不回填。解决确认 XML 里modeOUT和jdbcType都写对。如果还是 null把 MyBatis 日志级别调到 DEBUG看CallableStatement的registerOutParameter有没有被调用。另外OUT 参数用Map接收时parameterType要写map不能写具体的实体类。4.5 空 List 导致的 ORA-06531现象传入空 List 时存储过程报ORA-06531: 引用未初始化的集合。原因createArrayOf传空数组时Oracle 侧拿到的是一个未初始化的嵌套表p_orders.COUNT会直接抛异常而不是返回 0。解决在TypeHandler里判断 List 为空时不调用createArrayOf而是ps.setNull(i, Types.ARRAY, T_ORDER_TAB)。同时在存储过程开头加IF p_orders IS NULL OR p_orders.COUNT 0 THEN RETURN; END IF;双保险。这个坑我在两个项目里都踩过空集合的处理一定要在 Java 侧和 PL/SQL 侧都做。5. 把批量插入压到一次往返几个能直接抄的优化技巧5.1 用 FORALL 加 SAVE EXCEPTIONS 做部分失败容忍默认的FORALL遇到一行失败就整批回滚。如果业务允许部分成功可以加SAVE EXCEPTIONSDECLARE e_bulk_errors EXCEPTION; PRAGMA EXCEPTION_INIT(e_bulk_errors, -24381); BEGIN FORALL i IN 1 .. p_orders.COUNT SAVE EXCEPTIONS INSERT INTO T_ORDER (order_no, user_id, amount, create_time) VALUES (p_orders(i).order_no, p_orders(i).user_id, p_orders(i).amount, SYSDATE); EXCEPTION WHEN e_bulk_errors THEN -- SQL%BULK_EXCEPTIONS 里记录了失败的行号和错误码 FOR j IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP DBMS_OUTPUT.PUT_LINE(失败行 || SQL%BULK_EXCEPTIONS(j).ERROR_INDEX || 错误码 || SQL%BULK_EXCEPTIONS(j).ERROR_CODE); END LOOP; -- 成功的行已经插入这里可以选择提交或回滚 COMMIT; END;SQL%BULK_EXCEPTIONS是个伪集合ERROR_INDEX对应p_orders里的下标ERROR_CODE是 Oracle 错误码。拿到这些信息后可以回传给 Java 侧做失败明细记录。注意SAVE EXCEPTIONS下成功的行已经插入是否提交取决于业务容忍度。5.2 用 DML 错误日志表替代手动记录如果不想在存储过程里写DBMS_OUTPUT可以建一张错误日志表用LOG ERRORS子句-- 先建错误日志表 BEGIN DBMS_ERRLOG.CREATE_ERROR_LOG(T_ORDER, ERR_T_ORDER); END; / -- FORALL 里加 LOG ERRORS FORALL i IN 1 .. p_orders.COUNT SAVE EXCEPTIONS INSERT INTO T_ORDER (order_no, user_id, amount, create_time) VALUES (p_orders(i).order_no, p_orders(i).user_id, p_orders(i).amount, SYSDATE) LOG ERRORS INTO ERR_T_ORDER (batch_insert) REJECT LIMIT UNLIMITED;失败的行会自动写入ERR_T_ORDER包含错误码、错误信息、行数据。这个方案比手动解析SQL%BULK_EXCEPTIONS更省事适合失败率低但需要留痕的场景。REJECT LIMIT UNLIMITED表示不限制失败行数如果设成具体数字超过后整批回滚。5.3 验证批量插入是否真的生效写完别急着上线用下面这段 SQL 验证-- 查最近一批插入的数据量和时间分布 SELECT COUNT(*) AS cnt, MIN(create_time) AS min_time, MAX(create_time) AS max_time FROM T_ORDER WHERE create_time SYSDATE - 1/24; -- 查错误日志表里有没有残留 SELECT * FROM ERR_T_ORDER WHERE ORA_ERR_TAG$ batch_insert;第一段确认数据确实进去了第二段确认没有静默失败的行。如果ERR_T_ORDER里有数据但业务没感知说明异常处理逻辑有漏洞需要检查SAVE EXCEPTIONS后的提交策略。5.4 一个我常用的参数速查表参数建议值说明单批 List 大小500超过 1000 易触发驱动层问题VARCHAR2 长度目标列长度 20%留余量避免 ORA-06502NUMBER 精度与目标列一致避免隐式转换丢精度FORALL 模式SAVE EXCEPTIONS需要部分成功时用事务提交方Spring 统一提交存储过程内不 COMMITOUT 参数类型与存储过程声明一致不匹配会静默不回填这张表是我几个项目里沉淀下来的默认值具体项目还要根据数据量和驱动版本微调。比如 ojdbc8 和 ojdbc11 对 ARRAY 大小的容忍度就不一样升级驱动后建议重新压测一遍。最后说个习惯每次改完TypeHandler或存储过程我都会先用 10 条、500 条、2000 条三档数据各跑一遍确认小批量正确、中批量性能达标、大批量不 OOM。这个习惯帮我拦住了好几次上线前的隐患。希望帮到你。本文还有配套的精品资源点击获取