《Spring Boot + JPA 实战》第10招 别总查完整Entity

那天周会上,小王把订单列表接口超时的问题摆出来。我是老李,在团队里负责后端性能优化。点开日志一看,一个 /api/orders 列表查询,SQL 拉出来 42 个字段,订单表、用户表、明细表、商品表、地址表全都 join 上。前端只需要订单号、状态、金额、创建时间四个字段,结果网络传输 1.8MB,接口响应 2.4 秒。这问题在列表页、报表和查询接口里非常普遍:为了拿一个订单编号,把整个 Entity Graph 都加载出来。为什么现在要讲?因为很多团队还在把 Controller 直接返回 JPA Entity 当成默认方案,功能是能跑,但接口越跑越慢,数据泄露、递归序列化、懒加载异常也跟着来。

01、痛点

订单列表接口是典型的读多写少场景。前端表格往往只需要几个列,比如订单号、下单时间、支付状态、实付金额。可后端代码里,经常写成 orderRepository.findAll() 或者 findByUserId(userId),方法返回 List<Order>。

Order 是一个 JPA 实体,它身上挂着很多关联:@ManyToOne 的 User,@OneToMany 的 OrderItem,OrderItem 里又关联 Product,Product 里还可能有 Category、Shop、Supplier。JPA 默认对 @ManyToOne 和 @OneToOne 是 EAGER 加载,对 @OneToMany 是 LAZY 加载,但一旦 Controller 把实体返回给 Spring MVC 做 JSON 序列化,序列化器会顺着 getter 一路往下读,懒加载的集合也会被触发。结果就是一个订单编号,背后触发了几十条 SQL,构造了成百上千个 Java 对象。

最麻烦的是,这个加载过程很多时候不是一次性完成的,而是在 Jackson 序列化过程中逐个触发的,产生的 N+1 查询非常隐蔽。你去看业务代码,只有一行 findAll,但看 SQL 日志,已经刷了几十页。每个订单拉出 OrderItem 列表,每个 OrderItem 又去加载 Product,如果 Product 再带出别的关联,性能就雪崩。

还有一个容易被忽视的问题:Controller 直接返回 Entity 时,实体仍然可能处于持久化上下文管理之中。某些配置下,延迟加载已经关闭,序列化时直接抛 LazyInitializationException;另一些场景下,实体被序列化后又回到持久化上下文,可能产生脏检查、意外更新甚至循环引用。即便这些问题都避开,实体通常包含内部字段、版本号、审计信息,甚至敏感数据,会不知不觉暴露给前端。

所以,订单列表接口的慢,不是数据库本身撑不住,而是我们让数据库和 ORM 做了一件很笨的事:为了查询几个展示字段,加载并维护了一个庞大的业务对象图。

02、是什么

解决这个问题的核心思路,是把查询结果从 Entity 里解耦出来。JPA 提供的能力叫 Projection,也就是投影。简单说,就是只从表中选取需要的字段,把这些字段映射到一个轻量对象上,而不是把整行、整个关联图都塞进实体。

常见的 Projection 有三种形态。

第一种是 Interface Projection。定义一个接口,只声明需要暴露的 getter 方法,比如 interface OrderSummary { String getOrderNo(); String getStatus(); java.math.BigDecimal getAmount(); }。Spring Data JPA 会根据接口方法名去查询结果中匹配同名字段。这种投影可以用于实体查询,也可以用于 JPQL 查询。写法简单,适合快速出统计结果,缺点是接口必须暴露 getter,返回值字段比较固定,不容易附加额外逻辑。

第二种是 DTO Projection。定义一个普通的 DTO 类,在 JPQL 里使用构造函数表达式,比如 select new com.example.dto.OrderListDTO(o.orderNo, o.status, o.amount, o.createdAt) from Order o where o.userId = :userId。DTO 类只需要一个对应参数的构造函数。这种方式最可控,SQL 查什么字段,DTO 里就是什么字段,不会多查,也不会触发关联加载。

第三种是 Dynamic Projection。在 Repository 方法上增加一个 Class<T> type 参数,同一个查询方法可以动态返回不同投影类型。比如 List<T> findByUserId(Long userId, Class<T> type),调用时可以传 OrderListDTO.class,也可以传 OrderSummary.class。动态投影适合报表系统,同一个查询主体可以按不同终端需求返回不同形状的数据。

