ARTICLE DETAIL

资讯详情

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

Hibernate聚合函数详解:从HQL到Criteria API的实战指南

Hibernate聚合函数详解:从HQL到Criteria API的实战指南 这期接着聊Hibernate系列的聚合函数。老规矩先回答那个很多人私信问我的事儿Hibernate现在还有人用吗我这么说吧我手上还有两个老项目跑着Hibernate 5Spring Boot 3.x的新项目虽然默认JPA但底层还是Hibernate。聚合函数这玩意儿不管是Hibernate还是MyBatis只要你跟数据库打交道就绕不开。本文不扯那些花里胡哨的直接围绕Hibernate聚合函数从底层原理到实操代码再到踩坑记录一次讲透。1. 聚合函数的本质Hibernate在底层帮你做了什么先搞清楚一个概念聚合函数不是Hibernate发明的它是SQL标准里的东西。Hibernate做的是把Java方法调用翻译成SQL语法然后再把数据库返回的结果集映射回Java对象。这个翻译和映射的过程才是Hibernate聚合函数的精髓。拿最经典的count举例。如果你直接用JDBC你得这么写String sql select count(*) from user where age ?; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, 18); ResultSet rs ps.executeQuery(); if (rs.next()) { int count rs.getInt(1); }而用Hibernate的HQLQueryLong query session.createQuery(select count(u) from User u where u.age :age, Long.class); query.setParameter(age, 18); Long count query.getSingleResult();表面上看Hibernate帮你省了写JDBC模板代码的功夫。但底层真实发生的事情是Hibernate会先把from User u转换成数据库对应的表名把u.age转换成表的列名然后拼出一条完整的SQL发到数据库。这个转换过程由org.hibernate.hql.internal.ast.QueryTranslatorImpl这个类来完成。我特意去翻过Hibernate 5.x的源码HQL解析器会把语句拆成语法树聚合函数在语法树里对应的是AggregateNode节点。关键点来了Hibernate在翻译聚合函数的时候会做类型推断。比如count(u)返回的是Longsum(u.age)返回的可能是Long或BigDecimalavg(u.age)返回的是Double。这个推断逻辑在org.hibernate.type.LongType、BigDecimalType这些类型映射器里体现。翻译完之后Hibernate还会把查询语句丢到QueryPlanCache里做缓存下次执行同样的HQL就不用再重复解析和生成SQL直接执行已经准备好的SQL模板就行。这也是为什么你在循环里反复执行同一个聚合查询第二次开始性能会有明显提升的原因。理解了这一层你就能明白Hibernate聚合函数本质上就是SQL聚合函数的ORM化封装。它没有改变聚合的语义只是改变了调用方式。所以那些纠结用了Hibernate是不是就不用学SQL聚合了的同学趁早打消这个念头。HQL聚合函数跟SQL聚合函数几乎一一对应SQL玩得转HQL就是顺手的事。2. 常见的Hibernate聚合函数逐个拆解Hibernate支持的聚合函数日常项目里用得最多的无外乎这五个count、sum、avg、max、min。接下来逐个掰开揉碎讲每个都带上实现细节和需要注意的坑点。2.1 count统计数量的细节你没注意过count的用法有两种一种是count(u)一种是count(u.id)还有一种SQL里常写的count(*)。在HQL里count(*)也支持但实际开发里我更推荐count(u)或者count(u.id)。为什么因为Hibernate的count(*)翻译成SQL后确实没问题但当你在select count(*) from User u这种写法里返回类型默认是Long。而count(u.id)这种写法Hibernate会自动把id列的not null约束纳入考量——虽然产物SQL基本一样但对新手来说count(u)的可读性和语义清晰度更高一看就知道统计的是User实体。还有一个容易踩的坑count返回的类型是Long。如果你用Integer去接Hibernate会在运行时抛出TypeMismatchException。我早年写代码的时候就在这翻过车当时是这么写的Query query session.createQuery(select count(u) from User u); Integer count (Integer) query.uniqueResult(); // 这里就炸了正确做法是用Long接收Long count (Long) session.createQuery(select count(u) from User u).uniqueResult();另外count(distinct u.name)这种去重统计HQL里同样支持。它对应的SQL是select count(distinct name) from user。这个在统计去重用户数、去重订单数的时候非常常用。但注意一点distinct后面跟的字段越少越好字段多了SQL执行计划会变得复杂尤其在MySQL上去重统计本来就是个资源大户。2.2 sum返回类型是个大坑sum是用来求和的但它的返回值类型有讲究。如果对整数类型的字段求和Hibernate返回的是Long如果对小数类型的字段求和返回的是BigDecimal配合Double的组合逻辑。其实更准确地说Hibernate会根据实体里字段映射的Java类型来决定求和返回类型。这里我掏一个活生生的案例。业务需求是统计订单表里所有订单金额的总和订单金额在实体里是BigDecimal类型BigDecimal totalAmount (BigDecimal) session.createQuery(select sum(o.amount) from Order o).uniqueResult();如果你实体里金额字段用的是Double类型那么这里的返回值就是Double。最忌讳的做法是用String去拼接SQL里求和结果比如直接String.valueOf(sumResult)先转成字符串再去前端做格式化这既绕弯又容易引入精度问题。再提一个sum的坑null值问题。如果一张表是空的或者sum字段在符合条件的记录里全是nullSQL标准里sum函数会返回nullJava里接收就是null而不是0。很多人没做判空就直接参与业务计算结果NullPointerException教你做人。我的习惯是查询完先判空或者用coalesce函数兜底Long total (Long) session.createQuery(select coalesce(sum(u.age), 0) from User u).uniqueResult();coalesce是SQL标准函数Hibernate会原样透传给数据库。MySQL、PostgreSQL、Oracle都支持放心用。2.3 avg精度丢失问题要重视avg求平均值Hibernate映射的返回类型是Double。这里有个常见的理解偏差avg返回的是浮点数不要指望它返回BigDecimal。如果你的业务要求精确到小数点后两位比如平均分、平均价格正确的做法是拿到Double之后自行四舍五入Double avgScore (Double) session.createQuery(select avg(s.score) from Student s).uniqueResult(); BigDecimal result BigDecimal.valueOf(avgScore).setScale(2, RoundingMode.HALF_UP);这个自行处理精度的动作极其重要。MySQL的avg函数默认返回的字段类型是double本身就存在精度问题。如果你直接在数据库里round之后再给应用层会好一些但从经验来看我更推荐拿原始值到应用层做计算。因为一旦涉及国际化、多种数据库的切换round的位数在不同数据库里未必一致。比如SQL Server的round默认是4舍5入而PostgreSQL的round(double precision)返回的还是double precision细节一堆。Hibernate帮你屏蔽了数据库方言差异但你自己的精度处理逻辑得放到应用层来这是通用性最好的方案。2.4 max与min往往是最简单的但也别掉以轻心max和min的用法相对简单一个求最大值一个求最小值。返回值类型和字段本身的Java类型保持一致。比如Integer id字段的最大值返回IntegerDate字段的最小值返回Date。代码示例如下Date latestLoginTime (Date) session.createQuery(select max(u.lastLoginTime) from User u).uniqueResult(); Integer minAge (Integer) session.createQuery(select min(u.age) from User u).uniqueResult();用max和min的时候要留一个心眼聚合查询和普通查询在Hibernate的缓存策略上有区别。聚合查询的结果默认不会被放进二级缓存。这是很多老手都会忽略的细节。你可以自己验证用session第一次查max(u.lastLoginTime)然后再查一次看SQL日志里是不是执行了两次查询。答案是两次。所以如果你有个高频次的最大登录时间查询就不要指望Hibernate的缓存帮你扛要么自己加Cacheable注解对聚合查询并不生效要么在业务层做手动缓存。聚合查询走的是org.hibernate.cfg.Environment.QUERY_STATISTICS这类配置跟实体的缓存策略是两条路互不干扰。3. 聚合函数组合实战分组、条件过滤与多返回值的正确打开方式聚合函数单独用是入门组合起来才算真正实用。大部分业务场景里你不会只查一个全部数据的count或者sum而是要带过滤条件、按维度分组、甚至一次返回多个聚合结果。Hibernate在这里提供了三种常见写法我逐一展开。3.1 带条件的聚合查询where 与 having 的区别where过滤是基础的比如统计VIP用户的数量QueryLong query session.createQuery(select count(u) from User u where u.vipLevel :level, Long.class);关键是having。很多人分不清where和having在HQL里的边界。我的理解是where是聚合之前行的过滤having是聚合之后组的过滤。举个例子你统计每个部门的平均薪资只看平均薪资大于10000的部门QueryObject[] query session.createQuery(select u.department, avg(u.salary) from User u group by u.department having avg(u.salary) :minSalary); query.setParameter(minSalary, 10000.0); ListObject[] results query.list(); for (Object[] row : results) { String department (String) row[0]; Double avgSalary (Double) row[1]; }这里有三个细节值得掰扯掰扯。细节一聚合查询的返回结果是Object[]数组。当你select了多个字段比如部门名称和平均薪资Hibernate不会自动给你映射成某个实体而是返回一个Object[]。数组里每一项的位置跟你select里字段的顺序保持一致。项目里太常见的情况是大家忘了这个顺序结果取值的时候取错了。细节二group by里出现的字段必须出现在select里。这是SQL标准的要求Hibernate不会帮你特殊处理。上面的例子group by u.department那么select里必须有u.department。这个规定在MySQL的老版本里可以偷懒不写但换成PostgreSQL或者Oracle直接报错。Hibernate替你把这些底层的方言差异屏蔽了一部分但标准语义该守还得守。细节三having子句里可以使用聚合函数别名吗答案是部分方言支持部分不支持。早期Hibernate版本里having avg(u.salary) :minSalary这种写法最保险。如果你非要用别名比如select u.department, avg(u.salary) as avgSalary from User u group by u.department having avgSalary 10000在MySQL上Hibernate的5.x版本支持但在Oracle上我踩过坑会报无效的列名。所以我的建议是having里老老实实重复写聚合函数表达式别图省事用别名。3.2 多聚合函数一次查减少数据库往返很多时候一个页面上的统计卡片需要好几个数字用户总数、今日新增、平均年龄、最大订单金额。如果每个数字都单独发一条HQL那数据库就得多跑好几趟。Hibernate支持一条HQL里查多个聚合值QueryObject[] query session.createQuery(select count(u), avg(u.age), max(u.lastLoginTime) from User u); Object[] result query.uniqueResult(); Long count (Long) result[0]; Double avgAge (Double) result[1]; Date maxLogin (Date) result[2];这个写法的好处显而易见一条SQL搞定执行成本低。但坏处也有——返回的Object[]是弱类型可读性差。所以实践里我更推荐用Tuple或者DTO投影来接收。Hibernate 5之后的版本提供了javax.persistence.Tuple接口用起来舒服多了TypedQueryTuple query entityManager.createQuery(select count(u) as cnt, avg(u.age) as avgAge from User u, Tuple.class); Tuple tuple query.getSingleResult(); Long count tuple.get(cnt, Long.class); Double avgAge tuple.get(avgAge, Double.class);注意TypedQuery泛型里写的是Tuple.class然后字段上用别名。这个玩法的底层是Hibernate的ResultTransformer组件帮你把Object[]包装成了Tuple。所以我个人的取舍是聚合函数少比如一个两个Object[]完全够用聚合函数多了果断切Tuple代码可读性天差地别。如果你用的Spring Data JPA还可以用接口投影那个我放到第4节一起讲。3.3 分页情况下的 count 查询优化说到分页这恐怕是Hibernate聚合函数使用频率最高的场景。Spring Data JPA的分页查询本质上是先执行一条count聚合查询拿到总量再执行分页查询拿当前页数据。Hibernate的Query接口里提供两个方法list()和uniqueResult()。分页时的count查询效率问题在表数据量大的时候会特别扎眼。以Spring Data JPA为例PageUser page userRepository.findAll(PageRequest.of(0, 10));底层自动生成的count查询是select count(u) from User u。这个count查询默认会Include所有join的表如果你写的查询里带着left join fetch这种带抓取语义的关联很容易出现count查询把关联表全部join一遍的坑。我遇到过最夸张的一次一个原本100ms内能完成的列表查询因为count查询把三张关联表全join了直接飙到1秒多。解决方案也简单Spring Data JPA里自定义count查询用Query注解指定count语句Query(value select u from User u left join u.orders o where o.status :status, countQuery select count(u) from User u where exists (select 1 from Order o where o.owner u and o.status :status)) PageUser findByOrderStatus(Param(status) String status, Pageable pageable);这个countQuery的语义是只统计满足条件的主表记录数不去join出重复行再count。很多博客只说有这个属性但没强调为什么。实际上select count(u) from User u left join u.orders o在o.status有过滤条件时会因为关联产生笛卡尔效应导致count结果比实际主表行数多分页总数直接错乱。所以countQuery的核心价值在于用exists代替join保证count语义和主查询语义一致但不产生关联膨胀。当然如果你用的不是Spring Data JPA是原生的HibernateCriteria或者Query同样注意别在count查询里带left join fetchfetch只对实体加载有影响对count只有坏处。4. Criteria API与Spring Data JPA中的聚合函数玩法除了HQL字符串Hibernate还给了一套类型安全的Criteria API跟聚合函数配合起来非常灵活尤其在动态条件多的场景里比HQL字符串拼接好用得多。4.1 Criteria API 写聚合告别字符串拼接从Hibernate 5.x开始官方主推的是CriteriaBuilder这套JPA标准接口。写个聚合countCriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaQueryLong cq cb.createQuery(Long.class); RootUser root cq.from(User.class); cq.select(cb.count(root)); cq.where(cb.greaterThan(root.get(age), 18)); Long count entityManager.createQuery(cq).getSingleResult();这段代码对应HQL就是select count(u) from User u where u.age 18。好处是类型安全字段名写错编译期就报错而不是像HQL字符串那样运行时才炸。再做sum和avg:CriteriaQueryObject[] cq cb.createQuery(Object[].class); RootOrder root cq.from(Order.class); cq.multiselect(cb.sum(root.get(amount)), cb.avg(root.get(amount))); Object[] result entityManager.createQuery(cq).getSingleResult();论灵活度Criteria API绝对是动态查询场景的首选。比如前端传过来多个筛选条件用HQL你得层层拼接StringBuilder用Criteria API就是不断地往Predicate列表里addListPredicate predicates new ArrayList(); if (StringUtils.hasText(name)) { predicates.add(cb.like(root.get(name), % name %)); } if (minAge ! null) { predicates.add(cb.greaterThanOrEqualTo(root.get(age), minAge)); }这套组合拳在真正写复杂报表查询的时候比HQL字符串拼接舒服太多了。但它的缺点也挺明显代码行数多、缩进层级深、可读性不如一句HQL来得直白。所以我的习惯是静态查询写HQL动态条件多的时候才上Criteria各取所长。4.2 Spring Data JPA就是Hibernate的门面聚合的约定要懂Spring Data JPA底层用的就是Hibernate所以聚合玩法基本一脉相承。最常见的写法是Query注解里直接写JPQLpublic interface OrderRepository extends JpaRepositoryOrder, Long { Query(select count(o) from Order o where o.status :status) long countByStatus(Param(status) String status); Query(select avg(o.amount) from Order o where o.createdDate between :start and :end) Double avgAmountBetween(Param(start) Date start, Param(end) Date end); }Spring Data JPA还支持接口投影返回值直接映射成DTO接口配合聚合函数非常省事。注意接口投影是Spring Data统一支持的机制但不依赖Hibernate的聚合实现底层是由Spring Data的ProxyFactory生成代理类来完成的public interface OrderStats { String getStatus(); Long getCount(); Double getTotalAmount(); } Query(select o.status as status, count(o) as count, sum(o.amount) as totalAmount from Order o group by o.status) ListOrderStats findOrderStats();这个方法签名里JPQL的别名status、count、totalAmount要跟接口方法名匹配。Spring Data在运行时为OrderStats生成代理对象把查询结果的Object[]按别名塞进对应的方法。这个玩法比Tuple又前进了一步代码可读性直接起飞而且自带强类型。需要注意的坑是count作为方法名的时候要小心跟Spring Data JPA的派生查询方法冲突。如果接口里自己声明了long countByStatus(String status)而Query里的别名也写成count有时候IDE的代码提示会混淆。所以我建议别名尽量语义化比如totalCount、orderCount别直接用count这种全称。4.3 聚合函数与缓存Hibernate一级缓存为什么帮不上忙Hibernate的缓存分成一级缓存Session级和二级缓存SessionFactory级。一级缓存是默认开启的作用范围是一个Session生命周期内主要缓存实体对象。但聚合查询的结果是一个标量值不是实体对象所以一级缓存对聚合查询完全不生效。这也解释了为什么同一Session里执行两次count查询SQL日志里会有两条记录——数据没进缓存第二次查询还是会真实请求数据库。二级缓存可以配置但同样的道理它缓存的是实体id, 实体映射聚合查询的结果也不会被自动缓存。所以如果你有一个报表页面页面上三个统计卡片每次打开都要重新跑三条聚合SQL这个场景的性能优化方向就应该是业务层缓存——把统计结果按分钟级缓存在Redis或本地内存里而不是指望Hibernate帮你缓存。我之前接手过一个老项目打开首页要执行7条聚合SQL把首页响应时间拖到2秒多。后来就是加了一个本地缓存组件把统计结果缓了5分钟首页直接变成毫秒级。这事儿属于Hibernate之外的业务设计问题但理解Hibernate缓存边界之后就不会白费力气在二级缓存配置上瞎折腾了。5. 聚合查询的常见报错与性能坑我都替你踩过了这部分是压箱底的干货。聚合查询报错和性能问题每个资深开发基本都遇到过。我把这几年见过、踩过的高频问题整理成速查表再挑三个容易炸的场景展开细说。5.1 常见报错速查表先收藏再看报错信息触发原因解决思路TypeMismatchException用Integer接收count的Long返回值统一用Long或BigDecimal接收QueryException: unexpected tokenHQL里写了数据库专属函数如MySQL的IFNULL换HQL支持的coalesce或nullifNonUniqueResultException用uniqueResult()接收聚合结果但SQL返回多行检查group by后是否真的只返回一行改用list()InvalidDataAccessApiUsageExceptiongroup by里使用了select里没有的字段补全select字段或调整group byQuerySyntaxException: expecting (HQL聚合函数后面漏了括号检查语法count(u)别写成count uSQLGrammarException翻译后的SQL跟当前数据库方言不兼容检查hibernate.dialect配置确认实体字段类型映射这里面最典型的就是第一条。我至今还记得当年用Hibernate 3的时代query.uniqueResult()返回的是Object很多人拿着Object直接toString()然后字符串转数字绕了一大圈还容易出乱子。现在的Hibernate泛型化做得够好了QueryLong直接限定返回类型能从编译期避免一部分类型问题。5.2 用 count 时莫名多了一条 left join怎么排查有个很隐蔽的问题你写select count(u) from User u但Hibernate日志里打印出来的SQL竟然带着left join。这不是Hibernate发疯了而是因为你实体里的映射关系里配置了ManyToOne的fetch FetchType.EAGER。Hibernate在翻译HQL的时候会按照实体映射关系自动补join。这个问题的根源是实体设计。比如我的User实体里有ManyToOne(fetch FetchType.EAGER) private Department department;那么即使HQL里只查了u触发count时也可能带上left join department。对于count来说这个join是多余的还会带来性能损耗。怎么解决最有效的方式是把聚合查询的实体里的EAGER改成LAZY。ManyToOne默认本来就是LAZY但有人习惯显式写成EAGER加上ManyToOne的默认fetch类型在JPA规范里正好是EAGERHibernate的ManyToOne默认是EAGER所以不少人在这上面吃亏。另一种方式是在聚合查询里用Hibernate的Fetch注解或者专门的DTO投影查询只查询需要的字段不关联多余的实体。排查思路很简单打开Hibernate的SQL日志看打印出来的SQL是不是比预想的多然后把实体里的关联字段逐个改成LAZY再试基本能定位到是哪个关联惹的祸。改完之后原来的select count(u)就能干净地翻译成select count(*) from user了。5.3 聚合查询性能优化从 SQL 日志里找线索聚合查询性能问题第一刀肯定是砍在哪条SQL慢上。Hibernate配置里把SQL日志打开非常关键spring: jpa: show-sql: true properties: hibernate.format_sql: true但光看SQL日志还不够得会看慢查询日志。你可能会想Hibernate的SQL日志能看出执行时间吗答案是能但不能直接看。Hibernate默认的日志输出不带执行时间要用jdbc.interceptor这种扩展点来做监控或者让DBA在MySQL端开启slow_query_log两相配合。我习惯的做法是在开发环境直接看show-sql出来的SQL粘到数据库里手动EXPLAIN看执行计划。聚合查询慢八成出在以下三个方面第一全表扫描。avg(u.age)这种聚合如果没有相应的索引数据库就得全表扫一遍。办法是给聚合字段建索引尤其group by的字段和where里高频过滤字段要优先建。第二临时表排序。group by触发数据库的临时表排序数据量大时极端吃内存。MySQL里会看到Using temporary; Using filesort。这种问题靠索引来避免排序group by字段的索引顺序要匹配。第三join膨胀。前面提到过了count查询带着莫名join数据量上来之后性能直线下降。这个一定要在SQL日志里抓到抓到之后按5.2节的方式处理。这几板斧下去聚合查询能慢的可能性不多了。如果你是用Spring Boot 3.x Hibernate 6.x我还建议把Hibernate 6的NamedQuery和Query里的countQuery用起来本质上就是在编译期把SQL固定住减少运行时解析开销。虽然这优化微乎其微但大厂面试官最爱问这些边角料的性能细节能答上来就是加分项。6. 我个人的使用习惯和最后一点感悟写了这么多最后分享几个我个人的使用习惯。第一聚合查询返回的DTO投影和Tuple优先于Object[]。虽然这会让代码行数变多但维护的时候省下的心远大于写的时候费的力。第二count和exists的选择当你只是要判断是否存在时别用count用exists。HQL里的select 1 from User u where exists (...)在数据库执行层面比count高效得多。虽然count在Hibernate里是万金油但性能敏感场景还是要挑对工具。第三也是最重要的一点永远记得在HQL里你能用SQL里的思维但也要尊重ORM的边界。聚合查询的结果不是实体对象它天生就不该进实体缓存。想要缓存自己想办法别跟Hibernate较劲。我这些年见过太多同事纠结Hibernate还有没有人用这种问题。其实工具本身没有过时过时的是老一套的用法。Hibernate从最早的hbm.xml配置时代走到现在JPA规范的标准化让它的生态更加稳定。就算你转向了MyBatis-Plus聚合查询该懂的东西还是得懂只是换了个API名而已。聚合函数这块的硬骨头啃下来后续再遇到复杂的报表统计、数据看板开发心里就有底了。下一篇打算聊聊Hibernate的Formula和派生查询也是评论区呼声比较高的方向。回见了。
返回列表