脱管订单的金额已经变成 20.00。执行 session.merge(order) 后,再把原引用改成 99.00,提交时数据库应该保存哪个值?决定结果的是修改发生在哪个 Java 对象上。对于本篇已存在数据库行的脱管对象,merge 把状态复制到当前持久化上下文中的托管对象,并返回该对象;原引用仍然脱管。

实验沿用前面章节的 PurchaseOrder。运行基线是 JDK 21、Hibernate ORM 7.1.36.Final 和真实 PostgreSQL;源码固定为 ca7715d7c1afbd46d518752115848bffd9322413。本篇区分 API 对对象状态的承诺、冻结实现的复制过程和本地数据库实验结果。

输入引用与返回引用

Session.merge(T) 的契约说明复制目标具有相同标识;上下文没有对应对象时需要加载,尚未保存的对象则保存其副本。它明确指出传入对象不会因为这次调用而与 Session 建立关联。Jakarta Persistence 3.2 §3.3.7.1 的实体合并规则规定脱管状态复制、关联级联以及未抓取 LAZY 字段的处理边界。

这不是“任何 merge 都返回另一个对象”的承诺。传入对象已经托管时,当前实现进入 entityIsPersistent,其结果仍是原托管对象;未保存实例与脱管实例也分别进入不同分支。本篇的 assertNotSame 只检验已经关闭旧 Session、数据库行仍存在的订单。

merge 的语义也不等于 HTTP PATCH。一个已加载的普通可写标量字段如果被设置为 null,该值可能参与复制;不能把 null 统一解释为“调用方没提交这个字段”。对于局部修改命令,更直接的做法是加载当前托管实体,再修改命令中明确出现的字段。规范要求忽略未抓取的 LAZY 字段,并不能推出忽略所有空值。

冻结实现中的复制顺序

DefaultMergeEventListener.onMerge 为顶层合并建立 MergeContext 和实体副本观察器;合并结束或异常退出时,在 finally 中清理两者。这份记录只覆盖一次顶层合并及其级联图,不能据此推导不同请求之间的冲突检测。

已存在的脱管实体进入 entityIsDetached。它按实体名和标识通过 session.get 解析托管目标。上下文已经包含该实体时可以复用,不能把每次 merge 都说成必定多发一条 SELECT。

找到目标后,监听器先把输入与目标的对应关系放入 copyCache,再校验目标并执行合并级联。随后取得输入属性与目标原属性,经过 Interceptor.preMerge、TypeHelper.replace 和 persister.setValues 将替换后的属性设置到目标,最后执行 postMerge、脏属性处理以及 event.setResult(result)。7.1.36 的这一脱管路径实际使用 TypeHelper.replace,阅读旧版本中同名类的 copyValues 片段不能替代核对当前方法体。

先登记再级联,使循环对象图能够识别已经处理的对象,并让关联属性替换可以利用同一份输入到托管对象的映射。该映射解决对象身份与循环访问问题,不提供业务字段冲突的自动合并规则。

另一个分支发生在目标行不存在时。实现通过 persister.isTransient 进一步判断:如果能够确认输入原本是脱管实体,抛出 StaleObjectStateException;若确认是新对象或信息不足,则转入 transient 路径。因此“带着 id 调 merge,找不到行就一定 INSERT”不成立。本篇正常场景预先提交订单,明确避开这个存在性分支。

属性复制完成后,后续更新由托管实体的脏检查、flush 与事务推进。merge 返回并不证明 UPDATE 已提交。本篇用 SessionFactory.inTransaction 完成事务,再从新的 JDBC 连接查询;数据库金额才是该场景的终态依据。

手算两次单方修改

两组实验使用不同订单,初始金额都是 10.00。旧 Session 提交并关闭后,输入对象 D 脱管;调用前先将 D.amount 改成 20.00,调用得到托管对象 M。

时刻 只改输入 D 只改返回 M
数据库初始值 10.00 10.00
merge 前 D.amount 20.00 20.00
merge 返回时 M.amount 20.00 20.00
返回后单方修改 D.amount = 99.00 M.amount = 30.00
提交前 D / M 99.00 / 20.00 20.00 / 30.00
预期提交金额 20.00 30.00

第一组的 99.00 仅改变脱管对象内存;复制并不建立两个普通标量字段之间的持续同步。第二组修改的是当前会话跟踪的 M,预期提交 30.00。这两组共同排除了“数据库保存的是最后被 set 的那个值”这种忽略对象身份的解释。

完整可编译测试位于仓库 examples/hibernate-lab/src/test/java/blog/hibernate/Chapter06Test.java,其中 mutationsAfterMergeFollowOnlyTheManagedReturnValue 同时断言引用不同、输入不在 Session 内、返回对象在 Session 内,以及两个金额终态。教学模型只新增测试,不修改共享订单实体。

准备第 00 篇的三个数据库环境变量后,在实验目录执行:

1
./mvnw -B -ntp -Dtest=Chapter06Test test

