企业应用架构09:乐观、悲观并发与事务边界
两个客服都查到同一台设备可租,随后各自确认请求。两个事务可能都正常提交,数据库没有语法错误,却留下两份重叠预约。企业应用架构08:聚合映射、继承与Lazy Load 检查了映射与加载,本篇让两个真实连接按受控顺序交错,比较无保护、版本校验和显式锁。
并发测试必须让两份旧观察同时存在
实验使用两个线程,每个线程单独创建 JDBC 连接,显式关闭自动提交并设置 READ_COMMITTED。屏障确保两个线程都完成读取,闩锁再控制第一个事务提交后,第二个事务使用此前读到的值继续执行。它们不是在单线程里依次读到最新数据的两个函数调用。
SQL 日志保留连接编号、线程名称、绑定版本、受影响行数和提交回滚。每个等待都设置上限,调度条件不成立时测试失败,不会无限等待。正常运行要求两个连接编号不同,避免错误地把共享一个连接的串行操作称作数据库并发。
首先测试一个金额累加教学场景。初值为 25.00,两个事务分别要增加 5.00 和 7.00,期望都被接受时最终为 37.00。两者先读取 25.00,第一个写 30.00 并提交,第二个继续根据旧值写 32.00,最终读回 32.00,第一份增量丢失。
sequenceDiagram
participant A as 连接A
participant D as 数据库
participant B as 连接B
A->>D: 读25与版本0
B->>D: 读25与版本0
Note over A,B: 屏障:双方完成读取
A->>D: 写30并提交
B->>D: 用旧值写32
alt 没有版本条件
D-->>B: 影响1行,覆盖
else 要求版本仍为0
D-->>B: 影响0行,冲突
end
这里重现的是读取、计算、覆盖写入的丢失更新。若业务本来可以用一条原子加法 SQL 表达,可能有更直接的方案;本例保留读后计算,是为了检查客户端持有旧状态时的协议,不宣称每个累加都需要完整版本对象。
版本条件必须和写入处于同一事务
受保护的 UPDATE 同时修改金额、递增版本,并在 WHERE 中要求版本等于读取时的值。第一个事务影响一行,第二个事务影响零行。零行是应用判断出的 VERSION_CONFLICT,没有数据库 SQLException,也就不能给它编造一个 SQLSTATE。
第二个事务回滚后,用新连接重新读取当前金额与版本,再应用增加 7.00 的动作,成功把金额写成 37.00。重试不是重新发送第一次算好的 32.00;业务意图与旧快照计算结果必须分开,否则重试仍可能覆盖已接受的修改。
版本校验只保护参与同一协议的更新。如果某条后台 SQL 不带版本条件,或者写入后不增加版本,其他调用者仍可能接受过时状态。实验覆盖的是所列两条写入路径,不证明数据库里所有未来写入天然安全。
没有预约行时,应该竞争什么
设备预留的反例更接近租赁。两个线程先确认目标区间没有 RESERVED 合同,再用不同合同号插入同一设备的相同区间。主键不同,外键合法,普通 CHECK 也通过,最终真实数据库中有两份重叠记录。
只给新合同加版本不能协调尚不存在的两行。本例选择稳定存在的设备行作为竞争点:先读设备版本,再查询可用性,提交时按原版本更新设备版本,然后在同一事务插入预约。第二个事务的设备版本条件失败,回滚,不再插入自己的预约。
读取顺序有含义。若先查可用,再读取版本,另一个事务可能恰好在两次读取之间提交,当前事务会把过时的“空闲”与新版本拼在一起。实验实现先记录版本,后检查占用,让观察期间发生的协议内变更能够使最终版本校验失败。
flowchart TB
V[读取设备版本] --> Q[查询目标区间占用]
Q -->|已有重叠| R[拒绝预约]
Q -->|当前空闲| C[条件更新设备版本]
C -->|影响0行| X[回滚并报告冲突]
C -->|影响1行| I[同事务插入预约]
I --> T[提交]
X --> N[新事务重新读取并检查]
N --> R
受保护场景最终只有一份预约。重新检查时,目标区间已经被占用,因此不能把冲突重试直接解释成一定成功。金额例子可以重新应用增量,预约例子则应返回占用拒绝,重试策略取决于业务动作的含义。
设备级版本会把同一设备上不重叠时段的并行请求也纳入同一个竞争范围,可能产生保守冲突。本章没有测试更细粒度的区间锁或数据库排斥约束,也没有比较吞吐量。选择稳定竞争对象是本例的正确性安排,仍有并发度成本。
悲观路径先锁设备,再检查占用
另一条路径先对设备执行 SELECT FOR UPDATE,然后查询占用并插入。第一个连接持有锁,第二个连接真实进入同一条锁查询。主线程在持锁者尚未释放时等待第二个任务一百五十毫秒,任务仍未完成,随后才允许第一个连接插入并提交。
第二个连接取得锁后重新查询,看到已经提交的预约,因而不插入。这里锁定的是设备行,没有尝试锁定一个当前不存在的预约结果。锁必须覆盖检查和写入整个数据库事务,提前释放后再插入会重新出现竞争窗口。
等待时间由实际单调时钟记录。短暂等待断言只用于确认指定调度下任务尚未结束,不是性能指标;数据库调度、机器负载和驱动实现都会影响具体毫秒数。测试不把日志中的时间当作服务延迟保证。
锁超时场景为第二个连接设置二百毫秒的 LOCK_TIMEOUT,同时保持第一个连接不释放锁,直到第二个连接实际失败。H2 返回 HYT00、错误码 50200,失败连接显式回滚。随后第一个连接提交,新连接加锁重试时确认已有一份预约并拒绝重复预留。
二百毫秒是配置值,不是异常必须恰在二百毫秒抛出的承诺。本机记录可能更长,正文不将精确时间写成验收不变量。验收看的是确实遇到数据库锁超时、退出当前事务,以及重试重新检查后的最终状态。
数据库锁与 Offline Lock 的边界
PoEAA 的 Optimistic Offline Lock 与 Pessimistic Offline Lock 条目署名 David Rice,关注跨多个系统事务的业务事务。客服打开页面、思考并编辑,再提交,可能持续很久;不能为了覆盖这段时间一直持有数据库连接和行锁。
本章的版本条件演示了提交时检测旧观察的机制,显式行锁演示了短数据库事务里的竞争控制。它没有实现应用层的长事务占用登记、租约到期、锁拥有者、会话失效和人工解锁,因此不能把 SELECT FOR UPDATE 直接命名为完整的 Pessimistic Offline Lock。
业务长事务如果使用乐观策略,需要把原版本或其他校验依据带到最终提交;采用应用层悲观登记,则要处理持有者消失和租约恢复。两种策略都还需要授权检查,防止一个调用者解除另一个调用者的占用,这些不在当前 JDBC 实验里。
隔离级别没有替代业务协议
H2 2.1.214 文档说明 READ_COMMITTED 是默认隔离级别,未提交数据不会被其他连接直接读到,但不可重复读与幻读仍可能发生。所有实验连接显式设置该级别,日志记录其 JDBC 值,避免把环境默认值当成不可变前提。
数据库事务确保一次已提交写入集合的边界,不会自动知道“同一设备不能有重叠预约”的跨行规则。无保护反例已经表明,两份各自合法的 INSERT 可以共同违反业务不变量。需要把验证与竞争点放在一个被所有相关写入者遵循的协议中。
本章没有测试死锁,锁超时是实际注入的数据库失败;没有连接中断、提交结果未知、跨进程重启或网络故障。版本冲突可以明确判断影响零行,连接断开时却可能无法直接判断提交结果,不能机械复用相同重试逻辑。
H2 的错误码、锁实现和隔离行为不能直接代表 PostgreSQL 或 MySQL。替换数据库时需要使用对应驱动和真实连接重跑交错场景,包括影响行数、锁等待、超时和失败事务后状态。兼容某些 SQL 语法不构成并发语义等价证明。
连接生命周期也是协议的一部分
线程各自创建和关闭连接,主线程只通过屏障、闩锁和 Future 接收结果,不直接在工作线程的连接上提交。这样可以把数据库行为与测试协调分开。连接编号是日志关联标识,独立性来自实际分别调用 DriverManager,而不是手工给同一连接贴两个名称。
每个场景开始前清理上一个场景的数据并重新初始化,清理在独立事务内提交。无保护分支留下的坏状态不会成为下一条受保护场景的输入。最后读回也使用新的连接,断言的是已提交结果,不是某个写线程还未提交的私有视图。
运行与证据
在既定 JDK 21、H2 2.1.214 环境中执行:
1 | |




