没有调用 update,事务提交时仍然出现了 UPDATE:只要对象还受当前持久化上下文管理,修改对象就可能成为 flush 的输入。Hibernate 如何知道它变了?答案需要同时检查实体状态、旧值的保存方式、属性类型的相等规则,以及是否启用了字节码增强。

把属性设成原值,与先改成其他值再改回来,是两个不同的实验。修改 byte[] 的一个元素,也不同于给字段赋一个新数组。只统计 setter 调用次数,无法正确预测这几种情况。

本篇基线为 JDK 21、Hibernate ORM 7.1.36.Final、PostgreSQL 17.6;实现固定到提交 ca7715d7c1afbd46d518752115848bffd9322413。以下推导限定普通可写标量属性,不包含集合、生成列、版本属性或自定义 Interceptor。完整实验单独映射两张表,不修改系列的订单模型。

loadedState 是比较基线

Session.find 返回的托管对象在持久化上下文中有对应的 EntityEntry。对象保存当前属性,条目保存状态、标识和 loadedState 等信息。loadedState 是按映射属性顺序排列的状态数组,不能把数组下标随意理解为 Java 字段声明顺序。

例如某个映射的属性顺序为 [label, payload],加载后的逻辑状态是:

1
2
loadedState = ["old", byte[]{1, 2}]
current = ["old", byte[]{1, 2}]

两个数组的内容相同,但 payload 必须有独立的旧值副本。若旧值与实体指向同一个可变数组,执行 payload[0] = 9 就会同时修改“旧值”,比较算法再完整也无法恢复已经丢失的历史。

把 label 改为 new,把数组首字节改为 9,手算比较为:

属性 加载快照 当前值 类型层判断 脏属性下标
label old new 字符串内容不同 0
payload [1, 2] [9, 2] 数组内容不同 1

脏属性集合是 [0, 1]。若只修改数组内容,结果是 [1];若 label 经过 old → new → old 且数组未变,完整快照比较得到没有脏属性。这个计算取决于 flush 时的最终值,不保留赋值历史。

实验在修改前读取实际映射属性名寻找 payload 下标,并断言 record.getPayload() 与 entry.getLoadedState()[payloadIndex] 不是同一引用。它既不硬编码属性顺序,也不使用复制后的新 Session 对象来冒充原条目的快照。

flush 从条目状态进入类型比较

DefaultFlushEntityEventListener.onFlushEntity 首先调用 entry.requiresDirtyCheck(entity)。它随后取得属性状态,执行必要的脏检查,并在确有更新需要时安排更新动作。最终 SQL 的执行在动作阶段,不能把检测出脏属性直接等同于事务已经提交。

冻结版本的具体条目实现是 EntityEntryImpl。requiresDirtyCheck 要求实体可修改,并且不能已经明确判定为无变化。只读状态、不可变实体映射、增强跟踪信息和可变属性都会影响这个入口判断。

普通快照路径在 performDirtyCheck 中取得 entry.getLoadedState(),调用 persister.findDirty(current, loadedState, entity, session)。没有 loadedState 的分支还可能涉及数据库快照;它不等于“每次 flush 都先 SELECT 一次旧值”。对于已经加载并持有快照的本篇对象,比较使用内存状态。

AbstractEntityPersister.findDirty 委托 DirtyHelper.findDirty。后者遍历可检查属性,并调用各自 Type.isDirty。未抓取属性有特殊哨兵处理,属性是否可检查、列是否参与检查也进入条件;这不是对全部字段无条件调用 Object.equals。

基本类型的 AbstractStandardBasicType 再连接到类型的相等与可变性契约。多数普通基本类型根据新旧值是否相同决定脏状态;源码还保留特殊可变性计划下保守认定为脏的分支。因此“对象引用相同就没改”和“equals 一定决定所有类型”都过于宽泛。

模式:判断一个修改是否会写库,应先确认对象是否受当前上下文管理,再确认条目是否可修改,最后追踪该属性实际使用的类型契约。脱管对象的普通字段变化不会自行进入这个 Session 的 flush。

TypeHelper 与可变性契约

旧版本资料常把脏检查直接画成 TypeHelper.findDirty。在本篇冻结实现中,普通比较由 DirtyHelper 完成;TypeHelper.deepCopy 的重要职责是按照类型复制状态,而非充当这里的比较入口。

deepCopy 遍历类型数组,只处理复制掩码允许的属性,保留未抓取等特殊值,其余调用 Type.deepCopy。对于基本类型,这会进入 JavaType 的 MutabilityPlan.deepCopy。字符串等不可变值可以安全复用引用;可变值需要保持逻辑旧状态所需的副本。

