一次数据库操作返回失败的 CompletionStage,并不说明它写入的数据已经回滚。两个 Play 实验应用都先写入一行,再返回尚未完成的 Stage。Ebean 已经提交第一行,异步线程写入第二行时又单独提交一次。Hibernate 同样已经提交第一行,异步线程却因为访问关闭的 EntityManager 而失败。

这两个结果由同一个时间边界引起:事务包装器看见的是回调函数返回,Stage 的最终完成发生在包装器返回之后。接入 ORM 时,依赖解析、实体增强、数据库事务和异步任务各有自己的完成条件。compile 成功、SQL 已发送、Stage 失败,分别只能回答其中一部分问题。

两个独立应用,先固定实际运行版本

本篇使用两个独立的 Java 应用:examples/play-electives/e03-ebean 和 examples/play-electives/e03-hibernate。它们共享实验方法,各自持有数据库 schema,不把两个 ORM 同时装进主线最小应用。主线工程入口仍在 Play 00:最小应用。

层次 Ebean 应用 Hibernate 应用 核验方式
构建工具 sbt 1.10.7、Scala 2.13.15、JDK 21 相同 构建文件与执行日志
Play 核心 3.0.6 3.0.6 Compile / dependencyClasspath 和 dependencyTree
集成入口 sbt-play-ebean 8.3.0、PlayEbean Play javaJpa 插件、构建文件及解析结果
ORM Ebean 15.1.0 Hibernate ORM 6.6.3.Final 实际运行 classpath
数据库 PostgreSQL 17.6、JDBC 42.7.5 相同 真实连接与 SQL 日志
独立版本构件 Play JSON / Play Functional 3.0.4 相同 实际 JAR 文件及哈希

Play Ebean 8.3.0 的源码在 Dependencies.scala 第 17–28 行 将默认 Play 版本写成 3.0.2、Ebean 写成 15.1.0。仅看 plugins.sbt 中的 Play 3.0.6,不能断言每个运行时构件都已经升级。

实验的 Ebean 构建显式依赖 guice、javaJdbc 和 evolutions,这些依赖由 Play 3.0.6 插件提供;最终解析的核心构件均为 3.0.6,没有留下 3.0.2 核心 JAR,也没有加入强制 dependencyOverrides。其中 evolutions 负责补齐同版本依赖,实验并未启用自动建表:两个应用都通过自己的 SQL 脚本创建表。Play JSON / Play Functional 的 3.0.4 则是独立版本,不能把它们当成核心框架未收敛的证据。

Ebean 工程的插件文件与构建文件如下,测试依赖保留在可复跑工程中:

1
2
3
// project/plugins.sbt
addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.6")
addSbtPlugin("org.playframework" % "sbt-play-ebean" % "8.3.0")
1
2
3
4
5
6
7
8
9
10
11
12
// build.sbt
ThisBuild / scalaVersion := "2.13.15"
ThisBuild / organization := "example.playlab.e03"
ThisBuild / version := "0.1.0"
lazy val root = (project in file(".")).enablePlugins(PlayJava, PlayEbean).settings(
name := "e03-ebean",
libraryDependencies ++= Seq(guice, javaJdbc, evolutions,
"org.postgresql" % "postgresql" % "42.7.5",
"junit" % "junit" % "4.13.2" % Test,
"com.github.sbt" % "junit-interface" % "0.13.3" % Test),
Test / parallelExecution := false
)

Hibernate 工程只启用 PlayJava,增加 javaJpa 和 org.hibernate.orm:hibernate-core:6.6.3.Final。javaJpa 提供 Play 的 JPA 集成入口,Hibernate 才是执行实体映射和持久化的 provider。应用配置将 db.default.jndiName 设为 DefaultDS,jpa.default 设为 e03Unit;conf/META-INF/persistence.xml 再将这个 persistence unit 关联到 DefaultDS、models.Entry 和 Hibernate provider。它采用 RESOURCE_LOCAL 事务,hibernate.hbm2ddl.auto=validate 只校验已有表。

