这期接着聊Hibernate系列的聚合函数。老规矩,先回答那个很多人私信问我的事儿:Hibernate现在还有人用吗?我这么说吧,我手上还有两个老项目跑着Hibernate 5,Spring 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的HQL:
Query<Long> 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)返回的是Long,sum(u.age)返回的可能是Long或BigDecimal,avg(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字段在符合条件的记录里全是null,SQL标准里sum函数会返回null,Java里接收就是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字段的最大值返回Integer,Date字段的最小值返回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用户的数量:
Query<Long> query = session.createQuery("select count(u) from User u where u.vipLevel > :level", Long.class);关键是having。很多人分不清where和having在HQL里的边界。我的理解是:where是聚合之前行的过滤,having是聚合之后组的过滤。举个例子,你统计每个部门的平均薪资,只看平均薪资大于10000的部门:
Query<Object[]> 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); List<Object[]> 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里查多个聚合值:
Query<Object[]> 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接口,用起来舒服多了:
TypedQuery<Tuple> 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为例:
Page<User> 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)") Page<User> 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 fetch,fetch只对实体加载有影响,对count只有坏处。
4. Criteria API与Spring Data JPA中的聚合函数玩法
除了HQL字符串,Hibernate还给了一套类型安全的Criteria API,跟聚合函数配合起来非常灵活,尤其在动态条件多的场景里比HQL字符串拼接好用得多。
4.1 Criteria API 写聚合:告别字符串拼接
从Hibernate 5.x开始,官方主推的是CriteriaBuilder这套JPA标准接口。写个聚合count:
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Long> cq = cb.createQuery(Long.class); Root<User> 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:
CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class); Root<Order> 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列表里add:
List<Predicate> 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注解里直接写JPQL:
public interface OrderRepository extends JpaRepository<Order, 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") List<OrderStats> 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 token | HQL里写了数据库专属函数(如MySQL的IFNULL) | 换HQL支持的coalesce或nullif |
NonUniqueResultException | 用uniqueResult()接收聚合结果,但SQL返回多行 | 检查group by后是否真的只返回一行,改用list() |
InvalidDataAccessApiUsageException | group by里使用了select里没有的字段 | 补全select字段,或调整group by |
QuerySyntaxException: expecting "(" | HQL聚合函数后面漏了括号 | 检查语法,count(u)别写成count u |
SQLGrammarException | 翻译后的SQL跟当前数据库方言不兼容 | 检查hibernate.dialect配置,确认实体字段类型映射 |
这里面最典型的就是第一条。我至今还记得当年用Hibernate 3的时代,query.uniqueResult()返回的是Object,很多人拿着Object直接toString(),然后字符串转数字,绕了一大圈还容易出乱子。现在的Hibernate泛型化做得够好了,Query<Long>直接限定返回类型,能从编译期避免一部分类型问题。
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规范里正好是EAGER(Hibernate的@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和派生查询,也是评论区呼声比较高的方向。回见了。