除了这三种形态,还有一个更根本的理论区分:事务写模型和查询读模型。事务写模型由 Entity 承担,它关心业务规则、状态变化、聚合一致性和持久化上下文。查询读模型由 Projection 承担,它只是一份结果快照,不参与事务状态管理,不需要被 ORM 追踪。把这两类模型分开,很多看似复杂的性能问题都会变得简单。

03、为什么

投影带来的第一层收益,是 SQL 字段变少。一个实体查询可能 select 42 个字段,而投影查询只 select 4 个字段。字段数量减少,数据库扫描的数据页更少,网络传输更小,尤其是当订单表包含大字段、备注、扩展 JSON 之类列时,效果更明显。

第二层收益,是对象数量大幅下降。实体查询会构造 Order、User、OrderItem、Product 等大量对象,其中很多对象只是为了维持关联关系而存在。投影查询只构造需要的 DTO 或接口代理,假设订单列表返回 200 条数据,原来可能产生 1000 多个实体对象,投影只需要 200 个轻量对象。对象数量下降,堆内存占用降低,GC 压力也随之下降。

第三层收益,是序列化开销降低。Jackson 序列化实体时,要处理嵌套对象、懒加载代理、循环引用、双向关联等复杂情况。序列化一个 Order 实体,它可能带着 User 对象、List<OrderItem>,而每个 OrderItem 又带着 Product,Product 再带别的关联,整个序列化过程会不断深入,甚至触发更多查询。投影对象一般是扁平的 DTO,字段都是简单类型,序列化成本几乎可以忽略。

第四层收益,是接口隔离开关更清晰。Controller 直接返回 Entity,意味着数据库表结构的任何变化都可能直接影响 API 响应。加一个内部字段、改一个关联关系,前端可能就收到一堆额外数据甚至报错。投影 DTO 可以只暴露对外承诺的字段,内部表结构变化不影响接口契约。

第五层收益,是避免持久化上下文带来的副作用。Entity 被序列化时,可能触发懒加载,也可能因为已经脱离持久化上下文而抛异常。投影对象没有这些问题,它天生就是游离的数据快照,不会触发懒加载,也不会被脏检查。

04、架构

在架构层面,可以把 Entity 和 Projection 的边界看成一种轻量的读写分离,或者叫命令模型与查询模型的分离。

Entity 适合表达事务中的业务状态。一个订单在创建、支付、取消、发货这些流程中,需要维护状态变化,需要校验订单项、库存、用户余额,这时候 Order 实体就是业务核心。它必须是完整的聚合根,包含所有必要关联,受持久化上下文管理,让事务提交时能够正确更新数据库。

Projection 适合表达查询结果。订单列表页、报表、统计接口,本质上只需要读取数据,不需要改变任何状态。这些场景不应该进入 Entity 的生命周期管理。它们要的是不可变、扁平、稳定的数据形状。Projection 可以理解为数据库查询结果到应用层 DTO 的直接映射,它跟业务实体无关,只跟查询维度有关。

落到代码结构上,Controller 不应该直接接触 Entity,Service 层负责事务边界,Repository 层提供两类方法:一类返回 Entity,用于写操作;另一类返回 Projection,用于读操作。例如 OrderRepository 里既可以有 Order save(Order order),也可以有 List<OrderListProjection> findOrderListByUserId(Long userId)。

这样做还有一个好处:查询模型可以独立演化。订单列表需要增加一个优惠金额字段,只需要改 DTO 和 JPQL;订单统计需要换一种聚合口径,只需要新增一个 Projection。不会因为改查询而影响写模型,也不会因为改写模型而破坏已有查询接口。

从数据库视角看,Projection 本质上是把选择权从 ORM 手里拿回开发者手里。ORM 自动生成的实体查询,往往倾向于加载全部列和必要关联,而投影查询则明确指定列,数据库优化器更容易生成高效的执行计划,也更容易用上覆盖索引。

05、实战

我们以订单系统为例,做三个投影改造。

第一个是订单列表接口。前端需要订单号、状态、实付金额、创建时间四个字段。原来的代码可能是这样:

@GetMapping("/api/orders")
public List<Order> listOrders(@RequestParam Long userId) {
    return orderService.listOrders(userId);
}

现在我们定义一个新的 DTO:

public class OrderListProjection {
    private final String orderNo;
    private final String status;
    private final BigDecimal amount;
    private final LocalDateTime createdAt;