还需区分两套注解命名空间:本篇实体使用 jakarta.persistence,Play 表单依赖仍包含 Hibernate Validator 6.2.5.Final。实际的 jakarta.validation-api:2.0.2 JAR 内是 javax.validation 类,不能根据构件名字把它替换成 Hibernate Validator 7。Hibernate persistence unit 明确设置 validation-mode=NONE;以下实验不包含实体 Bean Validation 验收。

实体增强发生在数据库连接之前

两个工程使用相同形状的实体。文件为 app/models/Entry.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package models;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "orm_entry")
public class Entry {
@Id
public String id;
public String label;

public Entry() {
}

public Entry(String id, String label) {
this.id = id;
this.label = label;
}
}

源码没有实现 Ebean 的接口,但正常构建后的 models.Entry 实现了 io.ebean.bean.EntityBean,还包含 _ebean_props、_ebean_intercept 等字段。字段访问相关的方法也出现在 javap -p 输出中。这些内容由构建过程写入字节码,JPA 注解本身不会在 Java 编译器中产生它们。

flowchart LR
    A[Entry.java<br/>实体与字段] --> B[javac<br/>生成 class]
    B --> C[PlayEbean<br/>按模型包执行增强]
    C --> D[javap<br/>检查 EntityBean 与字段]
    D --> E[应用启动<br/>注册实体与连接数据库]
    E --> F[真实 SQL<br/>提交与回滚]
    C -. 错误包名可漏掉实体 .-> G[compile 退出 0<br/>class 未增强]
    G --> H[启动失败<br/>BeanNotEnhancedException]

插件的 PlayEbean.scala 第 80–96 行 创建离线转换器,使用 playEbeanModels 的包模式处理编译产物,并在这一段捕获 NonFatal。因此,增强任务与编译任务的成功条件不能混为一谈。不过,下面的错误包名实验只证明实体没有被选中,不能据此断言它触发了 NonFatal 分支。

在 Ebean 工程目录执行临时 sbt 会话配置:

1
2
3
4
bash ./sbtw 'set Compile / playEbeanModels := Seq("wrong.models.*")' clean compile
"$JAVA_HOME/bin/javap" -p -classpath target/scala-2.13/classes models.Entry
bash ./sbtw 'set Compile / playEbeanModels := Seq("wrong.models.*")' 'testOnly OrmTest'
bash ./sbtw clean test stage

本次记录的前三步分别得到:编译退出码 0;实体没有 EntityBean 接口;测试启动应用时抛出 BeanNotEnhancedException,测试命令退出码 1。最后一步恢复正常构建,两个测试通过。再次检查增强后的 class,并比对它与 stage 包中实体 class 的哈希相同,才闭合从编译目录到可运行包的验证。

这里有两个配置位置:构建期 playEbeanModels 决定增强哪些 class;运行期 ebean.default=["models.*"] 决定注册哪些实体。只把运行期包名写对,无法弥补已打包实体没有增强的问题。反过来,只见到增强字段也不能代替应用启动和 SQL 验证。

Hibernate 工程没有配置可选的 Hibernate 字节码增强器,javap 输出仍是普通实体,未实现 ManagedEntity;真实持久化与回滚测试照常通过。这是本篇选用的配置结果,不是 Hibernate 不支持字节码增强的结论。

SQL 发出、事务提交与独立可见性

实体表只有 id 和 label 两列。每次实验生成一个 e03-UUID- 前缀,成功行、回滚行和异步行各用不同后缀。计数查询通过 play.db.Database 另取 JDBC 连接执行,没有复用 ORM 的当前事务或一级缓存。

场景 Ebean 操作 Hibernate 操作 独立连接结果
正常提交 DB.execute 内保存 withTransaction 内 persist 返回后 1 行
同步异常 保存后抛 IllegalArgumentException persist、flush 后抛同类异常 返回后 0 行
worker 内完整事务 保存后停在闸门 persist、flush 后停在闸门 放行前 0 行,完成后 1 行

