有了订单 id,订单是否已经成立

订单接口创建金额为 12.34 的 PurchaseOrder,调用 persist 后立即取得主键。随后库存检查失败,事务回滚。调用方若只判断 id != null 就发送“下单成功”,会把一次未提交的写入当成业务事实。这个错误在序列和数据库自增主键下都可能发生,只是插入语句出现的时点不同。

本章要求的不变量是:只有提交成功、另一条 READ COMMITTED 连接可见订单行,才把持久化订单视为成立。Java 对象是否托管、是否取得主键、动作是否入队、数据库是否已经执行 INSERT,要分别记录。单表模型沿用第 03 篇,只增加测试专用的 IDENTITY 对照实体,不提前修改共享订单模型。

研究对象固定为 Hibernate ORM 7.1.36.Final、Jakarta Persistence 3.2、Java 21,源码 SHA 为 ca7715d7c1afbd46d518752115848bffd9322413。PostgreSQL 序列模型使用 allocationSize = 1;IDENTITY 模型只有主键和业务单号,没有关联、级联、批量设置或监听器扩展。这些条件决定了后文队列断言的适用范围。

persist 先选择事件路径

SessionImpl.persist(Object) 构造 PersistEvent,交给 firePersist。后者检查事务同步状态和未解决的写入依赖,遍历 PERSIST 监听器组,再做操作后的依赖检查与异常转换。这段代码没有直接拼装 INSERT,也没有调用事务提交。

这种分工使同一个 API 能处理不同对象状态。SQL 是否出现,要继续追踪事件监听器选择的状态分支和插入动作类型。仅在 SessionImpl.persist 上设断点,看到方法正常返回,不能推出数据库提交成功;监听器已完成的工作仍然属于当前工作单元。

DefaultPersistEventListener.persist 根据持久化上下文中的 EntityEntry 和 getEntityState 结果分支:

识别结果 冻结实现的主要处理 对调用方的影响
TRANSIENT entityIsTransient 调用 saveWithGeneratedId 进入新对象持久化路径
PERSISTENT entityIsPersistent 执行级联检查 对同一托管实例重复 persist 不等于再插入一行
DETACHED 抛出 PersistentObjectException,外层可能转换 persist 不是将脱管修改复制回托管对象的 API
DELETED 改回 MANAGED,清除删除状态并取消排队删除 尚在同一上下文中的删除可以进入取消删除分支

“重复 persist 没有作用”也不够准确:目标实体不会因此多出一次新建动作,但级联仍可能传播到关联对象。本章无关联,所以重复调用的可见结果才简化为队列大小不变。已执行 DELETE 后的全部边界、代理与级联依赖需要单独实验,不能从这张表推出所有对象图都能无条件恢复。

PersistContext 记录当前级联处理中已访问的对象。临时保存状态则记录在持久化上下文里。两者服务于不同范围:前者抑制重复遍历,后者让会话能够识别正在处理的实体,避免递归过程中重复建立保存动作。它们都不是事务已经提交的标记。

主键生成的时机改变 INSERT 的位置

AbstractSaveEventListener.saveWithGeneratedId 先询问生成器两个问题:标识符是否在执行数据库写入时生成,是否能够在执行之前生成。方法的参数名和分支比“所有 persist 都延迟 SQL”的口诀更有解释力。

序列生成器能够先取得值,并通过 persister 把标识符写回对象。这里的“执行之前”指实体插入之前,不保证零次数据库访问。allocationSize = 1 的 PostgreSQL 序列获取本身就可能产生 nextval 查询。看到 SQL 日志出现一条 SELECT,也不能认定订单行已经插入。

IDENTITY 的标识符依赖 INSERT 执行结果。进入保存流程时还没有最终 id,后续 EntityIdentityInsertAction 执行后再取得生成值。固定源码中的延迟条件是:当前没有活动事务、事件不要求立即访问 id、生成器属于执行时生成,三项同时成立。这个条件说明“IDENTITY 总在 persist 内执行”也过宽。本章明确先开启事务,只验证活动事务内、无依赖的早期插入路径。

普通序列路径建立 EntityInsertAction;IDENTITY 路径建立 EntityIdentityInsertAction。两者通过 addInsertAction 进入动作队列。队列的意义不能简化为“放进去后总等 flush”:ActionQueue.addResolvedEntityInsertAction 对 early insert 直接执行,对普通插入才加入等待列表。

