说实话,每次看到有人在群里问"Hibernate还有没有人用"这种问题,我都觉得挺感慨的。Hibernate从当年SSH时代的一哥,到后来被MyBatis和Spring Data JPA轮番冲击,确实不像以前那么"热搜"了。但你要是去翻翻那些银行、物流、电商的老系统,还有一大堆新项目里用了JPA标准的场景,就会发现它活得比想象中好多了。今天这篇文章不聊Hibernate该不该被淘汰这种口水话题,而是把一个非常核心、但很多新手一知半解的API讲透——Hibernate的Criteria API怎么用。
很多人的困惑是:明明可以用HQL,也可以用原生SQL,EntityManager里直接createQuery不是挺方便的吗?为什么还要一个Criteria API?这个东西写起来啰嗦、可读性差,到底图什么?说实话,我早年也这么想过,直到在真实项目里遇到了动态查询条件拼到怀疑人生、SQL注入隐患、还有一堆需要按条件拼接查询的场景之后,才理解了Criteria API存在的意义。
1. Criteria API的整体设计与核心思路
1.1 什么是Criteria API,它和HQL、SQL的关系
Criteria API本质上是Hibernate提供的一套面向对象的查询API,它不写字符串形式的查询语句,而是用Java对象、方法调用来"拼装"查询条件。Hibernate的查询体系里,大致有三个层次:
- HQL:面向对象的类Hibernate查询语言,写
from User where age > 18这种字符串 - 原生SQL:直接写给数据库看的SQL语句,
select * from t_user where age > 18 - Criteria API:完全用Java API方法调用构建查询,如
criteria.add(Restrictions.gt("age", 18))
HQL和SQL都是字符串,字符串的缺点很致命——拼的时候容易出错,错只能在运行期暴露,而且动态条件(用户可能填这个条件也可能不填)拼起来非常痛苦,还得防注入。Criteria API把所有条件变成了类型安全的方法调用,IDE能自动补全,编译期就能发现很多低级错误,动态条件更是随手加一个if判断就行。
用生活化类比来说,HQL好比你在手机上手写搜索关键词,灵活但容易打错;原生SQL就像你直接跟数据库客服对话,效率最高但容易鸡同鸭讲;Criteria API则是点外卖时选筛选项——辣度、口味、配送距离都是现成的选项,勾选即可,系统自动帮你组织好一切。
1.2 Hibernate 5和6时代的两种形态
这里必须先把版本讲清楚,因为网上很多教程写的API你拿到新项目里根本跑不起来,全是版本差异闹的。
从Hibernate 5.2开始,Hibernate官方把老旧的org.hibernate.Criteria接口标记为废弃状态,转而推荐使用JPA标准里的Criteria API,也就是位于javax.persistence.criteria包下的那一套。Hibernate 6更激进,旧版原生Criteria已经彻底移除,强制要求使用JPA风格。
所以你在网上搜“Hibernate Criteria”,会看到两种完全不同风格的代码:
// 老古董写法(Hibernate 4/5.0时代),现在基本只存在于老项目 Session session = sessionFactory.openSession(); Criteria criteria = session.createCriteria(User.class); criteria.add(Restrictions.eq("status", 1)); criteria.addOrder(Order.desc("createdAt")); List<User> users = criteria.list();// 当前推荐写法(Hibernate 5.2+ / Hibernate 6,JPA标准) CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<User> query = cb.createQuery(User.class); Root<User> root = query.from(User.class); query.select(root) .where(cb.equal(root.get("status"), 1), cb.greaterThan(root.get("age"), 18)) .orderBy(cb.desc(root.get("createdAt"))); List<User> users = entityManager.createQuery(query).getResultList();这篇文章重点讲后者,因为它是标准、是未来,也是新项目里你真正会面对的东西。但老代码我也不能完全丢下,后面会专门用一节讲老项目的兼容与迁坑经验。
2. 核心概念拆解:从CriteriaBuilder到Root
2.1 三个核心对象:CriteriaBuilder、CriteriaQuery、Root
要上手JPA风格的Criteria API,必须先搞清楚三个核心对象的角色分工。很多新手一上来就卡在这一团概念里,其实理清了就非常简单。
- CriteriaBuilder(工厂/构造器):作为查询的"总装车间",它负责创建所有条件组件。通过
entityManager.getCriteriaBuilder()拿它,然后可以创建CriteriaQuery对象,还可以用它构建各种条件、表达式、排序等。 - CriteriaQuery(查询声明/容器):它定义"查什么表、查哪些字段、什么条件、怎么排序、怎么分组"。本身定义的构造是
cb.createQuery(User.class),指结果类型。 - Root(根实体):代表查询的"主表实体",通过
query.from(User.class)获得,之后所有字段的引用都是root.get("字段名")。
来一个三行版本的直观示例:
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class);这三行构成了任何Criteria查询的基础底座。Root<User>表达的就是SQL里from后面的主表,它承载了后续所有条件、排序、选择的字段入口。
2.2 动态条件怎么拼:where、and、or的灵活组合
动态查询可以说是Criteria API最值的用的地方。老的方式拼HQL,你可能写一堆StringBuilder,然后记得加where 1=1,条件一多眼睛都花了。用Criteria就清爽得多。
基础单条件:
cq.where(cb.equal(root.get("status"), 1));多条件默认是AND关系,直接逗号分隔即可:
cq.where( cb.equal(root.get("status"), 1), cb.greaterThan(root.get("age"), 18) );OR条件则要显式用cb.or:
cq.where( cb.or( cb.equal(root.get("status"), 1), cb.equal(root.get("status"), 2) ) );混着用就嵌套,cb.and包cb.or,或者反过来,和SQL里的括号逻辑一致:
cq.where( cb.and( cb.equal(root.get("deleted"), 0), cb.or( cb.like(root.get("name"), "%张%"), cb.like(root.get("email"), "%zhang%") ) ) );最关键的是,这些条件可以全部放进List<Predicate>集合里,循环添加:
List<Predicate> predicates = new ArrayList<>(); if (name != null && !name.isEmpty()) { predicates.add(cb.like(root.get("name"), "%" + name + "%")); } if (minAge != null) { predicates.add(cb.greaterThanOrEqualTo(root.get("age"), minAge)); } if (status != null) { predicates.add(cb.equal(root.get("status"), status)); } cq.where(predicates.toArray(new Predicate[0]));这个模式我愿称之为Criteria API的第一黄金用法。你根本不需要再为"用户到底填没填这个查询条件"而头疼,一个if判断一个add动作,查询条件多到几十个也不怕,代码依然清清楚楚。
2.3 排序、分页、去重这些常规操作的写法
排序用orderBy,可以组合多个排序字段:
cq.orderBy( cb.desc(root.get("createdAt")), cb.asc(root.get("id")) );分页需要交给createQuery之后生成的TypedQuery来处理:
TypedQuery<User> tq = entityManager.createQuery(cq); tq.setFirstResult((pageNo - 1) * pageSize); tq.setMaxResults(pageSize); List<User> users = tq.getResultList();setFirstResult是偏移量,setMaxResults是每页条数。这里有个老生常谈的坑:setMaxResults在Hibernate底层对不同数据库方言的翻译不一样。比如MySQL会翻译成limit ?,Oracle可能是fetch first ? rows only。所以在不同数据库上分页SQL的兼容性,Hibernate其实已经帮你处理了,你只需要关注逻辑值。
去重操作也很简单:
cq.select(root).distinct(true);或者对字段去重:
cq.select(root.get("department")).distinct(true);2.4 常用条件操作符一览表
顺手整理一个对照表,方便你写代码时快速查阅:
| 需求 | Criteria写法 | SQL语义 |
|---|---|---|
| 等于 | cb.equal(root.get("status"), 1) | status = 1 |
| 不等于 | cb.notEqual(root.get("status"), 1) | status <> 1 |
| 大于 | cb.greaterThan(root.get("age"), 18) | age > 18 |
| 大于等于 | cb.greaterThanOrEqualTo(root.get("age"), 18) | age >= 18 |
| 小于 | cb.lessThan(root.get("age"), 60) | age < 60 |
| 区间 | cb.between(root.get("age"), 18, 60) | age between 18 and 60 |
| LIKE | cb.like(root.get("name"), "%张%") | name like '%张%' |
| IN | root.get("status").in(1, 2, 3) | status in (1,2,3) |
| 为空 | cb.isNull(root.get("remark")) | remark is null |
| 非空 | cb.isNotNull(root.get("remark")) | remark is not null |
| 是否存在 | cb.exists(subquery) | exists (...) |
| 同属性比较 | cb.equal(root.get("age"), root.get("realAge")) | age = real_age |
这些操作基本覆盖了日常90%的查询需求。本质上一个都没记的负担都没有,CriteriaBuilder上所有条件方法命名和SQL关键字基本一一对应,用多了自然就记住了。
3. 实操环节:从一个完整的查询场景说起
3.1 场景定义:带条件的分页用户列表
现在我给你一个特别常见的业务需求,咱们把它完整做一遍:一个用户管理后台的用户列表接口,需要支持按姓名模糊搜索、按年龄最小值过滤、按状态过滤,还要按创建时间倒序分页返回。这个需求完美体现了Criteria API的用武之地。
先定义实体(简化版):
@Entity @Table(name = "t_user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private Integer age; private Integer status; // 0停用 1启用 private String email; @Column(name = "created_at") private LocalDateTime createdAt; // getter/setter 省略 }3.2 完整实现代码
直接上代码:
public PageResult<User> queryUserPage(String name, Integer minAge, Integer status, int pageNo, int pageSize) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); // 主查询 CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); List<Predicate> predicates = new ArrayList<>(); if (name != null && !name.isEmpty()) { predicates.add(cb.like(root.get("name"), "%" + name + "%")); } if (minAge != null) { predicates.add(cb.greaterThanOrEqualTo(root.get("age"), minAge)); } if (status != null) { predicates.add(cb.equal(root.get("status"), status)); } cq.where(predicates.toArray(new Predicate[0])); cq.orderBy(cb.desc(root.get("createdAt"))); // 执行分页 TypedQuery<User> typedQuery = entityManager.createQuery(cq); typedQuery.setFirstResult((pageNo - 1) * pageSize); typedQuery.setMaxResults(pageSize); List<User> list = typedQuery.getResultList(); // 统计总数,这个得单独发一条count查询 CriteriaQuery<Long> countQuery = cb.createQuery(Long.class); Root<User> countRoot = countQuery.from(User.class); countQuery.select(cb.count(countRoot)); // 注意:count的条件与主查询完全一致 List<Predicate> countPredicates = new ArrayList<>(); // 这里复用之前的判断逻辑,我一般建议抽一个方法组装查询条件 if (name != null && !name.isEmpty()) { countPredicates.add(cb.like(countRoot.get("name"), "%" + name + "%")); } if (minAge != null) { countPredicates.add(cb.greaterThanOrEqualTo(countRoot.get("age"), minAge)); } if (status != null) { countPredicates.add(cb.equal(countRoot.get("status"), status)); } countQuery.where(countPredicates.toArray(new Predicate[0])); Long total = entityManager.createQuery(countQuery).getSingleResult(); return new PageResult<>(total, list); }3.3 为什么统计总数要单独写一条count查询
细心的人可能发现了:主查询查列表,我额外搞了一个CriteriaQuery<Long>去查count。为什么不用主查询直接统计?
因为列表查询要select出实体字段,要排序要分页;count查询只需要一行count(*)。两者在SQL层面本质就是两条完全不同的SQL,硬揉在一起只会出问题。JPA Criteria API也提供了cb.count配合select来构建聚合查询。
这种"查询条件重复写两遍"的麻烦,很多人会吐槽。我的经验是:把组装条件的逻辑抽成一个方法,或者定义成BiFunction<CriteriaBuilder, Root<User>, List<Predicate>>,列表和count共用。代码会清爽很多。
另外,如果你的查询里有join、group by,count会更加复杂,那种场景建议直接用cb.countDistinct(root.get("id"))来避免join导致的行数膨胀。
4. 进阶能力:从单表查询到关联、聚合与子查询
4.1 join关联查询的正确姿势
单表查询是基本功,但业务里迟早要碰多表关联。Criteria的join写法在早期版本里确实不好用,新版已经友好很多了。
假设有另一个实体Order,和User是一对多关系:
@Entity @Table(name = "t_order") public class Order { @Id private Long id; @Column(name = "order_no") private String orderNo; @ManyToOne @JoinColumn(name = "user_id") private User user; private BigDecimal amount; }查询"下单金额大于100元的用户":
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); Join<User, Order> join = root.join("orders", JoinType.INNER); cq.select(root).distinct(true) .where(cb.greaterThan(join.get("amount"), new BigDecimal("100")));需要注意的几个点:
root.join("orders")里面用的orders是User实体里对应的集合属性名,不是数据库表名。实体里得有类似private List<Order> orders;的属性。JoinType.INNER对应inner join,JoinType.LEFT对应left join。- 一旦join了,列表结果可能会出现重复行,因为一条用户有多条符合条件订单。所以上面我用了
distinct(true)。 - join之后,你可以用
join.get("amount")来引用关联实体的字段,from后面那个root只管主表。
4.2 投影查询:不查整个实体,只取需要的字段
很多时候查列表不需要实体的所有字段,比如只查id和name用于下拉选择。这种场景用实体查询会多加载一堆没用到的字段,浪费内存。Criteria API支持直接查字段投影:
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class); Root<User> root = cq.from(User.class); cq.multiselect(root.get("id"), root.get("name")); cq.where(cb.equal(root.get("status"), 1)); List<Object[]> result = entityManager.createQuery(cq).getResultList(); for (Object[] row : result) { Long id = (Long) row[0]; String name = (String) row[1]; }这种方法拿到的是Object[]数组,你得自己按顺序取,不够直观。更好的做法是在实体里定义DTO构造器,然后用cb.construct构造查询:
CriteriaQuery<UserSimpleDTO> cq = cb.createQuery(UserSimpleDTO.class); Root<User> root = cq.from(User.class); cq.select(cb.construct(UserSimpleDTO.class, root.get("id"), root.get("name"))); List<UserSimpleDTO> list = entityManager.createQuery(cq).getResultList();要求UserSimpleDTO必须有对应的构造器public UserSimpleDTO(Long id, String name),而且最好有@QueryProjection之类的JPA支持(标准JPA没有这个注解,那是QueryDSL的)。实测中cb.construct配合构造器在Hibernate里执行很稳定,性能远好于加载整个实体。
4.3 聚合函数与分组查询
聚合也是常见需求。比如统计每个用户的订单总金额:
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class); Root<User> root = cq.from(User.class); Join<User, Order> join = root.join("orders", JoinType.LEFT); cq.multiselect(root.get("name"), cb.sum(join.get("amount"))) .groupBy(root.get("id"), root.get("name")); List<Object[]> result = entityManager.createQuery(cq).getResultList();还可以叠加having条件,过滤分组后的结果:
cq.multiselect(root.get("name"), cb.sum(join.get("amount"))) .groupBy(root.get("id"), root.get("name")) .having(cb.greaterThan(cb.sum(join.get("amount")), new BigDecimal("1000")));聚合函数有cb.count、cb.sum、cb.avg、cb.max、cb.min,基本可以覆盖报表统计类的需求。注意groupBy后面最好把select中出现的非聚合字段全带上,否则有些数据库严格模式下会报错。
4.4 子查询:exists表达式
子查询在Criteria里用Subquery接口实现。拿"查询所有存在订单的用户"举例:
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); Subquery<Long> sq = cq.subquery(Long.class); Root<Order> orderRoot = sq.from(Order.class); sq.select(orderRoot.get("id")) .where(cb.equal(orderRoot.get("user"), root)); // 关联外部查询的root cq.where(cb.exists(sq));这里有个反直觉的点:sq.from(Order.class)得来的orderRoot,它的条件里可以引用外层root。这个机制叫做"关联子查询"。实际执行时,对于每个用户,数据库都会判断是否存在对应订单。如果你担心性能,这种exists子查询在数据量大时可能不如join,需要结合执行计划去判断。
4.5 DetachedCriteria:跨层传递查询条件的思路
再提一个老Hibernate时代的经典功能:DetachedCriteria。它的作用是,你可以在Service层甚至Controller层组装好查询条件,然后丢给DAO层去执行,而且不用持有Session。
// 老API写法,理解思路即可 DetachedCriteria dc = DetachedCriteria.forClass(User.class); dc.add(Restrictions.eq("status", 1)); ... List<User> users = dc.getExecutableCriteria(session).list();新版JPA风格没有直接对应的DetachedCriteria。常见替代方案是自己封装一个查询参数对象CriteriaQuery<T>无处持有(CriteriaQuery不能脱离EntityManager使用),所以我一般建议用一个包含Predicate集合的查询DTO在层间传递。如果你在JPA环境下硬要模拟DetachedCriteria的感觉,可以写一个Specification<T>风格的自己封装,思路都是把"查询条件"当作可传输的对象。
这个知识点在今天主要是用来读老项目代码的。你要是维护那种用了八九年的老系统,里面十有八九躺着一堆DetachedCriteria。
5. 常见问题与排查技巧实录
5.1 为什么提示类转换异常或无效路径
新手最常见的报错之一是java.lang.IllegalArgumentException: Unable to resolve attribute,通常是root.get("xxx")里的属性名写错了。注意Criteria里用的是实体Java属性名(驼峰命名),不是数据库字段名。比如数据库字段是created_at,实体属性名是createdAt,你写root.get("created_at")就会报错。
另一个常见问题是类型不一致。比如cb.greaterThan(root.get("age"), 18),如果age在实体里是Integer没问题,但如果你是拿字符串参数直接传入,Hibernate会尝试类型转换,转换失败则直接异常。稳妥做法是先把参数转换成目标类型再传:
Integer age = Integer.valueOf(paramAge); predicates.add(cb.greaterThanOrEqualTo(root.get("age"), age));5.2 count查询与列表查询条件不一致导致分页总数错误
这个是最隐蔽的坑,而且业务上一旦出现,页面直接崩。
列表查询你加了一个新条件A,count查询忘了加,结果列表只有2条数据,总数却显示100。用户翻到第二页,页面直接空白。我这边的经验是:条件组装必须是一个方法管理,不要在两个地方各写各的。
如果你用Spring Data JPA,Specification天然把条件封装好了,可以直接复用到count查询。原生Hibernate下就自己抽公共方法,或者至少写单元测试把两种查询的条件一致性跑一遍。
5.3 N+1查询问题与fetch策略
用Criteria查询实体列表时,如果实体有关联集合或关联对象,并且没有显式做join fetch,那么查询主列表后,Hibernate访问每个实体的关联对象时都会再发一条SQL,产生N+1问题。比如查询用户列表,然后遍历每个用户的orders,如果没有预先抓取,就会变成1条查询+N条查询。
Criteria里做fetch的方式如下:
CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); root.fetch("orders", JoinType.LEFT); // 注意fetch和join区别 cq.select(root).distinct(true);fetch会生成一条带left join的SQL,一次性把orders也查出来。注意fetch了集合之后,同样可能出现主结果行数膨胀,需要distinct。
5.4 老项目从旧Criteria迁移到JPA Criteria的注意事项
如果你正在维护老项目,很可能会面临从org.hibernate.Criteria迁移到JPA标准API的问题。步骤大体是这样:
- 把
session.createCriteria(User.class)改为entityManager.getCriteriaBuilder()三件套 criteria.add(Restrictions.xxx(...))改为cq.where(cb.xxx(...))criteria.addOrder(Order.desc("xxx"))改为cq.orderBy(cb.desc(root.get("xxx")))criteria.setFirstResult/criteria.setMaxResults改到TypedQuery上criteria.list()改为entityManager.createQuery(cq).getResultList()- 有
DetachedCriteria的地方,要么改成在Service层组装参数DTO,要么先迁移到普通Criteria再处理
迁移过程最大的拦路虎是那些用了alias、createAlias的复杂查询。新API里对应的是root.join、root.fetch。还有一个老API特有的criteria.setProjection(Projections.rowCount()),新版里面用cb.count(root)加上cq.select(...)。
我迁过一个模块200多个查询方法的老项目,说实话工作量不小,但大部分是机械替换。关键在于你有足够的测试用例兜底,否则迁移之后查询结果对不对完全靠肉眼。
5.5 参数值绑定与SQL日志排查技巧
排查Criteria生成的SQL,很多时候比排查HQL更麻烦,因为SQL是运行时动态构建的。建议排查时开Hibernate的SQL日志:
logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE这样你能看到实际翻译出来的SQL,以及绑定到?上的参数值。如果你发现生成的SQL不是你想要的,别急着骂Hibernate,先核对:字段名、属性名、关联关系映射这三样。
6. 选型观点:现在到底该不该用Criteria API
我在写这套东西时,总有人问同一个问题:网上都说Spring Data JPA只要写方法名就能查询,比Criteria简单太多了,为什么还要学这玩意儿?
我的观点很明确:如果项目里的查询条件都是固定的、场景简单的,直接用Spring Data JPA方法名派生查询确实爽,比如findByNameAndStatus这种。一旦查询条件变成动态的——用户在前端想怎么筛就怎么筛,字段几十个,可选条件任意组合——方法名派生就废了,你需要Specification,而Spring Data JPA的Specification底层就是JPA Criteria API。
换句话讲:Criteria API是Spring Data JPA”高级玩法“的底层能力。你不学它,等于只用了框架的一小半。
另外,从代码可维护性角度看,动态查询用Criteria确实比字符串拼接HQL舒服太多。这也是为什么我无论如何都建议有Hibernate经验的开发者,花点时间把Criteria API这套东西吃透。它看起来啰嗦,但它是Hibernate查询体系里最“编程化”的部分,扩展性最强。
最后分享一个小技巧收尾:如果你还在老项目里维护那种几十行的大长HQL字符串,不妨在有空的时候把其中动态条件复杂的部分改造成Criteria版本,你会立刻体会到什么叫“代码能编译查错就不怕写错”。这不是我赶时髦,而是真真切切在一行行改过之后得到的体会。