Hibernate 反例中显式 flush 很重要:没有它,回滚前可能尚未发出 INSERT,零行只能证明最终没有数据。加入 flush 后,服务器日志能同时记录 INSERT 与随后的 ROLLBACK,从而证明数据库确实撤销了已经执行的写入。flush 负责把待执行变更同步到数据库,并不等于提交事务。

PostgreSQL 原始日志的一段 Ebean 回滚记录如下。PID 418 的事务中,INSERT 已经到达服务器,随后是 ROLLBACK:

1
2
3
4
2026-10-03 11:01:41.259 UTC [418] LOG:  execute <unnamed>: BEGIN
2026-10-03 11:01:41.259 UTC [418] LOG: execute <unnamed>: insert into orm_entry (id, label) values ($1,$2)
2026-10-03 11:01:41.259 UTC [418] DETAIL: Parameters: $1 = 'e03-9f2e962e-b569-4891-aeb7-adaf1c19fcc6-rolled-back', $2 = 'rollback'
2026-10-03 11:01:41.261 UTC [418] LOG: execute S_1: ROLLBACK

异常的外层类型也不同。Ebean 15.1.0 的 DB.execute 路径把实验中的 IllegalArgumentException 包成 jakarta.persistence.PersistenceException;测试同时断言外层和 cause。JPA 包装器直接重新抛出原异常。将测试写成“捕获任意异常即通过”,会把连接失败、错误 schema 和预期回滚混在一起。

本次日志由实验连接执行 SET log_statement='all' 获得,汇总时按带有实验标识的 PostgreSQL backend PID 筛选。它保留了参数绑定、BEGIN、INSERT、ROLLBACK、COMMIT 与独立 SELECT。SQL 日志证明服务器执行过程,独立连接的行数证明可见结果;两者结合后,才适合讨论事务边界。

返回 pending Stage,事务已经结束

Ebean 实验直接实例化 Play 的 TransactionalAction,安装一个 delegate,并调用真实 call 方法。delegate 同步保存 early 行,随后返回一个被闸门阻挡的 Stage;异步 worker 只有在主实验完成独立查询之后才会放行。这个安排固定了先后关系,不依赖“线程大概还没调度”的时间猜测。

TransactionalAction.java 第 13–17 行 的关键调用是 DB.executeCall(() -> delegate.call(req))。继续沿 Ebean 15.1.0 的真实源码向下读:

文件与行号 处理顺序 对 pending Stage 的含义
DefaultServer.java 635–645 建立 scope,调用 callable,finally 中 complete callable 返回 Stage 对象后就执行 complete
ScopedTransaction.java 73–84 完成当前事务,随后 pop 并清理 scope scope 生命周期不会等待异步回调
ScopeTrans.java 128–142 未回滚则提交;对本层创建的事务执行 commit 本实验没有外层事务,early 行在此提交

以上 Ebean 内部源码来自 15.1.0 官方 source JAR,SHA-256 为 91e3b542f3245bd703b94a71ed27bf7f5b83f3bc9598d9a29949830167e45575。嵌套事务还涉及已有 scope 与传播设置,表中“本层提交”的观察不能推广到所有嵌套调用。

sequenceDiagram
    participant C as 调用线程
    participant T as 事务包装器
    participant P as PostgreSQL
    participant W as 受闸门控制的 worker
    C->>T: 调用同步回调
    T->>P: 写入 early
    T->>W: 创建异步任务,等待闸门
    T->>P: 提交 early
    Note over T: 清理事务资源
    T-->>C: 返回 pending Stage
    C->>P: 独立连接查询 early = 1
    C->>W: 放行
    alt Ebean
        W->>P: 无原事务的 save,另行提交 late
        W-->>C: 随后抛异常,Stage 失败
    else JPA
        W->>W: 原 EntityManager 已关闭
        W-->>C: persist 抛 IllegalStateException
    end

