Java EE 企业应用 21:并发审批怎样避免覆盖和死锁
两人都看到待审批,只能有一个决策生效
同一张申请进入 SUBMITTED 后,审批员甲点击批准,审批员乙点击拒绝。两人的页面都曾读到待审批,并不代表两次写都应成功。若使用 UPDATE purchase_request SET status='APPROVED' WHERE id=? 和另一条拒绝语句,后来的写可能覆盖前一个决定。读到“合法状态”与“成功提交状态迁移”之间必须有一个原子检查点。
累计工程已在 JdbcRequestStore.transition 中使用 SQL 条件更新:WHERE id=? AND tenant_id=? AND status=? AND version=?,写入 status 并把 version 加 1,更新行数不为 1 则抛 RequestConflictException;用例还调用领域状态机,禁止 DRAFT 直接批准。webapp 将冲突映射到 HTTP 409。这里 version 是显式 SQL 列,没有 @Version 注解,也不需要为讲解并发把主线改成 JPA。JPA 的单实体冲突例子参见JPA 事务与冲突;跨行/锁的适用边界参见乐观锁之外的边界,后者的跨行实验同样不能冒充已运行结果。
行数是胜负凭据,不是 HTTP 到达先后
在 PostgreSQL 16 下,单条带状态与版本条件的 UPDATE 是原子语句。若甲抢先更新同一行,乙在锁等待后针对已变更行重新评估条件,预期得到更新 0 行;已有应用代码因此报冲突,不能把 executeUpdate() 的 0 当成“也成功了”。事务回滚后,客户端应重新读取实际状态,展示“已被处理”;不要静默换成新版本号再覆盖对方决策。tenant_id 参与条件可避免错改另一个租户的行,但前端传入的演示 X-Lab-Tenant 不可信,不是第 24–26 篇要求的身份与授权。
同一行的版本列并不保护“同一预算池最多审批总额 100”这种跨行规则;两人审批不同申请时版本各自前进,可能都成功。第 20 篇的预算共同行/可序列化方案仍需另做。purchase_order(request_id) 已有 UNIQUE 约束,防止同一申请产生多张订单;它并不解决同一客户重试创建不同申请的问题(第 22 篇)。数据库约束、SQL 条件更新和业务状态判断分别挡不同的竞争。
两个 psql 终端复现一赢一输
以下拟做双会话实验,NOT_RUN;仅在隔离 javaee_lab 建测试行,A、B 均连接 PostgreSQL 16,用数据库口令环境变量而非博客文本。先在任一终端 INSERT INTO purchase_request(tenant_id,status,total) VALUES ('race-lab-unique','SUBMITTED',3.50) RETURNING id,version;,将返回的 id 填入两端的 \set request_id。不得复用已有真实申请。
1 | |
1 | |
随后 A COMMIT;,等待 B 解锁;B 预期 UPDATE 0,然后 B ROLLBACK;,再用第三个新连接 SELECT status,version FROM purchase_request WHERE id=...,期望 APPROVED,1。若 B 先获得锁则交换胜负,不能把哪个人成功写死在断言里;如果 B 更新 1,先检查 A 是否真的保持事务、是否同一个 id、是否与实验前的版本一致。场景尚无自动栅栏脚本,也没有本篇真实响应记录;单跑 scenarios/09-lab-procurement.sh 只证明单请求主线,不能证明双人争抢。
死锁不是“再试一次 SQL”
还有跨行场景:批量审批先锁申请 A 再锁申请 B;另一事务反过来先锁 B 再锁 A。两个事务相互等待,数据库必须中止其中之一。用同库新增两张 SUBMITTED 测试申请,终端 A、B 均 BEGIN;A UPDATE ... WHERE id=:first_id,B UPDATE ... WHERE id=:second_id;A 随后对第二行发更新并等待,B 随后对第一行发更新。PG16 预期一端得到 SQLSTATE 40P01,失败端应 ROLLBACK,幸存者完成后 COMMIT。记录 \set VERBOSITY verbose 下的 SQLSTATE、获得锁的顺序、最终两行状态;若等候顺序未构成环,只能报告“未触发死锁”。在多终端交互里不能顺序等第一条阻塞查询返回后才发第二条,否则永远凑不齐环。
实现层应统一加锁顺序(例如先小 id 后大 id),并限制 SQLSTATE 40P01、必要时 40001 的完整事务重试次数和退避;再次读取业务状态及租户边界,不能只重放最后一次 UPDATE。重试整段时必须避免事务外已经发送不可撤销通知;第 23 篇的 outbox 把“通知任务创建”纳入同库事务。死锁演练、超时、锁等待与有界重试代码当前均未落地,NOT_RUN;勿在生产库锁行做实验。操作卡见并发审批实验卡。
已实现的单请求入口与故障断言
按 examples/javaee-enterprise/README.md 部署受限的本地教学入口之后,可运行已存在的单请求场景:
1 | |
它已有归档的单租户业务路径证据;以上两连接竞争和死锁均无对应受控命令记录。未来 HTTP 级双人实验应新建申请、先提交、让两请求同步从旧版本起跑,记录每次响应、SQL 更新行数与第三连接终态;仅有一个 409 不能证明另一个事务成功提交。实际运行时要为可能等待的 SQL 设置测试环境的有界超时,并区分锁超时 55P03 与死锁 40P01,不得全部当成可重试成功。
更新条件必须对应同一次业务决定
从页面发出审批请求时,申请可能已经从 SUBMITTED 变成 REJECTED,或者仅仅是同一状态下的其他字段发生了修改。只带 id 的 UPDATE 无法区别这些情况;带上旧 status 可避免从不允许的状态继续流转,带上旧 version 可察觉其他更改,带上 tenant_id 避免把另一个租户的同 ID 误当成合法目标。四个条件缺一项可能产生不同的缺陷:删掉版本,可能覆盖了别人对其他属性的更新;删掉状态,可能让已拒绝申请重回批准;删掉租户,可能对非预期对象产生动作。现有 SQL 使用这四项不是为了“把 WHERE 写长”,而是把业务决策时读到的前提重新带到写入那一刻。
条件更新返回 0 行也有多种原因:行不存在、租户不匹配、状态已变或者版本变更。外层的权限与异常契约不应向无权访问者暴露别的租户的对象是否存在;当前演示租户头完全可伪造,因此本文只讨论 SQL 竞争控制,不宣称访问控制已完成。对有权限的审批员,冲突后的处理应查最新记录、核对新版本并请用户重新决定;不能在服务器悄悄把 WHERE 里的旧版本替换成最新版本再执行刚才的“批准”。那样虽然 SQL 再次更新一行,业务上却抹掉了另一个人的拒绝。
行级锁和版本条件并非两个互斥的“选型标签”。PG16 在并发 UPDATE 同一行时先发生锁等待,后来的语句根据隔离级别重新评估或因并发更新失败;JDBC 检查更新行数,再把这类实际结果转换成应用冲突。乐观条件指的是不预先长期占锁让用户“编辑中”独占申请,而不是数据库在更新时从不加锁。若批准过程要跨多行读取并保持共享不变量,还需额外设计锁顺序或可序列化事务;只在这张申请上查版本无法防止另一张申请也获批后总额越界。
锁顺序怎样形成环,以及怎样拆环
把两条申请记为 A、B。事务甲先改 A,未提交;事务乙先改 B,也未提交。甲下一步改 B,被乙的行锁阻塞;乙下一步改 A,被甲的行锁阻塞,等待图是“甲等乙,乙等甲”。只有两条会话都保持着各自已经获得的锁,这个图才能成立。若甲先提交再让乙开始,或者两端其实重用了一个事务,都不会出现这个死锁。PG16 的死锁检测会选择牺牲其中一个事务;失败端收到 40P01 后该事务的所有已做 SQL 不应继续当作成功,另一端仍须真正完成提交才算胜者。
统一按申请 ID 升序更新两行,可把同一类批量操作的获取顺序一致化。此时乙即使想同时操作 A、B,也会先在 A 上等甲,尚未拿到 B,不再形成前述两边互相持锁等待的环。不过如果别的业务流程先锁订单再锁申请,而批量审批先锁申请再锁订单,新的一对顺序依然可能死锁;“这个方法里排序了”不足以证明所有数据访问路径都无死锁。部署时需要列出真正会在同一事务里访问的表与行,检查跨用例顺序,保留死锁异常和有限重试以应对仍未穷尽的并发交错。
不是所有等待都叫死锁。终端 B 单独等 A 释放锁,A 最后按时提交,B 获得执行机会并更新 0 行,这是正常竞争和业务冲突;B 因 lock_timeout 先中止该语句,对应的可能是 55P03,与数据库判出等待环的 40P01 不同;客户端连接等不及先断开,又未必能说明服务端事务结果。实验时应分别保存服务器给的 SQLSTATE、终端退出码、哪一行更新成功以及第三连接终态。把所有等待简单归类为“失败就再试一次”容易制造重试风暴,掩盖真正的应用锁顺序错误。
从 PostgreSQL 两会话到真实 HTTP 的证明链
两条 psql 会话只能证明给定 PG16 数据、SQL 与交错下的行为;当前 Web 入口还需额外证明 HTTP 请求确实进入同一版 DAO、每个请求的受管事务覆盖哪几条语句,以及 REST 层如何把数据库冲突转成 409。未来场景应预建一张已提交申请,让两个审批请求在 DAO 读取后停在栅栏,再一起执行相互冲突的条件更新。结果不能只数“收到一个 204 和一个 409”:还要确认状态为唯一胜者的决定、版本从旧值前进一次、订单未被顺路创建、没有跨租户越权。若只有 204 而另一请求超时,需查日志和 SQL 终态,不能擅自把超时算作 409。
正常对照也不是多余的:一位有权审批员在提交后的申请上执行一次 approve,预期状态变为 APPROVED、版本加一;再次以旧版本重复审批,预期不改变版本。随后安排两人竞争同一初始版本,断言只出现一种终态。对于批量死锁实验,还需另选两张申请,确保两端写入的租户和初始版本一致,失败受害者回滚后由第三连接读取完整两行集合。场景 09 已说明顺序采购路径可用,却不具备上述并发栅栏;这些对照必须等另建测试入口才能记为“通过”。
重试时还要决定用户操作的语义。如果死锁受害者的整段事务回滚,可以在上限内从头重读并再次判断;如果另一个审批员已经批准同一申请,重试不能继续沿用“旧快照下我也能批准”的想法。对两张不同申请的批量操作,第二轮可能只剩一张仍可审批;业务是全部成功或全部失败,应在一个新事务里重新判定整个批次,不能把第一次已经回滚的半张清单当作完成进度。外部系统若已经收到通知,数据库事务重试不能撤销通知,应把通知意图放进同事务 outbox,避免在行锁试验中混进不可回滚副作用。
上线排查时,还要把“程序只尝试了一次条件更新”与“程序重试后仍只有一个决策”分开计数。前者由 SQL 更新行数证明;后者需要逐次记录重试次数、旧版本、返回的冲突以及最终新版本。两位审批员各自点一次按钮,可能在中间经过连接池等待、JTA 事务创建和异常映射,不应把一次 HTTP 调用简单等同于一条立即成功的 SQL。测试中保存每个请求的关联标识,可以把数据库终态、接口响应与服务端异常接在同一条时间线上;否则一次 409 可能其实来自之前的请求,而不是本次受控竞争。
此外,审批失败后的响应设计要与重试策略一致:版本冲突应让用户获得更新后的申请状态,而不是遮蔽成网络 500 后驱动客户端不停重放;数据库死锁则可能允许有限重试,但若重试时发现申请已被他人处理,就必须停下并报告业务冲突。把两类异常混为一个通用重试分支,会使“只能有一个决策生效”的数据库约束看似有效,实际用户却持续收到误导性的成功或无限等待。终态、响应与重试上限需要一起进入验收矩阵。
两道练习与答案
练习一: 两个不同申请各从版本 0 改为 1,最终当日审批总额超标。给版本列加大整数是否能修复?
解: 不能。版本条件只检测同一申请行的竞争,位宽与跨行不变量无关。把预算余量放进共同条件更新的行,或在可序列化事务里验证聚合并完整重试;用约束防止能由数据库直接表达的非法值,然后检查总额终态。
练习二: 死锁受害者收到 40P01,代码在同一 JDBC 事务中立即再执行刚失败的 UPDATE,是否可认为已经恢复?
解: 不可。失败事务应回滚并释放连接的事务状态,新事务重读申请状态后按固定顺序重跑完整决策;给出有限重试预算,超额时返回冲突或暂不可用,别无限重复或重放已发出的事务外副作用。另一事务可能已经推进版本。
限制与版本资料
当前代码未配置正式认证/授权,version 也不是防租户冒用的凭据;重复订单与消息投递分别在 22、23 篇另讲。参考:PostgreSQL 16 Explicit Locking(死锁)、PostgreSQL 16 Transaction Isolation、PostgreSQL 16 错误码、Java SE 21 PreparedStatement Javadoc。数据库锁行为是 PG16 范围的推论,不是对所有 JDBC 驱动的保证。