    public OrderListProjection(String orderNo, String status, BigDecimal amount, LocalDateTime createdAt) {
        this.orderNo = orderNo;
        this.status = status;
        this.amount = amount;
        this.createdAt = createdAt;
    }

    // getter 省略
}

然后在 Repository 里增加一个投影查询方法:

@Query("select new com.example.dto.OrderListProjection(o.orderNo, o.status, o.amount, o.createdAt) " +
       "from Order o where o.user.id = :userId order by o.createdAt desc")
List<OrderListProjection> findOrderListByUserId(@Param("userId") Long userId);

Controller 改成返回 List<OrderListProjection>。这样 SQL 只查询 order_no、status、amount、created_at 四列,不再加载用户、订单项、商品等关联。

第二个是订单统计接口。比如需要按天统计订单数和总金额。我们可以使用 Interface Projection:

public interface OrderDailyStat {
    LocalDate getOrderDate();
    Long getOrderCount();
    BigDecimal getTotalAmount();
}

Repository 方法可以用 JPQL 的 group by 查询:

@Query("select function('date', o.createdAt) as orderDate, count(o) as orderCount, sum(o.amount) as totalAmount " +
       "from Order o where o.createdAt >= :startDate and o.createdAt < :endDate group by function('date', o.createdAt)")
List<OrderDailyStat> findDailyStat(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);

这里的函数写法可能因数据库不同而略有差异,实际项目中可以用数据库方言支持的日期函数替代。Interface Projection 的好处是聚合查询可以直接映射到接口,不用额外写 DTO。

第三个是客户消费榜。这个接口有时需要按客户展示消费总额、订单数、最近下单时间,有时又只需要展示客户姓名和消费总额。这种多形状需求可以用 Dynamic Projection:

public interface CustomerRankProjection {
    String getCustomerName();
    BigDecimal getTotalSpent();
    Long getOrderCount();
}

public interface CustomerSimpleProjection {
    String getCustomerName();
    BigDecimal getTotalSpent();
}

Repository 方法可以这样定义:

@Query("select c.name as customerName, sum(o.amount) as totalSpent, count(o) as orderCount " +
       "from Order o join o.customer c group by c.name order by totalSpent desc")
<T> List<T> findCustomerRank(Class<T> type);

调用时传具体类型即可。Spring Data JPA 会根据返回类型自动适配投影。

接下来我们对比一下 Entity 和 Projection 的开销。假设订单列表返回 200 条数据。

如果用 Entity 查询,每个 Order 实体会关联一个 User、一个 Customer、一个 List<OrderItem>,每个 OrderItem 又关联一个 Product。即便部分关联是懒加载,在序列化时也会被触发。SQL 字段可能多达 42 列,对象数量可能达到 1000 个以上,序列化后的 JSON 可能超过 1.8MB。响应时间可能达到 2 秒以上。

用 DTO Projection 后,SQL 字段只有 4 列,对象数量就是 200 个轻量 DTO。没有关联加载,没有嵌套序列化,JSON 可能只有 80KB 左右。接口响应时间可能降到 300 毫秒以内。这个对比非常直观。

06、洞见

很多人问:Controller 为什么不推荐直接返回 JPA Entity?最直接的原因是,Entity 不是为序列化而生的。它承担的是业务状态和持久化职责,而不是数据传输职责。

Entity 有一个重要特征:它是有状态的。它受持久化上下文管理,拥有版本号、懒加载代理、集合包装器等。这些特征在事务内部是优点,可以帮助我们维护业务一致性。但一旦 Entity 走出事务边界,进入 Controller 和序列化层,这些特征就变成了麻烦。懒加载可能失效,代理可能无法序列化,集合可能触发意外查询,双向关联可能造成循环引用。

另一个深一点的原因,是接口契约会变得脆弱。如果把 Entity 直接返回给前端,表结构的变化会直接影响 API 响应。你加了一个内部备注字段,前端就能看到;你改了一个关联关系,前端可能收到一大块意料之外的数据。这种隐式耦合会让前后端协作越来越困难。

Projection 的价值在于,它强制我们把查询需求显式化。订单列表要什么字段,DTO 里就写什么字段,JPQL 里就查什么字段。没有隐式关联扩散,没有意外序列化。Projection 是一个结果快照,它不参与事务,不维护状态,不触发懒加载。它和 Entity 的职责边界非常清楚。

所以更准确的表述是:Entity 更适合表达事务中的业务状态,Projection 更适合表达查询结果。写操作时用 Entity 维护业务规则,读操作时用 Projection 返回数据。两者可以共存于同一个 Repository,但不要混在一起。

