
简介围绕Mybatis传List参数调用Oracle存储过程的难题这份PDF以完整思路与可落地配置为主线面向需要在Oracle环境下实现批量数据插入的Java开发人员。内容从建立库表、创建Oracle对象类型与数组、编写存储过程到Mybatis中配置parameterMap与自定义TypeHandler再到服务端调用与异常回滚逐步说明如何将List集合封装为Oracle ARRAY类型传入存储过程。同时指出该方法可突破Mybatis批量插入无法实时返回主键、foreach标签循环长度受限等限制并便于在存储过程中灵活控制事务。资源为1个PDF文档压缩包仅61KB虽体积小巧但步骤完整含SQL示例、XML配置及关键Java类代码截图思路。已有1961人学习适合遇到Mybatis批量插入瓶颈、希望借助存储过程提升可靠性的中高级开发者参考。1. 传 List 进 Oracle 存储过程为什么 Mybatis 这层最容易卡住做 Java 后端的人大多遇到过这种场景业务层拼好了一个 List 或 List 想直接传给 Oracle 存储过程做批量处理结果在 Mybatis 映射层就被卡住——要么报“无效数据类型”要么存储过程根本收不到数组。这个问题在 Mybatis 面试题里也经常被拿来考察因为表面上只是“传个参数”实际牵扯到 Oracle 的 PL/SQL 类型系统、JDBC 的 Array 接口、Mybatis 的 TypeHandler 三者的配合。很多人第一次做的时候会下意识地把 List 当普通字符串拼进 SQL或者干脆用临时表绕过去但一旦数据量上来或者存储过程已经上线就不得不面对真正的解决方案。这篇文章要解决的就是把一个 Java 的 List 参数完整、安全、可控地送进 Oracle 存储过程并拿到处理结果。适合正在写 Mybatis 映射、维护 Oracle 存储过程接口、或者面试前想彻底搞懂底层逻辑的读者。我会先从 Oracle 这边为什么“不认” List 讲起再给出三种可落地姿势最后用一份最小可运行配置带你走通全流程并列出我在生产环境里踩过的坑。2. Mybatis 传 List 到 Oracle 存储过程的三种姿势先搞清 Oracle 的“数组”语法2.1 为什么不能用普通 IN 参数直接传 List在 MySQL 里你可以很任性WHERE id IN (...)然后把 List 展开成多个问号或者用FOREACH拼进 SQL。但 Oracle 存储过程的入参类型是强类型的它不认识 Java 的java.util.List也不认识 Mybatis 的List概念。如果你在存储过程里定义一个v_ids VARCHAR2然后想把整个 List 塞进去Oracle 只会收到一个字符串而不是一个集合。更底层的原因是 JDBC 规范里批量参数需要使用java.sql.Array来传递而 Mybatis 默认并不会把你传进来的 List 自动转成java.sql.Array。常见的“无效列类型”报错本质上就是 Mybatis 不知道该怎么把 List 映射成 Oracle 的哪个 SQL 类型。所以第一步不是写 XML而是先在 Oracle 里定义一个能让 JDBC 识别的“数组类型”。2.2 方案一自定义类型 Oracle 数组CREATE TYPE核心思路在 Oracle 里用CREATE TYPE定义一个表类型比如CREATE TYPE id_list AS TABLE OF NUMBER然后存储过程的入参写成id_list。Java 侧通过 JDBC 的oracle.sql.ARRAY把 List 转换后传入。这是最正统的做法性能最好适合几百到几千条的批量。具体到 Mybatis你不需要直接操作oracle.sql.ARRAY而是借助 Oracle JDBC 驱动自带的支持在 XML 里把jdbcType指定为ARRAY同时通过typeHandler指定一个继承自AbstractTypeHandler的类在setParameter方法里完成List到java.sql.Array的转换。后面第三章会给你完整的代码。2.3 方案二用字符串拼接 存储过程内部分解如果 Oracle 那边没有权限建 TYPE或者存储过程已经定义成 VARCHAR2 入参那么字符串拼接是成本最低的妥协方案。Java 侧把 List 拼成一个带分隔符的长字符串比如1,2,3,4存储过程内部用REGEXP_SUBSTR或者写一个循环逐个拆分。这个方案的优点是几乎不依赖 Mybatis 的特殊处理XML 里就是普通 String 参数缺点是分隔符有可能和数据本身冲突而且 Oracle 里拆字符串的循环写得不好会非常慢。另外如果 List 里的内容是字符串还要考虑引号转义和 SQL 注入风险。我一般只把它用在 List 长度不超过几十条、且数据内容可控的场景。2.4 方案三游标 / 临时表适合大批量当你要传几万条数据进存储过程并且存储过程内部需要对这些数据做多表关联、复杂计算时数组和字符串都不太合适。常见做法是先把 List 批量插入一个临时表GTTGlobal Temporary Table然后存储过程直接查询这个临时表。Mybatis 侧只需要正常的INSERT操作存储过程侧也不涉及集合类型两边都轻松。但这个方案有个额外成本你要管理临时表的生命周期还要考虑并发会话的数据隔离。Oracle 的 GTT 在事务或会话结束时会自动清数据所以要注意存储过程执行完之前临时表数据不能被其他会话覆盖。2.5 三种方案的对比什么时候我选哪一种方案数据量对 Oracle 的依赖Mybatis 复杂度执行效率适用场景自定义类型 数组几百几千需要 CREATE TYPE 权限中等需要 TypeHandler高批量更新、批量匹配、正式接口字符串拼接几十几百低极低中快速临时方案、无 DDL 权限临时表几万以上中等低取决于插入和查询设计复杂计算、多表关联、数据仓库脚本选型时不要只看“能不能跑”还要看“跑崩了怎么处理”。数组方案失败时会明确报错字符串方案失败时往往是存储过程内部逻辑异常排错维度完全不同。3. 实操自定义类型 Mybatis 传 List 的最小可跑通配置这一章我们直接动手。假设业务场景是传入一组订单号存储过程根据订单号批量更新订单状态。我会从建 TYPE、建存储过程、写 Mybatis XML、写 Java 调用四个步骤走一遍。3.1 第一步在 Oracle 里创建 TYPE 和存储过程先创建两个类型一个用于存字符串列表一个用于存数字列表。实际项目中数字列表更常见我用订单号举例。-- 创建一个元素类型为 NUMBER 的表类型 CREATE OR REPLACE TYPE ORDER_ID_LIST AS TABLE OF NUMBER; / -- 创建一个元素类型为 VARCHAR2 的表类型如果 List 里装的是字符串就用这个 CREATE OR REPLACE TYPE STRING_LIST AS TABLE OF VARCHAR2(64); /然后创建存储过程。这里入参类型必须是刚才定义的类型。存储过程体里可以直接把入参当作一个集合来遍历CREATE OR REPLACE PROCEDURE BATCH_UPDATE_ORDER_STATUS( p_order_ids IN ORDER_ID_LIST, p_new_status IN VARCHAR2, p_updated_num OUT NUMBER ) IS BEGIN UPDATE order_table SET status p_new_status WHERE order_id IN (SELECT column_value FROM TABLE(p_order_ids)); p_updated_num : SQL%ROWCOUNT; COMMIT; END BATCH_UPDATE_ORDER_STATUS; /参数说明p_order_ids使用ORDER_ID_LIST类型而不是VARCHAR2。存储过程内部用TABLE(p_order_ids)把这个表类型转成一个可以查询的临时数据集然后塞进子查询里。column_value是 Oracle 对集合元素默认的列名不需要额外定义。p_updated_num是输出参数用来返回实际更新的行数。3.2 第二步写 Mybatis 的 XML 映射Mybatis 的 XML 里不能直接写LIST这种类型必须通过jdbcType和typeHandler配合。这里的关键点jdbcType要写成ARRAYtypeHandler指向我们自定义的转换类。update idbatchUpdateOrderStatus statementTypeCALLABLE parameterTypemap {call BATCH_UPDATE_ORDER_STATUS( #{orderIds, jdbcTypeARRAY, typeHandlercom.example.handler.OrderIdListTypeHandler, modeIN}, #{newStatus, jdbcTypeVARCHAR, modeIN}, #{updatedNum, jdbcTypeNUMERIC, modeOUT} )} /update逻辑说明statementTypeCALLABLE告诉 Mybatis 这是一次存储过程调用内部会使用 JDBC 的CallableStatement。orderIds参数的typeHandler是重中之重因为 Mybatis 默认不知道如何把一个java.util.List转成java.sql.Array。updatedNum的modeOUT表示从数据库返回一个数值对应到 map 里的updatedNumkey。3.3 第三步写 Java 侧的自定义 TypeHandler这一步是很多人卡住的地方。写一个 TypeHandler继承BaseTypeHandler覆盖setNonNullParameter方法把 List 转换成java.sql.Array。package com.example.handler; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.sql.CallableStatement; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import oracle.sql.ARRAY; import oracle.sql.ArrayDescriptor; public class OrderIdListTypeHandler extends BaseTypeHandlerListLong { Override public void setNonNullParameter(PreparedStatement ps, int i, ListLong parameter, JdbcType jdbcType) throws SQLException { // 需要拿到 Connection 来创建 ArrayDescriptor Connection conn ps.getConnection(); // 数组类型名与 Oracle 中 CREATE TYPE 的名称一致 ArrayDescriptor descriptor ArrayDescriptor.createDescriptor(ORDER_ID_LIST, conn); // 把 ListLong 转成 Java 数组 Long[] ids parameter.toArray(new Long[0]); ARRAY oracleArray new ARRAY(descriptor, conn, ids); ps.setArray(i, oracleArray); } Override public ListLong getNullableResult(ResultSet rs, String columnName) throws SQLException { return null; } Override public ListLong getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return null; } Override public ListLong getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return null; } }逻辑说明ArrayDescriptor.createDescriptor(ORDER_ID_LIST, conn)这一行做的事情是从数据库连接里拿到名为ORDER_ID_LIST的类型元数据。这里最容易犯的错是把类型名拼成小写或者加上了 SCHEMA 前缀Oracle 里的类型名默认是大写如果创建时用了双引号小写那这里也要严格一致。最后ps.setArray(i, oracleArray)把 JDBC 数组设置到调用语句的对应位置。3.4 第四步Java 侧的 Mapper 接口与测试调用Mapper 接口不需要特殊签名参数用 Map 包裹即可。public interface OrderMapper { void batchUpdateOrderStatus(MapString, Object params); }调用时把 List 放进 Map并设置输出参数MapString, Object params new HashMap(); ListLong orderIds Arrays.asList(1001L, 1002L, 1003L); params.put(orderIds, orderIds); params.put(newStatus, SHIPPED); params.put(updatedNum, 0); // 作为 OUT 参数占位 orderMapper.batchUpdateOrderStatus(params); // 调用后 updatedNum 会被回填 Integer updatedCount (Integer) params.get(updatedNum); System.out.println(更新行数 updatedCount);参数说明params里的updatedNum初始值可以是 nullMybatis 会把它当作 OUT 参数的回填容器。调用完成后从同一个 map 里取updatedNum就能拿到存储过程返回的结果。如果你拿不到更新行数先检查 XML 里modeOUT是否写对以及 Java map 里的 key 和 XML 里#{updatedNum}是否完全一致。3.5 参数配置检查清单写完之后不要急着跑先检查下面几项检查项错误示例正确做法Oracle 类型名order_id_list小写与CREATE TYPE名称完全一致一般大写jdbcType 类型LISTARRAY存储过程参数方向漏写modeIN、OUT必须明确List 元素类型ListString传给NUMBER类型元素类型必须和 TYPE 定义一致typeHandler 是否注册XML 里写了类全名但没对应类确认类路径正确且编译进 classpath4. 避坑Mybatis 传 List 调 Oracle 存储过程的 5 个常见问题这一章是血泪经验每一条都是我在生产环境或协助同事排查时真正遇到过的。按「现象 → 原因 → 解决」的顺序写你能直接对照排查。4.1 现象ORA-00902 无效数据类型原因最常见的是jdbcType写成了LIST或VARCHARMybatis 在调用setArray时根本没有走到 TypeHandler而是尝试用普通字符串方式设置参数Oracle 自然不认。还有一个隐蔽原因自定义 TYPE 创建在了某个 Schema 下而连接用户的权限不足Oracle 报错时可能包装成无效数据类型。解决先确认 XML 里jdbcTypeARRAY和typeHandler是否同时存在。再执行SELECT type_name, typecode FROM all_types WHERE type_name ORDER_ID_LIST确认类型存在且当前用户有EXECUTE权限。如果类型存在但权限不足需要 DBA 执行GRANT EXECUTE ON ORDER_ID_LIST TO 你的用户。4.2 现象Missing IN or OUT parameter原因存储过程的参数比 XML 里写的多或者 XML 里参数顺序与存储过程定义不一致。Oracle JDBC 在按位置绑定参数时如果某个 IN 参数没有传入就会直接报这个错。另一个常见原因是 OUT 参数没有在 map 里占位比如把p_updated_num忘了放进 map。解决使用匿名 SQL 检查存储过程定义DESC BATCH_UPDATE_ORDER_STATUS。然后把 XML 里的#{...}数量和存储过程的参数数量、顺序逐一比对。注意Mybatis 的CALLABLE模式使用位置绑定不按参数名匹配所以顺序绝对不能反。4.3 现象java.sql.SQLException: 无效的列类型原因在 TypeHandler 里ArrayDescriptor.createDescriptor传入的类型名在数据库里不存在或者连接对象不是 Oracle 连接。我见过一个项目因为使用了 DBCP 连接池的代理连接导致ps.getConnection()返回的不是oracle.jdbc.OracleConnection最终ArrayDescriptor无法识别。解决在 TypeHandler 里先打印conn.getClass().getName()确认是oracle.jdbc.OracleConnection或包装类。如果是代理类尝试从连接池配置里开启accessToUnderlyingConnectionAllowedtrue或者改用((OracleConnection) conn).unwrap(...)获取底层连接。另外把ORDER_ID_LIST创建语句重新执行一遍确认名称没有拼写问题。4.4 现象当传入空 List 时存储过程直接被跳过原因如果使用FOREACH拼接方式空 List 会生成空 SQL导致语句报错或跳过。但走存储过程数组方案时空 List 会变成一个空的ARRAYOracle 存储过程里TABLE(p_order_ids)返回空集合更新行数为 0这不算错误。真正的问题是 Java 侧如果传Collections.emptyList()有些 TypeHandler 实现会抛出 NullPointerException因为parameter.toArray()在空列表时返回的是Long[0]本身没问题但如果你在 handler 里对参数做了别的业务判断可能就会 NPE。解决在进入 Mapper 之前对空 List 做显式判断如果orderIds null || orderIds.isEmpty()直接返回不调用存储过程。这样比让存储过程处理空集合更清晰也能避免无意义的数据库调用。如果你必须让存储过程执行那么 TypeHandler 里要去掉对parameter.size()的假设直接构建空 ARRAY。4.5 现象批量几千条后明显变慢原因一个是数组类型本身的构造效率问题另一个是存储过程内部写了循环逐条更新。比如用FOR i IN 1..p_order_ids.COUNT LOOP UPDATE ...那几千条就是几千次单行更新事务再一提交性能直接崩溃。还有一种情况是每次都创建ArrayDescriptor这个操作本身会查询数据字典批量循环里反复创建会累积开销。解决存储过程内部尽量用SELECT ... FROM TABLE(p_order_ids)做基于集合的操作避免逐行处理。如果必须逐行至少用BULK COLLECT减少上下文切换。TypeHandler 里的ArrayDescriptor可以缓存因为类型定义不会频繁变更。我在实际项目里把 descriptor 放进一个 static ConcurrentHashMap 按类型名缓存调用几千次的性能提升非常明显。5. 场景选型什么时候用字符串拼接什么时候必须用数组5.1 量级几十条 vs 几千条的分界线如果你要传的 List 只有几十条而且存储过程只是一个简单的状态更新字符串拼接反而更省事。为什么因为你可以完全绕开 TypeHandler 和自定义类型XML 里就是一个普通#{idStr}存储过程内部用REGEXP_SUBSTR把逗号分隔的字符串拆开。代码少调试容易出问题一眼就看得到。但量级一旦到几千条字符串拆分就不靠谱了。Oracle 用REGEXP_SUBSTR循环拆几千个字符串性能会指数级下降而且 SQL 的长度限制也可能把你卡住。这时候数组方案的优势就体现出来了数据以真正的集合形式存到数据库里存储过程可以直接使用集合查询不用做字符串解析。我的经验分界线大约是 200~300 条超过 300 条一律用数组不要犹豫。5.2 安全字符串拼接的 SQL 注入与分隔符转义字符串拼接最大的隐患不是性能而是数据本身。假设你的 List 元素不是纯数字而是一批文件路径或者用户输入的关键词里面自带逗号、单引号或者换行符。拼接时你必须要选一个“不可能出现在数据里”的分隔符比如ASCII 1控制字符但业务数据永远可能出乎意料。更危险的是如果你把 List 直接拼到动态 SQL 里而不是作为绑定参数等于把存储过程变成了 SQL 注入的通道。在数组方案里数据永远是通过setArray绑定Oracle 会把它当作一个集合而不是可执行代码不需要转义也不存在注入风险。如果你因为权限原因只能选字符串方案至少要做两件事一是使用StringJoiner而不是手写循环拼逗号二是在存储过程内部使用INSTR和SUBSTR按位置取值而不是把整个字符串拼进EXECUTE IMMEDIATE。5.3 维护建 TYPE 的权限与生产环境变更成本自定义 TYPE 方案需要 DBA 在数据库里跑一条 DDL 语句这在很多公司意味着要走变更流程。如果存储过程已经上线你还要考虑 TYPE 的版本管理因为表类型一旦被存储过程引用删除和重建都会遇到依赖问题。我见过项目里因为CREATE OR REPLACE TYPE执行失败导致现有存储过程全部失效最后只能回滚。临时表方案也有类似的维护成本只不过不需要建 TYPE但需要 DBA 提前创建 GTT 并授权。如果你的团队对数据库变更有严格要求而 List 量级又不大字符串拼接反而是风险最低的方案。所以选型不能只看技术还要看你的发布流程和数据库权限边界。反过来如果你已经获得 DBA 支持一次性把 TYPE、存储过程、授权都配好数组方案后续的运维成本是最低的。6. 一个更省心的技巧封装 List 参数处理器让 DAO 只关心业务6.1 自定义 TypeHandler 的写法与注册前面示例里TypeHandler 是针对ListLong的。实际项目中你可能同时需要ListString、ListInteger甚至ListBigDecimal。不要为每一种类型各写一个 handler那会让 Mapper 的 XML 变得臃肿。常见做法是写一个通用的ListTypeHandler通过构造参数传入 Oracle 类型名和元素类型。public class GenericListTypeHandler extends BaseTypeHandlerList? { private final String oracleTypeName; private final Class? elementClass; public GenericListTypeHandler(String oracleTypeName, Class? elementClass) { this.oracleTypeName oracleTypeName; this.elementClass elementClass; } Override public void setNonNullParameter(PreparedStatement ps, int i, List? parameter, JdbcType jdbcType) throws SQLException { Connection conn ps.getConnection(); ArrayDescriptor descriptor ArrayDescriptor.createDescriptor(oracleTypeName, conn); Object[] array parameter.toArray(); ARRAY oracleArray new ARRAY(descriptor, conn, array); ps.setArray(i, oracleArray); } // 省略 get 方法 }在 Mybatis 的 XML 里你可以通过typeHandler标签为特定参数指定构造参数。不过更灵活的方式是在 Mapper 接口方法上使用Param注解并在 XML 中用typeHandler属性配合parameterMap来注册。需要注意Mybatis 对带构造参数的 TypeHandler 支持并不直观我建议在 Spring Bean 配置里显式声明。bean idstringListTypeHandler classcom.example.handler.GenericListTypeHandler constructor-arg valueSTRING_LIST/ constructor-arg valuejava.lang.String/ /bean然后通过 Mybatis 的typeHandlersPackage扫描或显式配置注入。这个封装的好处是业务 Mapper 里不再关心 List 是怎么转成数组的只要在 SQL 里声明typeHandler就行。6.2 效果验证把日志里打印的参数当作测谎仪写完这套配置最怕的是“看着对跑起来错”。我建议你第一件事就是打开 Mybatis 的 SQL 日志把PreparedStatement的参数打印出来。Mybatis 默认会把绑定参数打印成 Parameters: [Ljava.lang.Long;1a2b3c你根本看不出来是不是数组。这时你需要在 TypeHandler 里加一行调试日志log.info(传入 List 元素个数: {}, 转化后的 Array 长度: {}, parameter.size(), oracleArray.length());这样每次调用都会打印出数组真正有多少个元素、什么时候生成的。如果看到数组长度为 0而你的 List 非空那就是 handler 里的parameter收到的是 null问题出在 XML 参数的mode或typeHandler绑定上。这个日志比任何 debug 都有效因为存储过程调用是一个黑匣子你不确认数组内容就永远不知道是 Java 侧还是 SQL 侧出了问题。6.3 我后来养成的习惯现在我做 Mybatis Oracle 存储过程相关的接口都会在一开始就要求 DBA 把那几个CREATE TYPE脚本放进初始化目录而不是临时去数据库里执行。因为存储过程、类型、授权这三样东西必须同时存在缺一个联调时就会陷入“谁能访问序列表”的扯皮里。另一个习惯是把存储过程入参的类型写成自定义表类型而不是普通的VARCHAR2这样即使将来 Mybatis 侧换人维护也不会有人再想着去拼逗号字符串。如果你只是临时解决一次需求字符串拼接当然最快但如果这个接口要长期存在数组方案才值得你投入时间。有一次我贪省事用了字符串方案后来业务方要求支持一万个订单号存储过程里拆字符串拆了三十多秒最后灰头土脸地换成了数组还要处理线上数据补偿。从那以后我再也不会为了几分钟的写法妥协。希望我的这套流程和踩坑记录能帮你在第一次做的时候就少走弯路。本文还有配套的精品资源点击获取