ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Hibernate中in查询的写法详解:参数绑定、空集合与性能优化

Hibernate中in查询的写法详解:参数绑定、空集合与性能优化

1. 为什么一个in能让人折腾一下午

做持久层开发的朋友大概都有过这种体验:明明 SQL 在客户端工具里跑得好好的,一放进代码里就报错,或者查出来的结果莫名其妙少了几条。我印象特别深的一次,是排查一个列表查询的问题,业务逻辑翻来覆去看了三遍都没毛病,最后定位到一行 HQL,问题就出在in后面那个括号里——参数传了个集合,但写法上踩了坑。这件事之后我养成了一个习惯:凡是涉及in的查询,写完必须单独跑一遍边界用例。

这篇内容就围绕Hibernate 写 SQL 语句中in的写法展开。说得具体点,是在 Hibernate 这个 ORM 框架里,当我们用 HQL、原生 SQL、Criteria 或者 JPA 的 Query 去构造带in条件的查询时,参数到底该怎么传、集合该怎么绑、有哪些看起来能跑其实埋雷的写法。它解决的核心问题是:让in查询在 Hibernate 的各种调用方式下都能稳定、安全、可维护地工作,而不是靠试错碰运气。

适合谁来参考?如果你正在用 Hibernate 或 JPA 做数据访问层,写过where id in (:ids)却不确定参数该怎么塞,或者遇到过in查询结果不对、参数绑定异常、集合为空时报错这类问题,那这篇基本能覆盖你 90% 的场景。哪怕你是刚接触 Hibernate 的新手,我也会把底层逻辑掰开讲清楚,保证看完能直接抄作业。

需要先说明一点:Hibernate 版本差异比较大,HQL 的解析器在不同大版本之间有过调整,下面讲的内容以常见的 Hibernate 5.x 和 6.x 为主,涉及差异的地方我会单独标出来。另外文中所有示例都是基于通用实践整理的,具体项目里还要结合你自己的实体映射来验证。

2. 先把in的几种写法分清楚

2.1 HQL 里的in:参数是集合,不是拼接字符串

很多人第一次写 HQL 的in,脑子里想的是 SQL 那套字符串拼接,于是写出类似where id in (:idList)然后试图把"1,2,3"这样的字符串塞进去。这么写大概率会报类型转换或者语法错误,因为 HQL 的in期望的是一个集合类型的参数,Hibernate 会把它展开成多个占位符。

正确的姿势是这样:

String hql = "from User u where u.id in (:ids)"; List<Long> ids = Arrays.asList(1L, 2L, 3L); List<User> users = session.createQuery(hql, User.class) .setParameterList("ids", ids) .list();

注意这里用的是setParameterList而不是setParameter。这是 HQL 里in最关键的一个点。setParameterList会告诉 Hibernate:这个参数是一个集合,请按集合元素个数展开成对应数量的绑定参数。底层实际生成的 SQL 大致是where id in (?, ?, ?),然后依次绑定。

为什么必须用setParameterList?因为 Hibernate 在解析 HQL 时,对in后面的参数有专门的语法处理。它需要知道这个参数是单值还是多值,才能决定生成几个占位符。用setParameter传集合,Hibernate 会把它当成单个对象处理,类型对不上就抛异常;即便某些版本侥幸没抛,生成的 SQL 也不对。这个设计其实很合理——占位符数量必须在 SQL 预编译阶段确定,而集合大小是运行时才知道的,所以框架必须有一个显式的入口来接收这个信息。

2.2 原生 SQL 里的in:两种思路,各有取舍

原生 SQL 的in写法分两条路。第一条是继续用命名参数加setParameterList,前提是你用的是 Hibernate 的createNativeQuery并且参数用命名方式:

String sql = "select * from t_user where id in (:ids)"; List<Long> ids = Arrays.asList(1L, 2L, 3L); List<User> users = session.createNativeQuery(sql, User.class) .setParameterList("ids", ids) .list();