这也可以理解为一种更务实的 CQRS。不是每个系统都要上事件溯源和终一致,但在应用层把读写模型分开,成本低,收益却很大。

07、行动

如果你的订单列表接口目前还是直接返回 Entity,建议按下面步骤改造。

第一步,梳理接口真正需要的字段。跟前端确认表格列、筛选条件、排序字段,只保留必要字段。不要试图一次覆盖所有潜在需求,缺字段以后再加。

第二步,定义 DTO Projection。字段设置成 private final,通过构造函数注入。这样 DTO 是不可变的,语义上更接近查询快照。

第三步,在 Repository 里添加 JPQL 构造函数查询。注意 JPQL 里的 new 表达式必须使用全限定类名,构造函数参数顺序要和 DTO 构造函数一致。

第四步,Controller 改为返回 Projection,不再返回 Entity。Service 层如果之前返回 Entity,也一并调整。

第五步,打开 SQL 日志,对比改造前后的 SQL 语句。确认字段列减少,确认没有新增的关联查询。

第六步,做一次性能对比。可以用同一批测试数据,观察响应时间、SQL 次数、网络传输大小。把数据记录下来,方便评估改造成果。

改造过程中有几个坑要注意。一是 DTO 构造函数参数名在 JPQL 中不重要,但顺序必须对应。二是如果 DTO 字段是 LocalDateTime 或枚举,要确保数据库驱动和 JPA 类型映射正确。三是动态投影的 Class<T> 参数如果传入 Entity 类,仍然会返回 Entity,开发者需要自己控制。四是投影查询返回的对象不会被持久化上下文管理,不要试图修改它并保存,那不会生效。

做完订单列表之后,可以顺手把报表、统计、消费榜这类读接口都排查一遍,逐个改成 Projection。改造动作不大,通常一个接口十几分钟就能完成,但性能和可维护性的提升会很明显。

08、小结

列表页、报表和查询接口只需要少量字段,不应该为了一个订单编号加载整个 Entity Graph。Entity 是事务写模型的核心,Projection 是查询读模型的结果快照。两者分开,接口更快,对象更少,序列化更小,边界更清晰。

JPA 提供了 Interface Projection、DTO Projection 和 Dynamic Projection 三种能力,分别适合快速查询、精确控制和动态形状。实际项目中,DTO Projection 因为最可控,最常被用在列表和详情接口;Interface Projection 适合统计聚合;Dynamic Projection 适合多端复用的报表系统。

如果你现在还在 Controller 里返回 Entity,不妨先把订单列表接口改成 DTO Projection,看看 SQL 日志和响应时间的变化。读模型和写模型一旦分离,很多看似复杂的性能问题都会变得简单。

09、链接与概念说明

本文涉及的主要概念如下:

Entity:JPA 实体,代表业务状态,受持久化上下文管理,适合事务写操作。

Projection:投影,只选取查询结果中的部分字段,映射到轻量对象或接口。

DTO Projection:通过 JPQL 构造函数表达式将查询结果映射到普通 DTO 类。

Interface Projection:定义接口,通过 getter 方法匹配查询结果字段。

Dynamic Projection:Repository 方法接收一个 Class<T> 参数,动态确定返回投影类型。

Entity Graph:JPA 用来控制实体关联加载策略的机制,和 Projection 是两个方向。Entity Graph 决定关联怎么加载,Projection 决定要哪些字段。

Persistence Context:持久化上下文,管理实体生命周期、懒加载和脏检查。Projection 不进入持久化上下文,因此没有这些副作用。

Closed Projection:接口投影中只暴露实体字段的投影方式,比如 getOrderNo()。

Open Projection:接口投影中使用 @Value 注解或默认方法做计算逻辑的投影方式,比如拼装完整地址。通常建议优先使用 Closed Projection,因为查询字段更明确。

关于官方文档,可以在 Spring Data JPA 官方参考文档中搜索 Projections 章节,了解接口投影、DTO 投影和动态投影的详细用法;也可以在 Jakarta Persistence 规范中查找构造函数表达式相关说明;Hibernate 官方用户指南中也有关于查询投影、DTO 映射和结果转换的详细介绍。阅读这些材料时,重点关注查询结果映射和事务边界部分。

posted @ 2026-10-10 23:49  李福春  阅读(1)  评论(0)    收藏  举报