乐观锁以外的并发边界:同一行与跨行约束不是一回事

两名审批员同时修改一份采购申请,@Version 很有用:一个事务提交后,另一个不能静悄悄覆盖这份申请的旧版本。问题是租户每月预算一万元时,两份各六千元的申请分别由两名审批员下单:它们写的是不同申请行,两个版本号都可以合法增加,但合计已经超额。另一个看起来相似的问题是两个人同时给同一份申请建订单,这时每申请唯一订单约束比版本号覆盖得更直接。选修 E02 以 07 篇的本地事务与冲突 和 09 篇的综合应用 为先修,把行版本、唯一键、悲观锁、隔离级别分别放在各自能够保护的边界上。

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

仍使用 examples/jpa 的 Java 21、Jakarta Persistence 3.2、Hibernate ORM 7.1.36.Final、PostgreSQL 16 和 pgJDBC 42.7.7。本工程只配置 RESOURCE_LOCAL;迁移脚本 有 purchase_request.version、purchase_order.request_id UNIQUE,没有租户预算表或预算校验代码。预算一万元是说明跨行不变量的扩展需求,不是既有系统已经实现的功能,也不是已运行的实验。实体与服务入口分别在 ProcurementRequest.java 与 ProcurementService.java。

行版本能防止什么

Jakarta Persistence 3.2 §3.5.1–3.5.2 规定 @Version 的乐观版本检查。两个上下文各自读到同一申请 SUBMITTED,T1 执行 approve(),T2 执行 reject(),若 T1 先成功提交,T2 的旧版本提交不能静默覆盖;冲突可能在 flush 或 commit 时被检测到,不应绑死捕获点。提交失败后要回滚,丢弃旧上下文,在新事务中读取终态并决定是否允许业务重试。版本只附着在那个受控实体上:未修改申请行的查询、批量 JPQL 更新、另一张表的订单和不同申请行,不会因此自动变成串行化操作。

现有 测试源码中的 optimisticConflictWithControlledInterleaving() 用两个 EntityManager 读取同一申请,再按顺序提交批准与驳回;测试预期第二个提交失败,最后用新上下文读出 APPROVED。已有运行记录记载这一组测试所在的六项测试成功,但并未验证跨行预算、同单竞争或悲观锁等待。仅凭这个断言推断“所有并发审批安全”,恰好忽略了需要保护的不变量跨了几行。

[PATTERN] 先写不变量,再找它落在哪些行:单实体状态竞争用版本;同一申请最多一单用唯一键;跨申请总预算需要一个所有竞争者都会争用的数据库条件或可串行化事务。不要给每个实体都加版本号就以为跨行问题解决了。

同一申请的唯一键与锁

假设 T1、T2 都看到申请 101 为 APPROVED,各自执行 ProcurementService.order();两边先把本地申请改为 ORDERED,然后准备插入订单。迁移中的 UNIQUE(request_id) 直接约束订单表中不能出现两个 101;申请的 @Version 则约束两边更新同一申请的旧状态。哪个冲突先暴露取决于 SQL 顺序、flush 时机与 PostgreSQL 执行,不预设异常类或必然的赢家。即使唯一键让失败者无法插第二单,服务仍必须回滚失败事务、用新事务查到既有订单,才有依据把一次重试答复为成功。

要在改状态前排队,可以尝试 Jakarta Persistence 的 LockModeType.PESSIMISTIC_WRITE。下面的类是设计示例,尚未加入工程,也未编译运行;它在专用本地事务中先按租户与 ID 查询并加锁,再检查状态、写订单并提交,失败时回滚且关闭 EntityManager。它不是替代数据库唯一键的理由。对于不支持所请求锁模式的数据库或 provider,具体失败与等待行为必须由实验确认。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package blog.jpa;

import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.LockModeType;

public final class LockedOrderService {
private final EntityManagerFactory factory;

public LockedOrderService(EntityManagerFactory factory) {
this.factory = factory;
}

public long order(long requestId, String authorizedTenant) {
try (EntityManager manager = factory.createEntityManager()) {
manager.getTransaction().begin();
try {
ProcurementRequest request = manager.createQuery("""
select r from ProcurementRequest r
where r.id = :id and r.tenantId = :tenant
""", ProcurementRequest.class)
.setParameter("id", requestId)
.setParameter("tenant", authorizedTenant)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.getSingleResult();
ProcurementOrder result;
if (request.status() == RequestStatus.ORDERED) {
result = manager.createQuery("""
select o from ProcurementOrder o
where o.request = :request and o.tenantId = :tenant
""", ProcurementOrder.class)
.setParameter("request", request)
.setParameter("tenant", authorizedTenant)
.getSingleResult();
} else {
result = new ProcurementOrder(request);
request.markOrdered();
manager.persist(result);
}
manager.getTransaction().commit();
return result.id();
} catch (RuntimeException failure) {
if (manager.getTransaction().isActive()) {
manager.getTransaction().rollback();
}
throw failure;
}
}
}
}

