1. 概述

JPA 规范提供了两种数据加载策略:立即加载(eager)和懒加载(lazy)。虽然懒加载能有效避免加载不必要的数据,但当我们需要在已关闭的 Persistence Context 中访问未初始化的数据时,就会遇到问题。尤其是访问懒加载的元素集合(Element Collection),是开发中常见的“踩坑”点。

本文将聚焦于如何正确加载懒加载的元素集合数据,并介绍三种实用解决方案:

  • 使用 JPA 查询语言(JPQL)
  • 借助实体图(Entity Graph)
  • 利用事务传播机制保持 Persistence Context 活跃

这些方案各有适用场景,可根据实际需求灵活选择。

2. Element Collection 的懒加载问题

默认情况下,@ElementCollection 关联关系采用懒加载策略。这意味着一旦 Persistence Context 关闭,再访问该集合就会抛出异常。

我们通过一个员工与电话号码的模型来演示这个问题:

@Entity
public class Employee {
    @Id
    private int id;
    private String name;
    @ElementCollection
    @CollectionTable(name = "employee_phone", joinColumns = @JoinColumn(name = "employee_id"))
    private List<Phone> phones;

    // standard constructors, getters, and setters
}

@Embeddable
public class Phone {
    private String type;
    private String areaCode;
    private String number;

    // standard constructors, getters, and setters
}

上面的模型表示:一个员工可以拥有多个电话号码,phones 是一个嵌入式对象(@Embeddable)的集合。

接下来定义一个 Spring 风格的 Repository:

@Repository
public class EmployeeRepository {

    @PersistenceContext
    private EntityManager em;

    public Employee findById(int id) {
        return em.find(Employee.class, id);
    }

    public void save(Employee employee) {
        em.persist(employee);
    }

    public void remove(int id) {
        Employee employee = em.find(Employee.class, id);
        if (employee != null) {
            em.remove(employee);
        }
    }
}

现在写一个测试来复现问题:

public class ElementCollectionIntegrationTest {

    @Autowired
    private EmployeeRepository employeeRepository;

    @Before
    public void init() {
        Employee employee = new Employee(1, "Fred");
        employee.setPhones(
            Arrays.asList(
                new Phone("work", "+55", "99999-9999"),
                new Phone("home", "+55", "98888-8888")
            )
        );
        employeeRepository.save(employee);
    }

    @After
    public void clean() {
        employeeRepository.remove(1);
    }

    @Test(expected = LazyInitializationException.class)
    public void whenAccessLazyCollection_thenThrowLazyInitializationException() {
        Employee employee = employeeRepository.findById(1);
        // ❌ Persistence Context 已关闭,访问 phones 触发异常
        assertThat(employee.getPhones().size(), is(2));
    }
}

⚠️ 测试会抛出 LazyInitializationException,因为 findById 方法执行完后事务结束,Persistence Context 被关闭,此时再访问 phones 集合就会失败。

虽然可以通过将 @ElementCollection(fetch = FetchType.EAGER) 改为立即加载来解决,但这并不是最优解——它会导致每次查询都加载电话数据,造成性能浪费。

3. 使用 JPA 查询语言(JPQL)加载数据

✅ JPQL 允许我们精确控制查询投影,从而主动加载懒加载字段。

我们可以在 EmployeeRepository 中新增一个方法,使用 JOIN FETCH 显式加载 phones 集合:

public Employee findByJPQL(int id) {
    return em.createQuery(
        "SELECT e FROM Employee e JOIN FETCH e.phones WHERE e.id = :id", 
        Employee.class)
        .setParameter("id", id)
        .getSingleResult();
}

📌 关键点:

  • JOIN FETCH 会在一条 SQL 中通过内连接(INNER JOIN)将主实体和集合数据一起查出
  • 生成的 SQL 类似:
SELECT e.*, p.* 
FROM Employee e 
INNER JOIN employee_phone p ON e.id = p.employee_id 
WHERE e.id = ?

这样返回的 Employee 对象中的 phones 集合已经是初始化状态,即使在事务外也可安全访问。

4. 使用 Entity Graph 加载数据

Entity Graph 是 JPA 提供的另一种声明式方式,用于定义查询时需要加载的属性图谱。

我们可以在 Repository 中使用它来显式指定加载 phones 字段:

public Employee findByEntityGraph(int id) {
    // 创建一个属性图,包含 name 和 phones
    EntityGraph<Employee> entityGraph = em.createEntityGraph(Employee.class);
    entityGraph.addAttributeNodes("name", "phones");

    // 设置查询属性,使用 fetchgraph 模式
    Map<String, Object> properties = new HashMap<>();
    properties.put("javax.persistence.fetchgraph", entityGraph);

    return em.find(Employee.class, id, properties);
}

📌 注意:

  • fetchgraph 表示只加载图中定义的字段,其他字段为 null
  • 若想保留其他字段的默认加载行为,可使用 javax.persistence.loadgraph

Entity Graph 的优势在于解耦了加载逻辑与查询语句,适合需要复用加载策略的场景。

5. 在事务范围内加载数据

最简单粗暴但非常有效的办法:延长 Persistence Context 的生命周期

Spring 的 @Transactional 注解可以将事务和 Persistence Context 绑定到当前线程,直到方法执行完毕。

我们修改测试方法,加上事务注解:

@Test
@Transactional
public void whenUseTransaction_thenFetchResult() {
    Employee employee = employeeRepository.findById(1);
    // ✅ 此时 Persistence Context 仍处于打开状态
    assertThat(employee.getPhones().size(), is(2));
}

📌 原理:

  • @Transactional 创建了一个事务代理
  • 事务从方法开始到结束持续存在
  • 所有在此期间执行的 JPA 操作共享同一个 Persistence Context
  • 因此即使在方法内部访问懒加载集合也不会抛异常

⚠️ 注意:这种方式适用于 Service 层或测试场景,但在 Web 层(如 Controller)滥用可能导致长事务,影响性能。

6. 总结

面对懒加载集合在关闭的 Persistence Context 中无法访问的问题,我们有三种主流解决方案:

方案 适用场景 优点 缺点
✅ JPQL + JOIN FETCH 精确控制查询 SQL 级别优化,性能好 查询耦合度高
✅ Entity Graph 多场景复用加载策略 声明式、可复用 配置稍复杂
@Transactional 测试或短事务场景 简单直接 易误用导致长事务

📌 实际开发建议:

  • 优先考虑 JPQL + JOIN FETCH,控制粒度最细
  • 多个接口需要相同加载逻辑时,使用 Entity Graph
  • 单元测试中可放心使用 @Transactional,避免 mock 复杂数据

示例代码已托管至 GitHub:https://github.com/baeldung/tutorials/tree/master/persistence-modules/spring-data-jpa-enterprise-2


原始标题:Working with Lazy Element Collections in JPA