ARTICLE DETAIL

资讯详情

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

Hibernate投影查询实战:Projections从基础到性能优化

Hibernate投影查询实战:Projections从基础到性能优化 写这个系列到第33篇了今天想认真聊聊Hibernate的投影Projections。先给还不了解的朋友快速定位一下投影查询就是“只取我需要的列”而不是把整个实体表全部捞出来。很多性能问题、统计报表场景本质上都可以通过投影来解决。这篇文章不只讲API怎么用还会把为什么要这么设计、实际项目中怎么组合条件与聚合、以及那些文档里不会写的坑一并说清楚适合正在维护老项目、做报表接口或者准备面试时被问到“Hibernate查询优化”的朋友。先说个我自己的真实体验。前阵子接手一个订单统计接口数据量上来之后每次请求都要等好几秒。问题不在SQL写得有多烂而是查询直接把整张订单表20多个字段全部select出来页面上只用其中3个字段。后来用Projections把查询改成只取需要的列和聚合结果响应时间肉眼可见地降了下来。这件事让我意识到很多性能问题不是框架不好用而是使用姿势不对。正好最近“hibernate还有人用吗”这个讨论又冒出来了我的看法很直接在企业级存量系统里Hibernate依然活得很好而投影查询正是它最值得掌握的实用能力之一。下面从原理到实操完整拆一遍。1. 投影查询到底解决什么问题1.1 一上来就整行查询性能从哪丢的开发新手最容易犯的一个错误就是图省事直接criteria.list()或者findAll()把整个实体对应的所有字段全部查出来。这么做在数据量小的时候没什么感觉一旦数据量上来问题就非常明显。假设user表有20个字段页面上只需要id、name、avatar这3个字段。全行查询等于每行多查了17个用不到的字段1万行就是17万个字段值的网络传输和对象创建开销100万行呢这个开销是线性放大的。更致命的是password、token、email这类敏感字段也会跟着一起加载到内存里。即使你没有把它们返回到前端它们也已经躺在堆内存里了白白增加了安全风险面。还有一个容易被忽略的成本Hibernate对查询出来的实体会放入持久化上下文一级缓存进行快照管理。查询出来的实体越多、字段越多快照维护成本就越高。如果你只是要展示数据完全没有必要让这些行进入托管状态。投影查询的结果是普通的Object数组或DTO对象不进一级缓存没有快照管理内存开销会小很多。1.2 投影查询适合哪些场景依据我这些年做项目的经验投影查询最适用的场景大概有这么几类列表页、报表页只需要实体的一小部分字段统计类需求例如总数、求和、平均、最大最小分组统计例如按状态、按分类统计订单数量和金额联动下拉、树形选择只需要id和名称这类基础字段。反过来如果你的业务需要对这个查询结果进行修改并回写数据库那就不适合用投影。因为投影返回的不是被管理的实体对象修改它不会自动同步到数据库。同理如果你需要懒加载关联对象的属性投影也帮不上忙——它不会为你生成代理对象拿到的只是普通数据。1.3 为什么用 Projections 而不是直接写原生SQL你可能会问既然要控制查询列直接写一条原生SQL不就行了吗这个问题的答案我认为是“分情况”。原生SQL的优势是灵活、可调优劣势是动态条件拼接非常痛苦。比如查询条件里有时间范围、状态、关键字等可选项你要在Java代码里拼一串字符串拼错一个空格就是线上事故。而Criteria API加Projections可以编程式地添加条件、排序、分组代码结构是清晰的可读性也更好。另一个好处是数据库方言问题。原生SQL里的分页、日期函数在不同数据库中写法都不一样而Projections最终由Hibernate生成方言适配的SQL迁移数据库时不用大改代码。当然我也必须客观说一句新项目我更推荐用JPA的CriteriaBuilder或者直接写JPQL/HQLProjections更适合在存量Hibernate Criteria代码中做改造。两者并不冲突理解投影思想比死磕某个API更重要。2. Projections API 核心语法与常用投影类型2.1 最核心的 ProjectionList 用法Projections本身的API很简洁最核心的就是Projections.projectionList()配合add()把多个投影列组合到一起再通过criteria.setProjection(...)设置到查询上。先看个最简单的例子只查询用户的name和email两个字段Criteria criteria session.createCriteria(User.class); criteria.setProjection(Projections.projectionList() .add(Projections.property(name)) .add(Projections.property(email))); ListObject[] list criteria.list();注意这里返回的是ListObject[]每一行结果是一个Object数组数组里元素的顺序和你add投影列的顺序完全一致。也就是说list.get(i)[0]是namelist.get(i)[1]是email。代码写多了之后你会发现这种下标访问方式确实容易记混所以一般我建议配合别名和DTO转换一起用这个在后续章节会详细讲。这里要强调一下Projections.property(name)里的字符串是实体的属性名不是数据库表里的列名。Hibernate会通过映射关系自动转换为对应的SQL列。如果你手滑把属性名写错了运行时会抛出org.hibernate.QueryException: could not resolve property这个问题只能在运行时暴露所以最好把属性名收拢到常量类里避免魔法字符串乱飞。2.2 常用投影方法速查表Projections工具类提供了很多静态方法我整理了一个速查表方便大家平时查阅方法作用返回结果类型Projections.property(field)投影单个属性通常为Object实际类型取决于属性Projections.count(id)统计非空记录数LongProjections.countDistinct(status)去重后统计记录数LongProjections.sum(amount)求和Number子类具体看字段类型Projections.avg(amount)求平均值DoubleProjections.min(createdAt)求最小值属性对应类型Projections.max(createdAt)求最大值属性对应类型Projections.rowCount()总记录行数LongProjections.groupProperty(status)分组依据列属性对应类型Projections.distinct(Projections.property(field))去除重复值属性对应类型举个例子统计订单表的基础指标Criteria criteria session.createCriteria(Order.class); criteria.setProjection(Projections.projectionList() .add(Projections.count(id)) .add(Projections.sum(amount)) .add(Projections.avg(amount)) .add(Projections.min(createdAt)) .add(Projections.max(createdAt))); Object[] result (Object[]) criteria.uniqueResult();用uniqueResult()的前提是你明确知道结果只有一行。聚合查询通常就是一行这个用法很常见。需要注意的是sum、avg这类聚合函数在空表或者没有任何匹配记录时可能返回null而不是0。这个坑等下我会专门在常见问题里展开。2.3 groupProperty 分组统计怎么玩统计场景里分组是跑不掉的。Projections里对应分组的方法是groupProperty它既指定了按哪个字段分组同时这个字段本身也是查询结果的一部分。看一个按订单状态分组的例子Criteria criteria session.createCriteria(Order.class); criteria.setProjection(Projections.projectionList() .add(Projections.groupProperty(status)) .add(Projections.count(id)) .add(Projections.sum(amount))); ListObject[] rows criteria.list();查询结果是每个状态一行形如PAID 120 50000.00 PENDING 30 8000.00 CANCELED 10 2000.00有一个容易踩的坑分组后的条件过滤和分组前的条件过滤位置完全是两回事。分组前过滤用criteria.add(Restrictions.eq(...))作用在where子句上但如果你需要分组后的过滤比如只显示订单数大于100的状态Criteria原生API没有一个直接的having方法。遇到这种需求我的建议是先用Criteria把结果查出来再在Java内存里过滤或者干脆放弃Criteria改用HQL在HQL里直接写having count(id) 100。死磕Criteria并不是好选择。3. 完整实操一个用户订单统计改造案例3.1 业务场景与实体结构用实际业务场景来走一遍流程。假设现在需要做一个“用户下单统计报表”要按用户展示订单数量、订单总金额、平均订单金额。数据库有两张表user和orders用户和订单是一对多关系。实体类大致长这样Entity Table(name users) public class User { Id private Long id; private String name; private String status; // 省略其他字段和getter/setter } Entity Table(name orders) public class Order { Id private Long id; Column(name user_id) private Long userId; private String status; private BigDecimal amount; Column(name created_at) private Date createdAt; ManyToOne JoinColumn(name user_id, insertable false, updatable false) private User user; // 省略其他字段和getter/setter }注意这里createAlias用到了Order里的user关联属性所以Order实体中维护了ManyToOne User user这个关联。不过如果实体没有关联关系你也可以用原生SQL自己写join列名并不强制。3.2 三种实现方式横向对比实现同样的“按用户统计订单数、总金额、平均金额”原生SQL、HQL、CriteriaProjections三种方式都行我实际项目中都用过优缺点非常明显实现方式优点缺点原生SQL直观、贴近数据库、容易调优动态条件拼接麻烦、不同数据库方言要自己管、返回Object[]HQL/JPQL面向实体属性、有select new DTO语法动态条件还是拼接字符串Criteria Projections动态条件代码化、聚合分组方便复杂having不好写、老API逐渐边缘化先看原生SQL的写法select u.name, count(o.id) as order_count, sum(o.amount) as total_amount, avg(o.amount) as avg_amount from orders o join users u on o.user_id u.id where o.status PAID group by u.name order by total_amount desc这段SQL在MySQL上没问题但换到Oracle、PostgreSQL就得检查有没有区别。如果还想动态加时间范围条件Java代码里拼字符串拼到怀疑人生。再看HQL的写法String hql select u.name, count(o.id), sum(o.amount), avg(o.amount) from Order o join o.user u where o.status :status group by u.name order by sum(o.amount) desc; ListObject[] rows session.createQuery(hql, Object[].class) .setParameter(status, PAID) .list();HQL已经比原生SQL友好多了至少面向实体属性不用关心数据库差异。但是动态条件依然逃不掉拼接。有一个好用的点是HQL支持select new com.example.UserOrderReport(u.name, count(o.id), sum(o.amount))这个后面讲到DTO对接时会详细对比。最后看我们今天的主角CriteriaProjectionsCriteria criteria session.createCriteria(Order.class); criteria.createAlias(user, u); criteria.setProjection(Projections.projectionList() .add(Projections.groupProperty(u.name)) .add(Projections.count(id)) .add(Projections.sum(amount)) .add(Projections.avg(amount))); criteria.add(Restrictions.eq(status, PAID)); criteria.addOrder(Order.desc(Projections.sum(amount))); ListObject[] rows criteria.list();这段代码的优势在于如果后面需求增加一个“创建时间大于某个时间点”的条件你只需要再add一个Restrictions.gt(createdAt, startTime)不用改字符串模板。对于条件经常变动的后台管理系统这种代码维护起来会舒服很多。3.3 加条件、排序和分页投影查询同样支持条件过滤、排序和分页。这里容易混淆的一个点是Restrictions条件作用在分组之前也就是SQL里的where排序如果写在分组之后是对聚合结果排序的。再补全一个带时间范围过滤和分页的完整写法Criteria criteria session.createCriteria(Order.class); criteria.createAlias(user, u); ProjectionList projList Projections.projectionList() .add(Projections.groupProperty(u.name)) .add(Projections.count(id)) .add(Projections.sum(amount).as(totalAmount)); criteria.setProjection(projList); criteria.add(Restrictions.eq(status, PAID)); criteria.add(Restrictions.ge(createdAt, startTime)); criteria.add(Restrictions.lt(createdAt, endTime)); criteria.addOrder(Order.desc(totalAmount)); criteria.setFirstResult(0); criteria.setMaxResults(10); ListObject[] rows criteria.list();这里我给sum(amount)设置了一个别名totalAmount然后排序直接引用别名。这个操作在实际开发里非常常用因为聚合列没有原始列名不取别名排序时会很麻烦。分页方面setFirstResult和setMaxResults会由Hibernate翻译成数据库的分页语句底层依然是从数据库层面分页的不会先把所有结果加载到内存再截取。3.4 实操中的一个坑createAlias 导致统计翻倍这里分享一个我实际踩过的坑。有一次用上述代码做用户订单统计怎么查都对不上数订单总数明显比数据库真实数据多。排查到最后发现是createAlias(user, u)在底层生成了一个left join。如果某个用户有多条订单join后用户信息就重复了而我又用count(id)统计这个id是订单id统计结果本应该没问题。但问题出在我写成了count(u.id)这就把同一个用户的id在每条订单记录里都数了一遍自然就翻倍了。遇到这种情况正确的做法是统计订单数用订单自己的id即count(id)如果确实需要统计用户数或对用户列做聚合要考虑countDistinct(u.id)或者把createAlias改成createCriteria(user, u, JoinType.INNER_JOIN)并通过子查询的方式去关联。这个细节不会出现在任何入门教程里却是实际统计报表最容易出问题的地方。4. 投影结果与 DTO/VO 对接的优雅姿势4.1 Object[] 下标访问让人抓狂如果你只是一个小查询用Object[]还行。但报表类的查询通常有七八列每一列都用row[0]、row[1]、row[2]去取代码的可读性会急剧下降。举个真实的例子我见过同事写的代码for (Object[] row : rows) { String name (String) row[0]; Long count (Long) row[1]; BigDecimal total (BigDecimal) row[2]; ... }这段代码过了两周自己回来看都要想一想下标2到底是总金额还是平均金额更不用说新接手的同事了。能坚持用下标读完整个报表代码的都算狠人。所以我在实践里都会用DTO去承接投影结果而不是让Object[]到处流传。4.2 aliasToBean 一键转 DTOHibernate的Transformers.aliasToBean(DTO.class)可以帮我们把投影结果自动映射为DTO对象。前提很简单投影列必须起别名且别名与DTO的属性名一致。定义统计DTOpublic class UserOrderReport { private String name; private Long orderCount; private BigDecimal totalAmount; private Double avgAmount; // 必须要有无参构造和对应属性的setter }查询代码Criteria criteria session.createCriteria(Order.class); criteria.createAlias(user, u); criteria.setProjection(Projections.projectionList() .add(Projections.groupProperty(u.name).as(name)) .add(Projections.count(id).as(orderCount)) .add(Projections.sum(amount).as(totalAmount)) .add(Projections.avg(amount).as(avgAmount))); criteria.setResultTransformer(Transformers.aliasToBean(UserOrderReport.class)); ListUserOrderReport reports criteria.list();这段代码的原理是Hibernate在构造结果时读取投影列的别名找到DTO中对同名的setter或者字段把数据库返回的值塞进去。所以DTO中的属性名必须和别名完全一致大小写也不能差同时DTO要有无参构造函数和对应的setter方法。有一点我要特别提醒聚合函数返回的类型和DTO字段类型经常对不上例如avg在Hibernate中往往返回Doublesum对BigDecimal字段返回BigDecimal没问题但对Integer字段可能返回Long。如果DTO中的字段类型和返回值类型不匹配aliasToBean的自动转换有时会静默失败或者抛异常。稳妥的做法是DTO里聚合字段类型用包装类型例如Long、BigDecimal、Double避免用int、float这类基础类型或者干脆在SQL层就处理好类型。4.3 关联查询与多表投影实际报表很难只查一张表。通过createAlias可以做关联表的投影。比如查询每个用户的最近一笔订单时间和订单金额Criteria criteria session.createCriteria(User.class); criteria.createAlias(orders, o); criteria.setProjection(Projections.projectionList() .add(Projections.groupProperty(name).as(name)) .add(Projections.max(o.createdAt).as(lastOrderTime))); criteria.setResultTransformer(Transformers.aliasToBean(UserLastOrderDTO.class)); ListUserLastOrderDTO list criteria.list();这里要特别小心一对多关联带来的重复行问题。如果一个用户有5条订单join之后用户行会变为5行虽然用group by name能合并成1行但如果你还想在结果里带上一些用户基础字段这些字段本身并不会因为group by发生变化所以没问题。但如果你用了Restrictions限定订单条件一不留神就可能把没有匹配订单的用户也过滤掉了。这个需要在写条件时搞清楚业务上是要inner join还是left join。4.4 对比 HQL select new 与 aliasToBean说了这么多其实HQL还有一种更优雅的方式String hql select new com.example.UserOrderReport(u.name, count(o.id), sum(o.amount)) from Order o join o.user u group by u.name; ListUserOrderReport list session.createQuery(hql, UserOrderReport.class).list();写法上select new比aliasToBean更简洁但它要求DTO有对应的全参构造函数而且必须与查询的属性顺序完全一致。相比之下aliasToBean更灵活也不依赖构造器参数顺序适合条件时刻变化的场景。这两种方式各有适合的场合HQL适合静态查询CriteriaaliasToBean适合动态条件比较多的查询。我一般凭团队代码风格选择没有绝对的优劣。5. 常见问题与性能陷阱实录5.1 投影查询真的更快吗诚实地说不一定。投影查询减少的是select出来的列数量降低的是网络传输、内存占用和对象初始化的开销。但数据库底层的扫描方式和过滤方式并没有改变。举个例子如果查询条件是where age 20而age列没有索引数据库照样要全表扫描。投影查询省的是“取出20列还是3列”的差异而不是“扫不扫全表”的差异。但有一种情况投影查询收益非常大当你需要查询的列恰好都在一个覆盖索引里时数据库可以只扫描索引而不回表。这就是为什么很多报表接口只取少量列后性能大幅提升的原因。所以我的建议是优化SQL时先看explain执行计划再决定用投影还是加索引。投影永远是“把需要的列拿全没用的一列都不要”但不要指望它单独解决所有性能问题。5.2 聚合函数返回NULL这是一个安静又危险的坑。用uniqueResult()执行聚合投影时如果查出来的结果是空数据集很多聚合函数的返回值实际上是null。看这个例子Criteria criteria session.createCriteria(Order.class); criteria.add(Restrictions.eq(status, PAID)); criteria.setProjection(Projections.sum(amount)); Object result criteria.uniqueResult(); // 注意如果没有任何PAID订单这里result可能是null在报表系统里你后面的代码通常会写total.add(new BigDecimal(result.toString()))之类然后直接空指针。解决办法有两个先查countcount为0就直接返回零值对象或者改用HQL写select coalesce(sum(o.amount), 0) from Order o where o.status :status在数据库层面就把null转成0。Criteria里没有直接的coalesce写法这是我真实的经验。5.3 属性名写错只能在运行时报错如前所述Projections.property(fieldName)里的字符串是实体属性名编译期不会检查写错了只能在运行时崩溃。一个常见的做法是把属性名定义成静态常量。比如public final class OrderFields { public static final String ID id; public static final String STATUS status; public static final String AMOUNT amount; }这样写虽然麻烦但对于几十上百个查询条件的复杂系统来说能大大减少因为改名没同步导致的线上事故。我见过有人重构实体字段名后忘了改Criteria里的字符串项目启动不报错查一次就抛异常这种问题排查起来还挺花时间的。5.4 老API被标记废弃后怎么处理Hibernate 5.x开始传统的session.createCriteria被标记为deprecated但并没有移除老项目还是在用。新的JPA标准方案是CriteriaBuilder CriteriaQuery Root它也能做投影。做一个简短的对照帮助从Projections迁移到JPA Criteria操作Hibernate ProjectionsJPA CriteriaBuilder属性投影Projections.property(name)cb.get(root, name)复合投影列表ProjectionListcriteriaQuery.multiselect(...)聚合计数Projections.count(id)cb.count(root.get(id))求和/平均Projections.sum(amount)cb.sum(root.get(amount))分组Projections.groupProperty(status)criteriaQuery.groupBy(root.get(status))需要提醒的是如果项目还在用Hibernate 3或4升级到Hibernate 6之后Transformers.aliasToBean的行为也会变化老代码不一定能顺利跑起来。在新代码上就按JPA的标准方式写老代码在不升级的前提下继续用Projections也是没问题的。工作里没必要“看见废弃就重写”控制好技术债才是更务实的选择。6. 回应“Hibernate还有人用吗”的大实话6.1 别被“旧”这个字吓到“hibernate还有人用吗”这个问题你只要去翻几个大型企业的存量Java项目答案自己就出来了。Hibernate作为JPA规范最流行的实现直到现在依然是Spring Data JPA底层的默认实现。Spring Boot新项目里写spring-boot-starter-data-jpa底层跑的就是Hibernate。新项目其实天天都在用Hibernate只是很多人感觉不到它的存在。我在写这个系列文章的过程中不断被问到类似问题框架是不是太老了、要不要换MyBatis、要不要换JOOQ。我的观点始终是技术选型不看新旧只看是否匹配当前团队、当前业务、当前维护成本。Criteria和Projections确实不如当年风光但它们解决的问题——动态查询、聚合统计、DTO映射——在今天的任何持久层框架里都存在对应物。把投影的思路吃透你迁移到任何新框架都只是换个写法的问题。6.2 维护老系统者的实用建议如果你和我一样日常工作是维护那些跑了五六年的老系统那Projections是你工具箱里值得重视的一项。老系统的特点是不敢大改能用最小成本把速度和稳定性提上去就是胜利。把全行查询改成投影查询不需要改实体、不需要改数据库、不需要改前端接口只需要改一段查询代码这就是最小的改动换最大的收益。但也要承认老代码里用CriteriaProjections写出来的查询在可读性上天然比HQL差一些。所以我的日常实践是核心报表查询优先用HQL的select new DTO纯动态条件多的后台列表查询再用CriteriaProjectionsaliasToBean两条路都走互相补充。最后说点个人的体会吧。我见过不少人一看到“Criteria”就摇头结果项目里跑着一坨坨几百行的SQL字符串拼接。投影Projections这个API确实不新但它真正想教你的一件事是查询的第一步永远不是“查什么表”而是“我需要哪些列”。这个习惯放到今天依然受用。你在把项目迁到JPA、Spring Data甚至任何ORM框架时这个思考方式同样能帮你写出更干净、更高效的查询代码。希望这篇Projections的拆解对你有用。
返回列表