JPA 和高级关联:完整类指南
这篇文章把 JPA 关联、获取策略和投影一次讲透。都是实际项目里反复踩过的坑,不是教科书上的理论。
I. JPA 介绍和关联类型
JPA(Java 持久化 API)是 Java ORM 的标准,实现通常用 Hibernate。它让你用 Java 对象操作数据库,不用手写 SQL(大部分时候)。
什么是关联?
关联定义实体之间的关系,对应业务对象在现实中的联系。四种基本类型:@OneToOne(用户对应一个档案)、@OneToMany(订单包含多个订单行)、@ManyToOne(多个员工属于一个部门)、@ManyToMany(学生和课程多对多)。
基本概念
往下看之前,这几个概念得先理清:
所有者端:持有外键的一方,负责管理数据库中的 FK 关系。反向端:用 mappedBy 声明,不碰 FK。FetchType:决定数据什么时候加载,LAZY 是用到才查,EAGER 是立刻查全。CascadeType:定义哪些操作(PERSIST、MERGE、REMOVE 等)会级联传播到关联实体。
II. 一对一关系(@OneToOne)
实体 A 的一个实例关联实体 B 的一个实例。拆表的理由无非几种:把很少访问的大字段分离出去、给高频写入的主实体减负、或者敏感数据需要单独的权限控制。
默认行为
⚠️
@OneToOne默认是FetchType.EAGER。这是个坑,几乎每次都要手动改成LAZY。
基本示例:单向
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
private UserProfile profile;
// Getters and setters
}
@Entity
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
// Getters and setters
}高级示例:使用 @MapsId 的双向
双向 @OneToOne 的最佳实践是用 @MapsId。两张表共享主键,反向端能真正实现懒加载,还省了一个额外的 FK 列。
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private CustomerDetails details;
// 维护同步的实用方法
public void setDetails(CustomerDetails details) {
if (details == null) {
if (this.details != null) {
this.details.setCustomer(null);
}
} else {
details.setCustomer(this);
}
this.details = details;
}
}
@Entity
public class CustomerDetails {
@Id
private Long id; // 与 Customer 相同的 PK
private String address;
private String phone;
private LocalDate birthDate;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // 将 Customer 的 PK 映射为 FK 和 PK
@JoinColumn(name = "customer_id")
private Customer customer;
// Getters and setters
}生成的 SQL
CREATE TABLE customer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255),
email VARCHAR(255)
);
CREATE TABLE customer_details (
customer_id BIGINT PRIMARY KEY, -- PK 和 FK 同时
address VARCHAR(255),
phone VARCHAR(255),
birth_date DATE,
FOREIGN KEY (customer_id) REFERENCES customer(id)
);@OneToOne 中的常见错误
这三个坑我反复见过。默认 EAGER 导致 N+1 是最常见的——改成 LAZY 就行。反向端懒加载不生效是因为 Hibernate 无法创建代理,用 @MapsId 或字节码增强解决。orphanRemoval = true 别忘了加,否则父级删除后详情变成孤儿数据。
III. 一对多(@OneToMany)和多对一(@ManyToOne)关系
这俩是硬币的两面:一个实体上有 @ManyToOne,反过来就是 @OneToMany。
A. @ManyToOne - 外键端
定义在持有外键的实体上。多个子实体指向同一个父实体。
⚠️
@ManyToOne默认也是FetchType.EAGER。务必改成LAZY,这是我写 JPA 代码的第一条铁律。
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // 始终 LAZY!
@JoinColumn(name = "department_id", nullable = false)
private Department department;
// Getters and setters
}B. @OneToMany - 集合端
定义在持有集合的实体上。好消息是 @OneToMany 默认就是 FetchType.LAZY,不用改。
@Entity
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Employee> employees = new ArrayList<>();
// Getters and setters
}C. 双向性和所有权
双向关联必须有一方是所有者,另一方是反向。
🔑 关键规则:"多" 端(
@ManyToOne)必须是所有者。这不是建议,是性能要求。
完整示例:订单和订单行
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime orderDate;
private String status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
// ✅ 维护双向同步的实用方法
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}
}
@Entity
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private Integer quantity;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order; // 此端管理 FK
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderLine)) return false;
OrderLine that = (OrderLine) o;
return id != null && id.equals(that.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}生成的 SQL
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_date DATETIME,
status VARCHAR(50)
);
CREATE TABLE order_line (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_name VARCHAR(255),
quantity INT,
price DECIMAL(10,2),
order_id BIGINT NOT NULL, -- FK 由 @ManyToOne 管理
FOREIGN KEY (order_id) REFERENCES orders(id)
);性能陷阱:为什么多端必须是所有者?
这个问题的本质是 SQL 生成的数量。@ManyToOne 做所有者时,INSERT 语句直接带上 FK,一条搞定。但如果让 @OneToMany 做所有者,Hibernate 得先 INSERT 子记录,再额外发 UPDATE 去设置 FK——每条记录多一次数据库往返。
// ❌ 错误:`@OneToMany` 作为所有者(无 mappedBy)
@OneToMany
@JoinColumn(name = "order_id") // 这使 Order 成为所有者
private List<OrderLine> lines;
// 结果:INSERT order_line + UPDATE order_line SET order_id = ?// ✅ 好:`@ManyToOne` 作为所有者
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// 结果:INSERT order_line(包含 order_id)@OneToMany / @ManyToOne 中的常见错误
双向关联不同步是最隐蔽的坑——双方状态不一致,查出来的数据就对不上,必须用 addLine、removeLine 这类工具方法保证同步。LazyInitializationException 几乎都是因为在事务外访问了懒加载集合,要么用 JOIN FETCH 预加载,要么确保 @Transactional 覆盖到。集合里出现重复项,多半是 equals/hashCode 没按 ID 或业务键实现。
IV. 多对多关系(@ManyToMany)
@ManyToMany 用起来方便,但坑不少。
基本示例:双向
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>(); // ✅ 使用 Set,而不是 List
// 实用方法
public void addCourse(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void removeCourse(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Course)) return false;
Course course = (Course) o;
return id != null && id.equals(course.getId());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}生成的 SQL
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255)
);
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255)
);
CREATE TABLE student_course (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES student(id),
FOREIGN KEY (course_id) REFERENCES course(id)
);最佳实践和常见错误
第一条,永远用 Set 不用 List。用 List 时 Hibernate 删除一条关联记录会先删掉该学生的所有关联行,再把剩下的逐条 INSERT 回去——大规模删除加重新插入,性能灾难。Set 的话只删一行。第二条,双向关联必须有工具方法同步双方。第三条,FetchType.LAZY 是默认值,别去改。第四条,级联只给 PERSIST 和 MERGE,千万别给 REMOVE——多对多关系里的实体是共享的,删一个学生把课程也级联删了,其他学生就炸了。第五条,equals/hashCode 按 ID 实现。
为什么 @ManyToMany 中不使用 List?
// ❌ 错误:使用 List
@ManyToMany
private List<Course> courses = new ArrayList<>();
// 删除一个课程时,Hibernate 执行:
// DELETE FROM student_course WHERE student_id = ? -- 全部!
// INSERT INTO student_course VALUES (?, ?) -- 重新插入剩余
// INSERT INTO student_course VALUES (?, ?)
// ...// ✅ 好:使用 Set
@ManyToMany
private Set<Course> courses = new HashSet<>();
// 删除一个课程时,Hibernate 执行:
// DELETE FROM student_course WHERE student_id = ? AND course_id = ? -- 仅一个高级模式:链接实体
当关联本身需要携带额外属性(注册日期、成绩等),就得把多对多拆成两个多对一,中间加一个链接实体。
@Entity
public class Enrollment {
@EmbeddedId
private EnrollmentId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("studentId")
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("courseId")
@JoinColumn(name = "course_id")
private Course course;
private LocalDate enrollmentDate;
private Double grade;
// 构造函数、getter、setter
}
@Embeddable
public class EnrollmentId implements Serializable {
private Long studentId;
private Long courseId;
// equals, hashCode
}V. 高效数据加载:N+1 问题和获取策略
A. N+1 SELECT 问题
N+1 是 JPA 性能问题的头号杀手。查 1 次拿到主实体列表,然后对每个实体的关联属性各查 1 次,总共 N+1 条 SQL。查 100 条订单就是 101 次数据库往返。
// 导致 N+1 的代码
List<Order> orders = orderRepository.findAll(); // 1 个查询
for (Order order : orders) {
// 每次访问 lines 都会触发额外查询
System.out.println(order.getLines().size()); // N 个查询
}-- 查询 1:获取订单
SELECT * FROM orders;
-- 查询 N:每个订单一个
SELECT * FROM order_line WHERE order_id = 1;
SELECT * FROM order_line WHERE order_id = 2;
SELECT * FROM order_line WHERE order_id = 3;
-- ... N 次B. 解决方案 1:JOIN FETCH(JPQL)
JOIN FETCH 告诉 Hibernate 在主查询里直接把关联数据一起查出来。
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lines WHERE o.status = :status")
List<Order> findByStatusWithLines(@Param("status") String status);
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.lines l " +
"JOIN FETCH o.customer " +
"WHERE o.orderDate >= :date")
List<Order> findRecentOrdersWithDetails(@Param("date") LocalDateTime date);
}-- 带 JOIN 的单个查询
SELECT DISTINCT o.*, l.*, c.*
FROM orders o
INNER JOIN order_line l ON o.id = l.order_id
INNER JOIN customer c ON o.customer_id = c.id
WHERE o.order_date >= ?C. 解决方案 2:实体图
实体图用声明式方式定义要预加载的关联,跟 JOIN FETCH 效果一样,但代码更干净,查询方法可以复用同一个图。
@Entity
@NamedEntityGraph(
name = "Order.withLinesAndCustomer",
attributeNodes = {
@NamedAttributeNode("lines"),
@NamedAttributeNode("customer")
}
)
public class Order {
// ...
}public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(value = "Order.withLinesAndCustomer")
List<Order> findByStatus(String status);
// 或定义内联
@EntityGraph(attributePaths = {"lines", "customer"})
Optional<Order> findById(Long id);
}@Service
@Transactional(readOnly = true)
public class OrderService {
@PersistenceContext
private EntityManager em;
public List<Order> findOrdersWithDetails() {
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("lines");
graph.addSubgraph("customer").addAttributeNodes("details");
return em.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
}D. 解决方案 3:批处理获取
批处理获取把 N+1 压缩成 N/batchSize + 1。Hibernate 用 IN 子句批量加载关联,而不是逐条查。
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 25) // 每个查询最多加载 25 个集合
private List<OrderLine> lines;
}-- 而不是 N 个单独查询:
SELECT * FROM order_line WHERE order_id IN (1, 2, 3, ..., 25);
SELECT * FROM order_line WHERE order_id IN (26, 27, 28, ..., 50);
-- 等等。全局配置:
# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25E. 陷阱:MultipleBagFetchException
❌ 错误:
cannot simultaneously fetch multiple bags
当你试图在同一条 JPQL 里 JOIN FETCH 多个 List 类型的集合时,Hibernate 会抛这个异常。因为多个 bag 的笛卡尔积无法正确分页。
// ❌ 这会失败
@Query("SELECT o FROM Order o JOIN FETCH o.lines JOIN FETCH o.payments")
List<Order> findWithLinesAndPayments();两种解法。一是把 List 换成 Set:
@OneToMany(mappedBy = "order")
private Set<OrderLine> lines = new HashSet<>();
@OneToMany(mappedBy = "order")
private Set<Payment> payments = new HashSet<>();二是拆成多条查询,分别 fetch 再在内存里合并:
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lines")
List<Order> findWithLines();
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.payments WHERE o IN :orders")
List<Order> fetchPayments(@Param("orders") List<Order> orders);F. 何时不使用 JOIN FETCH
⚠️ 有时,N+1 比 大量 JOIN 更好。
这个反直觉但很关键。多个 JOIN FETCH 拼在一起会产生笛卡尔积。假设 54 条订单,每条关联 54 个行和 54 个支付,JOIN 结果是 157,464 行。数据库光传输这些重复数据就够慢了。
拆成 3 条独立查询,各自 54 行,总共 162 行。快得多。
经验法则:1 到 2 个关联用 JOIN FETCH,再多就考虑批处理获取或拆分查询。
VI. 投影:优化数据检索
不是每次查询都需要完整的实体。列表页只要标题和日期,为什么要查出整棵实体树?投影只取需要的字段,省内存、省网络。
A. 基于接口的投影
最简单的写法。定义一个接口,Spring Data JPA 自动生成动态代理。
// 定义投影
public interface OrderSummary {
Long getId();
LocalDateTime getOrderDate();
String getStatus();
// 使用 SpEL 的计算属性
@Value("#{target.lines.size()}")
int getLineCount();
}
// 在仓库中使用
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(String status);
@Query("SELECT o.id as id, o.orderDate as orderDate, o.status as status " +
"FROM Order o WHERE o.customer.id = :customerId")
List<OrderSummary> findSummariesByCustomer(@Param("customerId") Long customerId);
}接口投影的局限:代理对象无法自定义 equals/hashCode,复杂查询性能不如 DTO。
B. 基于类的投影(DTO)
复杂查询的首选。用 Java 16+ 的 record 一行搞定。
public record OrderDTO(
Long id,
LocalDateTime orderDate,
String status,
String customerName,
BigDecimal totalAmount
) {}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT new com.example.dto.OrderDTO(
o.id,
o.orderDate,
o.status,
c.name,
SUM(l.price * l.quantity)
)
FROM Order o
JOIN o.customer c
JOIN o.lines l
WHERE o.status = :status
GROUP BY o.id, o.orderDate, o.status, c.name
""")
List<OrderDTO> findOrderSummaries(@Param("status") String status);
}传统 POJO 也行,就是更啰嗦:
public class OrderDetailDTO {
private Long orderId;
private String customerName;
private List<LineDTO> lines;
public OrderDetailDTO(Long orderId, String customerName) {
this.orderId = orderId;
this.customerName = customerName;
this.lines = new ArrayList<>();
}
// Getters, setters
}
public class LineDTO {
private String productName;
private Integer quantity;
private BigDecimal price;
public LineDTO(String productName, Integer quantity, BigDecimal price) {
this.productName = productName;
this.quantity = quantity;
this.price = price;
}
}C. 动态投影
同一个查询方法,根据传入的 Class 类型返回不同投影。适合一个 API 端点服务多个视图的场景。
public interface OrderRepository extends JpaRepository<Order, Long> {
// 接受任何投影类型的通用方法
<T> List<T> findByStatus(String status, Class<T> type);
<T> Optional<T> findById(Long id, Class<T> type);
}
// 使用
List<OrderSummary> summaries = orderRepository.findByStatus("PENDING", OrderSummary.class);
List<OrderDTO> dtos = orderRepository.findByStatus("PENDING", OrderDTO.class);
List<Order> entities = orderRepository.findByStatus("PENDING", Order.class);D. 元组投影
不想专门建 DTO 类时用 Tuple 或 Object[]。缺点是可读性差,字段靠下标取,重构时容易出错。
@Query("SELECT o.id, o.status, COUNT(l) FROM Order o LEFT JOIN o.lines l GROUP BY o.id, o.status")
List<Object[]> findOrderStats();
// 或使用 Tuple
@Query("SELECT o.id as id, o.status as status, COUNT(l) as lineCount " +
"FROM Order o LEFT JOIN o.lines l GROUP BY o.id, o.status")
List<Tuple> findOrderStatsTuple();
// 使用
List<Tuple> stats = orderRepository.findOrderStatsTuple();
for (Tuple t : stats) {
Long id = t.get("id", Long.class);
String status = t.get("status", String.class);
Long count = t.get("lineCount", Long.class);
}投影比较
简单查询用接口投影,代码最少。复杂查询上 DTO 或 record,性能最好、控制力最强。一个端点要返回多种结构时考虑动态投影。临时跑数据查询可以用元组,正式代码里我不推荐。
VII. 原生查询
JPQL 搞不定的时候(数据库特定函数、窗口函数、CTE),就得写原生 SQL。
A. JPQL vs 原生查询
JPQL 胜在数据库无关、可移植。原生查询胜在能用完整 SQL 能力,代价是绑定了具体数据库。
B. 基本语法
public interface OrderRepository extends JpaRepository<Order, Long> {
// 简单原生查询
@Query(value = "SELECT * FROM orders WHERE status = ?1", nativeQuery = true)
List<Order> findByStatusNative(String status);
// 使用 @NativeQuery(Spring Data 3.x)
@NativeQuery("SELECT * FROM orders WHERE YEAR(order_date) = :year")
List<Order> findByYear(@Param("year") int year);
// 带分页
@Query(
value = "SELECT * FROM orders WHERE status = :status",
countQuery = "SELECT COUNT(*) FROM orders WHERE status = :status",
nativeQuery = true
)
Page<Order> findByStatusPaged(@Param("status") String status, Pageable pageable);
}C. 原生查询的投影
// 投影到接口
public interface OrderNativeSummary {
Long getId();
String getStatus();
Integer getLineCount();
}
@Query(value = """
SELECT o.id, o.status, COUNT(l.id) as lineCount
FROM orders o
LEFT JOIN order_line l ON o.id = l.order_id
GROUP BY o.id, o.status
""", nativeQuery = true)
List<OrderNativeSummary> findNativeSummaries();
// 投影到 Map
@NativeQuery("SELECT * FROM orders WHERE id = :id")
Map<String, Object> findRawById(@Param("id") Long id);D. 原生查询的分层 DTO
复杂结构(比如订单带嵌套的订单行列表)用原生查询投影比较麻烦。需要自定义仓库实现,用 ResultTransformer 或手动组装。
// 分层 DTO
public class OrderWithLinesDTO {
private Long id;
private String status;
private List<LineDTO> lines = new ArrayList<>();
}
// 自定义仓库实现
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
@SuppressWarnings("unchecked")
public List<OrderWithLinesDTO> findOrdersWithLinesNative() {
String sql = """
SELECT o.id as orderId, o.status,
l.id as lineId, l.product_name, l.quantity, l.price
FROM orders o
LEFT JOIN order_line l ON o.id = l.order_id
ORDER BY o.id
""";
List<Object[]> results = em.createNativeQuery(sql).getResultList();
Map<Long, OrderWithLinesDTO> orderMap = new LinkedHashMap<>();
for (Object[] row : results) {
Long orderId = ((Number) row[0]).longValue();
OrderWithLinesDTO order = orderMap.computeIfAbsent(orderId, id -> {
OrderWithLinesDTO dto = new OrderWithLinesDTO();
dto.setId(id);
dto.setStatus((String) row[1]);
return dto;
});
if (row[2] != null) { // 如果有行
LineDTO line = new LineDTO(
(String) row[3],
((Number) row[4]).intValue(),
(BigDecimal) row[5]
);
order.getLines().add(line);
}
}
return new ArrayList<>(orderMap.values());
}
}E. 何时使用原生查询
数据库特定函数(PostgreSQL 的 JSONB、数组操作)、窗口函数(ROW_NUMBER、RANK)、CTE(WITH 子句),这些 JPQL 写不了,必须上原生 SQL。简单 CRUD 和需要跨数据库可移植的场景,老老实实用 JPQL。
VIII. 自定义仓库
Spring Data JPA 的派生查询和 @Query 覆盖了大部分场景,但动态条件查询(根据用户筛选条件拼接 WHERE 子句)还是得用 Criteria API。这时候就写自定义仓库。
结构
// 1. 带自定义方法的接口
public interface OrderRepositoryCustom {
List<Order> findOrdersDynamic(OrderSearchCriteria criteria);
List<OrderWithLinesDTO> findOrdersWithLinesNative();
}
// 2. 实现(名称必须以 "Impl" 结尾)
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public List<Order> findOrdersDynamic(OrderSearchCriteria criteria) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> order = query.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
if (criteria.getStatus() != null) {
predicates.add(cb.equal(order.get("status"), criteria.getStatus()));
}
if (criteria.getFromDate() != null) {
predicates.add(cb.greaterThanOrEqualTo(
order.get("orderDate"), criteria.getFromDate()));
}
if (criteria.getCustomerId() != null) {
predicates.add(cb.equal(
order.get("customer").get("id"), criteria.getCustomerId()));
}
// 动态 JOIN FETCH
if (criteria.isFetchLines()) {
order.fetch("lines", JoinType.LEFT);
}
query.where(predicates.toArray(new Predicate[0]));
query.distinct(true);
return em.createQuery(query).getResultList();
}
}
// 3. 主仓库扩展两者
public interface OrderRepository extends
JpaRepository<Order, Long>,
OrderRepositoryCustom {
// 标准查询方法在这里
}IX. 最佳实践总结
关联
几乎所有 JPA 性能问题都可以归结到这几个点:FetchType 没用 LAZY、双向关联没同步、多对多用了 List。记住这些就够了:
@ManyToOne和@OneToOne一律FetchType.LAZY,没有例外- 双向关联里"多"端必须是所有者
@ManyToMany用Set不用List- 双向关联写工具方法维护同步
- 双向
@OneToOne用@MapsId @ManyToMany里别用CascadeType.REMOVE
获取
N+1 问题优先用 JOIN FETCH 解决,1 到 2 个关联时效果最好。声明式场景用实体图,代码更清晰。多个集合要预加载时用批处理获取,避开 MultipleBagFetchException。别盲目 JOIN,笛卡尔积爆炸时拆成多条查询反而更快。
投影
只读查询用投影,别查出完整实体树。DTO 用 record,简洁又不可变。简单场景接口投影够用,复杂场景上自定义仓库。
X. 代码审查清单
review JPA 代码时过一遍这个清单,能拦住大部分常见问题:
- 所有
@ManyToOne和@OneToOne关联是否都有FetchType.LAZY? -
@ManyToMany集合是否使用Set而不是List? - 双向关系中
@ManyToOne端是否是所有者? - 是否有实用方法来同步双向关联?
- 实体中是否正确实现了
equals()和hashCode()? - 加载集合的查询是否使用
JOIN FETCH或实体图? - 只读查询是否使用投影?
-
@ManyToMany中是否避免了CascadeType.REMOVE? - 带多个 JOIN 的查询是否不生成巨大的笛卡尔积?
本文基于 Hibernate ORM 官方文档、Spring Data JPA 文档,以及 Vlad Mihalcea 和 Thorben Janssen 的实践总结。