本篇 byte[] 使用 PrimitiveByteArrayJavaType:内容相等通过 Arrays.equals 判断,其 ArrayMutabilityPlan 复制数组。这样原地修改元素虽然没有字段赋值,仍然可以与独立旧数组比较。

加载阶段的 EntityInitializerImpl.deepCopy 直接使用属性可变性计划。因此,不应把所有快照创建都描述为经过同一个 TypeHelper 方法。更新动作执行后的 EntityUpdateAction 则会调用 TypeHelper.deepCopy,随后通过条目 postUpdate 更新状态。

自定义类型有一个容易漏掉的反例:把可变对象声明为不可变,或者让 deepCopy 直接返回原引用。原地修改后,快照和当前值一起变,比较可能得到相等,最终丢失 UPDATE。另一种错误是复制正确,但 areEqual 总返回 true,同样会漏掉变化;总返回 false 则会制造无意义更新。序列化到数据库能正确往返,并不能单独证明脏检查正确。

模式:自定义类型至少需要相互一致的复制、可变性和相等规则。验证时同时覆盖“替换引用”和“原地修改”,仅用 String 或一次数据库往返不足以检查这份契约。

只读对象仍然可以在 Java 中变化

session.setReadOnly(record, true) 是 Hibernate 的实体跟踪设置。它不冻结 Java 对象,也不把 JDBC 连接改成数据库只读事务。

EntityEntryImpl.setReadOnly 将条目状态改为 READ_ONLY,并释放 loadedState。isModifiableEntity 对该状态返回 false。实验因此直接断言原条目的 loadedState 变为 null,再改 label 和数组内部内容。

对于本篇没有关联的标量实体,预期 flush 不产生 UPDATE;Java 对象已经成为 new / [9,2],数据库仍应保持 old / [1,2]。这两个状态必须分别观察。只打印 Java 对象会得出修改已保存的错误结论,只看无 UPDATE 又可能忽略业务代码正在使用一个偏离数据库的对象。

恢复为可修改时,当前实现从对象当前值重建快照,并经过 TypeHelper.deepCopy。因此不能期待“重新设为可写”自动补交只读期间的所有历史修改。这一恢复行为是源码边界,本篇实验只验证切换为只读后的标量变化。

只读也不能推广为任意对象图都绝不产生 SQL。集合维护、级联与删除有独立语义;本篇实体不含这些映射,所以结果不能替代关联场景的实验。

增强路径记录了什么

构建期增强给实体增加 SelfDirtinessTracker 等接口,并改写属性写入路径。修改发生时可以记录属性名,flush 不必总是完整比较全部不可变属性。

getDirtyProperties 先考虑 Interceptor;在 tracker 可用的情况下,进入 getDirtyPropertiesFromSelfDirtinessTracker。本篇没有自定义 Interceptor 或自定义脏检查策略,避免它们改变路径。

关键条件是 tracker.hasDirtyAttributes() || persister.hasMutableProperties()。即使 tracker 没有记录字段写入,只要映射存在可变属性,就不能因此排除变化。resolveDirtyAttributeIndexes 会先比较可变属性,再合并 tracker 记录的可更新属性下标。

数组元素写入没有给实体的 payload 字段赋新引用。它可能在 flush 前显示 tracked=[],但可变类型比较仍会得到 dirty=[payload]。增强没有把可变对象内部变化变成不存在,它只是需要另一条检测路径。

原值回写则呈现相反边界。直接调用 setLabel("old") 可以保持无脏属性;先设为 new 再设为 old,tracker 已记录 label 发生过写入。在此冻结版本、此映射下,合并属性名的路径不会再次用 loadedState 排除这个不可变属性,所以可能仍安排 UPDATE。两组最后都保存 old,但 SQL 数量不同。不能只用终态相同断言增强前后的执行行为完全一致。

这些是指定版本和编译产物的实现行为。测试必须检查运行中实例是否真正实现 tracker,而不能仅依据 POM 中出现增强插件就宣称增强生效。关闭增强也要 clean,防止上轮改写过的 class 留在 target 中。

动态 UPDATE 处理的是列集合

两份实体映射只在 @DynamicUpdate 上有区别。它们的表都有 id、label、payload 三列;id 是应用分配的主键,本篇没有版本列。

UpdateCoordinatorStandard 在动态更新且脏属性下标已知时,据此构造参与更新的属性。只改 label 时,普通映射可以使用 SET label=?,payload=?,动态映射则使用 SET label=?。

决定哪些属性脏与决定 UPDATE 包含哪些列发生在不同层。动态更新不会让所有实体从快照检查自动变成增强跟踪,也不保证少分配对象。减少 SET 列还可能增加 SQL 形状,语句复用、批处理和数据库成本需要独立测量。