1
2
3
4
5
6
7
8
9
10
11
12
Session.persist(order)
→ PERSIST 事件监听器
→ 判断对象状态:TRANSIENT
→ saveWithGeneratedId
├─ SEQUENCE:取得 id → 写回 Java 对象
│ → EntityInsertAction → 普通插入队列
│ → flush → INSERT
└─ IDENTITY,活动事务、无依赖
→ EntityIdentityInsertAction → early insert
→ INSERT → 取得 id → 写回 Java 对象
→ 返回调用方
→ 事务后续仍可 commit 或 rollback

上游 IdentityGeneratedKeysTest 把活动事务内 persist 后 id 非空与事务外 persist 后 id 为空分别写成断言;事务外显式 flush 还应抛出 TransactionRequiredException。这是已阅读的上游测试契约,未在本次执行上游 Gradle 测试,不能列为本次运行证据。它与延迟条件共同限定了“立即插入”的语境。

IDENTITY 早期插入还可能迫使此前排队的普通插入先执行:ActionQueue.addInsertAction 在处理 early insert 时调用 executeInserts。因此,把两个不同策略的实体混在同一 Session 中,再观察第一个实体是否延迟到显式 flush,结论可能与单实体实验不同。本章隔离两种映射,避免把这种队列交互误归因于序列生成器。

回调、快照与依赖检查各自保证什么

performSave 调用 callbackRegistry.preCreate,随后处理标识符和保存动作。对于已在前一步取得的序列 id,回调可以看到分配值;IDENTITY 此时尚未完成数据库插入,不能在回调中假设最终数据库 id 已就绪。手工分配策略还会在回调之后读取 id;缺失必要 id 时可直接失败,无须等到数据库约束错误。

performSaveOrReplicate 添加 Status.SAVING 条目,先做保存前级联,读取并处理属性值、建立插入动作,再做保存后级联。实体最终成为托管对象与插入动作等待执行之间,可以存在一段时间。此时对象有 id、有 EntityEntry,但数据库里还没有对应订单行。

不可空的瞬态关联会影响动作能否解决。ActionQueue 检测到未解决依赖时,把动作保存在 unresolved 结构中;操作结束时还会检查遗留问题。这就是本章必须排除关联的原因:一个孤立的新订单能成功入队,不能证明带有未持久化客户的订单也能成功。异常发生于 persist 返回前还是 flush,必须由具体映射、级联和依赖决定。

这条调用链还解释了观察工具应放在哪里。断点看事件和状态分支,动作队列看等待执行的插入,SQL 记录看语句文本,同事务 JDBC 查询看当前连接真正能读到的行,事务外新连接则看提交后的可见性。任何单个计数都不能同时代表这些层次。

避免观察动作触发自动 flush

完整可执行测试位于 examples/hibernate-lab/src/test/java/blog/hibernate/Chapter05Test.java,使用现有 Maven 工程和真实 PostgreSQL。三个独立测试分别覆盖序列提交、序列回滚、IDENTITY 回滚。每次使用随机业务单号关联日志、参数与终态查询,避免历史数据让断言误通过。

测试从 before-persist 开始,随后记录 after-persist、after-repeat-persist、after-flush。检查点读取内部 ActionQueue.numberOfInsertions(),因此只对固定 Hibernate 版本有意义,不建议业务代码依赖这个 SPI。它表示等待执行的普通插入数量,不能作为已执行 INSERT 的累计数。

同事务行数通过 Session.doReturningWork 使用当前 JDBC 连接查询。这样不会为了观察而先执行一条 HQL 查询并触发自动 flush。新连接显式使用 READ COMMITTED,查询同一个业务单号。两条连接的查询都读取数据库;session.contains 则只判断会话是否管理该实例。

检查点 SEQUENCE:待执行插入 / 本事务行 / 新连接行 IDENTITY:待执行插入 / 本事务行 / 新连接行
persist 前 0 / 0 / 0 0 / 0 / 0
persist 返回 1 / 0 / 0 0 / 1 / 0
对同一实例再次 persist 1 / 0 / 0 0 / 1 / 0
显式 flush 后 0 / 1 / 0 0 / 1 / 0
rollback 后,新连接 0 行,Java id 仍非空 0 行,Java id 仍非空