这条路和 HQL 基本一致,Hibernate 会帮你展开占位符。第二条路是自己拼占位符,适合参数数量动态且你想完全掌控 SQL 形态的场景:

String placeholders = ids.stream().map(i -> "?").collect(Collectors.joining(",")); String sql = "select * from t_user where id in (" + placeholders + ")"; Query query = session.createNativeQuery(sql, User.class); for (int i = 0; i < ids.size(); i++) { query.setParameter(i + 1, ids.get(i)); }

自己拼占位符的好处是可控性强,坏处是要自己处理空集合、参数顺序、类型推断这些琐事。我个人的取舍是:能用setParameterList就用它,只有在需要和复杂原生 SQL 混排、或者框架层封装要求统一占位符风格时才手动拼。手动拼的时候一定要用?占位符而不是直接拼值,这是防注入的底线,后面还会细说。

2.3 Criteria 与 JPA 的in:API 更友好,但坑没少

Criteria API 里in是通过CriteriaBuilder.in()或者直接给Path.in(Collection)来构造的:

CriteriaBuilder cb = session.getCriteriaBuilder(); CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); cq.where(root.get("id").in(ids)); List<User> users = session.createQuery(cq).getResultList();

JPA 的Query接口则和 HQL 类似,用setParameter传集合在多数实现里也能工作,因为 JPA 规范对集合参数有约定。但这里有个容易忽略的点:不同 JPA 实现(Hibernate、EclipseLink 等)对空集合的处理策略不一样,有的抛异常,有的生成in ()这种非法 SQL,有的干脆返回空结果。所以即便 API 看起来统一,空集合的边界还是得自己兜住。

2.4 一张表看清四种写法的差异

写法参数绑定方式空集合行为适用场景
HQLsetParameterList通常报错或生成非法 SQL面向实体的查询
原生 SQL + 命名参数setParameterList同上需要原生 SQL 但想省事
原生 SQL + 手动占位符循环setParameter自己控制,可提前返回复杂 SQL、动态条件
CriteriaPath.in(Collection)实现相关,需兜底类型安全的动态查询

这张表建议存下来,下次写in之前扫一眼,能省不少调试时间。接下来我会把每种写法背后的原理和实操细节展开讲,尤其是那些文档里不写、但实际会咬人的地方。

3. 参数绑定背后的机制,搞懂了就不慌

3.1setParameterList到底做了什么

要理解setParameterList,得先知道 Hibernate 处理查询的两个阶段:解析阶段和绑定阶段。解析阶段,Hibernate 把 HQL 编译成内部的可执行查询结构,这时候它看到in (:ids),会记录“这里有一个多值参数,名字叫 ids”。绑定阶段,你调用setParameterList("ids", list),Hibernate 拿到集合大小 N,就把那个多值参数展开成 N 个位置占位符,然后逐个绑定。

这个机制解释了几个常见现象。第一,为什么集合大小变化会导致执行计划缓存命中率下降——因为占位符数量变了,SQL 文本就变了,数据库那边就是一条新语句。第二,为什么超大集合会出问题——比如传进去一万个 id,Hibernate 要展开一万个占位符,SQL 文本长度爆炸,数据库解析开销剧增,甚至超过参数个数上限。第三,为什么空集合会炸——N 等于 0,展开成零个占位符,in ()不是合法 SQL。

所以用setParameterList的时候,心里要有一根弦:集合大小是有代价的。我一般会在业务层对集合大小做个上限保护,比如超过 1000 个 id 就分批查询再合并,而不是一股脑塞进去。

3.2 为什么不能直接拼字符串进 HQL

有人图省事,把 id 列表拼成"1,2,3"直接字符串拼进 HQL:"from User u where u.id in (" + idStr + ")"。这么写能跑,但有两个致命问题。一是 SQL 注入,只要 idStr 的来源不完全可信,就是敞开的门。二是执行计划无法复用,每次 id 组合不同,HQL 文本就不同,Hibernate 的查询计划缓存和数据库的语句缓存全部失效,高并发下性能会明显劣化。