实验在 flush entity 默认监听器之后追加一个只观察事件的监听器,记录真正的 event.getDirtyProperties();同时通过 StatementInspector 保存实际 SQL。对于同一个 label 场景,两种映射应都检测到 [label],SET 列才产生差异。它没有另外手动调用一次 findDirty 然后将那个推算值冒充实际 flush 结果。

四组对照与可复跑入口

可运行源码位于仓库的 examples/hibernate-lab。实体在 src/main/java/blog/hibernate/Chapter07MutableRecord.java,测试在 src/test/java/blog/hibernate/Chapter07Test.java。JDK 21 是运行要求;这些源码不承诺在 Java 8 上编译。

在实验目录设置好第 00 篇的 PostgreSQL 环境变量后,顺序运行:

1
2
mvn clean test -Dtest=Chapter07Test -Dchapter07.enhanced=false
mvn clean test -Penhanced -Dtest=Chapter07Test -Dchapter07.enhanced=true

enhanced profile 使用与 ORM 同版本的 hibernate-enhance-maven-plugin,在 process-classes 阶段处理实体 class,启用 dirty tracking 和 lazy initialization,关闭关联自动管理。此处不引入运行期 javaagent。两条命令不能并行写同一个 target。

对照组 实验操作 需要同时观察的内容
原值回写 same:old→old;revert:old→new→old tracker、脏属性、UPDATE 数量、提交值
可变值 replace:替换数组;internal:修改数组首字节 快照独立引用、payload 脏标记、数据库 bytea
只读 setReadOnly 后修改字符串与数组 loadedState 为 null、Java 当前值、无 UPDATE、数据库原值
增强开关 两次 clean test,分别有无 profile 实际 tracker 接口、上述每个场景的两种列集合

每次运行覆盖六种操作、两种动态更新设置,即 12 个场景;两次合计 24 个。每个场景先在独立事务插入基线对象,再用新 Session 加载和修改,显式 flush 后提交,最后通过独立 JDBC 连接回读已提交值。随机主键避免把旧行结果混入本次实验。

测试也在显式 flush 前后读取 JDK 的线程分配计数,输出 flushThreadAllocatedBytes。这包含当前线程上的 ORM、日志及 JDBC 相关分配,不包含其他线程或数据库内存;首次执行还可能受初始化和 JIT 阶段影响。单次原始数字只能保留观测边界,不能用于判定增强或动态更新更快。多轮吞吐、分位延迟、分配和数据库工作量的公平比较留给第 38 篇。

当前执行结果以仓库 examples/hibernate-lab/evidence/07/ 的双组日志与 Surefire 报告为准。文章定稿时需要核对两次增强状态、24 个场景、全部 JDBC 终态和分配计数;未取得这些证据前不能标记 LAB_VERIFIED。

容易误判的两条推理

“调用 setter 就会更新”忽略了相等值、只读条目和最终快照比较。直接赋相等值、先改再恢复、数组内部修改分别触及不同分支,必须与实际增强产物一起判断。

“UPDATE 少了几列,所以脏检查更便宜”则把 SQL 构造结果当成检测成本。无增强时动态映射仍可能扫描可检查属性;有增强且含可变值时仍需要比较可变内容。SQL 日志只证明 SQL 的形状,不能证明 Java 层少分配了多少对象。

练习

给 byte[] 替换实验增加“新引用、相同内容”的场景。在普通快照路径中,比较依据为什么不是引用不等?再观察增强后的 tracker 与最终 dirty 是否一致。这个新增场景需自行运行,不能从本篇的“内容不同”结果外推。

把 label 恢复原值的实验改成先 flush 一次、再恢复、再 flush。第一次 flush 如何改变 loadedState?分别预测两次 SQL 和最终数据库值,再用日志与独立连接回读确认。

编写一个故意错误的可变类型:deepCopy 返回自身。用不替换引用的内容修改构造漏更新反例,再修复复制与相等契约。这项练习针对自定义类型,本篇 byte[] 实验没有实现该错误类型。

参考资料与导航

上游 DirtyTrackingTest 验证相等赋值不产生脏标记及多属性跟踪;它使用上游增强测试基础设施,本地没有运行这套上游测试,不能把其存在当作本篇 PG 实验结果。上游 ReadOnlyTest 提供只读实体场景的进一步阅读入口。

判断目标 应核对的证据
属性实际被检测为脏 flush 事件的脏下标与映射属性名
更新包含哪些列 StatementInspector SQL 和绑定日志
更新是否已提交 提交后独立 JDBC 回读
优化是否降低整体成本 相同负载的重复测量,不能只看 SET 列数

上一篇:06:merge 复制到哪个对象。下一篇讨论 flush 的触发与查询空间,继续区分“对象发生变化”和“此刻需要同步到数据库”。