订单先保存,再撤掉全部明细,如果两次事务复用同一个 EntityManager,额外调用一次 merge,在 Hibernate 7.4.14.Final 上会让提交回滚。11 月 6 日公布的稳定版 7.4.15.Final 修复了这条路径。Spring Boot + H2 的四组对照中,旧版只有“复用上下文并调用 merge”失败,升级后四组都提交成功,明细表也确实删空。
实体还在托管,为什么提交却说它已脱管
场景不涉及把集合换成另一个 List。一个订单原来有一条明细,业务调用 order.lines.clear() 删除明细,关联上配置了 orphanRemoval=true。订单已经托管,但通用保存逻辑又调用了 em.merge(order)。
出错条件还有一层:创建订单的事务已经提交,EntityManager 没有关闭,第二个事务继续使用原来的订单对象。事务结束不等于持久化上下文被清空。实验在删明细之前用 em.contains(order) 检查,结果仍是 true。
这与每次事务新建上下文、重新 find 的路径不同。后者也放进了对照组,旧版正常,因此不能把这次修复泛化成“清空 orphanRemoval 集合都会失败”,也不能直接套到所有 Spring Data JPA 的 save 调用上。
官方 7.4.15 变更表列出了 HHH-20973、HHH-20670 和 HHH-19419。对应修复的说明是保留 managed collection 的状态,并补充清空集合后 merge、孤儿行删除等回归覆盖。下面只验证订单明细这条路径。
用 Boot 启动,事务边界由实验显式控制
环境为 Java 21.0.12.1、Spring Boot 4.1.1、H2 2.4.240。Hibernate 分别固定为 7.4.14.Final 和 7.4.15.Final。两版都是正式版,不是 RC 或快照;这里测试的是 Hibernate 的集合合并行为,不是 Boot 自动配置的新功能。
项目的 pom.xml 如下。覆盖 hibernate.version 是为了控制实验变量,不表示 Boot 的所有组合都已做过兼容性验证。
<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>4.1.1</version><relativePath/></parent>
<groupId>demo</groupId><artifactId>orphan-merge-demo</artifactId><version>1.0</version>
<properties><java.version>21</java.version><hibernate.version>7.4.14.Final</hibernate.version></properties>
<dependencies>
<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>
</dependencies>
<build><finalName>orphan-merge-${hibernate.version}</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/resources/application.properties:
spring.main.web-application-type=none
spring.main.banner-mode=off
spring.datasource.url=jdbc:h2:mem:orphan;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
logging.level.root=WARN
src/main/java/demo/Demo.java 中,每组各建一张订单,提交后再清空明细。new-context 会先关掉旧 EntityManager,再重新查询订单;same-context 则保留原上下文。失败时显式回滚,最后另开 EntityManager 查询数据库,避免只看内存中的空 List。
package demo;
import jakarta.persistence.*;
import java.util.*;
import org.hibernate.Version;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class Demo {
public static void main(String[] args) {
try (var context = SpringApplication.run(Demo.class, args)) { }
}
@Bean
CommandLineRunner run(EntityManagerFactory emf) {
return args -> {
System.out.printf("RUNTIME hibernate=%s java=%s pid=%d%n", Version.getVersionString(), System.getProperty("java.version"), ProcessHandle.current().pid());
for (boolean reuseContext : new boolean[]{false, true}) {
for (boolean merge : new boolean[]{false, true}) {
trial(emf, reuseContext, merge);
}
}
};
}
static void trial(EntityManagerFactory emf, boolean reuseContext, boolean merge) {
String scenario = (reuseContext ? "same-context" : "new-context") + (merge ? "-merge" : "-no-merge");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
PurchaseOrder order = new PurchaseOrder();
OrderLine line = new OrderLine();
line.order = order;
order.lines.add(line);
em.persist(order);
em.getTransaction().commit();
Long id = order.id;
if (!reuseContext) {
em.close();
em = emf.createEntityManager();
}
String result = "COMMITTED";
try {
em.getTransaction().begin();
if (!reuseContext) order = em.find(PurchaseOrder.class, id);
System.out.printf("BEFORE scenario=%s managed=%s lines=%d%n", scenario, em.contains(order), order.lines.size());
order.lines.clear();
if (merge) em.merge(order);
em.flush();
em.getTransaction().commit();
} catch (RuntimeException e) {
result = e.getClass().getSimpleName();
System.out.printf("FAILURE scenario=%s message=%s%n", scenario, e.getMessage());
e.printStackTrace(System.out);
if (em.getTransaction().isActive()) em.getTransaction().rollback();
} finally { em.close(); }
try (EntityManager check = emf.createEntityManager()) {
long rows = check.createQuery("select count(l) from OrderLine l where l.order.id = :id", Long.class).setParameter("id", id).getSingleResult();
System.out.printf("RESULT scenario=%s status=%s childRows=%d%n", scenario, result, rows);
}
}
@Entity(name="PurchaseOrder") @Table(name="purchase_orders")
public static class PurchaseOrder {
@Id @GeneratedValue Long id;
@OneToMany(mappedBy="order", cascade=CascadeType.ALL, orphanRemoval=true)
List<OrderLine> lines = new ArrayList<>();
}
@Entity(name="OrderLine") @Table(name="order_lines")
public static class OrderLine {
@Id @GeneratedValue Long id;
@ManyToOne PurchaseOrder order;
}
}
在项目目录分别构建、启动两个独立 JVM。两次运行的 H2 内存库也随进程隔离:
mkdir -p logs
set -o pipefail
mvn -B -Dhibernate.version=7.4.14.Final -DskipTests package 2>&1 | tee logs/build-7.4.14.Final.log
java -jar target/orphan-merge-7.4.14.Final.jar 2>&1 | tee logs/run-7.4.14.Final.log
mvn -B -Dhibernate.version=7.4.15.Final -DskipTests package 2>&1 | tee logs/build-7.4.15.Final.log
java -jar target/orphan-merge-7.4.15.Final.jar 2>&1 | tee logs/run-7.4.15.Final.log
差异在 commit,不只看 flush 是否返回
完整旧版日志包含异常栈。为便于对照,下面用同一条过滤规则保留运行版本、提交前状态、失败信息和查库结果,未改写输出内容:
rg '^RUNTIME|^BEFORE|^RESULT|^FAILURE' logs/run-7.4.14.Final.log | tee logs/summary-7.4.14.Final.log
rg '^RUNTIME|^BEFORE|^RESULT|^FAILURE' logs/run-7.4.15.Final.log | tee logs/summary-7.4.15.Final.log
7.4.14.Final:
RUNTIME hibernate=7.4.14.Final java=21.0.12.1 pid=2009170
BEFORE scenario=new-context-no-merge managed=true lines=1
RESULT scenario=new-context-no-merge status=COMMITTED childRows=0
BEFORE scenario=new-context-merge managed=true lines=1
RESULT scenario=new-context-merge status=COMMITTED childRows=0
BEFORE scenario=same-context-no-merge managed=true lines=1
RESULT scenario=same-context-no-merge status=COMMITTED childRows=0
BEFORE scenario=same-context-merge managed=true lines=1
FAILURE scenario=same-context-merge message=Error while committing the transaction [org.hibernate.DetachedObjectException: Given entity is not associated with the persistence context]
RESULT scenario=same-context-merge status=RollbackException childRows=1
7.4.15.Final:
RUNTIME hibernate=7.4.15.Final java=21.0.12.1 pid=2009504
BEFORE scenario=new-context-no-merge managed=true lines=1
RESULT scenario=new-context-no-merge status=COMMITTED childRows=0
BEFORE scenario=new-context-merge managed=true lines=1
RESULT scenario=new-context-merge status=COMMITTED childRows=0
BEFORE scenario=same-context-no-merge managed=true lines=1
RESULT scenario=same-context-no-merge status=COMMITTED childRows=0
BEFORE scenario=same-context-merge managed=true lines=1
RESULT scenario=same-context-merge status=COMMITTED childRows=0
旧版的 same-context-merge 在调用 merge、显式 flush 之后,到 commit 才抛出 RollbackException。异常链中是 DetachedObjectException: Given entity is not associated with the persistence context,堆栈经过孤儿删除路径 Cascade.deleteOrphans;这不表示前面检查为托管态的订单本身已经脱管。回滚后重新查库,childRows=1,删除并没有落库。不能仅凭 flush 没报错就认定修改成功。
新版本同一路径是 COMMITTED childRows=0。这同时验证了事务提交和孤儿行删除。其他三组两版均成功:新上下文重新查询后再改集合没有复现;保留原上下文但不额外 merge 也没有复现。
因此,这个案例中的旧版处理路径已经很明确:实体仍托管时,清空集合后由脏检查提交,不额外 merge,实验同样得到零条明细。如果保存层确实会合并这类跨事务保留的对象,7.4.15 修复的是保留原有调用后的提交结果。实验没有覆盖 detached 对象回传、替换整个集合或其他数据库方言。
版本与项目
Hibernate ORM 是 Java 的对象关系映射项目,仓库为 hibernate/hibernate-orm。7.4.15.Final 使用 Apache License 2.0。
本次版本与行为依据:7.4 官方发布页、7.4.15 Release、变更记录、HHH-20973 集合状态修复及HHH-20670 回归用例。
一起交流
分享你的思考,让讨论更进一步。