JPA 持久化 01:实体身份与工作单元
两次 find 命中一行,拿到的是同一个对象吗
审批页面加载采购申请,后台也按相同 ID 加载。它们持有的 ProcurementRequest 可能代表同一数据库行,Java 引用却不是同一个。如果页面把自己持有的旧对象从上下文移走后改成 SUBMITTED,数据库不会仅凭主键相同自动更新。这不是 Provider 忘记写库,而是“相同数据库身份”与“当前上下文正在管理该引用”属于两个问题。把 == 的结果当成整个进程的身份保证,会在跨请求缓存、重试和审批工作单元里产生隐蔽的旧状态。
本章只谈采购申请的生命周期,使用累计工程的 purchase_request 与 purchase_line。00 篇解释 SE 持久化单元启动;完整源码入口在 examples/jpa/。这个工程使用 PostgreSQL 16 独立数据库 jpa_lab,RESOURCE_LOCAL,Java 21,Hibernate ORM 7.1.36.Final,JPA 规范版本 3.2。已有 6 项真实数据库测试的证据仅覆盖其实际断言;本章提出的“修改脱管对象后不写入”的新增探针仍须标 NOT_RUN。
一份上下文的唯一性保证
Persistence 3.2 §3.1 对持久化上下文的定义是:给定一个持久化实体身份,在该上下文中有唯一的实体实例。这里要同时固定实体类型、主键和上下文范围:在同一个 EntityManager 里先 persist(request) 再以已分配的 ID find(ProcurementRequest.class, id),应得到当前托管对象;新开的 EntityManager 即使找到同一行,也不受旧上下文的引用相等约束。这个身份映射解决的是当前工作单元内对同一行维护两份互相冲突的 Java 状态的问题,不提供跨上下文缓存一致性,更不承担租户授权。
实际 ProcurementRequest.java 的 @Id @GeneratedValue(strategy = IDENTITY) 让数据库分配数值主键。@Version 记录并发修改所需版本,而不是用 Java 引用相等解决并发冲突;tenantId 是字段,不意味着调用 find(id) 时自动检查发起者租户。测试里创建随机租户,只用于避免数据相互污染;服务代码还要调用租户校验,数据库更要有相应约束。@OneToMany 使申请与明细相关联,addLine 在 DRAFT 下按单价乘数量计算 total;submit 要求至少有一条明细。这些业务方法改变字段,但只有托管对象的变化才会由当前上下文负责同步。
对对象状态也应有可观测而非想象的判据。new ProcurementRequest(tenant) 还没 persist 时是新对象;persist 后成为托管对象,EntityManager.contains(request) 可以检查这一点(§3.3.2、§3.3.8)。clear() 清空整份上下文,原引用脱管;detach(request) 只针对它及必要的级联;close() 终止这份应用管理上下文。clear() 不是向数据库发一次 SELECT,detach() 也不自动撤回已提交的 INSERT。若在插入尚未同步前清空上下文,未同步的变化可能丢失,因此必须先确认持久化动作已经成功提交,才能把“有无数据库行”和“哪个引用被管理”拆开观察(§3.3.6–3.3.7)。
提交与上下文寿命尤其不能混淆。当前工程是 Java SE 应用管理的 EntityManager,一次 commit() 不自动把该上下文里的所有实体脱管。真实的身份测试是在 commit 之后主动 manager.clear(),再 find 新实例。如果删掉 clear(),下一次在原上下文 find 仍可能返回旧的托管引用;这种实验差异来自上下文成员资格,而不是 PostgreSQL 是否复制了对象。容器管理的事务作用域上下文与应用管理上下文又有不同生命周期(规范 §3.4、§7.7–7.8),不能用一个环境中提交后引用的行为总结所有 JPA 环境。
可以按时间把一张申请拆成四个不同身份问题。第一步,新对象尚无数据库分配的 id,只存在 Java 引用;此刻询问 find 不成立,主键还没有持久化身份。第二步,在这个工程的 identity 主键策略下,persist 使对象进入上下文,数据库生成 ID 之后,同上下文 find 应指回同一实例;不能从已有 ID 反推出事务已经提交。第三步,提交后显式 clear,旧 Java 对象还活着,旧 ID 也在,但 contains(old) 不再为真;按 ID 在原管理器再次 find 会返回新的托管副本。第四步,把旧引用的 status 改成 SUBMITTED,只会改变旧对象字段,不会按相同 ID 自动替换新副本。每一步的问题不同:主键归属、上下文成员、事务终态和引用别名不能被一句“这就是同一个对象”全部覆盖。
这种区分直接影响采购审批的参数设计。假如控制层把申请实体从前一次 HTTP 请求暂存起来,在下一次请求继续调用 approve(),执行方法并不说明此对象仍被当前工作单元管理;它可能已经脱管,甚至还停留在旧版本的 SUBMITTED。较可靠的入口是传入申请 ID 和租户 ID,在当前事务/上下文重取并核对租户、状态和版本,再进行审批。当前 ProcurementService.findForTenant 读取实体后显式比较租户;它能阻止该服务入口返回跨租户申请,却不能让所有直接使用 EntityManager.find() 的代码自动带上租户谓词。把实体上的 tenantId 误当数据库会话级隔离,会使另一条绕过服务的方法出现越权路径。身份保证只说同一上下文内的一致引用,不说哪些用户可以读取那份引用。
再看明细集合:ProcurementRequest 对 ProcurementLine 有关联,addLine 同时新增明细并更新金额;若清空上下文后对旧申请再加明细,旧对象的 Java 集合和金额可能一起改变,却都没有被新的上下文追踪。这不是只对 status 才成立的特殊规则。反过来,对已经托管的实体更改金额字段,也不能只看金额 getter 确认数据库已更新:还要区分已经 flush、已经 commit、下一上下文读回哪个值。01 篇的身份模型正好是 02 篇 flush/rollback 分析的前提:只有明确字段修改发生在哪个上下文、对象当时是否受管理,谈“脏检查没执行”或“提交没生效”才有意义。
已运行入口只证明了哪些事情
完整 Java 入口为 ProcurementJpaTest.java 中的 lifecycleAndServerCalculatedTotal(),启动辅助类和环境检查在 DatabaseSupport.java。该测试用 JUnit assertSame 检查 persist 后同一上下文 find 的引用,再提交、clear、find,用 assertNotSame 检查新引用,并检查回读金额等于 6.75、有一条明细。它没有调用 submit(),也没有对脱管引用再次赋值,因此不能把 6/6 的整套通过记录扩张为“脱管修改失败路径已实测”。实际测试的完整 Java 文件(含包名、imports、@BeforeAll/@AfterAll 与异常释放)已由上述源码链接提供,不要把某几行 assertSame 剪出来当成另一个编译入口。
在 examples/jpa 已配置专用 PostgreSQL 16 并执行 db/migrations/01-init.sql 后,可用以下命令复查相同方法。此处的按方法筛选命令 NOT_RUN;已有原始证据是从该目录运行 ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" clean test,结果为 6 tests、0 failures、0 errors、0 skipped、退出码 0,保存在 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/。该记录的 Java 为 21.0.12.1、数据库 PostgreSQL 16.15,主干并非对所有环境的断言。
这个已运行测试里的 assertNotSame 有严格的解释范围:它只比较 clear 前的原引用和 clear 后重新查得的引用,证明引用不相等;金额和明细的断言确认读取结果,没有在两个活跃事务中交错更新,所以不证明重复读取时的隔离快照一定一致。并发修改另涉及 @Version:两个上下文各自持有一份合法托管对象,单凭每份内部 find 返回同一实例,根本无法阻止二者在旧版本上相互竞争。当前工程另有乐观冲突测试,但把那条断言当成本章“清空上下文”测试的证据也会模糊问题。一个测试只对自己的输入、路径和断言负责。
1 | |
实验应观察两个来源:断言里的 assertSame/assertNotSame 针对 Java 引用,明细和金额回读针对新加载结果;后者不表示已有对象随数据库变化实时刷新。find 有权优先使用上下文中的托管对象,不要仅凭 SQL 日志中有无 SELECT 评判身份保证。某一次 Hibernate 输出的 SELECT 次数也不能拿来约束其他 Provider。跨上下文同时修改同一申请,涉及 @Version 的冲突处理,属于后面的并发篇,不属于本篇 == 演示。
脱管写入的反向探针(NOT_RUN)
为了真正区分“Java 字段已变”与“数据库状态已变”,下面是一个完整的新 JUnit 测试文件草案,若需运行应另存到 examples/jpa/src/test/java/blog/jpa/Detached01ProbeTest.java,但本次只写文章,未落盘到累计工程。它使用与真实工程相同的包、实体、连接辅助类和 jpa_lab 库;submit() 必须在有明细时才合法。新建事务只提交种子行,接着 clear,修改旧引用后开启空事务并提交,最后用新 EntityManager 回读 DRAFT。
1 | |
将文件保存后可执行 cd examples/jpa && ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" -Dtest=Detached01ProbeTest test;该文件和命令均 NOT_RUN,不能引用既有 6/6 声称它已通过。代码没有依赖某个固定 ID:随机租户和数据库 identity 避免重复插入撞主键,但需在真正运行后记录生成的请求 ID 与行查询,才能把测试结果与真实数据库证据相连。JUnit 断言给出功能判据,数据库终态还应由新连接查 purchase_request,不能借用旧上下文里的 getter。若想记录底层 SQL,必须保存查询命令、输出与对应代码 SHA;文中的推导不是 SQL 原始观测。
merge(detached) 是另一条路径:按 §3.3.7.1,它把旧对象状态复制到托管实体实例,并返回托管副本;原对象不因此自动变托管。若在反向探针里额外 merge(request),数据库 DRAFT 的判据就不再适用。refresh 则重新把数据库状态读给受管理的对象,不能用来证明旧的脱管对象可自动写回。工程里如果把 detached 引用塞进缓存或传给下一个请求,必须明确是只读快照、显式合并还是以主键在新工作单元重取,不能以“它有 ID”代替状态管理策略。
脱管副本与新的托管副本同时存在时,最容易把业务方法作用在错误引用上。例如已经 clear 之后,控制层保留 request 旧引用;数据层又按 ID 重新加载为 current。此时调用 request.submit(),再查看 current.status(),两个字段并不必须一起变化。若把 request 返回给上游,调用方还会认为已经成功提交;数据库实际可能仍为 DRAFT。要避免这种“两个合法对象、一个错误工作单元”,应在审批操作开始时绑定当前上下文,从该上下文读取受管理实体,再以该受管理实体作为业务方法的作用对象。是否用显式 merge 得做清楚选择:merge 会复制输入对象携带的状态,输入越旧,覆盖新状态的风险越大;应结合版本、权限和业务校验决定,而不是把 merge 当成通用 save。
另一种身份混淆发生在事务回滚之后。调用 rollback 并没有把 Java 对象的字段自动改回事务开始时的值;即使它原来受管理,规范 §3.4.3 也要求把回滚后的上下文当成不可靠的状态来源。审批失败时继续使用那份引用判断“申请是否已经提交”,可能造成重复执行、漏掉明细或者向客户端返回没有提交的状态。下一次工作单元重新用主键查行时,可以核对字段和版本,但仍需检查请求者租户。数据库 ID 只是定位材料,不是访问凭证,也不是事务成功的证明。
在记录探针结果时,不能只打印 request.status() 后说“提交失败”。这个 getter 已经在旧 Java 对象上读到 SUBMITTED,它既没有查询数据库,也不能反证数据库是 DRAFT。应先保证种子 DRAFT 行成功提交,再在 clear 之后断言 contains(request) 为 false;空事务之后让新的 EntityManager 根据同一个 ID 重读,并核对 DRAFT。若未来要用 psql 作第二种独立核查,需要记录生成的 ID、连接字符串所指的专用库和查询的原始输出,避免把先前测试留下的申请当成当前事务的结果。只有身份链和数据库链同时闭合,才能把失效写入归因于脱管状态,而不是读错了行。
理解边界的两道练习
练习一:提交种子申请后删去 manager.clear(),直接修改原 request 并打开第二个事务提交,为什么 DRAFT 断言不再可靠?解答:应用管理的 EntityManager 没有因第一笔事务提交自动销毁其上下文。原对象仍可能托管,submit() 的字段修改因此进入后续事务的同步范围;实验必须显式脱管才能隔离旧对象。
练习二:从另一个 EntityManager 得到相同主键的 ProcurementRequest,Java == 为假能否证明其中一行是伪造的?解答:不能。唯一实例只在同一持久化上下文中成立;跨上下文相同身份允许不同引用。可比较实体类型和主键确定数据库身份,但租户归属与字段是否新鲜仍须通过服务校验和版本/重读机制另行证明。
实际排障时还要保留“在哪份上下文看到什么”的时间信息:若先在上下文 A 缓存了 DRAFT,再由上下文 B 提交 SUBMITTED,A 中原有引用的 getter 依然可能是 DRAFT。此时 A.find(id) 返回同一托管对象,不是一次强制刷新;应在需要最新数据库状态的业务边界选择 refresh 或创建新的工作单元,并考虑版本冲突处理。上下文身份一致性并不能代替跨事务的新鲜度保证;把同一引用读得稳定误写成“数据库没有变化”,会使排查方向完全颠倒。
官方资料与限制
- Jakarta Persistence 3.2 §3.1、§3.3.2、§3.3.6–3.3.8(身份与状态)
- Jakarta Persistence 3.2 §3.4、§7.2、§7.7–7.8(上下文寿命)
- Hibernate ORM 7.1 User Guide §6 Persistence Context:只作这个实现的对照,不把内部缓存结构当规范。
本章不证明跨租户访问安全、脱管修改探针已经运行或多线程共享 EntityManager 可行。研究卡见本章实验说明。






