1. 为什么一个in写法值得单独记一笔
做后端开发时间长了,你会发现真正让人加班的往往不是什么高深架构问题,而是in这种看起来最没技术含量的语法。Hibernate里写原生SQL或者HQL的时候,in的坑特别密集:参数个数不确定、集合为空直接报错、拼接字符串被注入、批量查询性能断崖式下跌,每一个都能让你在联调现场抓头发。
这篇内容就是围绕Hibernate写SQL语句中in的写法展开,把HQL的in、原生SQL的in、setParameterList的用法、空集合处理、批量参数上限、以及和join方案的取舍讲透。适合已经能用Hibernate做基本CRUD、但一遇到动态条件查询就心里没底的同学,也适合写了几年但一直靠“能跑就行”糊弄过去、想系统梳理一遍的老手。我不打算复述官方文档,而是按实际项目里踩过的顺序,把每种写法的适用边界和隐藏成本摊开讲。
先说结论性的判断:in在Hibernate里从来不是“一种写法”,而是至少四种写法,分别对应HQL、原生SQL、Criteria和批量参数绑定,每种在参数类型、空值行为、SQL生成方式上都不一样。搞混它们,就是大部分线上问题的源头。
2. 先把四种in写法摆到台面上
2.1 HQL里的in:面向实体和属性,不是面向表
HQL的in操作对象是实体属性,不是数据库列。这是最容易被忽略的一点。你写from Order o where o.status in (:statusList),Hibernate解析的是实体Order的status属性,最终映射到哪张表哪一列由映射文件或注解决定。这意味着参数类型必须是属性对应的Java类型,传字符串数组给枚举属性会直接抛类型转换异常。
HQL的in支持两种参数形式:位置参数和命名参数。位置参数?在Hibernate 5之后已经不推荐,因为多个in混用时索引极易错位。命名参数:statusList配合setParameterList是标准做法。这里有个细节:HQL的in后面跟的集合,Hibernate会展开成in (?, ?, ?)这样的占位符列表,展开发生在SQL生成阶段,不是数据库层。
String hql = "from Order o where o.status in (:statusList) and o.createdAt > :start"; Query query = session.createQuery(hql); query.setParameterList("statusList", statusList); query.setParameter("start", startDate);这段代码看起来平平无奇,但setParameterList对空集合的处理是直接抛IllegalArgumentException,不是返回空结果。很多人第一次遇到空集合报错就是栽在这里。
2.2 原生SQL里的in:回到数据库方言,参数绑定更挑剔
原生SQL的in走的是JDBC参数绑定路径,Hibernate在这里只是薄薄一层包装。写法上可以用命名参数,但底层仍然是PreparedStatement的占位符。关键差异在于:原生SQL的in参数类型由数据库驱动决定,Hibernate不做类型推断。你传一个List<Integer>给一个varchar列,某些驱动会静默转换,某些直接报错,行为不一致。
String sql = "select * from t_order where status in (:statusList)"; SQLQuery query = session.createSQLQuery(sql); query.setParameterList("statusList", statusList); query.addEntity(Order.class);原生SQL的in还有一个隐蔽问题:当集合元素很多时,Hibernate展开的占位符数量受数据库限制。不同数据库对in列表长度的上限不同,超过上限不是Hibernate报错,而是数据库层报语法错误或性能骤降。这个上限必须提前知道,不能等线上炸了才查。
2.3 Criteria的in:类型安全但API啰嗦
Criteria API的in通过Restrictions.in实现,参数是Collection。它的优势是编译期类型检查,劣势是动态条件拼接时代码量翻倍。Criteria的in对空集合同样不友好,Restrictions.in("status", emptyList)生成的SQL是in (),直接语法错误。所以Criteria场景下空集合判断必须前置。
2.4 批量参数绑定:setParameterList的真实行为
setParameterList不是简单地把集合塞进去,它内部会判断集合大小,决定是展开成多个占位符还是走其他路径。集合元素类型如果是基本类型包装类,走的是标准展开;如果是实体对象,需要配合in后面跟实体属性路径。这里有个经验:setParameterList对Set和List的处理一致,但顺序不保证,如果业务依赖in的顺序(比如配合order by field()),必须用List并自己控制排序。
四种写法的对照如下:
| 写法 | 参数类型要求 | 空集合行为 | SQL展开方式 | 适用场景 |
|---|---|---|---|---|
| HQL in | 实体属性类型 | 抛异常 | 占位符展开 | 实体查询、动态条件 |
| 原生SQL in | 数据库列类型 | 抛异常或语法错误 | JDBC占位符 | 复杂报表、跨表统计 |
| Criteria in | 编译期检查 | 生成in ()语法错误 | 占位符展开 | 类型安全的动态查询 |
| setParameterList | 集合元素类型 | 抛IllegalArgumentException | 按集合大小展开 | 所有需要绑定的in场景 |
3. 空集合和超大集合:两个必须提前处理的边界
3.1 空集合不是“查不到”,是“直接崩”
这是in写法里最高频的线上事故。业务代码里一个筛选条件没传值,集合是空的,直接进setParameterList,Hibernate抛IllegalArgumentException: Invalid list of values。更糟的是有些老版本Hibernate对空集合的处理是生成in (),数据库报语法错误,错误信息完全指不到业务代码。
正确的处理方式是在绑定前做短路判断:集合为空时,要么直接返回空结果,要么改写查询条件。返回空结果是最安全的,因为in空集合的语义就是“不匹配任何值”,和where 1=0等价。
if (statusList == null || statusList.isEmpty()) { return Collections.emptyList(); } query.setParameterList("statusList", statusList);这里有个容易忽略的点:null和空集合要分开判断。setParameterList传null的行为在不同Hibernate版本里不一致,有的抛异常,有的当成空集合处理。统一用isEmpty()判断最稳。
注意:不要在HQL里写
in (:list)然后指望Hibernate自动处理空集合,这个行为从来没有被保证过,版本升级时最容易变。
3.2 超大集合要主动分批,不能赌数据库上限
in列表的长度上限因数据库而异,但普遍规律是超过一定数量后,SQL解析成本急剧上升,执行计划可能从索引扫描退化成全表扫描。实际项目里我见过传五千个ID进in的,数据库直接超时。
分批处理是标准解法:把大集合切成固定大小的批次,每批单独查询,结果合并。批次大小没有统一答案,经验值在500到1000之间,具体要看数据库类型和索引情况。分批的代价是多次网络往返,所以批次不能太小,否则往返开销超过查询本身。
int batchSize = 500; List<Order> result = new ArrayList<>(); for (int i = 0; i < idList.size(); i += batchSize) { List<Long> batch = idList.subList(i, Math.min(i + batchSize, idList.size())); result.addAll(query.setParameterList("ids", batch).list()); }分批时要注意去重和顺序问题。如果业务要求结果按传入ID顺序返回,分批查询后需要自己重排,不能依赖数据库返回顺序。
3.3 用join替代in的时机判断
不是所有in都该用in。当in的集合来自另一张表的查询结果时,用子查询或join往往更高效。比如“查所有有未支付订单的用户”,写where u.id in (select o.userId from Order o where o.status = 'UNPAID'),数据库优化器可能把子查询物化,成本很高。改成join后,执行计划通常更稳定。
但join不是万能替代。当集合来自外部参数(比如前端传的ID列表),in是唯一选择,因为参数不在数据库里。判断标准很简单:集合数据源在数据库内,优先考虑join或exists;集合数据源在应用层,只能用in,此时重点放在分批和空集合处理上。
4. 从拼SQL到参数绑定:一条完整的实操链路
4.1 动态条件拼接的正确姿势
实际业务里的in很少是静态的,通常是多个筛选条件之一。用StringBuilder拼HQL是常见做法,但拼接时必须只拼结构,不拼值。值一律走参数绑定,这是防注入的底线。
StringBuilder hql = new StringBuilder("from Order o where 1=1"); Map<String, Object> params = new HashMap<>(); if (statusList != null && !statusList.isEmpty()) { hql.append(" and o.status in (:statusList)"); params.put("statusList", statusList); } if (startDate != null) { hql.append(" and o.createdAt >= :startDate"); params.put("startDate", startDate); } Query query = session.createQuery(hql.toString()); for (Map.Entry<String, Object> entry : params.entrySet()) { if (entry.getValue() instanceof Collection) { query.setParameterList(entry.getKey(), (Collection<?>) entry.getValue()); } else { query.setParameter(entry.getKey(), entry.getValue()); } }这段代码的关键在于参数收集和绑定分离。拼接阶段只决定“要不要这个条件”,绑定阶段统一处理集合和单值。这样空集合判断只在一个地方做,不会漏。
4.2 参数类型对齐:枚举、日期、自定义类型
in的参数类型必须和属性类型严格对齐。枚举属性传字符串列表,Hibernate在HQL层可能不报错,但生成的SQL参数类型和列类型不匹配,数据库层报错。日期类型同理,java.util.Date和java.time.LocalDateTime在Hibernate 5前后的支持不一样,混用会导致参数绑定失败。
自定义类型(比如用AttributeConverter转换的字段)在in里要特别注意:setParameterList绑定的是转换后的数据库类型还是转换前的Java类型,取决于Hibernate版本和转换器配置。稳妥做法是先用单值查询验证类型,再扩展到集合。
4.3 原生SQL的结果映射不能省
原生SQL的in查询如果忘了addEntity或setResultTransformer,返回的是Object[]数组,不是实体。这个错误在编译期发现不了,运行时才暴露,而且错误信息指向的是类型转换,不是映射缺失。
SQLQuery query = session.createSQLQuery(sql); query.setParameterList("statusList", statusList); query.addEntity(Order.class); List<Order> orders = query.list();addEntity要求SQL查询的列名和实体映射完全对应,列名不一致时要用addScalar或别名对齐。这一步在联调时最耗时间,建议先把SQL在数据库客户端跑通,确认列名后再接Hibernate。
4.4 事务和会话边界对in查询的影响
in查询本身不涉及写操作,但会话边界会影响结果。在openSession模式下,两次in查询之间如果会话没关,一级缓存可能返回旧数据。批量分批查询时,每批之间是否clear会话,取决于业务对一致性的要求。如果只是展示用途,不清缓存问题不大;如果后续要基于查询结果做更新,必须确保会话状态干净。
5. 踩过的坑和排查速查表
5.1 典型报错与根因对照
| 报错信息 | 根因 | 处理方式 |
|---|---|---|
| Invalid list of values | 空集合传给setParameterList | 绑定前判断isEmpty |
| SQL syntax error near ')' | Criteria的in空集合生成in () | 空集合短路返回 |
| Parameter value did not match expected type | 集合元素类型和属性类型不一致 | 对齐枚举/日期类型 |
| ORA-01795 / 类似列表超限错误 | in列表超过数据库上限 | 分批查询 |
| could not locate named parameter | 命名参数拼写或未绑定 | 检查参数收集逻辑 |
5.2 排查顺序:从参数到SQL到数据库
遇到in相关问题时,排查顺序应该是:先确认集合是否为空、元素类型是否正确,再打印生成的SQL和绑定参数,最后在数据库客户端复现。Hibernate的show_sql只打印SQL不打印参数,参数要用日志框架开TRACE级别才能看到。很多人卡在“SQL看起来没问题”上,其实是参数绑定错了。
提示:调试
in问题时,把setParameterList的集合内容打印出来,比看SQL有效得多。集合里混进一个null元素,某些数据库会静默忽略,某些直接报错,行为不一致。
5.3 性能排查:in不是慢,是执行计划变了
in查询慢的时候,先看执行计划,不要急着改写法。集合元素少时走索引,元素多时优化器可能选择全表扫描,这是成本模型决定的,不是in本身的问题。分批查询能缓解,但根本解法是确认索引是否覆盖了in列和过滤条件列。
另一个隐蔽的性能问题是in配合order by。数据库需要对in结果排序时,如果in列表很大,排序成本会叠加。这时候要么在应用层排序,要么确认排序列有索引。
5.4 版本差异:Hibernate 5和6的in行为变化
Hibernate 6对HQL解析器做了重写,in的参数绑定行为有细微变化。比如对空集合的处理,6.x某些版本不再抛异常而是生成in (null),结果变成查不到数据而不是报错。这种“静默行为变化”最危险,升级时必须回归测试所有in查询路径。
原生SQL的setParameterList在6.x里对集合类型的推断更严格,以前能跑的代码升级后可能报类型不匹配。建议在升级前把所有in写法列出来,逐个验证空集合、单元素、多元素、超大集合四种情况。
6. 我个人在实际操作中的几点体会
写了这么多,最后说几个文档里不会写、但实际项目里反复验证过的经验。
第一,in的参数永远不要相信上游传进来的集合。业务层拼装集合时混进null元素是常态,setParameterList对null元素的处理依赖数据库驱动,不可控。绑定前做一次过滤,成本极低,收益极高。
第二,分批大小不要拍脑袋定。我一般先用500试,观察执行时间和数据库负载,再调整。不同数据库差异很大,同一个数据库不同索引配置下最优批次也不一样,没有放之四海皆准的值。
第三,动态拼接HQL时,where 1=1这种写法虽然土,但比判断“是不是第一个条件”可靠得多。条件多了之后,维护“第一个条件不加and”的逻辑迟早出错,1=1的解析成本可以忽略。
第四,原生SQL的in能不用就不用。HQL和Criteria在类型安全和可维护性上的优势,在in这种高频写法上体现得特别明显。非要用原生SQL,通常是因为查询太复杂,这时候更应该把SQL单独管理,而不是混在业务代码里。
第五,升级Hibernate版本前,把所有in查询的空集合路径跑一遍。这个动作花不了半小时,但能避免上线后最尴尬的那类故障——查询不报错,但结果永远是空。