审批成功,订单却可能失败

采购申请从 SUBMITTED 变成 APPROVED,随后才能创建订单;订单表对 request_id 有唯一约束。假如审批状态已经提交,创建订单时却发现另一个请求抢先插入,应用不能再声称“审批和建单是一次原子操作”。反过来,把所有步骤放入一个事务也不能保证两个审批员不会同时读到旧状态。事务边界、并发控制和数据库约束各解决一件事:前者决定哪些写入一起提交,版本或锁控制相互覆盖,唯一键守住“每申请至多一单”。三者都不能代替租户鉴权和重复请求的业务语义。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

本系列以 Jakarta Persistence 3.2、Java 21、Hibernate ORM 7.1.36.Final 和 PostgreSQL 16 为讨论环境。已有累计工程 examples/jpa/ 采用 RESOURCE_LOCAL,不是 JTA 容器。归档的 PostgreSQL 16.15 回归有六项测试通过,其中两项直接覆盖本章的 flush 回滚和乐观锁交错;悲观锁双会话和真实 JTA 仍未运行。前置知识是实体的托管、flush、回滚及申请/订单关联;没有读过前六篇,仍可凭本章给出的状态、路径和判据独立核对。

提交的单位不是一次方法调用

规范 §7.5.1–7.5.3 区分 JTA entity manager 与 resource-local entity manager。前者参与外部开始和结束的 JTA 事务;后者由应用通过 EntityManager.getTransaction() 返回的 EntityTransaction 控制资源事务。persistence.xml 中的 <persistence-unit name="procurement" transaction-type="RESOURCE_LOCAL"> 和测试里的一次 begin()、commit(),只覆盖数据库资源上的本地事务。把 getTransaction() 用到容器管理的 JTA entity manager 上既不是迁移方案,也不能证明消息队列和数据库共同提交;JTA 的事务管理器、数据源、上下文传播必须另有运行环境和测试。

一个事务内托管实体的 Java 状态已变化,不等于数据库最终有这条变化。规范 §3.3.4 的 flush 是上下文与数据库的同步;成功执行 SQL 之后仍可回滚。规范 §3.4.3 还提醒:回滚后原托管对象可能带着不能安全复用的主键、版本等状态。故失败处理应回滚尚活动的事务、关闭该 EntityManager,再用新上下文检查数据库终态;不能靠观察内存中的 request.status() 宣告回滚成功。规范 §7.5.3 要求某些失败标记事务为仅回滚;commit() 失败时 Provider 必须回滚。数据库断连或提交结果不明等运维故障不能简单归类为“安全可重试”:先核对持久化终态与幂等键。

ProcurementJpaTest.java 的 rollbackAfterFlushDoesNotPersistTransition() 正好给出最小判据:先提交 DRAFT 申请;新事务里调用 submit() 并 flush(),随后 rollback();再由第三个 EntityManager 读取,要求仍是 DRAFT。这检验的是本地回滚后的持久状态,不检验 JTA。uniqueOrderAndTenantAndRetry() 则在审批完成后,使用服务端租户参数建单并再次调用,最后查询订单数量为一;它没有构造真正并发的双重插单,也不能把“租户字段存在”当作所有读取路径均有鉴权。工程中的 ProcurementService.java 和数据库唯一键分别承担业务检查与最终唯一性防线。

本地事务只有在 begin() 到 commit() 或 rollback() 的区间才提供原子性。测试里先将 DRAFT 申请提交,再启动第二笔事务更新为 SUBMITTED:因此第二笔失败时数据库保留 DRAFT,而不是将第一笔插入也撤销。若业务要求“审批并立即建单一起成功或一起失败”,不能沿用这个两次提交的示例作为服务实现。应先明确审批是否允许独立提交,再把需要共同成败的状态变更、订单写入放进同一事务;异常发生后检查两张表,要求状态和订单数量构成合法组合。单独检查 commit() 是否抛错仍不足够:如果服务捕获了订单唯一键冲突却继续向外返回成功,客户端得到的语义与数据库终态仍可能不一致。

