JPA 持久化 02:脏检查、flush 与回滚的三道边界
发出了 UPDATE,申请为什么还是 DRAFT
采购申请在事务内从 DRAFT 改为 SUBMITTED,执行 flush() 后,数据库可能已经收到 UPDATE;接着审批前置检查失败,事务回滚,另一个上下文重读仍是 DRAFT。若把“SQL 已经执行”“同一上下文读到 SUBMITTED”和“数据库已提交”合成一个事实,就会误报审批进度。另一个方向也成立:在默认 AUTO flush mode 下,同一事务内的 JPQL 查询不能忽略会影响结果的托管状态修改;但由此推断“查询之前必然执行一条固定 SQL”仍是不成立的。
本章沿用真正的 examples/jpa/ 工程,使用 jpa_lab 独立 PostgreSQL 16 库,Java SE RESOURCE_LOCAL,而非另设教学数据库。00 讲启动,01 讲身份;这里固定同一申请的一次修改来拆开内存、数据库事务与提交后的行。实体的 submit() 在有明细且处于 DRAFT 时才合法,ORDERED 只能经后续审批流程;单行状态实验不等于完整采购审批服务。
规范要求结果,不规定快照算法
对 EntityManager.find 返回的托管 ProcurementRequest 调用 submit(),首先改变的是 Java 字段。Persistence 3.2 §3.3.4 要求在同步时把托管实体持久化状态的改动写往数据库;应用无须对这个已托管对象调用一个虚构的 update() 方法。许多实现将探测哪些字段发生变化、决定是否生成 UPDATE 的工作称作脏检查。规范保证的是状态同步语义,不保证 Hibernate 采用哪种快照结构、多久遍历一次或每次恰好生成几条 UPDATE。若对象已经脱管,如 01 篇的反向探针所示,只修改它的 Java 字段,当前上下文并不会自动发现;这条前提比“改字段会写入”更重要。
第一条边界:托管对象的字段已改变,但数据库尚未必收到 SQL。第二条边界:manager.flush() 强制把已加入事务的上下文状态同步至数据库,不结束事务。第三条边界:EntityTransaction.commit() 返回成功之后,新上下文再读行,才能把结果当作本地事务的持久终态。在提交前,拿到数据库生成的 id、观察到 UPDATE、甚至当前上下文再 find 到 SUBMITTED,都不能跳过第三条边界。这个工程用 identity 主键,persist 新申请时为取得主键可能已经发了 INSERT;但插入发生的时点不能替代提交,也不能与后续托管对象的 UPDATE 时点混为一谈。
规范 §3.3.4 允许 Provider 在已加入活动事务后于其他时刻同步,不要求等到显式 flush。规范 §3.11.2 对 FlushModeType.AUTO 的查询提出可见性要求:只要托管上下文中存在可能影响查询结果的更新,Provider 必须让查询结果正确反映它们,手段可以是先刷 SQL,也可以是别的方法。COMMIT 模式下这些更新对提交前查询的影响不作同样保证。于是“JPQL count 显示 1”与“查询前日志里一定有一条 UPDATE”并非等价命题;为测试业务结果应断言 count,为研究某版本的 SQL 执行顺序才收集 SQL 日志与执行环境。两种证据目的不能互换。
实际 ProcurementRequest.java 的 status 是字符串形式的 enum 映射,并有 @Version。提交前更新可能涉及版本检查;版本冲突的失败处理留到并发章节。这里单工作单元只讨论字段修改、flush、提交/回滚,不推导并发申请一定不会重复下单。表上的状态 CHECK 拒绝不在允许集合的值,却不知道 DRAFT 能否直接 ORDERED;只有工程的业务方法限制转换路径,而是否在竞态中仍能守住路径,需另看锁定与约束。
将这份申请放进一条业务路径更容易看到边界:操作员点击“提交”,服务在本地事务查出 DRAFT 并检查明细,然后调用 submit()。如果此时仅回传对象上的 SUBMITTED,而下游因附加校验或数据库约束失败,最终回滚,页面就会看到一个数据库中不存在的成功状态。相反,若先成功提交再发送通知但通知超时,数据库的 SUBMITTED 仍然存在,错误处理若盲目重做 persist 可能制造重复请求。这两种分叉都不能靠 flush 修复:flush 解决的是事务内上下文到数据库的同步;对外发布和幂等重试需要独立的流程设计。在程序接口中,“已设置状态”“提交已返回”“消息已发送”应分别有明确时点和可核实证据。
为什么在脏检查实验里保留 @Version,却不将它作为回滚证据?版本列在 UPDATE 时可能参与匹配和变更,用于发现并发旧版本;但在失败事务里 Java 对象上的版本值未必能倒回。事务回滚意味着数据库中的 UPDATE 没有成为已提交结果,不意味着把全部 Java 内存恢复成事务开始的样子。后续逻辑若继续利用旧对象的状态或版本决定是否给同一单据下单,等于把一个废弃工作单元的猜测带入下一次操作。重新创建 EntityManager、按 ID 重载、重新执行状态与租户检查,比“给旧对象重新设回 DRAFT”更能避免遗漏明细和版本等伴随字段。
已运行的回滚实验从哪里进入
完整 Java/JUnit 代码入口是 ProcurementJpaTest.java 的 rollbackAfterFlushDoesNotPersistTransition();连接入口是 DatabaseSupport.java,DDL 在 01-init.sql。方法先在一个 EntityManager 的事务中创建带一条明细的 DRAFT 申请并提交,保存数据库分配的 ID;再在另一个 EntityManager 的事务中按 ID 查找、调用 submit()、flush()、rollback();最后新开上下文断言状态为 DRAFT。三次上下文分开,是为避免在第一份身份映射里读到未更新的本地实例而误以为数据库已验证回滚。
真实记录位于 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md 及同目录 stdout 和退出码文件。冻结组合为 Jakarta Persistence 3.2、Hibernate ORM 7.1.36.Final、Java 21.0.12.1、PostgreSQL 16.15、pgJDBC 42.7.7、Maven Wrapper 3.9.9、JUnit Jupiter 5.12.2。整体 clean test 执行后 6 项测试全部通过、退出码 0,其中包括上述回滚断言;这不能替代未运行的“查询触发 AUTO flush”探针,也不证明 JTA 或第二 Provider 的行为。测试没有记录具体 UPDATE SQL 的原始顺序,所以这里只称“调用了 flush 并验证回滚后的行”,不宣称日志中留有一条特定 UPDATE 的原始证据。
准备好专用数据库、迁移与 JPA_LAB_* 环境变量后,可按方法筛选重跑;这条筛选命令NOT_RUN,已归档运行的是从相同目录执行 ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" clean test。不要把已有整套通过记录附到新命令上。
1 | |
为何在回滚后要换新的上下文?规范 §3.4.3 描述事务回滚导致已加入该事务的上下文实例脱管,版本、生成值等字段的内存状态不保证自动回到回滚前。即便旧引用仍显示 SUBMITTED,也不能用它证明数据库里有此状态。关闭失败工作单元、开始新的上下文并按 ID 读取,才构成对最终状态的独立检查。若异常出在 commit() 内,事务结果可能需要额外证据确认;本测试仅覆盖确定调用了 rollback() 的路径,不得推广成任意提交异常均安全恢复。
同一事务的查询路径:完整待运行探针
以下文件若需要运行,可加入 examples/jpa/src/test/java/blog/jpa/AutoFlush02ProbeTest.java;这段代码仅写在文章中,未加入当前工程,NOT_RUN。它用工程原有的 DatabaseSupport 和真实实体先提交一条 DRAFT 行,再新开事务将托管对象改为 SUBMITTED,查询该 ID 和枚举状态的计数,最后提交、新建上下文读终态。需要时把 manager.flush() 显式移到查询前,与 AUTO 不显式刷新的版本对照;不能用一个版本的测试结果冒充另一个版本。
1 | |
保存文件后运行 cd examples/jpa && ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" -Dtest=AutoFlush02ProbeTest test,预期查询 count 为 1、提交后新上下文读 SUBMITTED,NOT_RUN。若要将失败路径改成真实数据库约束错误,需要单独准备非法写入的代码与输入、获取异常原文并确认是否已经标记 rollback-only;不能把上面已有的显式回滚测试说成“数据库约束抛了异常”。查询前是否已有 SQL、Hibernate 实际提交时更新几行,也待存档日志与代码 SHA 后再回答。
PostgreSQL 可见性不是 JPA 刷新协议
单独考察故障发生在何处,可以区分可直接回滚与需要核实终态的情况。submit() 因申请没有明细抛错时,状态还未修改,仍应按失败工作单元的规则结束事务;flush() 因数据库约束抛错时,即使异常被 Java 捕获,事务也可能已被标记为只能回滚,不能继续复用当前上下文做下一张申请;网络在 commit() 期间中断时,应用可能不知道服务端是否完成提交,应以新连接和幂等键核实。现有测试走的是确定路径:种子行成功提交,随后显式 flush、显式 rollback、关闭旧上下文、重新读取。它没有模拟提交中途断链。将这三种故障都概括为“捕获异常后重试一次”,会掩盖数据库可能已经有行的风险。
自动 flush 的试验也必须控制变量:先提交 DRAFT 种子行,关闭种子上下文;新事务内 find 并 submit;保持默认 AUTO,执行带 ID 和 SUBMITTED 条件的 JPQL 计数。只有这时,计数是否反映状态改变才是在检验查询语义。如果查询只按 ID 计数,DRAFT 与 SUBMITTED 都会返回 1,测试即使 Provider 完全忽略字段变化也会绿;如果忘记先持久化种子行,又可能把没有数据误判为不刷写。如果用同一上下文 find 对象再比较 getter,那只是内存里的 SUBMITTED,仍不是 JPQL 的结果。本章待运行探针把条件包含状态,并在提交后用新的上下文再次读取,前后分别验证查询可见性与事务终态。
上述探针不会断言具体 SQL 顺序。Hibernate 在这一特定版本可能选择查询前刷入 UPDATE,也可能在查询计划层做其他处理;JPA 规范要求查询结果可见,却不规定 SQL 日志格式。要研究真实 SQL,必须在冻结 Provider 与数据集下打开相应日志、保留原始记录,并将结果限定于该版本实现。反过来,SQL 日志里出现 UPDATE 也无法替代提交后独立读:它只能证明某个时刻尝试了写入。把“执行、可见、持久”各放在正确的断言位置,才不会在一次成功测试中无意漏掉回滚错误。
如果再开第二连接,在第一连接 flush 之后、rollback 之前对同一行普通 SELECT,PostgreSQL 16 默认 READ COMMITTED 下应读取先前已提交的 DRAFT。这是 PostgreSQL 16 隔离文档给出的前提下的预测,该双连接交错未在本章单独执行,NOT_RUN。已有测试只在回滚后用新上下文断言 DRAFT,不能说它已实测了 flush 与 rollback 之间的隔离快照。若第二连接试图更新同一行,还可能等待锁;SELECT 和 UPDATE 的行为不应互相替代。应用端缓存的旧对象是否刷新也不能从数据库的隔离级别直接推断:上下文中 find 可先返回原托管对象,必须 clear、refresh 或使用新的上下文,才能对读取来源作出明确解释。
本地 RESOURCE_LOCAL 事务不会跨 HTTP 回调、消息发送与数据库提交建立原子性。审批服务若在提交前对外声称成功,随后数据库回滚,就可能形成外部已看到 SUBMITTED 而行仍为 DRAFT 的分叉。反之,提交已成功但响应丢失,重试可能产生重复订单;需在订单申请关系上设唯一键并设计幂等路径,而不是多调用一次 flush。这一章只建立一条行状态可验证的基线,不把 PostgreSQL 隔离或 @Version 解释成整个采购业务的串行化保证。
即使业务只修改一个状态字段,也要考虑读写先后带来的错觉。若在 submit() 前先执行相同 JPQL 计数,返回 0 是正确的,但对后一次查询“能否看到变化”没有说明力;若 submit() 后在同一上下文只做 find,得到 SUBMITTED 仅是对象身份复用。最后如果事务回滚,却把这次 JPQL 返回过 1 当成持久证据,仍是把事务内可见结果越权解释为提交后终态。有效的正常实验必须包含修改后查询和成功提交后的新上下文读取;有效的回滚实验必须包含 flush 后显式 rollback 与新上下文的旧值断言。两个实验的结论应分别报告,不能用一个返回值代替整个因果链。
练习一:故障路径执行 flush(),随后把 rollback() 改为成功 commit(),原有“新上下文 DRAFT”断言还应通过吗?解答:对这次单行转换,成功提交后预期读到 SUBMITTED;DRAFT 断言应失败。差异来自提交或回滚,不是 flush 取得另一种“提交权限”。
练习二:把新探针的 AUTO 换成 COMMIT,查询前不显式 flush,count == 1 可由规范保证吗?解答:不可。§3.11.2 对 COMMIT 模式下上下文修改影响提交前查询的结果不作相同规定;若要保证查询前同步,应在活动事务里显式 flush,并依然以提交后的新上下文作为终态判据。
官方资料
- Jakarta Persistence 3.2 §3.3.4、§3.4.2–3.4.3(同步、提交、回滚)
- Jakarta Persistence 3.2 §3.11.2、§7.5.2–7.5.3(查询 flush mode 与资源本地事务)
- Hibernate ORM 7.1 User Guide:Flushing(仅实现对照);PostgreSQL 16 Transaction Isolation
另一个边界是尚未观察到的 SQL 形状:已通过的 JUnit 断言只要求回滚之后 DRAFT,不能从这一结果反推 Hibernate 必然只发了一条 UPDATE,也不能证明 flush 前一定没有 SQL。若未来采集 SQL,需先限定相同数据库版本、主键生成方式、映射版本和测试输入,才可比较“修改状态”路径产生的语句。把 Hibernate 统计计数误当成跨 Provider 性能结论,同样会越过实验支持的范围。
本章研究卡区分已归档的回滚测试与新增待运行查询探针,不能用整体 6/6 替代任一 NOT_RUN 判据。