Ebean 的 late 线程记录 DB.currentTransaction()==null。它调用 DB.save 后,服务器日志出现第二个 BEGIN / INSERT / COMMIT;接着人为抛出 IllegalArgumentException。最终 Stage 虽然失败,early 和 late 都各有一行。这里的失败发生在第二次保存已经完成之后,既无法回滚第一笔事务,也没有仍然开放的第二笔事务可供回滚。

JPA 的对照实验在 withTransaction 回调里创建 Stage,并故意把该回调接收的 EntityManager 捕获到 late 线程中。Play 3.0.6 的 DefaultJPAApi.java 第 155–194 行 先调用 block.apply(entityManager),再 commit,最后在 finally 中 close。返回值的泛型 T 可以恰好是 Stage,但这段代码并未等待 Stage。

因此,闸门放行后,Hibernate 实验中的 em.isOpen() 已经为 false。persist 抛出 IllegalStateException,late 行没有写入,early 行仍然存在。这个反例通过关闭状态解释失败;不能由此推导“跨线程传递一个仍然开放的 EntityManager 就安全”。

真实观察 Ebean Hibernate / JPA
包装器返回时 Stage 是否完成 false false
放行前独立查询 early 1 1
late 线程的原事务资源 当前事务为空 EntityManager 已关闭
Stage 的异常原因 人为抛出的 IllegalArgumentException persist 抛出的 IllegalStateException
Stage 失败后 early / late 行数 1 / 1 1 / 0

在专用 worker 内完成整笔同步事务

异步请求可以返回 Stage,事务内部仍然按同步顺序执行。调整的是调用层次:先把工作提交到数据库专用执行器,再由 worker 建立事务、执行所有 SQL、提交或回滚,最后完成 Stage。这样,Stage 对应的任务返回时,事务包装器已经处理完提交结果。

下面两份完整类分别在对应工程的依赖上编译。它们复用前面的 models.Entry,执行器由应用持有并传入;类本身不创建线程池,也不关闭共享资源。返回值为简单的 ID,避免把依赖后续数据库访问的实体状态直接送到事务外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package examples;

import io.ebean.DB;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import models.Entry;

