实验材料状态:本仓库尚未包含 examples/hibernate-lab/ 工程及原始证据。下文命令和输出来自云端文稿记录,待补交原文件后复核,当前不能据此认定实验已验收;运行命令暂不可在本仓库复跑。

问题是哪个观察者看到了订单

创建订单的代码执行到 persist(order),此时订单对象可能已经有 id;执行到 flush(),数据库可能已经接收 INSERT。这两个观察都不足以回答“另一个请求是否能看到订单”。本篇验证的结果是:提交成功后,新连接能读到订单;回滚后不能。Java 字段值、SQL 日志、驱动执行与数据库提交不能视作同一件事。

实验只建 PurchaseOrder 单表,订单号 order_no 与请求幂等键 request_key 分别唯一,金额为 numeric(19,2),拒绝负数。业务单号不是数据库主键;幂等键在本篇只承担唯一约束,不把唯一约束偷换成完整的重试幂等服务。客户、商品、库存和消息在后续需要关联与并发实验时引入,不能提前用复杂对象图遮住事务边界。

最小环境是 Java 21、Maven 3.9.9、Hibernate ORM 7.1.36.Final、Jakarta Persistence API 3.2.0、PostgreSQL JDBC 42.7.7 和 PostgreSQL 16.15。这一组版本是教学实验的冻结对象,不是当前最新版本或生产部署推荐。上游 Hibernate 使用自己的 Gradle 构建;本仓库的 Maven Wrapper 只构建教学工程。确切的版本、安装形式和来源见 writing-plans/hibernate/VERSIONS.md;本章没有 MySQL 对照,不能从 PostgreSQL 的隔离实验推广到所有数据库。

五层边界决定哪些证据有用

订单写入可以写成一条时间线,而不是“调用了 save 所以保存成功”:

步骤 此时能确定什么 此时不能确定什么
persist(order) Hibernate 将对象纳入本次 Session 的持久化工作单元;本实验 sequence 策略使 id 获得值 行已经提交、另一连接能读取
flush() Hibernate 把待同步变更转换为 JDBC 操作;测试中的 SQL 与绑定日志可核对语句和参数 事务已经提交,或者网络只发生了一次往返
JDBC execute 返回 驱动请求按当前事务到达数据库,未报错的执行可在本事务中观察 数据已经对其他事务可见
commit() 返回 本实验中的提交路径返回成功 远程系统已收到通知,或者任何未来读取在所有隔离级别下必然看到同一快照
新连接 READ COMMITTED 查询 在声明的隔离和取样时点验证订单行数与数据 它不替代生产环境并发、故障、超时和跨资源保证

Jakarta Persistence 定义实体操作与同步的可移植语义;Hibernate 实现有状态 Session、映射和写入协调;JDBC 提供连接、参数绑定、执行及提交接口;PostgreSQL 控制约束、事务提交和其他事务的可见性。Spring 尚未进入本实验,不能用 Spring 的事务传播规则解释原生 Session。SessionFactory 对应映射与服务配置、适于复用,短生命周期 Session 则持有一次工作单元相关的上下文;它不是另一条数据库连接上的观察者。

设计上需要区分写入排队和事务完成:若实体字段一变就发送 SQL,将难以统一处理多步变更、标识符及依赖顺序;即使已经发送 SQL,数据库也仍需保持事务的原子边界。flush 是将上下文状态同步到数据库事务的机会,不是事务边界。id 使用 sequence 时还可能先执行 nextval;序列取号与订单行提交不是一件事。实体对象的 id 不会因为数据库回滚自动变回 null,因此异常后不能把旧 Session 与旧对象当作数据库终态使用。

冻结源码把“做了什么”与“没有做什么”分开。StandardServiceRegistryBuilder.build() 处理服务配置后建立 registry;MetadataSources.buildMetadata() 调用 getMetadataBuilder().build(),工厂还需要后续 buildSessionFactory()。因此启动映射失败与写入时数据库约束失败处于不同阶段。

运行阶段,SessionImpl.persist(Object) 先检查 Session 是否开放,再触发 PersistEvent。DefaultPersistEventListener.persist() 查询上下文中的 EntityEntry 后按 DETACHED、PERSISTENT、TRANSIENT、DELETED 分支处理;本实验的新订单走 TRANSIENT,脱管对象不能借 persist 当作新订单提交。状态分支解释了为什么一个 API 调用不等于无条件执行 INSERT。

该文件的 SessionImpl.flush() 经事务检查进入 doFlush(),触发 FlushEvent。DefaultFlushEventListener.onFlush() 检查是否存在托管实体或集合:有则准备并执行待写动作;没有时只有队列仍有动作才进入另一执行分支。实测中的 INSERT 是前一种情况下 flush 后的数据库后果。与之相反,TransactionImpl.commit() 要求事务活动,并委派给事务驱动;本实验使用的 JDBC resource-local 驱动在 commitNoRollbackOnly() 才调用 jdbcResourceTransaction.commit(),而 rollbackOnly 走回滚分支。上游 TestFlushJoinTransaction.testIsConnectedFlushShouldThrowExceptionIfNoTransaction() 验证“没有事务时 flush 不能继续”的分支,但它使用 JTA,不是本篇 JDBC 本地事务隔离测试。后一断言由下文真实 PostgreSQL 集成测试承担。源码路径、相关上游测试和业务场景的运行证据因此各有作用域。

用 JDBC 建立没有 ORM 的对照

