Java EE 企业应用 20:数据库隔离怎样影响审批时看到的数据
审批员第二次查询,可能已经不是同一张清单
采购员提交申请,审批员在同一个审批事务中先查“待批总额”,核对预算后又查一次。两次结果不一致,可能是别人刚提交了一张申请;也可能是应用误把两个查询放在不同事务里。隔离级别描述数据库事务间允许观察到的交错,不保证审批业务不变量自动成立。把“查询第二次没变”当成“没有并发错误”,会遗漏跨行写偏差;把数据库异常统一重试,则可能重复对外下单。
版本固定为 Java 21/JDBC、Jakarta EE 11、Open Liberty 26.0.0.5、PostgreSQL 16.15。现有 001-initial.sql 有 purchase_request(tenant_id,status,version,total)、明细表、订单表,但没有“每租户同时只能有一张批准申请”的约束。本章不修改 schema 或工程,用独立数据库会话讲清观察边界;下面的双连接实验尚未执行,NOT_RUN。
隔离是数据库行为,不是注解的别名
JDBC Connection.setTransactionIsolation 在直接管理本地事务的场景控制连接级别;在受管事务里不可为了图方便私自调整已参与事务的连接、再手工提交。容器是否支持为某个事务设置隔离、如何映射到池连接,要在该服务器与数据源配置下核查。实验直接使用两个 psql 会话,可先把数据库本身的行为与应用服务器行为分开。用 SHOW transaction_isolation 记录实际级别,不能仅凭 Java 枚举推断服务器生效。
PostgreSQL 16 的 READ COMMITTED 为默认隔离级别:每条命令拿到开始时的快照,所以同一事务内两次普通 SELECT 可能看到不同已提交值。REPEATABLE READ 的事务快照保持不变,并在特定并发更新情况下以序列化失败阻止更新;但它不是跨行不变量的通用保险。PostgreSQL 的 READ UNCOMMITTED 实际按 READ COMMITTED 处理,不应写成这个数据库能观测脏读。SERIALIZABLE 用可序列化执行的效果约束并发结果,可能拒绝某次事务;应用必须从新事务重跑完整读—判断—写逻辑,不能只重试最后一条 UPDATE。
两会话观察:同一行的两次读
只在独立 javaee_lab 库运行,不要使用真实采购记录。先在会话 A 用 psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -U javaee_lab -d javaee_lab 连接(凭证由本机环境提供),准备一行并记下返回的 id。为免人工脚本误用别人的行,使用独立租户名并在实验后仅按记录的 ID 清理;下文 \set request_id 123 要换成返回值,A、B 两个独立会话都设置。
1 | |
1 | |
在 PG16 的 READ COMMITTED 下,A 预期先看到 DRAFT,0,再看到 SUBMITTED,1。失败对照不靠随机调度:新建另一条 DRAFT 申请,A 改成 BEGIN ISOLATION LEVEL REPEATABLE READ,在第一次读取后再由 B 更新并提交;A 第二次普通读取仍预期看到旧快照。两次均须独立手工按顺序交错;B 更新 0 行说明样本已改变,这次实验无效。记录会话 ID(SELECT pg_backend_pid())、时间、更新行数和最终新连接所见;REPEATABLE READ 下 A 的旧快照不等于全世界都没有提交。
从重复读延伸到写偏差
假设规则是某个采购池至少有一名可审批人。两位管理员各自看到两人均在岗,于是分别撤销自己的资格;修改的是不同的行,即使没有任何一行被覆盖,最后也可能无人可审批。这个模型不是当前采购表的字段或已实现需求,只用来说明跨行不变量。对采购可用更贴近的规则:同一租户同一预算窗口内获批总额不得超过限额。两个审批事务都先读 SUM(total),分别批准不同申请行;单行版本号都不冲突,总额却超标。
可选择把预算额度集中到一行上做带余额条件的原子更新,让竞争转化为同一行的冲突;也可以用 PostgreSQL SERIALIZABLE 包住全套读写并对 SQLSTATE 40001 做有限完整事务重试。若能直接表达为唯一约束或检查约束,优先由数据库兜底,但“合计不能超预算”不能用现有单行 CHECK (total >= 0) 表达。锁行的方案还要锁住真正代表预算的行,锁住两张各自申请并不能互斥。写偏差实验的预算表、锁和串行化重试逻辑尚未加入累计工程,NOT_RUN,不可用上面的两次读实验冒充验收。
现有入口和未做的故障注入
当前 examples/javaee-enterprise/scenarios/09-lab-procurement.sh 提供单调用的演示采购路径;可按 README 配置独立库并运行:
1 | |
它不保证两条连接的特定交错,也没有隔离级别切换或写偏差断言。失败实验应在隔离副本中增加预算/审批池约束,在两条 PostgreSQL 连接使用栅栏固定“读—各自写—提交”顺序,分别比较 READ COMMITTED、REPEATABLE READ 与 SERIALIZABLE;若收到 40001,回滚整个事务,以相同业务操作 ID 重新执行,记录重试上限、最终预算行及两条申请状态。没有命中设定交错只能记录为“未触发”,不能宣布级别安全。步骤卡在两会话实验卡。
同一个事务内,快照究竟何时取到
正常审批不是单个 SELECT。审批员打开列表、查看明细、对照预算、决定批准,最后执行状态迁移;这些动作是否在一个数据库事务内本来就需要定义。若列表是几分钟前的 GET 响应,而点击批准产生了另一个 HTTP 请求,不论后者选什么隔离级别,也不可能让前一个页面看到的快照自动变成当前事务的快照。审批服务端必须再次校验申请状态、版本和业务条件,然后以 SQL 条件更新作为最终决策。隔离保护的是事务内可观察的关系,不是用户浏览器长期缓存的“审批屏幕”。
PG16 的 READ COMMITTED 下,A 先查待批总额,B 提交新申请,A 再查会得到新的合计。这个“读到了新数据”不一定是缺陷:在发布报表时希望更及时的值,在核对一个审批决定时却必须把判断和写入关系定义清楚。若 A 先看到单条申请为 SUBMITTED,B 先改成 REJECTED 并提交,A 后来的 UPDATE ... WHERE status='SUBMITTED' AND version=... 更新 0 行,此时已有条件更新足以阻止同一申请的覆盖;这并不意味着聚合预算自动受到保护。把“同一行有更新竞争”与“不同申请共享预算”分开,才能针对后者引入共同预算行或可序列化约束。
在 REPEATABLE READ 里,A 第二次查询仍看到旧快照,并不意味着 B 回滚了。第三个新连接应当看见 B 提交后的终态;A 若试图更新 B 已修改的同一行,数据库可能拒绝本事务的更新,要求回滚后重做。从实验上看,“A 能读到旧状态”和“A 还能安全提交写入”是两道不同断言。实验记录应在 A 提交或回滚之后独立查最终行,而不是因为第二次查询仍然相同就给“审批成功”打勾。PG16 的 SERIALIZABLE 更需要记录提交阶段的异常:即使前面每条 SELECT、UPDATE 都执行了,也不能在事务最终被拒绝时把中间返回行当作持久结果。
范围读取、插入与写偏差不能混作一类
假设审批员的规则是“本周待批总额加本次申请不超过预算”。只看原有两张申请行并各加版本号,无法阻止另一事务插入第三张满足查询条件的申请。此时问题涉及范围和聚合,读集并非一个固定的申请 ID。PostgreSQL 的普通 SELECT 不会因为读过 WHERE tenant_id=? AND status='SUBMITTED' 就自动把未来可能插进来的每一行锁住。所谓“加锁即可解决”也要回答到底锁哪行:若只有申请行,B 插入全新申请不需要争抢 A 锁过的旧行。为预算窗口设共同预算行并做条件扣减,才把抽象的合计约束变成双方争抢的同一资源。
写偏差的关键不是“两个人都改了同一行”,而是两个人读到了同一组前提,却分别修改不同的行,使最后的全局断言为假。假设池内已有两名可审批人,规则是始终至少一人在岗;A、B 各在自己的事务里查人数 2,再各自撤销一人。每条更新都是合法的单行条件更新,彼此并不一定直接冲突,但最终人数为 0。把 version 加在每个审批人行上只会记录两次不同修改,无法表达“剩余人数至少 1”。这个教具并非采购工程现有表,也不能直接复制进生产;它用来追问业务不变量落在哪个可冲突的数据项上。
对本系列预算场景,可以设计一张 (tenant_id, budget_window) 唯一的 budget_balance 行,保存剩余额度。每次审批在同一事务里使用 UPDATE budget_balance SET remaining=remaining-:amount WHERE tenant_id=:tenant AND budget_window=:window AND remaining>=:amount,检查更新恰好一行,再修改申请状态。若其中一步失败,两个修改都回滚。此处是未实施方案:窗口算法、币种精度、金额调整及撤销额度的补偿规则仍需另定,不能只加表就宣称预算正确。这个条件 UPDATE 与 SERIALIZABLE 是不同的实现选择,后者要求将聚合查询、决定及写入全部放进受保护的事务,还要实现提交失败后的完整重试。
可序列化失败要重新做决定,不是重放语句
假设某事务先读“剩余 100”,然后批准 70;并发事务也读到“剩余 100”。其中一个事务若在 SERIALIZABLE 下被 PG16 以 40001 中止,正确的重试应从新的事务重新读取预算,可能因只剩 30 而拒绝第二张申请。若只重放最后一条 UPDATE,却沿用上一事务算好的“可以批准”,重试就绕过了原本需要重新验证的前提。这与幂等键的目标互补:内部数据库序列化重试需要重复决策过程,外部客户端超时重试需要识别同一业务操作。后者不应因为内部已经成功提交又生成第二笔订单。
重试还要有有限次数和退出策略。持续冲突时,不该让审批请求无限等待;应记录尝试次数、最后的 SQLSTATE 与最终申请状态,把暂时性冲突呈报给调用者或任务恢复层。40P01 是死锁,40001 是序列化失败,二者可能都允许从头再试,但触发机制不同;连接中断、权限错误、参数非法不应统统套用“重试三次”。若审批期间已经向外发过通知,重跑事务可能再次发送,所以消息生成应先按第 23 篇方案留在同库任务中,而不是混在一次失败事务旁直接调用远端。
实验结果应能驳倒错误解释
一份有用的两会话记录不是“观察到了不可重复读”七个字,而是 A、B 的进程 ID、各自隔离级别、申请 ID、A 第一次读值、B 更新行数及提交时刻、A 第二次读值、A 最终提交或回滚、第三连接最终读值。若 B 更新 0 行,说明样本不满足预定状态,不能继续把 A 的重复查询解释为隔离成功;若 B 被锁等待,要先检查是否误用带锁查询而不是宣称 PG 的普通读取被锁住。两个端口都指向同一数据库也不一定是两个事务,记录 pg_backend_pid() 正是为了排除此类误接。
扩展到预算不变量时,在实验前还须写下精确谓词,例如“租户 T 的窗口 W 中批准总额不超过 100”,并预先插入两张不同申请。A、B 均确认读到同一初值后才放行写入,最后从新连接按同一租户、窗口计算总额。若实际交错没有建立、某事务在读前已经看到了另一人的提交,那么即便总额符合规则也不是预定的反例;若发生序列化拒绝,要保存失败事务重试后的状态而非只展示失败栈。本批目前没有预算窗口字段或栅栏程序,所以正文只提供实验合同,不能贴出模拟 PASS。
如果只记录两个审批员的最终 HTTP 码,就无法判断冲突来自隔离拒绝、单行版本失败、权限不足还是客户端超时。实验报告应在数据库层记录 SQLSTATE 和事务结局,在用例层记录申请与预算的真实行集合,再在 HTTP 层记录对外响应。三层对齐后,才能说某种隔离配置在指定交错下守住了哪条不变量;某一条命令无异常并不能证明总额正确。本章的两会话示例可作为数据库语义的起点,不能当成完整审批系统验收。
两道练习与答案
练习一: A 在 READ COMMITTED 中第一次读到 DRAFT,B 提交 SUBMITTED,A 第二次又读到 SUBMITTED。是否说明 A 的事务中途自动提交了?
解: 不说明。PG16 的 READ COMMITTED 给每条语句新的已提交快照;A 可以一直没有提交。应先记录两个会话的 pg_backend_pid()、实际隔离级别和 B 的提交时点。换 REPEATABLE READ 再测普通 SELECT 才是对照,且结果只适用于冻结数据库实现。
练习二: 两位审批员在 REPEATABLE READ 下读到剩余预算 100,各批准不同申请、每张 70,单行 version 更新均成功。能凭申请版本断言总额不超过预算吗?
解: 不能。两个事务写不同申请行,单行乐观锁没有共享冲突点。将预算余量放到同一受约束行并条件扣减,或使用可序列化事务与有界完整重试;复测两张审批终态及预算汇总。不要把“快照稳定”误译为业务总量合法。
限制与版本资料
PostgreSQL 文档的例子是该数据库的实现语义,其他数据库的快照与锁策略要重验;本篇未测容器连接池隔离重置、死锁等待或跨租户身份安全。依据:PostgreSQL 16 Transaction Isolation、PostgreSQL 16 Explicit Locking、Java SE 21 Connection Javadoc、Jakarta Transactions 2.0。