public final class EbeanWrite {
private EbeanWrite() {
}

public static CompletionStage<String> save(Executor databaseExecutor, String id) {
return CompletableFuture.supplyAsync(() -> DB.executeCall(() -> {
DB.save(new Entry(id, "complete transaction on worker"));
return id;
}), databaseExecutor);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package examples;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import models.Entry;
import play.db.jpa.JPAApi;

public final class JpaWrite {
private JpaWrite() {
}

public static CompletionStage<String> save(
JPAApi jpa, Executor databaseExecutor, String id) {
return CompletableFuture.supplyAsync(() -> jpa.withTransaction(em -> {
em.persist(new Entry(id, "complete transaction on worker"));
return id;
}), databaseExecutor);
}
}

实验中的正确路径还在事务内部加入了有限闸门。worker 记录事务处于活动状态,保存实体;Hibernate 额外执行 flush;随后主实验通过另一条连接读取,结果为 0。放行并等待任务完成之后,再次读取为 1。两个实现的线程名都记录为 e03-orm-worker,证据同时覆盖执行线程、事务活动状态、提交前不可见和完成后可见。

这个结构并没有把 JDBC 变成非阻塞 API。数据库专用执行器仍需有界的并发与排队策略,容量需结合连接池大小和数据库承载能力确定。本篇没有做容量测试,也没有证明取消 Stage 会取消 SQL。尤其不能在 worker 内再次返回一个尚未完成的 Stage:那会重新引入同一个事务提前结束问题。

中断等待之后,仍要验证资源终态

每个实验请求使用一个有限生命周期的 worker、最多 5 秒的闸门等待与 Future.get。这是隔离实验结构;生产请求不应照搬成每请求创建线程池。两个连接池的配置均为最大 4 个连接、最小空闲 0、连接获取等待 3 秒;JDBC URL 限定连接超时 3 秒、socket 超时 5 秒。

中断测试先确认正确事务已进入 worker 并停在闸门,再设置协调线程的中断位,让它执行 Future.get。测试断言主异常原因仍是 InterruptedException。清理逻辑先记录并暂时清除中断状态,放行自有闸门,关闭并有界等待 worker,再用独立连接删除本次 UUID 前缀的数据,最后恢复中断位。清理失败会保留为异常;若已有主异常,try-with-resources 将关闭异常作为 suppressed 保存。

本次两个应用都观察到 workerTerminated=true 和 callerInterrupted=true。Ebean 清理了本次 4 行,Hibernate 清理了本次 3 行。这些行数也说明:协调线程停止等待,并没有使 worker 的数据库事务自动回滚。清理放行以后,worker 可以完成提交,实验随后才删除这些已提交数据。

HTTP 验证另外检查了 stage 进程退出和监听端口关闭。实验结束后,两个自有 schema 的表均为空,pg_stat_activity 中没有对应的 E03 应用连接。中断路径的这些终态只覆盖本次受控场景;第二次中断、数据库长时间不可达等组合故障仍需各自设计验证。

从工程、测试到 HTTP 复跑

下载本篇两套工程与实验源码,用SHA256SUMS核对压缩包;解压后的 play-electives 对应仓库中的 examples/play-electives,包含两个应用、HTTP验证器与原始证据。命令中的根目录可据此调整。

命令从博客仓库根目录执行,所需工具是 JDK 21、Python 3、curl、PostgreSQL 的 psql,以及一台可连接的 PostgreSQL 17.6。JAVA_HOME 指向本机 JDK 21 安装目录;以下 PG* 是公开的实验环境变量,口令仅用于本地实验数据库。连接必须允许设置实验连接自己的 log_statement,否则本篇默认连接初始化会失败。

1
2
3
4
5
6
7
8
: "${JAVA_HOME:?请先设置 JDK 21 的 JAVA_HOME}"
export PLAY_E03_ROOT="$PWD/examples/play-electives"
mkdir -p "$PLAY_E03_ROOT/.tools"
curl --fail --location --connect-timeout 5 --max-time 90 https://repo.maven.apache.org/maven2/org/scala-sbt/sbt-launch/1.10.7/sbt-launch-1.10.7.jar -o "$PLAY_E03_ROOT/.tools/sbt-launch-1.10.7.jar"
export PLAY_LAB_SBT_LAUNCHER="$PLAY_E03_ROOT/.tools/sbt-launch-1.10.7.jar"
export PGHOST=127.0.0.1 PGPORT=35711 PGDATABASE=play_lab PGUSER=postgres PGPASSWORD=lab-only-password
psql -v ON_ERROR_STOP=1 -f "$PLAY_E03_ROOT/e03-ebean/prepare_schema.sql"
psql -v ON_ERROR_STOP=1 -f "$PLAY_E03_ROOT/e03-hibernate/prepare_schema.sql"

默认应用配置使用上述本地地址,并分别选择 e03_ebean_b83f、e03_hibernate_b83f schema。使用其他地址时,还需覆盖 JVM 的 db.default.url、db.default.username、db.default.password;只修改 PGPORT 只会改变 psql 的连接地址,不会改变应用配置。两个 schema 只供 E03 实验使用,不要指向已有业务表。

接着逐个工程解析依赖、执行真实数据库测试并打包:

1
2
3
4
bash "$PLAY_E03_ROOT/e03-ebean/sbtw" update 'show Compile / dependencyClasspath' dependencyTree clean test stage
bash "$PLAY_E03_ROOT/e03-hibernate/sbtw" update 'show Compile / dependencyClasspath' dependencyTree clean test stage
"$JAVA_HOME/bin/javap" -p -classpath "$PLAY_E03_ROOT/e03-ebean/target/scala-2.13/classes" models.Entry
"$JAVA_HOME/bin/javap" -p -classpath "$PLAY_E03_ROOT/e03-hibernate/target/scala-2.13/classes" models.Entry

OrmTest 在每个应用中包含两个测试:真实事务与异步边界,以及中断等待后的清理。数据库必须在线;这里没有用 H2 或内存 mock 代替 PostgreSQL。错误模型包的负例应单独执行,随后用正常 clean test stage 恢复实体增强。

HTTP 可以在两个终端分别启动与请求。以下以 Ebean 为例,启动命令占据当前终端,观察完成后使用 Ctrl-C 关闭;Hibernate 则将工程名与可执行文件名换成 e03-hibernate:

1
bash "$PLAY_E03_ROOT/e03-ebean/target/universal/stage/bin/e03-ebean" -Dhttp.address=127.0.0.1 -Dhttp.port=19083 -Dpidfile.path=/dev/null
1
2
curl --fail --silent --show-error --connect-timeout 3 --max-time 20 http://127.0.0.1:19083/orm
curl --fail --silent --show-error --connect-timeout 3 --max-time 20 http://127.0.0.1:19083/orm

/orm 返回的 runPrefix 每次不同,workerTerminated 应为 true。Ebean 的 earlyRowsAfterStageFailure、lateRowsAfterStageFailure 均为 1;Hibernate 则分别为 1、0。两者的 correctRowsBeforeRelease 为 0,correctRowsAfterCompletion 为 1。这个端点会运行同步等待与数据库实验,只适合本地验证,不应作为业务接口部署。

原始隔离证据归档在 examples/play-electives/evidence/e03/isolated/。接入仓库后,两套工程重新各执行2项真实数据库JUnit与stage;各重跑2次生产HTTP,25个应用class与对应生产jar字节一致,进程SIGTERM退出143且监听端口关闭,见 shared-build.json、shared-*-http。下表保留隔离实验的详细反例;两轮都属于有限验证,未计作主线或负载测试。

验证层 本次结果 原始文件
实际依赖 核心 Play 3.0.6;独立 JSON 3.0.4 各应用的 dependencies.log、dependency-verification.json
编译、真实 PG 测试、stage 每个应用 2 个 JUnit 通过,stage 成功 test-stage-verified.log、TEST-OrmTest.xml
增强与错误包负例 Ebean 正常增强;错误包编译 0、启动失败 1 javap-final.txt、wrong-model-compile.log、wrong-model-startup.log
独立 stage HTTP 每个应用 2 次 HTTP 200;进程终止且端口关闭 http-observations.json、http-server.log
SQL 与退出状态 提交、回滚、独立查询、按前缀清理;无剩余 E03 连接 postgres-raw-sql.log、database-final-state.log

两个改动练习

练习一:让完整事务在返回之前失败。 在两个应用的正确 worker 路径中,放行闸门后、事务回调返回前抛出 IllegalArgumentException。保留 Hibernate 的 flush,将完成后行数断言改为 0,并分别检查 Ebean 的包装异常和 JPA 的原异常。验收需要 SQL 中的 INSERT / ROLLBACK、独立读取的零行以及 worker 退出三个结果;只检查 Stage 异常不够。这是待实施练习,不计入上面的已运行测试数量。

练习二:区分已经完成与尚未完成的 Stage。 将反例中异步创建的 Stage 换成事务回调内完成保存后返回的 CompletableFuture.completedFuture,然后恢复带闸门的版本。分别记录包装器返回时 Stage 状态、独立行数和事务资源状态。预测两者差异时,沿“SQL 发生在哪个线程、回调何时返回”推导,不根据方法返回类型决定事务是否跨越异步边界。不要删除闸门后靠多跑几次碰到期望顺序。

上一篇:E02。下一篇:E04。