examples/hibernate-lab/db/00-schema.sql 给出订单表、唯一约束、金额校验与 sequence;Chapter00Test 创建同结构表以便在独立实验数据库上复跑。测试连接设置 autoCommit(false),在事务内用 PreparedStatement 明确绑定合成订单号、幂等键、金额和状态,再分别执行 commit、rollback。每一次结果检查均重新获取连接,读取设置为 READ COMMITTED:

1
2
3
4
JDBC bind order_no=jdbc-commit-8f74fd35-5157-44fe-ab13-9f99b7a3c696 request_key=jdbc-commit-8f74fd35-5157-44fe-ab13-9f99b7a3c696 amount=12.34 status=NEW
JDBC before commit other connection count=0
JDBC after commit other connection count=1
JDBC after rollback other connection count=0

正常路径中,插入操作已返回,另一连接在提交前仍读到 0;提交成功后读到 1。反例中,同样执行过插入,显式回滚后读到 0。不能从两条日志数推导驱动的 prepare 次数、网络往返数或者服务器物理刷盘次数;这些不在本实验的采集范围内。

再把工作单元交给 Hibernate

LabDatabase.sessionFactory() 用 StandardServiceRegistryBuilder 配置测试数据库,用 MetadataSources.addAnnotatedClass(PurchaseOrder.class) 注册映射并建立 SessionFactory。启动时设置 hibernate.hbm2ddl.auto=validate,让映射与已有表结构不符时尽早失败,不允许 ORM 在背景中创建或改写表。服务注册构建失败时显式销毁 registry;每次操作用新的 Session,退出时关闭 Session 和工厂。运行前必须提供三项环境变量(密码不写入仓库):

1
2
3
4
5
export HIBERNATE_LAB_JDBC_URL='jdbc:postgresql://127.0.0.1:5432/hibernate_lab'
export HIBERNATE_LAB_USER='hibernate_lab'
export HIBERNATE_LAB_PASSWORD='<本地实验库凭证>'
cd examples/hibernate-lab
./mvnw -B -ntp -Dtest=Chapter00Test test

测试在每个 Hibernate 事务中调用 persist、flush,并用新连接在提交前、提交后或回滚后逐一断言。一个 commit 路径与一个 rollback 路径的原始日志片段如下;绑定参数是合成数据,数字 7、8 只属于该次运行:

1
2
3
4
5
6
7
8
9
10
DEBUG: select nextval('purchase_order_id_seq')
DEBUG: insert into purchase_order (amount,order_no,request_key,status,id) values (?,?,?,?,?)
TRACE: binding parameter (1:NUMERIC) <- [12.34]
TRACE: binding parameter (5:BIGINT) <- [7]
ORM after flush id=7 other connection count=0
ORM after commit other connection count=1
DEBUG: select nextval('purchase_order_id_seq')
TRACE: binding parameter (1:NUMERIC) <- [1.00]
TRACE: binding parameter (5:BIGINT) <- [8]
ORM after rollback id=8 other connection count=0

日志还保留了参数 2–4 的绑定和两条 INSERT 的 SQL 文本,本文省去重复的随机合成键,但不能因此说第二次没有执行 INSERT。执行完成后,另起 psql 连接按实验前缀核对终态:两次运行合计 jdbc/commit=2、orm/commit=2,没有 rollback 行。该表是“订单行终态”的证据;并非“序列号无间断”或“flush 可以替代 commit”的证据。完整原始命令、环境、退出码、控制台输出和终态保存在 examples/hibernate-lab/evidence/00/20261002T040900Z/。本次 2 个测试通过,0 失败、0 跳过;没有把 SQL 日志当成驱动 batch 或网络计数器。

练习和适用边界

手算反例。 同一订单对象在 persist 后取得 id=8,随后 flush 未报错,事务却执行 rollback。另一个连接按订单号查询得几行?Java 对象的 id 是多少?答案是新连接在本实验的隔离条件下读到 0 行,原对象仍持有 8。前者来自数据库事务的终态,后者来自 Java 内存;序列号不能当作提交凭据。把数字 8 换成其他已分配标识符,结论不变。

改动练习。 在 jdbcCommitAndRollbackHaveDifferentFreshConnectionOutcomes 中把提交路径改为 rollback(),仍断言新连接有一行。预期测试失败于哪一步?答案是提交路径的 assertEquals(1, count(committed)),实际读到 0;这说明断言确实覆盖了事务边界,而不是只检查了 executeUpdate() 的返回值。恢复代码后重新运行,不要通过取消断言让测试变绿。

本实验只验证单表写入与新连接可见性。对象身份、集合、映射启动错误、属性类型与 IDENTITY 对照仍待各篇独立实验;它也没有精确统计 prepare、executeBatch、网络往返或提交确认丢失。若程序在发送 commit 后连接中断,仅凭客户端异常无法断言是否已提交;这种结果未知的情况属于第 29 篇的问题,不能拿这里主动回滚的结果替代。

参考资料与实验入口

  • Jakarta Persistence 3.2 规范:持久化上下文、同步和事务语义(规范层)。
  • Hibernate ORM 7.1 Session API及ORM 7.1 官方版本说明:公开接口、版本基线;在线系列文档并非 7.1.36.Final 的不可变快照。
  • PostgreSQL 16 事务隔离:本章另一连接的 READ COMMITTED 观察范围。
  • 教学代码:examples/hibernate-lab/src/test/java/blog/hibernate/Chapter00Test.java;原始输出:examples/hibernate-lab/evidence/00/20261002T040900Z/。本文代码为独立教学实现,不是 Hibernate 上游源码。