ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Hibernate Criteria查询从入门到实战:动态条件、关联与性能优化

Hibernate Criteria查询从入门到实战:动态条件、关联与性能优化 1. 先弄清楚Criteria查询到底是个什么东西1.1 为什么会有Criteria这种写法先说个真实的场景。我刚接触Hibernate那阵子写查询基本靠HQL字符串。单个查询还好一旦牵扯到动态条件麻烦就来了。比如一个用户列表页面有姓名模糊查询、状态筛选、时间范围这些条件用户可能填也可能不填。用HQL拼字符串的话粗看是这样String hql from User u where 11 ; if (StringUtils.hasText(name)) { hql and u.name like :name ; } if (status ! null) { hql and u.status :status ; } QueryUser query session.createQuery(hql, User.class);这种写法用久了非常难受主要有几个痛点字符串拼接容易出错。少个空格、多个逗号运行期才炸。类型不安全。参数名写错、类型传错编译期根本发现不了。不好维护。条件多起来整个方法全是if拼字符串没法看。Criteria查询就是为解决这类问题而生的。它不是用字符串写条件而是用Java对象和方法调用把查询条件一步步“搭”出来。从设计思路上说它是“类型安全”的因为每一步调用都是在写代码编译器能帮你兜住最常见的手误。1.2 原生Criteria和JPA Criteria API是不是一回事这里必须先做一个区分因为好多人在这上面绕晕了。早期Hibernate自己有一套原生Criteria接口在Hibernate 3、4时代用得特别多。典型写法像这样Criteria criteria session.createCriteria(User.class); criteria.add(Restrictions.eq(status, 1)); criteria.add(Restrictions.like(name, 张%)); ListUser list criteria.list();链式调用简单粗暴。但它的参数是字符串比如name、status虽然比HQL好一点本质上还是“弱类型”。而且这套接口从Hibernate 5开始就被标记为deprecated到了Hibernate 6直接干掉了。原因后面细说。后来JPA规范自己定义了一套标准化的Criteria API包名是javax.persistence.criteria现在新版是jakarta.persistence.criteria。Hibernate作为JPA的实现自然也要支持这套标准接口。我们现在谈论“Hibernate的Criteria查询”严格来说更多是指Hibernate对JPA Criteria API的实现配合Hibernate自己的Session或JPA的EntityManager使用。所以这个问题的答案不能只讲老的Hibernate Criteria。你得知道历史演进也得会现代写法。这篇博文两边都会讲但重心放在现代JPA Criteria API上。1.3 一个最小Demo先建立直观印象我用一个最常见的“按条件查用户列表”来演示现代写法// 需要注入EntityManager或者兼容的Session CriteriaBuilder cb em.getCriteriaBuilder(); CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class); // 动态拼接条件 ListPredicate predicates new ArrayList(); if (StringUtils.hasText(name)) { predicates.add(cb.like(root.get(name), % name %)); } if (status ! null) { predicates.add(cb.equal(root.get(status), status)); } cq.where(predicates.toArray(new Predicate[0])); // 执行查询 ListUser users em.createQuery(cq).getResultList();一眼看过去和HQL/JPQL完全不同的风格。CriteriaBuilder是“工厂”负责生成各种条件等于、大于、like、聚合、排序等CriteriaQuery是“草稿纸”定义查询的返回类型、查询根、过滤条件、排序Root是查询的起点表示从哪张表开始查。把上面的代码和那段字符串拼接的HQL对比一下最大的区别是你不再拼字符串而是在拼对象和方法调用。后面我会专门写封装套路处理真实项目里那种十几个筛选条件的查询。先记着这个最小模型就够用了。2. 原生Hibernate Criteria那些“古典”但依然常见的写法2.1 核心接口与方法速览老项目里如果用的是Hibernate 4或者更早版本原生Criteria还是很常见的。我前几年接手过一个库存系统代码里全是这种写法Criteria criteria session.createCriteria(Product.class); criteria.add(Restrictions.ge(price, 100)); criteria.add(Restrictions.le(price, 500)); criteria.addOrder(Order.asc(createTime)); criteria.setMaxResults(20); ListProduct products criteria.list();这里几个核心类值得记一下类/接口作用Criteria查询对象对应一次查询Restrictions生成各种条件比如eq、ne、like、between、in、isNullProjections生成聚合、分组比如count、sum、avg、groupByOrder排序条件支持asc、descCriterion单个条件的接口Restrictions返回的就是它要表达逻辑与、或可以用Restrictions.and()、Restrictions.or()也可以在一个Criteria上连续多次add()效果是默认的与关系。这套写法的痛点我们已经说了弱类型。你可以把Restrictions.eq(name, 张三)里的name写错成nmae编译期完美通过运行期才报错。而且它的条件组合能力不算强复杂嵌套很容易写成一团。2.2 动态条件扩展的原始形态原生Criteria当年最让人舒服的就是动态条件扩展因为add()天然支持不断追加。Criteria criteria session.createCriteria(Order.class); if (customerId ! null) { criteria.add(Restrictions.eq(customerId, customerId)); } if (orderStatus ! null) { criteria.add(Restrictions.eq(status, orderStatus)); } if (minAmount ! null) { criteria.add(Restrictions.ge(amount, minAmount)); } criteria.addOrder(Order.desc(createTime));在只有字符串拼接的年代这种写法是“救星”。但也仅此而已——它没有解决字段名的类型安全问题。2.3 聚合统计Projections的用法原生Criteria里做分组统计也很直观Criteria criteria session.createCriteria(SaleRecord.class); ProjectionList projList Projections.projectionList(); projList.add(Projections.groupProperty(productId)); projList.add(Projections.sum(amount)); projList.add(Projections.count(id)); criteria.setProjection(projList); criteria.add(Restrictions.ge(saleTime, startTime)); ListObject[] results criteria.list();返回的是一个Object[]列表每个数组对应一行的分组统计结果。用起来灵活归灵活但类型安全依然无从谈起。这也是它最终被弃用的根本原因——JPA官方标准推出了更现代的替代方案Hibernate没必要维护两套功能重复的接口。顺便提醒一句如果工作里遇到老代码用了原生Criteria别急着全部重写。第一步可以先了解业务逻辑再逐步迁移到JPA Criteria API。没有严重性能问题或维护障碍的情况下平稳过渡更重要。新版Hibernate5.2以上虽然还能用session.createCriteria()但会有一行deprecation警告我建议新代码一律不要再用。3. 真正的主力JPA Criteria API的完整拆解3.1 为什么要从HQL跳过来现在的主流情况是JPA已经把Criteria API标准化了Hibernate只是其中一个实现。Spring Data JPA底层用的是Hibernate你在Repository方法里写的Query是JPQL但你也可以用Specification接口去构建Criteria查询。这套API的好处我直接说点实际的编译期检查。root.get(name)里的name如果拼错运行期依然会报错……对这里还是字符串。但真正严谨的写法会配合StaticMetamodel生成元模型类用User_.name来引用属性这样连拼错字段名都在编译期拦截。动态查询能力强。Predicate本质是一个对象你可以把它塞进集合最后统一where条件数量完全动态控制。标准化。代码不依赖Hibernate私有接口换个JPA实现比如EclipseLink也能跑。组合关系清晰。and、or、not、in、exists这些逻辑操作都有对应方法嵌套条件也写得出来。3.2 三个核心角色Builder、Query、Root现代Criteria API有三大件理解这三个角色就能玩转大半CriteriaBuilder这是工厂负责造一切。获取方式CriteriaBuilder cb entityManager.getCriteriaBuilder();它能生成Predicate条件、各种聚合函数、Order排序、Path路径表达式、子查询等。几乎所有查询构造的第一步都是它。CriteriaQuery这是查询的结构定义有点像“施工图纸”。它通过select、from、where、orderBy、groupBy等方法一步步描述查询长什么样。CriteriaQueryUser query cb.createQuery(User.class); RootUser user query.from(User.class); query.select(user) .where(cb.equal(user.get(status), 1)) .orderBy(cb.desc(user.get(createTime)));Root这是查询的“根”表示从哪张主表查。你可以通过root.get(字段名)拿到字段路径继续往下还能关联出关联对象的属性。比如root.get(department).get(name)这相当于SQL里join之后访问另一张表的字段前提是User和Department之间有关联关系ManyToOne等。三个角色各司其职理解了它们的边界后面写复杂查询就不会乱。3.3 手写一个可复用的动态多条件查询理论讲完直接上一个实战封装。这个套路我在不同项目里用过多遍堪称万能模板。场景用户管理列表支持按姓名模糊搜、按状态过滤、按注册时间区间过滤、按部门过滤分页返回。public PageUser searchUsers(UserSearchCriteria criteria, Pageable pageable) { CriteriaBuilder cb entityManager.getCriteriaBuilder(); // count查询和列表查询分开构造性能更稳 CriteriaQueryLong countQuery cb.createQuery(Long.class); RootUser countRoot countQuery.from(User.class); ListPredicate predicates buildPredicates(cb, countRoot, criteria); countQuery.select(cb.count(countRoot)); if (!predicates.isEmpty()) { countQuery.where(predicates.toArray(new Predicate[0])); } Long total entityManager.createQuery(countQuery).getSingleResult(); // 列表查询 CriteriaQueryUser listQuery cb.createQuery(User.class); RootUser root listQuery.from(User.class); ListPredicate listPredicates buildPredicates(cb, root, criteria); if (!listPredicates.isEmpty()) { listQuery.where(listPredicates.toArray(new Predicate[0])); } listQuery.orderBy(cb.desc(root.get(createTime))); // 分页 TypedQueryUser typedQuery entityManager.createQuery(listQuery); typedQuery.setFirstResult((int) pageable.getOffset()); typedQuery.setMaxResults(pageable.getPageSize()); ListUser content typedQuery.getResultList(); return new PageImpl(content, pageable, total); } private ListPredicate buildPredicates(CriteriaBuilder cb, RootUser root, UserSearchCriteria criteria) { ListPredicate predicates new ArrayList(); if (StringUtils.hasText(criteria.getName())) { predicates.add(cb.like(root.get(name), % criteria.getName() %)); } if (criteria.getStatus() ! null) { predicates.add(cb.equal(root.get(status), criteria.getStatus())); } if (criteria.getStartTime() ! null) { predicates.add(cb.greaterThanOrEqualTo(root.get(createTime), criteria.getStartTime())); } if (criteria.getEndTime() ! null) { predicates.add(cb.lessThanOrEqualTo(root.get(createTime), criteria.getEndTime())); } if (criteria.getDepartmentId() ! null) { predicates.add(cb.equal(root.get(department).get(id), criteria.getDepartmentId())); } return predicates; }为什么要把条件构建抽出一个单独方法因为count查询和列表查询的条件必须完全一致如果不抽出公共方法后面改条件时漏改一处的概率极高会导致分页总数不对。这个坑我踩过所以强烈建议用公共方法。有人说我直接用Spring Data JPA的Specification不就行了其实Specification底层就是这个东西它就是JPA Criteria API的一层封装。自己手写一遍能帮你彻底理解Specification内部发生了什么。4. 进阶玩法关联、聚合、子查询与性能4.1 多表联合查询怎么写Criteria里做关联查询通常用join。比如查“张姓用户下的所有订单并过滤订单金额大于100的”CriteriaBuilder cb em.getCriteriaBuilder(); CriteriaQueryOrder cq cb.createQuery(Order.class); RootOrder order cq.from(Order.class); JoinOrder, User user order.join(user); ListPredicate predicates new ArrayList(); predicates.add(cb.like(user.get(name), 张%)); predicates.add(cb.greaterThan(order.get(amount), 100)); cq.where(predicates.toArray(new Predicate[0])); ListOrder orders em.createQuery(cq).getResultList();order.join(user)相当于SQL里的inner join。如果想左连接用join(user, JoinType.LEFT)。这里有个很大的坑如果你只查Order实体本身join之后Hibernate默认会返回所有匹配的订单这在SQL层面是对的。但如果你用root.get(user).get(name)来构造查询条件并不会自动把User里的字段给你加载进结果里的。也就是说你在查询条件里用了user.name但在遍历订单时访问order.getUser().getName()仍然可能触发懒加载。这是新手最容易犯的错后面排查部分细讲。4.2 分组与聚合一次搞定统计报表报表场景用Criteria写统计也很方便。比如按商品分类统计销量总额CriteriaBuilder cb em.getCriteriaBuilder(); CriteriaQueryTuple cq cb.createTupleQuery(); RootSaleRecord root cq.from(SaleRecord.class); cq.multiselect( root.get(category), cb.count(root.get(id)), cb.sum(root.get(amount)) ); cq.groupBy(root.get(category)); cq.having(cb.greaterThan(cb.sum(root.get(amount)), 10000)); ListTuple result em.createQuery(cq).getResultList(); for (Tuple tuple : result) { String category tuple.get(0, String.class); Long count tuple.get(1, Long.class); BigDecimal totalAmount tuple.get(2, BigDecimal.class); }Tuple是Criteria API提供的“行”类型比传统的Object[]更安全取数时指定类型即可。multiselect可以在一行里选多个列或聚合函数。groupBy对应SQL的GROUP BYhaving对应HAVING。注意一点聚合查询返回的列类型要和你实体的字段类型严格对应比如count返回Longsum返回的类型取决于你实体里金额字段的类型可能强转成Long/BigDecimal用tuple.get(index, 类型)时要写对否则运行期会有类型转换异常。4.3 子查询嵌套条件的优雅表达有些查询比较麻烦比如“找出最近30天有下单记录的所有用户”。用JPQL写子查询不难Criteria里也能写CriteriaBuilder cb em.getCriteriaBuilder(); CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class); SubqueryLong subquery cq.subquery(Long.class); RootOrder subRoot subquery.from(Order.class); subquery.select(subRoot.get(user).get(id)); subquery.where( cb.greaterThan(subRoot.get(createTime), thirtyDaysAgo) ); cq.where(root.get(id).in(subquery)); ListUser users em.createQuery(cq).getResultList();子查询的核心在于Subquery对象。你先定义子查询返回什么、条件是什么最后在外层查询用.in(subquery)或者其他比较运算符把它接进来。这里我想强调一个优化意识子查询在数据库里可能被优化成join也可能不会最终执行计划要看具体数据库和统计信息。如果数据量很大建议对比一下JPQL和原生SQL的执行计划。Criteria写起来方便但它不是银弹复杂查询该用原生SQL还是要用原生SQL。这里给个简单原则能通过join清晰表达就用join需要“存在性判断”或者“NOT IN”时子查询往往更直观。4.4 fetch join避免N1的关键列表查询最常见的性能灾难就是N1问题。用Criteria取订单列表遍历时访问order.getUser().getName()每访问一个订单就会发一条SQL查用户这种性能悲剧生产环境很容易出现。解决方案是在查询时主动抓取关联对象CriteriaBuilder cb em.getCriteriaBuilder(); CriteriaQueryOrder cq cb.createQuery(Order.class); RootOrder root cq.from(Order.class); root.fetch(user, JoinType.INNER); cq.select(root).distinct(true); ListOrder orders em.createQuery(cq).getResultList();fetch的含义就是让Hibernate一次性把关联对象查出来填充到内存。注意这里有几件事fetch之后select(root)可能会因为一对多关联产生重复行所以要加distinct(true)。fetch不能和setMaxResults在同一个查询里对同一关联使用。如果你同时用root.fetch(orders)一对多和分页内存中分页和SQL分页会出现不一致这是Hibernate的一个老坑。对已经懒加载的关联fetch是最直接的修复手段。但如果你用Open Session in View模式懒加载也算“临时能用”不过它会把数据库连接占用时间拉长在线程池紧张的系统里是个隐患。5. 常见问题与排查技巧实录5.1 类型转换异常Object[]还是Tuple很多人从原生Criteria切到JPA Criteria后最常遇到的是类型问题。原生Criteria里criteria.list()返回List元素是实体对象加了setProjection后返回的是ListObject[]。JPA这边如果你用createQuery(cq)而cq是CriteriaQueryTuple的那必须用Tuple接如果是CriteriaQueryObject[]才能强转成Object[]。我见过一个有意思的报错ClassCastException: [Ljava.lang.Object; cannot be cast to com.example.Order原因就是用了CriteriaQueryObject去接收聚合查询结果或者select选的是多列但泛型声明成了实体。排查思路很简单先看CriteriaQuery的泛型再看multiselect/select的是单实体还是多个列两者必须匹配。5.2 LazyInitializationException炒冷饭的高频问题懒加载异常几乎是Hibernate的“国民级问题”Criteria查询里也不例外。异常信息一般是org.hibernate.LazyInitializationException: could not initialize proxy - no Session原因很直接你在事务外或者Session关闭后去访问一个没被加载的关联属性。Criteria查询默认不主动加载关联所以关联属性是懒代理Session一关再访问就炸了。排查和解决思路按优先级排列用fetch主动加载最简单直接。改用DTO投影只查你需要的那几个字段避免加载无用的关联。注意投影到DTO时cb.construct(Dto.class, root.get(id), user.get(name))这种做法要求DTO有对应构造器。重新设计事务边界。如果业务逻辑里确实需要先查出来、再在后续逻辑中访问关联那就在同一个事务内完成。不要想着靠全局懒加载配置解决。把hibernate.default_batch_fetch_size调大也算是一种缓解方案但根本做法还是明确数据获取边界。5.3 分页与count查询的性能隐患动态查询最常见的性能问题就是count比列表查询还慢。为什么因为count查询通常把所有的join都加载了。比如你的列表查询为了过滤条件join了department又为了结果展示fetch了roles。在count查询你其实只需要统计主表数据总量不需要这些join。但如果代码是随手复制的count查询里可能带着无谓的fetch甚至多个join。判断标准很简单count查询中不要出现任何fetcht也不要出现为了展示而join的关联只要保留为了过滤条件而必须join的关联即可。我在工程里一般会把buildPredicates拆成两部分过滤用关联和展示用关联。过滤条件需要的join放进count和list两边展示用fetch只放list查询里。这样count查询的SQL会简洁很多性能差距在大表上非常明显。5.4 关于那个热搜问题Hibernate还有人用吗既然热词里有“Hibernate还有人用吗”我就说说自己的一线观察。答案是不仅有人用而且依然很主流。不信你可以数数手里的Spring Boot项目spring-boot-starter-data-jpa默认用的就是Hibernate。只是在很多新项目里大家习惯了通过Spring Data JPA的Repository接口操作EntityManager和Criteria API藏在了框架后面感觉上“没怎么用Hibernate”。另一个观察年轻团队直接使用Hibernate原生API的确实少了但Hibernate作为ORM引擎的地位并没有被撼动。大家不用HQL不代表Hibernate不重要就像你用Spring Boot框架但不直接写Spring的ApplicationContext代码你不能说Spring没人在用。至于Criteria查询用得少不少我的判断是复杂查询用得少动态查询依然离不开。只要你的项目存在“条件不固定、字段不固定”的查询场景Criteria API就依然是JPA体系里最正统的解法。相比拼接JPQL字符串它更安全、更可读。相比原生SQL它更贴合实体模型移植性更好。6. 写到这里我的一些真心建议6.1 Criteria和QueryDSL怎么选很多新项目会用QueryDSL它的体验确实比原生Criteria API更舒服缺点是引入代码生成多一个构建步骤学习曲线也有点陡。我的选型建议是这样的团队对JPA理解一般查询也不复杂直接Spring Data JPA的Query注解写JPQL就够了。查询场景复杂、条件动态多变团队能接受代码生成选QueryDSL开发体验确实流畅。不想引入额外框架想用纯粹标准那JPA Criteria API是你唯一且可靠的选择。它啰嗦一点但稳定、标准、跨实现。6.2 给新手的“最小学习路线”如果你从今天才开始接触Criteria我建议按这个顺序去练用CriteriaBuilderRoot写一个select * from user where status?把条件改成动态的Predicate塞进集合再where加关联查询join加过滤条件加聚合groupBy加having加子查询最后封装成通用查询工具每练一步最好把自己写的查询打印出SQL日志看看。你会在日志里看到Hibernate自动生成的SQL长什么样。把Java代码和SQL在脑子里对应起来比背任何接口文档都有用。6.3 一句经验总结我在实际项目中踩过最多的坑不是Criteria写不出来而是代码能跑SQL性能崩。Criteria让你把查询写得很“Java”但它生成的SQL质量完全取决于你怎么搭结构。所以每次写完复杂查询我都会先开show_sql看一眼生成的SQL再想想有没有多余的join、有没有重复查询。这个习惯帮我避免了至少三次线上性能事故。最后再分享一个细节点新版Hibernate 6里旧的原生Criteria已经被彻底移除了网上很多老博客里的代码在新版本里直接编译不过。如果你看到有人还在用session.createCriteria大概率是Hibernate 5之前的老项目。新项目、新依赖一律学JPA Criteria API这条路未来很长时间不会变。
返回列表