新建订单通常需要先读取申请、检查租户与 APPROVED 状态,再插入订单并把申请置为 ORDERED。检查与写入必须受同一个事务约束;否则读到的状态可在两次独立提交之间失效。仓库中的服务先找申请再检查状态,并以 request_id 唯一键兜底;它没有分布式幂等键,也没有为所有出站副作用定义补偿。重试时如果库中已经有订单,应返回可确认属于同一申请和租户的订单,而不是重新创建第二条;如果第一次提交的结果未知,要先从数据库确认订单是否存在。这里“回滚”只覆盖参与当前数据库事务的写入,已经发出的 HTTP 请求或消息不会被 EntityTransaction.rollback() 收回。

乐观锁把冲突移到写入时

ProcurementRequest 的 @Version private long version 是并发争用的依据。规范 §3.5.1–3.5.2 要求 Provider 在写入受版本管理实体时核对版本,不一致应报 OptimisticLockException;版本检查可能迟至 flush 或 commit(§3.5.5)。这不等于“每次读取立即加锁”,也不等于“所有业务不变量都受版本保护”。purchase_order.request_id UNIQUE 约束的跨行竞争,以及是否根据租户条件读取目标申请,仍须分别检查。映射的反向集合和实体以外的原生 SQL 更新也不能笼统算进这个版本检查范围。

同一个 SUBMITTED 申请被两个事务读到:A 执行 approve(),B 执行 reject()。A 先提交后,B 带旧版本写回,理想终态是 APPROVED 且 B 失败;如果 B 的失败被吞掉或继续复用失败上下文,后续操作反而会制造新故障。已有 optimisticConflictWithControlledInterleaving() 以两个 EntityManager 先后读取、A 先提交、B 再提交,随后用第三个上下文断言 APPROVED。它以 assertThrows(RuntimeException.class, ...) 接收异常,没有断言精确的 OptimisticLockException 异常链;作为测试设计,后续运行还应记录包装异常及数据库版本列,而不是据此推断固定的 PostgreSQL SQL 或冲突出现的唯一时机。测试没有线程屏障,也没有保证可推广到其他 Provider 的 SQL 次序。

一次冲突意味着当前决策所依赖的前提已失效。正确的重试单位是整个业务事务:新建 EntityManager,重新读取申请及租户、金额、状态,再决定是拒绝、保持已有 APPROVED,还是走新的审批请求;不是拿旧对象再 merge() 一次。规范 §3.5.5 允许在新事务中刷新或重新加载后重试,并规定 OptimisticLockException 使事务标记为回滚。若审批之后还发送通知,通知不能因事务回滚而提前对外宣称成功;出站消息与数据库的原子性并非 JPA 乐观锁所能提供。

还要分清锁冲突与写偏差的触发条件。@Version 针对同一申请行的变更;若 A 审批申请 1、B 审批申请 2,两个版本各自更新成功,并不能证明“同一租户审批总额不得超过预算”。若总额要按租户汇总,两个事务各自读取相同的旧汇总、写入不同的行,就可能都认为仍有额度。把申请版本列的类型从 long 换成其他支持的类型,也不会凭空让这两行互相冲突;须设计能参与冲突检测的共同资源、数据库约束,或在明确隔离级别下执行整笔事务并处理序列化失败。判据不是“两次 approve() 都没有抛异常”,而是两次请求结束后,跨行总金额仍满足业务上限。

悲观锁阻塞谁,取决于锁住了什么

规范 §3.5.3–3.5.4 的 PESSIMISTIC_WRITE 在获取实体锁后阻止其他事务修改或删除该实体,底层必须锁对应的数据库行;关联目标实体、集合中的每条明细并不会因此自动都锁住。锁模式所针对的是一个已定位的申请,而“某租户是否还没有相同订单”是谓词/跨行条件。即便锁住申请,别的写入通道仍可能试图插订单;最终必须由 UNIQUE(request_id) 拒绝重复。若所有建单路径都先锁同一申请并在同事务完成决策,锁可以协调这些遵守协议的调用者,但不是绕过数据库约束的理由。

