深入 Play E03:Ebean 与 Hibernate 集成,事务何时结束
一次数据库操作返回失败的 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 | |
1 | |
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 | |
源码没有实现 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 | |
本次记录的前三步分别得到:编译退出码 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 | |
异常的外层类型也不同。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 | |
1 | |
实验中的正确路径还在事务内部加入了有限闸门。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 | |
默认应用配置使用上述本地地址,并分别选择 e03_ebean_b83f、e03_hibernate_b83f schema。使用其他地址时,还需覆盖 JVM 的 db.default.url、db.default.username、db.default.password;只修改 PGPORT 只会改变 psql 的连接地址,不会改变应用配置。两个 schema 只供 E03 实验使用,不要指向已有业务表。
接着逐个工程解析依赖、执行真实数据库测试并打包:
1 | |
OrmTest 在每个应用中包含两个测试:真实事务与异步边界,以及中断等待后的清理。数据库必须在线;这里没有用 H2 或内存 mock 代替 PostgreSQL。错误模型包的负例应单独执行,随后用正常 clean test stage 恢复实体增强。
HTTP 可以在两个终端分别启动与请求。以下以 Ebean 为例,启动命令占据当前终端,观察完成后使用 Ctrl-C 关闭;Hibernate 则将工程名与可执行文件名换成 e03-hibernate:
1 | |
1 | |
/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。