锁查询找不到行会失败,不会“锁住一个空位置”;现有订单数据若与 ORDERED 状态不一致,getSingleResult() 也会报错,应把这看成待修复的不变量破坏,不能生成一张新订单蒙混过去。访问条件里的 authorizedTenant 必须来自认证结果。悲观锁对本事务访问的行施加约束,并不保护绕过此入口直接插入订单的写者;唯一键仍须保留。数据库锁竞争还会引入等待、超时或死锁的可能性;锁顺序和超时策略需要按实际业务设计,不把 PESSIMISTIC_WRITE 等同于“不会失败”。在 PostgreSQL 16 的 READ COMMITTED 下,等待更新锁的事务取得锁后如何看待已更新的行,要按 PostgreSQL 文档和实测解释,不能推广到任意数据库与隔离级别。

这个实现还依赖“先取锁,再判断状态”,不能在事务开始前读取一份 APPROVED 实体,随后仅对已有的 Java 对象调用 lock(),却继续使用先前缓存的业务判断。对需要排序锁定多个申请的批处理,更应约定统一锁顺序,例如始终按申请 ID 升序获取;T1 先锁 A 后锁 B、T2 先锁 B 后锁 A,会形成典型的循环等待。PostgreSQL 会检测死锁并终止其中一个事务,但它不会替应用判断哪一笔采购可以悄悄忽略。失败者应回滚整个事务,在重新核算业务状态后有限次重试;单纯在原上下文里重发 INSERT 既不能释放已持有的锁,也不能修正陈旧实体。

如果要评估悲观锁相对唯一键的增益,分别问三件事:第一,锁是否让等待者在读取到最新申请状态后走既有订单分支,从而减少无谓的冲突异常;第二,等待者能否被明确的超时策略终止,不让审批请求无限挂起;第三,移除应用锁后数据库约束是否仍兜住“至多一单”。这些是待执行的对照,不是 Hibernate 7.1 已验证的性能结论。JPA 锁超时提示可能受 provider 与数据库能力限制;不能因为设置过一个 hint,就假设 PostgreSQL 已按同一时间限制中断所有等待。

两份不同申请的预算超卖

把预算规则精确化:租户甲当前已下单金额为零,新来的两份已批准申请 A、B,各 6000 元;规则要求当月已下单合计不得超过 10000 元。T1 读“已下单总额 = 0”,判断 A 可下单;T2 在 T1 提交前也读到 0,判断 B 可下单。二者分别修改 A、B,分别插入不同 request_id 的订单。两次申请版本更新不冲突,订单唯一键也不冲突;最后总额为 12000 元。这是拟议预算规则下的交错推演,不是现有工程的数据库输出。实际方案还必须界定“当月”的时区、撤单退回额度及订单金额以哪个服务端字段为准,否则连要维护的不变量都没有定义好。

PostgreSQL 16 默认 READ COMMITTED 不会给这段“先查总额再插两行”的业务逻辑附加全局串行化;该数据库的 REPEATABLE READ 使用快照隔离,也不该被一句“读同一快照”误认为可避免所有写偏差。若用 PostgreSQL SERIALIZABLE,所有参与这个不变量的事务都要采用兼容的读写路径,并处理可能的 serialization failure:回滚后重试整个事务和预算检查,不能只重试订单 INSERT。这是 PostgreSQL 16 的隔离语义,不是 Jakarta Persistence 3.2 对所有数据库的承诺;是否出现该失败要用两连接并发测试记录 SQLSTATE 与终态。

另一种方案是为每个租户、每个月设置确实存在的预算汇总行,在同一个数据库事务里锁住这行、判断剩余额度并更新,再下单;所有写入入口必须遵循同一协议。当前工程没有这张表,尤其不能对一个查不到的预算行 SELECT FOR UPDATE,然后声称自己锁住了“租户的预算范围”。如果要加预算表,还需迁移、外键与状态修正流程,这已经超出本篇的只读写作范围。用 PostgreSQL advisory lock 等实现特性也必须显式定义锁键、生命周期与绕开该协议的写者;它不属于可移植 JPA 锁语义。

预算汇总行方案需要明确事务内的先后顺序:先锁或原子条件更新本租户本月的额度行,核算此次申请的服务端金额,再把额度占用、申请状态和订单插入一并提交。若订单唯一键冲突或后续步骤失败,整个事务回滚,预算占用也应回滚;绝不能在另一个连接里先扣额度,随后订单写入失败却留下扣款。若业务允许撤单,释放额度也得与订单终态变更遵循同一事务协议。此处的“月份”必须以确定的时区与业务时间字段生成唯一租户月份键,不能让两个应用实例因时区不同锁到不同预算行。现有 01-init.sql 没有这些字段,以上均为设计判据 NOT_RUN,并非声称已有代码能提供它们。

选择 SERIALIZABLE 也不只是把事务隔离参数调高:预算判断必须在事务中读取能表达本月已占额度的行集,然后和订单写入同事务提交;遇到 PostgreSQL 序列化失败时重新开始事务,重新读预算并重新判定金额是否允许。第二轮很可能因为预算确实不足而给出正常的业务拒绝,而不是永远追求“两笔都提交”。不按相同隔离与写入协议访问的后台任务或直接 SQL,仍可能使系统的业务约束脱离这一策略。数据库自身的 SQLSTATE 和行终态必须来自实际并发实验;把“Serializable 应能发现冲突”写成“这次已捕获 40001”是不实的。

