Spring Boot JPA 懒加载报错怎么改:事务内转 DTO 与查询投影完整对照
- 发布时间:2026-10-30 20:00:11
- 本文热度:浏览 5 赞 0 评论 0
- 文章标签: Spring Boot JPA Hibernate
- 全文共1字,阅读约需1分钟
订单查到了,访问订单里的客户名称却失败:
org.hibernate.LazyInitializationException:
could not initialize proxy ... no Session
这里“查到订单”和“查到订单关联的客户”是两件事。如果客户关系被配置为懒加载,订单对象里可能只有尚未初始化的客户代理。等代码真正调用 getCustomer().getName(),Hibernate 才需要查客户;此时持久化上下文已经关闭,就没有可用的会话去完成加载。
解决问题不必把所有关系都改成 EAGER。先确认接口到底需要什么:业务流程确实需要访问实体关系,可以在 Service 事务里完成读取与 DTO 转换;只需要三个展示字段,可以直接投影查询 DTO;有明确的关联读取需求,则为这条查询指定抓取范围。下面用同一个订单详情接口对照这三种处理。
建立一个会稳定报错的项目
示例固定 JDK 17、Spring Boot 3.5.0、Maven 3.8.8,数据库使用进程内 H2。它不是 MySQL 兼容性测试,也不连接现有数据库。每次启动创建两张表和两笔订单,应用停止后数据随进程消失。
新建 jpa-lazy-lab,保存下面的依赖与配置。spring.jpa.open-in-view=false 很重要:本例需要把持久化上下文的范围收在数据访问或 Service 事务内,不能让一次 Web 请求把上下文一直带到外层,从而掩盖报错。
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.0</version><relativePath/></parent>
<groupId>lab</groupId><artifactId>jpa-lazy-lab</artifactId><version>1.0.0</version>
<properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/resources/application.properties:
server.address=127.0.0.1
server.port=31896
spring.datasource.url=jdbc:h2:mem:lazy_lab;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.jpa.show-sql=true
spring.main.banner-mode=off
表结构由 SQL 明确建立,Hibernate 使用 validate 检查映射,不再另行创建或更新表。建表脚本先于 EntityManagerFactory 初始化;不要同时改成 ddl-auto=create,让两套机制争着创建同名表。
src/main/resources/schema.sql:
CREATE TABLE shop_customer (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE shop_order (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
total_cents BIGINT NOT NULL CHECK (total_cents >= 0),
CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES shop_customer(id)
);
CREATE INDEX idx_order_customer ON shop_order(customer_id);
src/main/resources/data.sql:
INSERT INTO shop_customer(id, name) VALUES (1, '小林'), (2, '小周');
INSERT INTO shop_order(id, customer_id, total_cents) VALUES (101, 1, 12900), (102, 2, 25800);
src/main/java/lab/lazy/LazyApplication.java:
package lab.lazy;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LazyApplication {
public static void main(String[] args) { SpringApplication.run(LazyApplication.class, args); }
}
实体只声明本例用到的字段。客户关联显式设为 LAZY,不要依赖 ManyToOne 的默认抓取方式。也没有给实体套自动生成全部字段的 toString,避免调试输出无意访问关联,让观察过程改变加载状态。
src/main/java/lab/lazy/Customer.java:
package lab.lazy;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "shop_customer")
public class Customer {
@Id private Long id;
@Column(nullable = false, length = 100) private String name;
protected Customer() {}
public Long getId() { return id; }
public String getName() { return name; }
}
src/main/java/lab/lazy/ShopOrder.java:
package lab.lazy;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "shop_order")
public class ShopOrder {
@Id private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@Column(name = "total_cents", nullable = false) private long totalCents;
protected ShopOrder() {}
public Long getId() { return id; }
public Customer getCustomer() { return customer; }
public long getTotalCents() { return totalCents; }
}
接口最终返回一个普通 record,里面只有订单编号、客户名与金额分值。它没有 Hibernate 代理,也不把持久化实体结构直接暴露给 JSON 序列化器。
src/main/java/lab/lazy/OrderView.java:
package lab.lazy;
public record OrderView(Long id, String customerName, long totalCents) {}
Repository 保留原有 findById,另外加两个有明确用途的查询。findViewById 用 JPQL 构造表达式直接生成 DTO,类名写全限定名,参数类型与 record 构造器一致。findWithCustomer 仍返回实体,但用实体图要求本次查询加载客户关系;它没有把整个实体模型的关系永久改成急加载。
src/main/java/lab/lazy/OrderRepository.java:
package lab.lazy;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface OrderRepository extends JpaRepository<ShopOrder, Long> {
@Query("select new lab.lazy.OrderView(o.id, c.name, o.totalCents) from ShopOrder o join o.customer c where o.id = :id")
Optional<OrderView> findViewById(@Param("id") long id);
@EntityGraph(attributePaths = "customer")
@Query("select o from ShopOrder o where o.id = :id")
Optional<ShopOrder> findWithCustomer(@Param("id") long id);
}
同一个转换,放在会话外还是会话内
broken 不加 Service 事务。它调用继承来的 findById,Repository 的读取事务结束后再进入 view,客户名称正是在这一步需要懒加载。
insideTransaction 的代码看起来几乎一样,区别是 Service 方法本身有事务。Controller 通过 Spring 注入的 Service 代理调用它,Repository 读取加入外层事务,DTO 组装也在这个范围里完成。方法返回时,客户名称已经是字符串,后续序列化不再请求数据库。
src/main/java/lab/lazy/OrderService.java:
package lab.lazy;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) { this.orders = orders; }
public OrderView broken(long id) {
ShopOrder order = orders.findById(id).orElseThrow(this::missing);
return view(order);
}
@Transactional(readOnly = true)
public OrderView insideTransaction(long id) {
ShopOrder order = orders.findById(id).orElseThrow(this::missing);
return view(order);
}
public OrderView projection(long id) { return orders.findViewById(id).orElseThrow(this::missing); }
public OrderView graph(long id) { return view(orders.findWithCustomer(id).orElseThrow(this::missing)); }
private OrderView view(ShopOrder order) {
return new OrderView(order.getId(), order.getCustomer().getName(), order.getTotalCents());
}
private ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND"); }
}
事务要覆盖“读实体到完成所需关联访问”的工作,不能只把注解挪到 Controller 后就认为查询范围设计完成了。

