JPA、Hibernate 与 Spring Data:对象状态何时同步到关系数据
修改实体字段后没有显式 update,数据库却在查询前出现 SQL;merge 返回后继续修改原对象,变更却没有落库。ORM 的关键不是注解映射,而是持久化上下文如何持有身份、快照和待执行动作。
Jakarta Persistence 把关系数据访问提升为持久化上下文中的对象状态机。规范明确:同一持久化上下文内,同一持久化标识只有一个受管实例;new、managed、detached、removed 是状态,不是四种 Java 类型。同步发生在 flush,flush 与 commit 又不是同一个动作。可直接对照 Jakarta Persistence 3.2 规范 与 Spring Data JPA 参考。
持久化上下文同时是身份映射与写入计划
EntityManager.find 首先检查一级身份映射;同一上下文再次读取同一标识,返回同一个受管实例。它减少重复物化,也意味着长事务会持续持有实体快照和关联引用。把持久化上下文当全局缓存会造成内存增长与陈旧读;事务型上下文应随工作单元结束而关闭,大批处理则周期性 flush/clear。
dirty checking 比较受管状态与快照,在 flush 阶段生成 INSERT/UPDATE/DELETE。AUTO flush 还可能在相关查询前触发,因此 SQL 不一定等到 commit 才出现;flush 成功也不代表事务提交,后续约束、锁或网络仍可令 commit 失败。排障必须区分“实体字段已修改”“SQL 已发送”“数据库事务已提交”三个时点。
persist 管理传入的新实例;merge 读取 detached 对象的状态并复制到一个受管实例,返回值才是后续应使用的对象。继续修改原 detached 引用不会被自动持久化。级联只传播指定操作,不表示 aggregate 边界自动正确;CascadeType.REMOVE 与 orphanRemoval 尤其要用数据库集成测试验证删除范围。
关联默认或显式的 LAZY 只是加载计划,访问代理时仍可能在不可控位置发 SQL。Open Session in View 把上下文延长到 Web 渲染阶段,表面消除 LazyInitializationException,却把查询数量、事务边界和错误位置推向接口层。更稳定的做法是为用例设计 fetch join、EntityGraph 或投影,并在事务内完成需要的数据物化。
Spring Data repository 是代理和方法解析层,不替代 EntityManager。继承的读取方法通常带 readOnly 事务语义,业务用例仍应在 service facade 建立完整事务边界。派生查询名、JPQL、Specification 与原生 SQL 最终都要接受 SQL 计划、返回规模和锁语义审查;save 也不能被理解成每次立即执行一条 UPDATE。
Hibernate 是 JPA provider,批处理顺序、二级缓存、bytecode enhancement、fetch 策略和 flush 优化属于实现能力。公开文章以 Jakarta Persistence 3.2 的实体与 flush 契约为基线,使用 Hibernate 7 时再核对实现文档;不要把某个 provider 的 SQL 时机写成所有 JPA 实现必须一致。
快照、flush 顺序与数据库约束共同决定失败位置
dirty checking 需要知道“原值是什么”。provider 可以保存加载快照,也可以借助字节码增强记录字段变更;两种方式影响内存和检查成本,却不改变应用看到的 JPA 契约。大事务管理数万实体时,快照、ActionQueue 和关联集合都会增长,即使最终只更新少量行。批处理任务应使用固定窗口读取,完成一批后 flush 并 clear,同时把业务进度放在数据库中,避免重启后从头扫描。
flush 的动作排序还受外键、级联和 provider 策略影响。Java 中先 remove 子对象再 persist 新对象,不保证 SQL 严格按代码顺序发送。唯一约束替换、父子迁移和有序列表更新必须在真实数据库上验证;依赖偶然 SQL 顺序的模型应改为显式两阶段更新或调整约束设计。
二级缓存位于持久化上下文之外,查询缓存又保存查询结果标识集合。它们只有在所有写入路径都参与失效协议时才安全;数据库脚本、其他服务和批处理绕过 provider 时,会产生陈旧对象。缓存命中率不能单独证明收益,还要观察失效广播、序列化、锁策略、堆占用与陈旧读窗口。高一致性核心数据通常先关闭二级缓存,用数据库和一级上下文建立正确基线。
悲观锁通过查询或 find 的 lock mode 请求数据库锁,必须处于事务内;锁的实际范围仍由 SQL、索引和数据库决定。乐观锁依赖版本属性,在 flush/commit 更新时以旧版本作为谓词,影响行数为零才转成 OptimisticLockException。捕获冲突后继续在同一事务里写通常不安全,应让事务回滚,再由上层根据业务语义重新读取、合并或返回冲突。
Repository 测试若只 mock 接口,无法发现 flush 时约束失败、N+1、锁和映射错误。关键查询至少用目标数据库或兼容度足够高的容器验证 SQL 数量、参数类型、返回顺序和并发冲突;单纯使用内存数据库可能掩盖方言、DDL 与隔离差异。
代理身份、主键生成与 equals 不能各自设计
惰性关联可能由代理对象承载,provider 也可能通过字节码增强拦截字段访问。业务代码若用 getClass() 严格比较实体类型,同一数据库身份的代理与真实类可能被判为不同;若 equals/hashCode 读取惰性字段,又会在集合操作中意外触发 SQL。实体相等策略应围绕稳定业务键或已经分配的持久化标识,并明确新实体在主键生成前的集合行为。
数据库自增、sequence、table generator 和应用生成 ID 还会改变 INSERT 时机。IDENTITY 往往要求较早执行 INSERT 才能取得标识,可能削弱 JDBC batch;sequence 可以预取一段标识并维持批处理。选择策略不能只看注解最短,而要结合数据库能力、批量写入、分片归属和 ID 是否需要在持久化前可用。
实体继承与 polymorphic query 会把 Java 类型层次翻译成单表 discriminator、joined tables 或每具体类表。映射策略改变 SQL join、空列、约束和迁移成本。领域继承并不必然适合数据库继承映射;如果查询总是按具体类型、字段差异巨大,组合或显式多表模型往往更清晰。任何继承策略都要拿真实查询计划验证,不能只凭对象模型优雅。
乐观锁的版本列也要覆盖脱离 JPA 的写入路径。批量 JPQL update、原生 SQL、脚本或其他服务若不更新 version,会绕过冲突检测;而 JPA bulk update 还不会自动同步当前持久化上下文中的已加载实体。执行后应 clear 或 refresh,并把受影响行数作为证据,否则后续读取可能继续使用旧快照。
用两个状态模型拆掉框架错觉
下面两个 Java 17 程序只保留本篇最关键的状态与分支。它们不连接真实数据库,因此不能证明驱动或数据库的厂商行为;它们用来证明调用方必须维持的不变量,真实集成测试再负责验证 SQL、锁和网络。
javac --release 17 -Xlint:all -Werror examples/backend-development/data-access/jpa-hibernate/EntityLifecycleDemo.java examples/backend-development/data-access/jpa-hibernate/MergeIdentityDemo.java
java -cp examples/backend-development/data-access/jpa-hibernate EntityLifecycleDemo
java -cp examples/backend-development/data-access/jpa-hibernate MergeIdentityDemostates=[new, managed, dirty, flushed, detached] sqlAtFlush=true commitAfterFlush=true
sameReference=false managedId=42 callerMustUseReturnedInstance=true输出的价值在于固定中间状态,而不是展示 API 能运行。修改实现后,如果资源没有复位、冲突被误报为成功、缓存跨越了更新边界或调用身份发生变化,模型应先失败,随后真实数据库测试再给出厂商级证据。