HQL 的字符串拼接还有个隐蔽的坑:如果 idStr 里混进了非数字内容,HQL 解析器可能在解析阶段就抛异常,错误信息往往指向一个莫名其妙的位置,排查起来很费劲。相比之下,参数绑定把“值”和“结构”彻底分开,结构固定、值可变,既安全又高效。这也是为什么我一直强调:in的参数永远走绑定,不走拼接。

3.3 集合元素类型与占位符类型的匹配

setParameterList展开占位符时,需要推断每个占位符的类型。多数情况下 Hibernate 能从实体映射或者集合元素类型推断出来,但有些场景会失手。比如实体主键是Long,你传了个List<Integer>,某些版本会报类型不匹配;再比如用原生 SQL 查一个没有实体映射的列,类型推断可能落到默认的序列化类型上,导致绑定失败。

我的经验是:集合的泛型类型尽量和实体属性类型严格一致,别依赖自动装箱拆箱的宽容。如果确实需要转换,在传参前显式转好,而不是指望框架兜底。原生 SQL 场景下,如果类型推断有问题,可以在createNativeQuery之后用addScalar明确声明结果列类型,或者在绑定参数时用带类型的重载方法把类型说清楚。

3.4 版本差异:Hibernate 6 的变化点

Hibernate 6 对 HQL 解析器做了较大重构,in的语法支持更贴近 SQL 标准,同时对参数类型的检查更严格。实际表现是:一些在 5.x 里“能跑但不太对”的写法,在 6.x 里会直接报错。比如把字符串当集合传、或者集合元素类型和属性类型不一致,6.x 的报错会更明确,这其实是好事,但升级时要有心理准备。

另外 6.x 对setParameterList的替代方案更丰富,JPA 的setParameter传集合的支持也更完善。如果你的项目还在 5.x,建议保持setParameterList的写法;如果已经上 6.x,可以逐步向标准 JPA 写法靠拢,减少对 Hibernate 私有 API 的依赖。这个迁移不是必须的,但方向上是更可移植的。

4. 完整实操:从零写一个稳的in查询

4.1 场景设定与实体准备

假设有一个用户表,实体叫User,主键id是Long,还有一个status字段。需求是:给定一批用户 id,查出其中状态为启用(status=1)的用户。这个需求简单到不能再简单,但正好把in的各个要点都覆盖到。

实体映射大致是这样(省略 getter/setter):

@Entity @Table(name = "t_user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Integer status; private String name; }

4.2 HQL 版本的完整实现

先写 HQL 版本,这是最常用的:

public List<User> findEnabledByIds(Session session, List<Long> ids) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); } String hql = "from User u where u.id in (:ids) and u.status = :status"; return session.createQuery(hql, User.class) .setParameterList("ids", ids) .setParameter("status", 1) .list(); }

这段代码有几个刻意的设计。第一,入口处对空集合直接返回空列表,不让它流到 Hibernate 层去触发异常。第二,setParameterList和setParameter混用,说明多值参数和单值参数可以共存,绑定顺序无所谓,靠名字匹配。第三,status用参数绑定而不是硬编码进 HQL,保持结构稳定。

这里补一个细节:如果ids里可能有重复元素,in本身不影响结果正确性,但会浪费占位符。如果集合来源不可控,可以在传参前distinct一下,减少无谓的展开。这个优化很小,但在大集合场景下有意义。

4.3 原生 SQL 版本的完整实现

原生 SQL 版本,我推荐命名参数加setParameterList的组合:

public List<User> findEnabledByIdsNative(Session session, List<Long> ids) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); } String sql = "select * from t_user where id in (:ids) and status = :status"; return session.createNativeQuery(sql, User.class) .setParameterList("ids", ids) .setParameter("status", 1) .list(); }

如果因为某些原因必须手动拼占位符,写法要更小心:

public List<User> findEnabledByIdsManual(Session session, List<Long> ids) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); } String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); String sql = "select * from t_user where id in (" + placeholders + ") and status = ?"; Query<User> query = session.createNativeQuery(sql, User.class); int index = 1; for (Long id : ids) { query.setParameter(index++, id); } query.setParameter(index, 1); return query.list(); }

手动拼的时候,参数顺序必须和占位符顺序严格对应,这是最容易出错的地方。我的做法是把所有参数按出现顺序收集到一个列表里,最后统一循环绑定,避免手数下标。另外Collections.nCopies生成占位符比流式拼接更直观,也不容易写错分隔符。

4.4 分批查询的封装

前面提到大集合要分批,这里给一个可复用的封装思路:

public List<User> findEnabledByIdsInBatches(Session session, List<Long> ids, int batchSize) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); } List<User> result = new ArrayList<>(); for (int i = 0; i < ids.size(); i += batchSize) { List<Long> batch = ids.subList(i, Math.min(i + batchSize, ids.size())); result.addAll(findEnabledByIds(session, batch)); } return result; }

batchSize取多少合适?这取决于数据库的参数个数上限和单次查询的合理耗时。常见数据库对单条语句的参数个数有上限,具体数值各产品不同,保守起见取 500 到 1000 之间比较稳妥。分批之后要注意结果去重和排序问题——如果业务要求按传入 id 的顺序返回,分批查询天然打乱了顺序,需要在合并后重新排序,这一点很容易被忽略。

4.5 参数选择的计算过程

为什么batchSize不能拍脑袋定?可以简单算一笔账。假设单条 SQL 文本里每个占位符加逗号大约占 2 个字符,1000 个占位符就是 2000 字符左右的 SQL 文本增量,这对数据库解析来说不算大。但如果取到 10000,文本增量到 20000 字符,解析开销和网络传输都上来了,而且很可能撞上参数个数上限直接报错。所以 500 到 1000 是一个在“减少往返次数”和“控制单语句开销”之间的平衡点。实际取值还要看你的数据库类型和驱动版本,建议在测试环境压一下再定。

5. 那些年踩过的坑与排查实录

5.1 空集合:最常见的翻车点

空集合是in查询的头号杀手。表现五花八门:有的报 SQL 语法错误,有的报参数绑定异常,有的静默返回全表数据(这个最吓人)。根因都一样——in ()不是合法 SQL,而框架在展开占位符时对零元素没有统一处理。

排查思路很直接:在调用查询之前打印集合大小,确认是不是空。解决方式就是在业务层入口做空集合短路,返回空结果。这个判断成本极低,但能挡掉一大类问题。我见过有团队在 DAO 基类里统一做了这个拦截,所有in查询自动受益,这个做法值得借鉴。

注意:空集合短路返回的是空列表,不是 null。返回 null 会让调用方多一层判空,容易引发空指针,统一返回空集合是更稳妥的约定。

5.2 集合里有 null 元素

集合非空,但里面混了 null,同样会出问题。in (?, ?, ?)里某个占位符绑定 null,SQL 语义上id in (1, null, 3)不会匹配 null 行,但某些数据库或驱动会在绑定阶段就报错。更隐蔽的是,如果集合来自上游某个可能产生 null 的转换,问题会延迟到运行时才暴露。

处理方式是在传参前过滤 null:ids.stream().filter(Objects::nonNull).collect(...)。过滤后如果集合变空,回到上一条的空集合短路逻辑。这个过滤要不要做,取决于数据来源的可信度,但只要集合来自外部输入或者多表关联结果,我都建议加上,成本可以忽略。

5.3 类型不匹配的报错定位

类型不匹配的报错信息往往不友好,可能只说“无法转换参数”而不告诉你哪个参数。定位方法是把查询拆开:先只保留in条件跑一次,确认是不是它的问题;再逐步加回其他条件。如果确认是in的类型问题,检查集合泛型和实体属性类型是否一致,特别注意Integer和Long、String和枚举这类常见错配。

还有一种情况是原生 SQL 查询的列没有实体映射,Hibernate 推断不出类型。这时候用addScalar显式声明,或者在绑定参数时用setParameter(name, value, Type)把类型说死。多花一行代码,省下半小时排查。

