
说实话每次看到有人在群里问Hibernate还有没有人用这种问题我都觉得挺感慨的。Hibernate从当年SSH时代的一哥到后来被MyBatis和Spring Data JPA轮番冲击确实不像以前那么热搜了。但你要是去翻翻那些银行、物流、电商的老系统还有一大堆新项目里用了JPA标准的场景就会发现它活得比想象中好多了。今天这篇文章不聊Hibernate该不该被淘汰这种口水话题而是把一个非常核心、但很多新手一知半解的API讲透——Hibernate的Criteria API怎么用。很多人的困惑是明明可以用HQL也可以用原生SQLEntityManager里直接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 18Criteria 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)); ListUser users criteria.list();// 当前推荐写法Hibernate 5.2 / Hibernate 6JPA标准 CriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaQueryUser query cb.createQuery(User.class); RootUser 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))); ListUser users entityManager.createQuery(query).getResultList();这篇文章重点讲后者因为它是标准、是未来也是新项目里你真正会面对的东西。但老代码我也不能完全丢下后面会专门用一节讲老项目的兼容与迁坑经验。2. 核心概念拆解从CriteriaBuilder到Root2.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(); CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class);这三行构成了任何Criteria查询的基础底座。RootUser表达的就是SQL里from后面的主表它承载了后续所有条件、排序、选择的字段入口。2.2 动态条件怎么拼where、and、or的灵活组合动态查询可以说是Criteria API最值的用的地方。老的方式拼HQL你可能写一堆StringBuilder然后记得加where 11条件一多眼睛都花了。用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.orcq.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%) ) ) );最关键的是这些条件可以全部放进ListPredicate集合里循环添加ListPredicate 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来处理TypedQueryUser tq entityManager.createQuery(cq); tq.setFirstResult((pageNo - 1) * pageSize); tq.setMaxResults(pageSize); ListUser 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 60LIKEcb.like(root.get(name), %张%)name like %张%INroot.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 PageResultUser queryUserPage(String name, Integer minAge, Integer status, int pageNo, int pageSize) { CriteriaBuilder cb entityManager.getCriteriaBuilder(); // 主查询 CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class); ListPredicate 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))); // 执行分页 TypedQueryUser typedQuery entityManager.createQuery(cq); typedQuery.setFirstResult((pageNo - 1) * pageSize); typedQuery.setMaxResults(pageSize); ListUser list typedQuery.getResultList(); // 统计总数这个得单独发一条count查询 CriteriaQueryLong countQuery cb.createQuery(Long.class); RootUser countRoot countQuery.from(User.class); countQuery.select(cb.count(countRoot)); // 注意count的条件与主查询完全一致 ListPredicate 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查询细心的人可能发现了主查询查列表我额外搞了一个CriteriaQueryLong去查count。为什么不用主查询直接统计因为列表查询要select出实体字段要排序要分页count查询只需要一行count(*)。两者在SQL层面本质就是两条完全不同的SQL硬揉在一起只会出问题。JPA Criteria API也提供了cb.count配合select来构建聚合查询。这种查询条件重复写两遍的麻烦很多人会吐槽。我的经验是把组装条件的逻辑抽成一个方法或者定义成BiFunctionCriteriaBuilder, RootUser, ListPredicate列表和count共用。代码会清爽很多。另外如果你的查询里有join、group bycount会更加复杂那种场景建议直接用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(); CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class); JoinUser, 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 ListOrder orders;的属性。JoinType.INNER对应inner joinJoinType.LEFT对应left join。一旦join了列表结果可能会出现重复行因为一条用户有多条符合条件订单。所以上面我用了distinct(true)。join之后你可以用join.get(amount)来引用关联实体的字段from后面那个root只管主表。4.2 投影查询不查整个实体只取需要的字段很多时候查列表不需要实体的所有字段比如只查id和name用于下拉选择。这种场景用实体查询会多加载一堆没用到的字段浪费内存。Criteria API支持直接查字段投影CriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaQueryObject[] cq cb.createQuery(Object[].class); RootUser root cq.from(User.class); cq.multiselect(root.get(id), root.get(name)); cq.where(cb.equal(root.get(status), 1)); ListObject[] result entityManager.createQuery(cq).getResultList(); for (Object[] row : result) { Long id (Long) row[0]; String name (String) row[1]; }这种方法拿到的是Object[]数组你得自己按顺序取不够直观。更好的做法是在实体里定义DTO构造器然后用cb.construct构造查询CriteriaQueryUserSimpleDTO cq cb.createQuery(UserSimpleDTO.class); RootUser root cq.from(User.class); cq.select(cb.construct(UserSimpleDTO.class, root.get(id), root.get(name))); ListUserSimpleDTO 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(); CriteriaQueryObject[] cq cb.createQuery(Object[].class); RootUser root cq.from(User.class); JoinUser, Order join root.join(orders, JoinType.LEFT); cq.multiselect(root.get(name), cb.sum(join.get(amount))) .groupBy(root.get(id), root.get(name)); ListObject[] 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(); CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class); SubqueryLong sq cq.subquery(Long.class); RootOrder 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)); ... ListUser users dc.getExecutableCriteria(session).list();新版JPA风格没有直接对应的DetachedCriteria。常见替代方案是自己封装一个查询参数对象CriteriaQueryT无处持有CriteriaQuery不能脱离EntityManager使用所以我一般建议用一个包含Predicate集合的查询DTO在层间传递。如果你在JPA环境下硬要模拟DetachedCriteria的感觉可以写一个SpecificationT风格的自己封装思路都是把查询条件当作可传输的对象。这个知识点在今天主要是用来读老项目代码的。你要是维护那种用了八九年的老系统里面十有八九躺着一堆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查询与列表查询条件不一致导致分页总数错误这个是最隐蔽的坑而且业务上一旦出现页面直接崩。列表查询你加了一个新条件Acount查询忘了加结果列表只有2条数据总数却显示100。用户翻到第二页页面直接空白。我这边的经验是条件组装必须是一个方法管理不要在两个地方各写各的。如果你用Spring Data JPASpecification天然把条件封装好了可以直接复用到count查询。原生Hibernate下就自己抽公共方法或者至少写单元测试把两种查询的条件一致性跑一遍。5.3 N1查询问题与fetch策略用Criteria查询实体列表时如果实体有关联集合或关联对象并且没有显式做join fetch那么查询主列表后Hibernate访问每个实体的关联对象时都会再发一条SQL产生N1问题。比如查询用户列表然后遍历每个用户的orders如果没有预先抓取就会变成1条查询N条查询。Criteria里做fetch的方式如下CriteriaQueryUser cq cb.createQuery(User.class); RootUser 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.SQLDEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinderTRACE这样你能看到实际翻译出来的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版本你会立刻体会到什么叫“代码能编译查错就不怕写错”。这不是我赶时髦而是真真切切在一行行改过之后得到的体会。