在 PostgreSQL 16 的具体实现中,可以用两个独立连接演示行锁:连接 A 对某行执行 SELECT ... FOR UPDATE 后保持事务未提交;连接 B 设置短 lock_timeout 再请求同一行的 FOR UPDATE,预期 B 超时或等待 A 释放,随后在失败事务中显式 ROLLBACK。这是 PostgreSQL 行锁与超时实验,不是“每个 Provider 必须生成 FOR UPDATE”的承诺。规范的 jakarta.persistence.lock.timeout 是提示;实现/数据库可能不支持所要求的具体超时语义。要验证 JPA 的悲观锁,还需在真实测试中对两个不同 EntityManager 调用带 LockModeType.PESSIMISTIC_WRITE 的 find/lock,用栅栏控制顺序并记录实际连接、异常和最终行。PessimisticLockException 与 LockTimeoutException 的回滚范围不同(§3.5.3);不能靠一个 catch 分支将两者都判为事务已可继续提交。PostgreSQL 语句报错后所在事务通常必须回滚,不能把 JPA API 的“仅语句回滚”解释为 PostgreSQL 此时一定还能继续执行 SQL。

双会话实验必须控制“谁先获得锁”,否则所谓悲观锁失败很可能只是脚本次序差异。先插入并提交一条专用申请,记下真实 id;在连接 A BEGIN 后,对该 id 发 SELECT ... FOR UPDATE,不要提交;在独立连接 B 的事务中设置本地 lock_timeout,再对同一 id 查询。如果 B 在 A 未结束前立即成功,应先确认两边是否误用同一连接、是否用错行或 A 已经提交;如果 B 因超时报错,先将 B 回滚,再让 A 提交,最后新连接查询申请状态。这个 SQL 实验只能证明所选 PostgreSQL 会话的行锁竞争。切换到 JPA PESSIMISTIC_WRITE 时,必须另记取得锁的时点、SQL 和异常链;短超时未生效不能据此断言整个 JPA 悲观锁机制不存在,因为提示可能由实现或驱动以不同方式处理。

事务失败还要区分“请求未得到锁”和“拿到锁后写入违反约束”。前者可能在尝试锁行时就失败;后者可能在 flush() 或提交阶段才抛错。两者的异常、事务是否还能继续以及客户端是否可重试,不能用同一个“乐观/悲观失败”枚举掩盖。PostgreSQL 普通事务里一条语句失败后会进入 aborted 状态,下一条查询通常只得到事务已中止的报错,须回滚并新开事务;这是数据库协议的观察要求,不应误写为所有数据库的 JPA 通则。为了排除连接池和 Provider 的影响,应分别保存两连接的会话标识、SQL 执行顺序、退出码及最终行版本;这些材料尚未采集,因此悲观锁实验仍为 NOT_RUN。

隔离级别属于数据库的可见性规则

JPA 的 @Version 检测“这行自读入后是否被别人改过”,不规定 PostgreSQL 的隔离级别。PostgreSQL 16 默认 Read Committed:一条语句使用开始时的快照,同一事务的下一条 SELECT 可能看到别人刚提交的结果;更新相同行可能等待并重新判断条件。Repeatable Read 的快照更稳定,写入冲突可能以序列化失败结束;Serializable 还会检测序列化异常并要求应用准备重试整个事务。这些都是 PostgreSQL 16 的行为,非 Persistence 3.2 的跨数据库承诺。即使两名审批员各自修改不同申请的版本行,跨申请额度限制也不一定由单行 @Version 阻止;需要适当的约束、隔离与完整事务重试策略。对订单唯一性,数据库唯一键比依赖某一种隔离模式更直接。