表中断言已在本轮 PostgreSQL 17.6 上全部通过,3 项测试均无失败、错误或跳过。StatementInspector 额外记录语句文本,Hibernate bind 日志记录合成参数;同事务查询确认行已经存在,新连接查询确认最终可见性。Inspector 回调本身不证明 JDBC execute 次数、批处理次数或提交次数。

准备好实验库环境变量后,在仓库根目录执行:

1
cd examples/hibernate-lab && ./mvnw -Dtest=Chapter05Test test

测试所需变量是 HIBERNATE_LAB_JDBC_URL、HIBERNATE_LAB_USER、HIBERNATE_LAB_PASSWORD。表结构由测试在专用实验库中创建;不要将这些建表测试指向业务库。既有共享表遵循第 00 篇 DDL,独立 IDENTITY 表名为 chapter05_identity_order。

本轮实验状态:LAB_VERIFIED,环境为 Amazon Corretto 21.0.11、PostgreSQL 17.6、Hibernate 7.1.36.Final、pgjdbc 42.7.7。证据目录是 examples/hibernate-lab/evidence/05/20261002-pg17/:test.stdout.txt 保存原始 SQL、bind 与阶段记录,exit-code.txt 为 0,JUnit XML 记录 3 项通过;environment.txt 与 db-final.txt 保存版本和独立连接终态。这是新的本地实验,版本不同于先前云端 PostgreSQL 16.15,不能将两次输出混为一次运行。MySQL 对照、事务外 IDENTITY、回调读取 id、混合策略触发提前插入均为 NOT_RUN 扩展,不包含在上述三项验收内。

手算:两次 persist 到底有几笔订单

同一事务对同一个序列订单对象连续执行两次 persist,再 flush,最后 rollback。对象从未 detach,业务单号保持不变。根据本文的分支,计算 INSERT 动作数、flush 后同事务行数、回滚后新连接行数,以及 Java id 是否为空。

答案分别是一个插入动作、一行、零行、非空。第二次调用命中 PERSISTENT 分支,不把同一托管实例重新作为 TRANSIENT 保存;flush 将排队动作送到数据库;rollback 撤销事务写入,但不会把普通 Java 字段恢复为构造时的值。

如果把第二次 persist 改成对另一个新对象调用,即使业务单号相同,也不能再用这个答案。两个 Java 实例分别进入新对象路径,业务唯一约束会在对应 INSERT 执行时判定冲突。需要区分“同一个对象重复操作”和“两个对象代表同一业务请求”;持久化上下文的对象身份管理不替代业务幂等约束。

改动练习:让观察结果发生一次可解释的变化

复制 identityRollbackExecutesInsertBeforePersistReturns,改为提交,保持所有提交前检查点不变,最终新连接行数改为一。执行后核对 CH05_FINAL 中策略、业务单号、终态行数和测试报告;仅将方法名改为 commit 而没有改变事务分支,不算完成。

进一步在序列测试中删除显式 flush,但仍提交。预计 after-persist 检查点保持不变,提交时发生必要同步,最终新连接为一行。若同时改成 rollback,应该在没有订单 INSERT 的情况下终止事务,Java id 仍可非空。该变体要求重新采集 SQL 文本和同连接状态,不能直接复用原测试表。

判定订单是否成立时,把“主键已分配”用于定位对象,把“插入已执行”用于定位数据库工作进度,把“提交后的新连接可见”用于确认当前实验中的持久化结果。若还要发送消息,需继续解决数据库提交与消息发送之间的协调,不能把 persist 返回当作消息发送的可靠触发点。

源码与系列导航

源码均固定在 ca7715d7c1afbd46d518752115848bffd9322413,对应段落已提供精确路径。研究覆盖事件分发、实体状态判断、生成器分支、动作构造与 early insert。规范入口为 Jakarta Persistence 3.2 实体实例生命周期;精确执行时机以本章冻结实现和受控测试为准。

前置文章:00 环境与证据链、01 对象身份、03 托管与脱管。上一章:04 Java 值怎样绑定到列。下一章:06 merge 复制到哪个对象。