测试输出的 CH06 changeReturned=false 和 CH06 changeReturned=true 分别带有合成订单 id、输入金额和提交金额。SQL/bind 日志用于核对写入参数;新 JDBC 连接显式采用 READ COMMITTED,用主键查询并断言恰好一行。日志行不能独立替代数据库终态断言。

同一 id 的两个脱管副本

单个对象复制可以定义清楚,关联图同时提供两份状态则可能矛盾。测试构造一个只有两条 @ManyToOne(cascade=MERGE) 关联的根对象。两条关联分别引用从两个独立 Session 读取的订单副本 A 和 B;两者 id 相同、引用不同,金额分别改成 20.00、30.00。

1
2
3
4
5
6
脱管根对象 G
first -> A(id=K, amount=20.00)
second -> B(id=K, amount=30.00)
|
v
当前上下文只能解析到同一个托管目标 M(id=K)

这组最小测试使用 Chapter06Test 内嵌的 CopyOrder 和 OrderGraph 映射及两张 ch06_ 专用表,只用于制造副本冲突,不提前改变后续关联章的业务模型。两个副本都通过真实查询获得,再关闭各自会话;测试没有把随手 new 一个带 id 的对象当成已经证明的脱管对象。

MergeContext.put 同时维护输入到托管目标以及反向映射。登记 A→M 后,再登记 B→M,反向表中发现 M 已经对应另一输入 A,于是调用 entityCopyDetected。映射基于 Java 对象身份;不是靠实体的业务 equals 去自动融合两份状态。

EntityCopyObserverFactoryInitiator 在未配置观察器时选择 disallow。EntityCopyNotAllowedObserver 随即抛出带有 Multiple representations 信息的 IllegalStateException。本篇没有覆盖配置,因此验证的是默认拒绝策略。

上游 testCascadeFromDetachedToNonDirtyRepresentations 使用未修改、equals 相等但引用不同的两个 Item,构造 Hoarder 关联图并断言 merge 抛出 IllegalStateException。它支持默认重复表示检测的结论;本篇金额变化及 PostgreSQL 回滚终态由独立教学测试验证,上游全仓测试未在本次运行。

此时还没有两条并发 UPDATE 在数据库中竞争,也不需要 @Version 才能触发。异常发生在一次 merge 对象图遍历期间,属于重复表示检测。乐观锁解决并发版本冲突,是另一个问题。若分别执行两次顶层 merge,每次都会创建新的 MergeContext,不能期待本次图内检测跨越两次调用自动保留。

配置 hibernate.event.merge.entity_copy_observer=allow 会放行副本;log 也允许并提供记录能力。这些配置不构成领域冲突解决算法。两个副本都声明了金额,业务必须先决定哪个状态有效,再构建一致的修改命令。为了让异常消失而启用 allow,会丢掉默认的冲突信号。本篇只实测 disallow,不把 allow/log 的数据库覆盖顺序写成实验结论。

失败后数据库应该保留什么

反例先提交金额 10.00 的订单及根对象,再在新事务合并冲突图。测试在 session.merge 调用处捕获并断言异常类型与消息特征,finally 中回滚,随后关闭 Session。新连接必须仍查到 10.00。若只断言抛异常、没有核对回滚终态,就无法完整回答订单数据是否被改变。

该异常由 Hibernate 在调用中抛出,不是数据库约束异常,因此本场景没有可报告的 SQLState。数据库失败实验才应记录驱动返回的 SQLState;不能为了凑证据字段编造一个数值。出现冲突后也不继续复用当前 Session 执行其他业务写入。

本次本地运行使用 PostgreSQL 17.6、JDK 21.0.11,两个 JUnit 测试全部通过,失败、错误、跳过均为零。真实输出确认只改输入提交 20.00,只改返回提交 30.00;冲突在 merge 调用内抛出 IllegalStateException,回滚后新连接读到 10.00。运行命令与证据索引见同名资产目录的 RUN.md,仓库原始输出保存在 examples/hibernate-lab/evidence/06/20261002-pg17/。上游测试引用及逐方法阅读记录见 writing-plans/hibernate/research-06.md。

练习

将第一组实验改为先在同一 Session 中 find 当前订单,再 merge 脱管副本,并断言 find 返回对象与 merge 返回对象相同。预期金额仍为 20.00;额外验证的是当前上下文目标复用,不应仅数 SELECT 日志判断引用相等。

将根对象的 second 改为引用同一个 A 实例后再次合并。预期不会因为“两条关联”本身抛副本冲突;触发条件是不同输入对象解析到同一托管目标。随后把两条金额都设为 20.00,但保留 A、B 两个实例,默认策略仍应拒绝,说明它检测的是重复实体表示,并没有逐字段比较后认定内容相同便放行。练习尚未纳入本篇已执行测试。

上一章:05 persist 返回时究竟完成了什么;下一章:07 脏检查与变更检测(尚未发布)。