为了便于观察,错误处理器把本次故意制造的懒加载异常映射为 500、LAZY_OUTSIDE_SESSION。这是告诉读者故障发生了,并没有通过捕获异常把客户名补齐;正式业务仍应修复加载边界。订单不存在使用 404,不能和加载方式错误混为一谈。
src/main/java/lab/lazy/OrderController.java:
package lab.lazy;
import java.util.Map;
import org.hibernate.LazyInitializationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestController
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) { this.orders = orders; }
@GetMapping("/orders/{id}/broken") public OrderView broken(@PathVariable long id) { return orders.broken(id); }
@GetMapping("/orders/{id}/transaction") public OrderView transaction(@PathVariable long id) { return orders.insideTransaction(id); }
@GetMapping("/orders/{id}/projection") public OrderView projection(@PathVariable long id) { return orders.projection(id); }
@GetMapping("/orders/{id}/graph") public OrderView graph(@PathVariable long id) { return orders.graph(id); }
}
@RestControllerAdvice
class LazyError {
@ExceptionHandler(LazyInitializationException.class)
ResponseEntity<Map<String,String>> handle() {
return ResponseEntity.internalServerError().body(Map.of("code", "LAZY_OUTSIDE_SESSION"));
}
}
在项目根目录构建并运行:
mvn clean package
java -jar target/jpa-lazy-lab-1.0.0.jar
另开终端,按顺序请求:
curl -i http://127.0.0.1:31896/orders/101/broken
curl -i http://127.0.0.1:31896/orders/101/transaction
curl -i http://127.0.0.1:31896/orders/101/projection
curl -i http://127.0.0.1:31896/orders/101/graph
curl -i http://127.0.0.1:31896/orders/999/projection
第一条应为 500 与 LAZY_OUTSIDE_SESSION,中间三条都返回 {"id":101,"customerName":"小林","totalCents":12900},最后一条为 404。继续请求第一条,仍应报错;不能因为刚查过客户,就假设所有后续请求都共用上一请求的持久化上下文。
控制台会输出 SQL。事务路线先查订单,在访问客户名时再查客户;DTO 投影明确选择需要的字段;实体图则让这一条查询把客户加载进来。这里没有把单条订单的成功结果当成列表查询性能结论。将来改成订单列表,需要查看实际 SQL 次数,避免在循环中对每条订单补一条客户查询。
测试别替业务补上一层事务
下面测试没有标注 @Transactional,否则测试框架创建的外层事务可能让 broken 也能加载客户,把本来要检查的问题藏起来。保存测试文件后重新执行 mvn clean package,应通过五个测试。
src/test/java/lab/lazy/LazyTest.java:
package lab.lazy;
import org.hibernate.LazyInitializationException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class LazyTest {
@Autowired OrderService orders;
@Test void brokenIsActuallyDetached() { assertThrows(LazyInitializationException.class, () -> orders.broken(101)); }
@Test void transactionMapsBeforeSessionCloses() { assertEquals(new OrderView(101L, "小林", 12900), orders.insideTransaction(101)); }
@Test void dtoProjectionHasNoLazyField() { assertEquals(new OrderView(101L, "小林", 12900), orders.projection(101)); }
@Test void graphLoadsRequestedAssociation() { assertEquals(new OrderView(102L, "小周", 25800), orders.graph(102)); }
@Test void absentOrderRemainsNotFound() {
assertEquals(404, assertThrows(ResponseStatusException.class, () -> orders.projection(999)).getStatusCode().value());
}
}
可以在练习副本中只删除 insideTransaction 上的注解,再运行 mvn test:对应测试应出现懒加载异常,其他查询路线不需要靠这条事务才工作。恢复注解后重新构建。
另一个对照是在停止应用后,用 java -jar target/jpa-lazy-lab-1.0.0.jar --spring.jpa.open-in-view=true 启动。此时同样的 broken 请求可以成功,因为 Web 请求范围内的上下文仍然开放;这说明错误是否出现受上下文边界影响,不证明那条方法已经补齐了自己的加载计划。对只读详情页,优先选择字段明确的 DTO 查询;需要围绕实体继续完成业务时,再在 Service 事务中加载并转换所需数据。练习结束后停止应用,不要把这组会反复初始化数据的脚本配置直接用于已有业务库。