JTA 改变的是事务控制与传播边界,不自动把 PostgreSQL 升到 Serializable,也不替实体补版本列。在真实 Jakarta EE 容器,须配置 JTA persistence unit 与容器提供的数据源,选择容器管理或显式 JTA 边界;在同一事务里读申请、执行审批与订单写入,检查成功提交和异常回滚的数据库终态。若还声称跨资源原子性,需要列出参加 JTA 的实际资源并验证失败恢复。仅把 XML 中 RESOURCE_LOCAL 改成 JTA,在 Java SE 运行现有 DatabaseSupport.factory(),不构成 JTA 实验;现有工程也没有这样的配置和容器入口。JTA:NOT_RUN。

验证真实容器中的 JTA,至少要检查三个边界。容器发起事务后,JTA entity manager 必须参与同一事务;异常要使申请和订单一起回滚。新请求应在新事务中重读数据库,不能复用失败上下文。若测试跨资源提交,还须确认参与资源受同一事务管理器控制,模拟其中一个参与者失败并记录恢复行为。只把单库本地测试迁到容器里、或者只观察异常类型,都不足以证明跨资源原子性。该系列当前没有这些容器、数据源、恢复日志和可运行命令,因此不提供假定可复制的 JTA 成功输出。

代码入口与待运行判据

在 examples/jpa/ 创建专用 jpa_lab 数据库和账号,审阅 01-init.sql 后参照 examples/jpa/README.md 设置 JPA_LAB_PSQL_URL、JPA_LAB_JDBC_URL、JPA_LAB_USER、JPA_LAB_PASSWORD。不要指向其他系列或共享库。以下命令在该目录执行,使用已存在的 ../hibernate-lab/mvnw 与上述测试文件;不要求读者创建本章尚未落盘的新测试:

1
2
bash ./migrate-lab.sh
../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" -Dtest=ProcurementJpaTest#rollbackAfterFlushDoesNotPersistTransition+optimisticConflictWithControlledInterleaving test

归档回归见 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md:Java 21.0.12.1、Hibernate 7.1.36.Final、PostgreSQL 16.15、pgJDBC 42.7.7、Maven Wrapper 3.9.9,clean test 退出码 0,六项测试通过、零失败/错误/跳过;逐文件哈希、原始输出与退出码在同目录。覆盖本章的两项断言分别是回滚后 DRAFT、冲突后 APPROVED,旧版本提交由宽泛的 RuntimeException 捕获。上方的筛选命令本身未单独执行,不能将 6/6 改写为单独筛选运行记录;迁移 stdout 没有归档,不记为独立迁移验收。悲观锁双连接和 JTA 各为 NOT_RUN;操作步骤与隔离注意见 实验说明。

练习与解答

练习一。 事务 A 和 B 都读到 SUBMITTED/version=4。A 审批提交后,B 将申请改为 REJECTED,B 的 commit() 失败。能否在 B 原有 EntityManager 上继续调用 markOrdered(),或只对 B 的 commit() 再试一次?

不能。版本冲突使 B 的决策和事务无效;回滚尚活动的事务并关闭上下文,从新上下文重新读取数据库中的 APPROVED 与最新版本,再依据业务规则决定请求是否仍有效。不能靠旧 Java 实体状态推断数据库终态;若出现未知提交结果还须借持久化标识检查,避免重复订单。

练习二。 在 Read Committed 下,两个事务各对不同申请审批,企业规则是“同一租户同时只能有一个待下单申请”。两行都有 @Version,是否足够?purchase_order.request_id UNIQUE 能否代替该规则?

都不足够。不同申请的行版本并不冲突;UNIQUE(request_id) 只约束同一申请的订单,不约束同一租户跨申请的状态组合。须把业务条件落到可检查的数据库约束或可串行化的事务协议,并对相关序列化失败重试整个事务;具体选型还要按 PostgreSQL 隔离行为和写入路径检验,不应声称 JPA 自行实现了跨行互斥。

限制与参考

本章只讨论同一数据库中的本地事务和可设计的并发实验;6/6 本地回归不能证明 JTA 容器、XA、双连接锁等待、SQL 形状与耗时,也没有提供跨租户鉴权的完整接口。已有冲突测试的异常断言比规范具体异常类型宽,不能替代包装链分析。未执行路径的预期不能当作观测结果。