前阵子线上一个批量查询接口突然报错日志里明晃晃挂着一段ORA-01795: maximum number of expressions in a list is 1000。一查代码发现是同事直接把一个可能包含两千多个ID的List交给了JPA拼成了where id in (?, ?, ...)。这类问题在JPA/Hibernate项目里非常典型搜索“jpa hibernate sql语句in超过1000之后的出错解决方法”能看到各种答案有说拆List的有说改数据库参数的还有说换SQL写法的。今天不打算只丢几个补丁而是把几种可落地的方案连同适用场景、坑点一次讲清楚方便你按实际情况选。1. 报错现场ORA-01795以及那些容易混淆的报错变体1.1 先看一眼报错判断是不是真在IN上限ORA-01795是Oracle数据库的硬性限制一条SQL里IN后面的表达式列表不能超过1000个。这个错误和Hibernate版本无关和JDBC驱动版本也无关纯粹是数据库层面的约束。你传2000个参数进去Oracle一定报错。但新手容易把另外两个报错和它混淆ORA-04030: out of process memory可能是IN参数太多导致SQL文本太长或绑定变量过多但本质上不是同一个问题。Argument list too long这个更多出现在操作系统层面的调用比如把整条SQL当成字符串去执行和JDBC预编译没关系。所以在动手改代码之前第一步是先确认日志里到底是不是ORA-01795再确认当前连的是不是Oracle。MySQL对IN表达式数量没有1000这个限制SQL Server的IN限制是in列表总字节数或嵌套级别表现又不一样。别拿Oracle的解法套到MySQL上。1.2 为什么JPA/Hibernate特别容易踩这个坑JPA的派生查询太好写了一个ListEntity findByIdIn(CollectionLong ids)就把活干了。但内部生成的SQL是一个完整的大IN列表Hibernate不会自动帮你分片。也就是说你传多少个ID它就拼多少个占位符超过1000就炸。还有一个隐藏场景如果你用了Specification或CriteriaQuery在cb.in(root.get(id), idList)这里传一个大list同样会炸。有些项目还喜欢把IN条件加在Query里比如Query(select o from Order o where o.customerId in :ids) ListOrder findByCustomerIds(Param(ids) CollectionLong ids);这种写法一旦ids超过1000一样ORA-01795。所以排查范围不只是某个方法而是整个项目里所有可能出现in (:xxx)或cb.in的地方。2. 简单的“拆IN”为什么不是正解分页、去重与一致性2.1 subList分批addAll三处暗坑很多人的第一反应是ids.subList(0, 1000)查一次ids.subList(1000, 2000)再查一次最后resultList.addAll。乍一看解决了报错实际埋了三个坑。第一个坑是分页失效。如果你的查询本来就带Pageable比如findByCustomerIdIn(ids, pageable)SQL层会对每个分片分别分页。假设ids有1800个每批900你取第1页10条等于只在第一批900个ID里取第二批的900个ID根本参与不进来数据直接少一半。第二个坑是去重和顺序。两个批次查出来的结果合并成一个List如果数据库里ID有重复或两次查询之间数据发生了变化合并后的List可能包含重复数据也可能缺失数据。第三个坑是count查询。很多接口需要返回总数总数查询同样要拼IN拆一次可不够得把count逻辑也拆一遍代码改起来到处是洞。2.2 OR拼接IN可行但执行计划容易失控还有人提议拆成多个IN然后用or拼起来相当于where id in (?, ?, ...) or id in (?, ?, ...)ORACLE的优化器碰上这种写法有时会做OR展开OR Expansion把多个IN的条件逐个展开成UNION ALL再合并结果。这个执行计划在你只拆两三个IN、每个IN几十上百个值时问题不大但如果你把一个2000的IN拆成2个1000的IN旧版本Oracle12c以前可能出现执行计划选择不当比如走全表扫描而不是索引范围扫描SQL突然变慢。更重要的是在JPA层面你还是得自己写拆分逻辑而且这个写法没有解决根本问题——如果哪天参数到了3000、5000你一样要维护更多的OR分支。IN列表的本质是多个相等条件的或关系只要列表长度超出数据库限制说明这种把大量值硬塞给数据库的做法已经接近边界了。3. 方案一Java侧分批查询合并结果ID量在几千到几万时3.1 分批工具与查询实现如果你的ID总量在几千到几万这个量级接口又必须一次性返回所有结果最简单可控的方式是Java侧分片查询每次塞给数据库不超过900个ID最后在内存里合并。先写一个通用的分片工具public static T ListListT partition(ListT list, int size) { if (list null || list.isEmpty()) { return Collections.emptyList(); } ListListT result new ArrayList(); for (int i 0; i list.size(); i size) { result.add(list.subList(i, Math.min(i size, list.size()))); } return result; }然后你的查询逻辑变成ListLong targetIds ...; // 可能超过1000 int batchSize 900; ListOrder finalResult new ArrayList(); for (ListLong batch : partition(targetIds, batchSize)) { finalResult.addAll(orderRepository.findByCustomerIdIn(batch)); }注意batchSize我建议取900而不是999或1000。为什么因为如果查询里还有其他条件或者用的是复合IN比如(a,b) in ((?,?),(?,?))表达式数会超过你的预期900留了缓冲不会刚好卡在边界上触发报错。3.2 合并时的去重与顺序恢复直接把addAll结果返回会有个隐患两次查询结果拼接顺序和你传入的targetIds顺序不一定一致。很多接口要求返回结果按ID顺序排列比如前端传了一个ID数组你返回的数据必须按这个顺序展示。推荐做法是用LinkedHashMap按原始ID顺序归并MapLong, Order orderMap new LinkedHashMap(); for (Long id : targetIds) { orderMap.put(id, null); } for (ListLong batch : partition(targetIds, batchSize)) { for (Order order : orderRepository.findByCustomerIdIn(batch)) { orderMap.put(order.getId(), order); } } ListOrder finalResult new ArrayList(); for (Order order : orderMap.values()) { if (order ! null) { finalResult.add(order); } }这里先占位再填充能保证返回顺序和targetIds一致同时天然去重。如果合并后finalResult.size()小于targetIds.size()说明有部分ID在数据库中不存在后续逻辑要处理这种缺失。这个校验很多项目都会忽略等到下游拿去关联时才发现空指针不如在源头暴露出来。3.3 注意事务快照与懒加载这种分批查询在默认事务隔离级别下Oracle通常是READ_COMMITTED每一批查询都是一个独立的statement两次查询之间如果别的会话提交了数据可能出现前一批查到、后一批查不到的情况。业务上如果对一致性要求高可以考虑把整个循环包在同一个事务里并且用REPEATABLE_READ或Oracle的闪回查询。不过大多数业务场景只是查明细见多识广一点的处理是接受微小窗口不必为此把隔离级别调高。还有懒加载问题findByCustomerIdIn返回的是实体对象如果实体上挂了OneToMany或ManyToOne遍历结果时很容易触发N1查询。我的做法是直接查DTO投影只取需要的字段避免把一堆关联数据拉进内存。4. 方案二全局临时表JOINOracle生产环境最稳的做法4.1 为什么临时表比长IN更适合Oracle如果ID量很大几万、几十万或者查询SQL本身就是动态拼出来的Java侧分批查询会变成几十次数据库往返性能很难看。这时候我建议放弃IN改用全局临时表Global Temporary TableGTT配合JOIN。原理很简单IN列表超过1000是因为数据库要在一个表达式列表里展开所有值那不如先把这些值放进一张表然后让SQL去JOIN这张表。JOIN操作不受1000个表达式限制走哈希连接时执行计划非常稳定而且索引利用更充分。Oracle的GTT有个关键特性事务结束后数据自动清空。也就是说不同会话之间不会互相干扰你把ID插进去查询完就结束事务数据自己消失不需要你费心清理。这一点在生产环境非常省事。4.2 建表、批量写入、改写查询的完整步骤第一步建临时表。一般建议加主键方便JOINcreate global temporary table tmp_ids ( id number(19) primary key ) on commit preserve rows;on commit preserve rows意味着事务提交后数据保留到会话结束如果你希望在commit后自动清空用on commit delete rows也可以。实际项目中我更喜欢preserve rows因为一个事务里可能要查好几次。第二步用JdbcTemplate批量插入。千万不能用循环一条一条insert2000个ID循环插入能插到怀疑人生。用batchUpdateAutowired private JdbcTemplate jdbcTemplate; public void batchInsertIds(ListLong ids, int batchSize) { jdbcTemplate.batchUpdate( insert into tmp_ids(id) values (?), ids, batchSize, (PreparedStatement ps, Long id) - ps.setLong(1, id) ); }第三步改写查询。可以继续走JPA但SQL要从IN改成JOINQuery(value select o.* from orders o inner join tmp_ids t on t.id o.customer_id where o.status VALID, nativeQuery true) ListOrder findByTmpIds();如果你的实体映射比较复杂原生SQL容易踩字段映射的坑也可以先用Criteria查实体再手动过滤ID在目标集合内的结果。不过既然都上临时表了说明数据量不小建议还是把查询写成原生SQL投影成DTO返回。4.3 批量插入与会话隔离细节临时表方案有几个细节值得专门说。第一临时表建在哪个schema下要确认清楚。有些生产库的应用账号只有DML权限没有DDL权限建表这一步得提前找DBA帮忙或者在初始化脚本里统一建好应用启动时检查表是否存在。第二batchUpdate不一定比单条insert快多少——关键是它减少了JDBC网络往返。实测下来批量500条和批量1000条差距不大但肯定好过一条条执行。每次往临时表插的时候可以先truncate再插避免上次事务残留脏数据。第三如果你的ID来源本身是另一个表比如“查出所有VIP客户的订单”那根本不用走临时表直接JOIN原表就行。临时表最适合的场景是ID来自外部系统比如前端传了一串商品ID或用户ID你没法在SQL里用子查询生成才需要这一步。5. 方案三JPA Specification动态拼接把IN始终压到上限以内5.1 通用的分片IN谓语工具方法如果项目里用了Spring Data JPA的Specification可以很优雅地解决IN超过1000的问题。核心思路是不要直接cb.in(root.get(field), allIds)而是先把ID列表分成多批每一批生成一个in谓词再用cb.or组合起来。public static Predicate buildInPredicate(CriteriaBuilder cb, Root? root, String fieldName, Collection? values) { if (values null || values.isEmpty()) { return cb.conjunction(); } ListPredicate orPredicates new ArrayList(); for (List? batch : partition(new ArrayList(values), 900)) { orPredicates.add(root.get(fieldName).in(batch)); } return cb.or(orPredicates.toArray(new Predicate[0])); }使用的时候SpecificationOrder spec (root, query, cb) - { Predicate cond1 buildInPredicate(cb, root, customerId, customerIds); Predicate cond2 cb.equal(root.get(status), VALID); return cb.and(cond1, cond2); }; ListOrder orders orderRepository.findAll(spec);这个方式生成的SQL类似where customerId in (?,?...) or customerId in (?,?...)每个IN都不超过900Oracle不会再报ORA-01795。5.2 count查询与复合条件要一起处理用Specification最大好处是分页和count查询都能复用同一套Predicate。JpaSpecificationExecutor里findAll(spec, pageable)会先执行count再执行分页查询两边的Predicate是一样的。你只要保证buildInPredicate里传入的values相同count查询就不会因为IN长度不同而报错。但这里有一个容易忽略的点如果分页查询总共2000个ID拆成3个INSQL会变成(id in (...) or id in (...) or id in (...))这个整体还是作为where条件的一部分。如果再加上其他or条件尤其是不小心把cb.or嵌进cb.and里括号一多SQL文本会很长也可能触发其他数据库限制。写完后务必打开show-sql看一眼实际生成的SQL。5.3 旧版本Oracle的OR展开问题前面提过OR展开的问题使用Specification拼接后同样会遇到。Oracle 11g以及更早版本多个IN的OR条件可能不会走最优的INLIST迭代器而是先OR展开成UNION ALL。这种情况下执行计划里能看到UNION-ALL或BITMAP OR字样。解决办法有两个把批量大小调小比如500让每个IN更小降低OR展开的概率。如果SQL已经写死了可以尝试用/* use_concat */或/* no_expand */提示控制优化器行为。但我不建议在JPA里写这种硬编码SQL维护难度太高不如直接用临时表方案替代。6. 方案四ID来源如果能交给SQLIN就该改写成EXISTS或分页拉取6.1 IN改EXISTS的例子有时候IN列表不是外部传参而是子查询的结果。比如“查出所有VIP客户的订单”新手会写成select * from orders where customer_id in ( select id from customers where level VIP )这个写法不会触发ORA-01795因为子查询结果不拉进表达式列表数据库优化器会自己处理。但如果业务里有人把子查询结果先查出来塞进Java List再传回IN那就绕了一大圈还踩了1000上限。正确做法是保留子查询或者改EXISTSselect * from orders o where exists ( select 1 from customers c where c.id o.customer_id and c.level VIP )EXISTS的好处是语义清晰对优化器更友好而且不存在“表达式列表”的概念。在JPA里用Criteria写SubqueryCustomer subquery query.subquery(Customer.class); RootCustomer subRoot subquery.from(Customer.class); subquery.select(subRoot.get(id)); subquery.where(cb.equal(subRoot.get(level), VIP)); Predicate existsPredicate cb.exists(subquery); cb.and(cb.equal(root.get(customerId), subquery.getSelection()));不过这一套写起来比较啰嗦如果是简单场景我更推荐直接用原生SQL的exists。6.2 海量外部ID分批游标拉取如果外部接口传进来几十万个ID再走临时表或者ORM都不太合适。这时候正确的做法是改变交互方式不要一次性传几十万ID而是让调用方按页传或者提供游标接口第一次传一个游标ID服务端按固定的批次大小比如1000往下拉返回最后一条的ID作为下一页游标。这样每次查询都是稳定的小IN或者干脆用where id :cursorId order by id limit :size内存占用稳定数据库压力也可控。这个方案本质上是把“一次查完”变成“分批消费”。虽然改动接口契约比较大但对大数据量场景是唯一能长期扛住的方案。很多报表导出、全量同步的需求都适合这么设计。7. 这几个方案怎么选实测对比与避坑清单7.1 条件与选型表场景推荐方案为什么ID量几千到几万需一次返回Java侧分批查询合并实现简单不需要DDL改动范围小ID量几万到十几万稳定优先临时表JOIN数据库往返少执行计划稳定不受1000限制项目已用Specification动态查询分片IN谓词工具复用现有查询框架count和分页统一处理ID列表来自数据库子查询改写EXISTS/JOIN从源头消除IN优化器更好发挥ID量几十万以上游标分页拉取避免内存爆炸控制单次查询成本我个人的经验是先把方案一做成通用组件能覆盖80%的场景。如果发现某个接口数据量特别大再单独为它上临时表。不要一上来就上临时表毕竟涉及建表权限和运维成本很多小团队没有DBA搞不定这些。7.2 调试时怎么确认SQL长什么样排查IN超过1000问题最直接的手段是打开Hibernate的SQL日志spring: jpa: show-sql: true properties: hibernate.format_sql: true然后复现请求看控制台打印的SQL是不是where id in (?, ?, ...)一大串。如果用的是MyBatis就开mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl。看到SQL后可以进一步用EXPLAIN PLAN FOR分析执行计划explain plan for select * from orders where customer_id in (1, 2, 3); select * from table(dbms_xplan.display);如果计划里出现INLIST ITERATOR说明IN条件走了索引扫描性能通常可以接受如果出现TABLE ACCESS FULL说明优化器认为IN列表太大不值得走索引这时候不管报不报错SQL都已经有性能隐患了。7.3 批量大小到底定多少这个问题看起来简单实际有不少讲究。我的经验值普通单字段IN批量大小取900复合IN比如两个字段组合的(a,b) in (...)批量大小取500因为每个“表达式”包含两列Oracle算表达式个数时按(a,b)整体算但为了保险还是留足余量。另外如果查询里还有not in尤其还涉及NULL值的坑优先改写成not exists。not in遇到子查询结果里有NULL整条SQL的结果集直接为空这是数据库基础里常见的坑和1000限制叠加起来更麻烦。最后说一点也许过时但真实的经验网上有人建议给Oracle加参数绕过1000限制比如alter system set ...我劝你直接忽略。1000是Oracle设计上的防御性上限不是bug硬调参数属于给系统埋雷换版本或者换数据库之后一定出问题。老老实实分片、临时表、改写EXISTS这三板斧足够覆盖绝大多数场景。我自己经手过一个批量审批系统原来用IN查2000多个ID接口动不动超时改成临时表JOIN之后执行计划稳稳走HASH JOIN响应时间降到之前的五分之一。从那以后我在设计查询接口时就会提前问一句这里的ID集合最大可能涨到多少。想清楚这个问题很多坑根本不会踩到。