5.4 常见问题速查表

现象可能原因排查动作解决方式
报 SQL 语法错误,提示in ()集合为空打印集合大小入口短路返回空列表
参数绑定异常,类型转换失败集合元素类型与属性不符检查泛型声明统一类型或显式转换
查询结果比预期少集合含 null 或重复检查集合内容过滤 null、去重
高并发下变慢集合大小波动导致计划缓存失效观察 SQL 文本变化分批、固定批次大小
升级后原写法报错版本对参数检查更严对比版本文档改用标准 JPA 写法

这张表是我自己排查时总结的,基本覆盖了in相关问题的常见形态。遇到新问题先往表里对,对不上再往下深挖。

5.5 一个容易被忽略的性能细节

in查询的集合如果来自另一个查询的结果,比如“先查出某类用户的所有 id,再用这些 id 去查订单”,这种两步走在大数据量下会很慢。更好的做法是用子查询把两步合成一步:where o.userId in (select u.id from User u where ...)。HQL 和原生 SQL 都支持子查询形式的in,这样数据库优化器有机会把两步合并执行,避免把大量 id 在应用和数据库之间来回搬。

当然子查询不是万能的,如果子查询本身很复杂或者结果集很大,合并执行未必更快。判断标准是看执行计划,而不是凭感觉。我的习惯是:id 数量在几百以内,两步走没问题;上千了,优先考虑子查询或者关联查询。

6. 写in查询的几条硬规矩

6.1 参数永远走绑定,绝不拼值

这条没有例外。不管是 HQL、原生 SQL 还是 Criteria,in后面的值一律通过参数绑定传入。字符串拼接值不仅带来注入风险,还破坏执行计划复用,是双重损失。手动拼占位符可以,但拼的只能是?或者命名占位符,不能是具体的值。这个界限要划清楚。

6.2 空集合和 null 元素在入口处理

把防御逻辑放在查询方法的最前面,而不是指望框架或者数据库兜底。空集合返回空列表,null 元素过滤掉,这两步加起来不到五行代码,却能挡掉大部分运行时异常。我现在的习惯是,任何接收集合参数的查询方法,第一段代码就是这两个判断,形成肌肉记忆。

6.3 大集合分批,批次大小有依据

不要等出了问题才想起分批。集合大小超过几百就考虑分批,批次大小根据数据库参数上限和实测性能来定,别拍脑袋。分批之后记得处理结果顺序和去重,这两点前面都提过,但实际项目里还是经常被漏掉。

6.4 优先用子查询替代两步查询

当in的集合来自另一个查询时,先想想能不能用子查询合并。能合并就合并,让数据库去做优化。合并之后如果性能不理想,再退回两步走,并且用分批控制单次数据量。这个决策顺序比反过来更合理,因为子查询的写法通常更简洁,维护成本更低。

6.5 版本升级前先跑一遍in的用例

Hibernate 大版本升级时,in相关的行为变化是重点检查项。升级前把项目里所有in查询的边界用例(空集合、单元素、大集合、含 null)跑一遍,记录行为差异,再决定要不要调整写法。这个准备工作花不了多少时间,但能避免升级后线上出问题再回头排查的被动局面。

7. 我个人在实际操作中的体会

写了这么多年持久层代码,我对in这个看似简单的语法有一个越来越深的感受:它的复杂度不在语法本身,而在边界。正常路径下,setParameterList一调就完事,谁都会写。真正拉开差距的是空集合、null 元素、大集合、类型不匹配这些边角情况,处理好了系统就稳,处理不好就是定时炸弹。

我现在写任何带in的查询,都会条件反射地先问自己三个问题:集合会不会为空?里面会不会有 null?最大可能有多大?这三个问题答完,代码结构基本就定了。这个方法不复杂,但确实帮我省了很多排查时间。如果你也在维护数据访问层,不妨把这个习惯带走,比记住某个具体 API 更有用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进