深入 Hibernate 07:脏检查怎样比较快照与当前值
没有调用 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 | |
两个数组的内容相同,但 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 | |
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 的触发与查询空间,继续区分“对象发生变化”和“此刻需要同步到数据库”。