[PATTERN] 行锁要锁住所有竞争事务都能找到的同一个对象;范围条件在没有稳定承载行时,锁住“查到的某些行”不等于锁住未来可插入的新行。并发方案从不变量的承载位置出发,而不是从可用的锁 API 倒推业务正确性。

实验矩阵:已有与待做

归档 RUN.md(证据路径:writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md,需仓库权限) 与同目录 test.stdout.txt、exit-code.txt、code-sha256.txt 是历史的 OBSERVED/PASS 记录:PostgreSQL 16.15 RESOURCE_LOCAL、Hibernate ORM 7.1.36.Final,ProcurementJpaTest 六项通过、退出 0,归档对当时未提交工程文件保留逐文件 SHA-256。其中 optimisticConflictWithControlledInterleaving() 覆盖同一申请的乐观冲突,uniqueOrderAndTenantAndRetry() 覆盖租户拒绝、顺序重试与订单数量。它不包含本选修的并发预算场景、锁等待交错或原始 SQLSTATE;本次正文未重新运行,不能升级为 E02 的 PASS。

正常实验(NOT_RUN)。 在单独 jpa_lab 中先复核顺序下单,然后用两个连接对同一已批准申请加锁:T1 持有锁,T2 请求锁并等待;T1 建单提交,T2 再读取状态与订单,验证同申请只有一单且同租户返回同一 ID。记录 READ COMMITTED 的实际配置、连接事务边界、语句顺序、版本和数据库终态,锁等待仅以观察为准,不编造等待时间。保留迁移、代码 SHA、版本、命令、退出码及原始日志。

失败实验(NOT_RUN)。 并发写同一 request_id,在不开应用锁时检验约束冲突后的回滚、新事务恢复与订单终态;另在明确实现预算规则及迁移之后安排不同申请各 6000 元的两事务交错,分别在 PostgreSQL 16 READ COMMITTED、REPEATABLE READ、SERIALIZABLE 下记录结果、SQLSTATE 和余额,不预设每一次运行都以特定异常结束。在没有预算表与规则代码前,这个实验不能执行成真实预算测试;JPA 的 @Version 现测也不能替代它。实验操作单见 实验说明。

控制交错时,T1 和 T2 必须是实际不同的数据库连接:开始事务后各自执行预算读或申请行读,在两个事务都完成读取后才放行写入,再分别提交。只在单线程循环里先后调用两次 order(),相当于测试顺序重试,不能说明并发。对于同申请实验,预期断言是最终 purchase_order 按申请 ID 计数不超过 1,申请状态与订单是否存在相容,失败事务关闭并在新上下文读回;对于跨行预算实验,预期断言是提交订单总额不超过 10000,若一个事务被数据库回滚,记录回滚后的全部受影响行。发生超时、死锁或序列化失败时,先记完整 SQLSTATE、事务次序和错误输出,再分析它属于哪一种失败;在实验尚未落地前不替数据库填写结果。

两道练习

练习一。 purchase_order.request_id 上有 UNIQUE,能否删去申请上的 @Version,并声称审批与驳回互斥?**解:**不能。唯一键只限制两张指向同一申请的订单,根本不约束审批员对 purchase_request.status 的并发更新。现有测试的批准/驳回竞争发生在订单创建以前;删掉版本控制可能使后提交者覆盖先提交者。即便保留版本,也不能把跨申请预算当成同一行冲突。

练习二。 两份申请都在不同的行,事务用 PESSIMISTIC_WRITE 分别锁住各自申请,并各自查到已下单金额为 0;预算 10000、每份 6000 时能保证不超支吗?**解:**不能。锁住 A 与锁住 B 不冲突,查询出的 0 也不是一把“租户预算锁”。需要让两条路径争同一条预算汇总行并在事务里更新,或以 PostgreSQL SERIALIZABLE 执行完整检查并针对 serialization failure 重试整个事务。两种办法都须把其他写入口纳入协议,再以真实交错与数据库终态验收。

边界与速查

冲突对象 可用机制 必须另查的事
同一申请的审批/驳回 @Version 与冲突后重读 flush/commit 失败后的回滚
同一申请重复建单 UNIQUE(request_id),必要时申请行悲观锁 失败后的数据库终态与重试响应
不同申请共享预算 稳定预算行上的串行更新或 PG16 SERIALIZABLE 全部写入口、序列化失败的整事务重试
租户身份 授权上下文、每个入口的租户谓词 不能用锁和版本代替鉴权

Hibernate 的异常包装、锁 SQL 及超时设置不是 Jakarta Persistence 通用契约;PostgreSQL 的隔离与唯一约束也不能证明其他数据库会以同样方式处理。跨库事务、JTA 容器、消息发送和预算实体迁移均未实测;不要从本地事务的两连接实验外推分布式原子性。

参考资料