Spring Data JPA 查询优化:先找到 N+1,再谈其他
Spring Data JPA 让数据访问变得轻松——直到开发时"瞬间"的页面在生产环境一次发几百条查询。这篇整理了作者最常用的技巧,让查询又快又可预测。
1. 找到并消灭 N+1
经典元凶:加载一个实体列表,然后在循环里触碰懒加载关联——每次迭代触发一条新查询。10 个订单加它们的行项目变成 1+10 条查询。规模上来后,50ms 的接口变 2 秒。
第一步是看见它。开发环境开 SQL 日志:
spring.jpa.show-sql: true
spring.jpa.properties.hibernate.format_sql: true
logging.level.org.hibernate.SQL: DEBUG
看到同一条查询带着不同 ID 反复出现,就是 N+1。
2. Fetch join:一条查询带出关联
JOIN FETCH 让 Hibernate 在同一条查询里把关联拉出来,而不是之后懒加载:
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") Status status);
一条查询,没有惊喜。注意别同时 fetch 多个集合——会产生笛卡尔积。一次 fetch 一个集合,或用 @BatchSize 提示。
3. EntityGraph:派生查询也能声明式预加载
想在派生查询上复用同样的急加载,@EntityGraph 保持声明式:
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(Status status);
4. 只读就别取实体
只读接口(列表、报表、下拉)很少需要完整托管实体。DTO 投影只选要用的列,跳过持久化上下文,返回更少数据:
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotal();
}
List<OrderSummary> findByStatus(Status status);
对报表这类高并发场景,这是杠杆最高的改动——每跳过的列都有累积效应。
5. 大结果集一律分页
返回无界列表是潜在的宕机。让 Spring Data 分页:
Page<OrderSummary> findByStatus(Status status, Pageable pageable);
大表深分页优先 keyset(游标)分页,别用大 OFFSET——后者让数据库扫描并丢弃行。
6. 把活推到数据库——并加索引
聚合、过滤、排序属于 SQL,不属于 Java 内存。没有索引就没有查询计划:确保 WHERE 和 JOIN 子句的列有索引,再用 EXPLAIN 确认。
实践建议
- 先测量再动手:开 SQL 日志数查询,优化真正拖后腿的那条,而不是你以为慢的那条;
- 修复优先级:N+1 → 只读投影 → 分页 → 索引,按影响排序;
- 笛卡尔积和深分页是隐藏陷阱,代码评审时重点盯。