Zademy

JPA 和高级关联:完整类指南

JPA; Hibernate; Spring Data; ORM; Performance
words 字

这篇文章把 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=25

E. 陷阱: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 的实践总结。