开始前,会这些就够了
- 理解事务读写与版本号检查的基本作用。
- 能运行 JDK 17 单文件 Java 程序。
- 本实验不需要数据库;它是固定两行的确定性模型。
- 可先读版本号与旧值覆盖,本篇继续检查同时涉及多行的规则。
虚构值班室里只有甲和乙,两人都在岗。规则是“至少留一人在岗”。甲查看名单,发现乙在,于是申请离岗;乙也查看同一份旧名单,发现甲在,于是申请离岗。两人修改的是自己的记录,单行版本检查都可能成功,最后却没有人值班。
这类问题常叫写偏斜:各次修改没有直接覆盖同一行,但共同破坏了跨行的业务规则。下面的 Java 程序只重放检查与提交顺序,不连接 PostgreSQL、MySQL 或 SQLite,也不声称复现了它们的全部隔离机制。
把例子带到自己的电脑上
下载完整练习包
需要 JDK 17;制作时实测 Microsoft OpenJDK 17.0.20.1。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- WriteSkewDemo.java:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的观察记录
- verification.json:对应源码的验证范围
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
java --source 17 WriteSkewDemo.java解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 SQLite 查询计数或固定并发模型,不代表远程数据库、并发压测或线上耗时。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先把必须始终成立的规则写出来
我们的规则是“固定两人中,在岗人数至少为一”。这是一项整体条件,不能靠甲这一行的布尔值合法、乙这一行的布尔值也合法来证明。每行存 true 或 false 都符合类型,合起来却可能是业务不允许的状态。先写规则,再讨论锁放哪里,比一开始就给每张表加版本号更清楚。
丢失更新关心的是一个修改被另一个基于旧值的保存盖掉。这里甲和乙各改不同的行,两次修改都被完整保留,恰恰因此让总人数变成零。它不是前一篇单行乐观锁示例换个名字;验收目标是检查修改所依赖的其他记录有没有变化。
四个时刻就能形成反例
按固定顺序记录:第一步甲读到两人在岗;第二步乙也读到两人在岗;第三步甲把自己改为离岗并提交;第四步乙仍按第二步的判断,把自己改为离岗并提交。无需真的同时按保存按钮,只要两个读取都早于相关提交,就已经拥有过时的决策依据。
若只检查自己要写的那一行,乙提交时会发现乙的版本没变,因此看不出甲已离岗。给乙的 UPDATE 增加乙行的旧版本条件,并不能检查甲行。类似地,两人只锁自己的记录,也没有形成需要互相等待的共同保护范围。判断一次修改安全吗,要列出它读过什么、依赖什么,再看保护覆盖了哪里。
还可以反过来检验这个结果是否合理:假如甲整个操作先完成,乙随后重新开始,就会读到只剩一人在岗而拒绝离岗;交换先后也一样。两个真正依次执行的顺序都不会得到零人。把最终状态与这些可能的串行顺序对照,比只观察两条语句是否同时运行,更能指出业务判断出了哪种问题。
先接受两次提交,再扩大检查范围
把下面完整代码保存为 WriteSkewDemo.java,使用 JDK 17 执行 java WriteSkewDemo.java。第一轮只比较待写行版本,两次提交都成功,输出 false、false。第二轮在提交前比较两条已读取记录的版本;甲成功后,乙的读取依据已经变化,所以返回 stale-read,暂不修改状态。
代码把版本比较和写入放在同一个 synchronized 方法内,避免模型在检查完成与写入之间再插入另一次提交。读取出的数组是副本,双方因此保留各自旧依据。不过主程序没有创建并行线程:这里用可重复的动作顺序观察协议,synchronized 也只保护这个 JVM 里的同一个 Roster 对象。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.Arrays;
/** JDK 17. A fixed two-row model, not a database isolation implementation. */
public class WriteSkewDemo {
record Snapshot(boolean[] onDuty, int[] versions) {
int count() {
int total = 0;
for (boolean value : onDuty) if (value) total++;
return total;
}
}
static final class Roster {
private final boolean[] onDuty = {true, true};
private final int[] versions = {0, 0};
synchronized Snapshot read() {
return new Snapshot(onDuty.clone(), versions.clone());
}
synchronized String leave(int person, Snapshot read, boolean checkAllReads) {
if (read.count() <= 1) return "rule-rejected";
for (int i = 0; i < versions.length; i++) {
if ((checkAllReads || i == person) && versions[i] != read.versions()[i]) {
return "stale-read";
}
}
onDuty[person] = false;
versions[person]++;
return "committed";
}
}
static void check(boolean ok) {
if (!ok) throw new AssertionError("unexpected model result");
}
public static void main(String[] args) {
Roster rowOnly = new Roster();
Snapshot first = rowOnly.read(), second = rowOnly.read();
check(rowOnly.leave(0, first, false).equals("committed"));
check(rowOnly.leave(1, second, false).equals("committed"));
check(rowOnly.read().count() == 0);
System.out.println("check written row only: " + Arrays.toString(rowOnly.read().onDuty()));
Roster allReads = new Roster();
first = allReads.read(); second = allReads.read();
String a = allReads.leave(0, first, true);
String b = allReads.leave(1, second, true);
String retry = allReads.leave(1, allReads.read(), true);
check(a.equals("committed") && b.equals("stale-read"));
check(retry.equals("rule-rejected") && allReads.read().count() == 1);
System.out.println("check both read rows: " + a + ", " + b);
System.out.println("retry with a fresh read: " + retry);
System.out.println("remaining on duty: " + allReads.read().count());
Roster sameRow = new Roster();
first = sameRow.read(); second = sameRow.read();
check(sameRow.leave(0, first, false).equals("committed"));
check(sameRow.leave(0, second, false).equals("stale-read"));
System.out.println("same-row version conflict is still detected: true");
System.out.println("Deterministic model only; no database or parallel threads.");
}
}
怎样运行:JDK 17;保存为 WriteSkewDemo.java,运行 java WriteSkewDemo.java。程序固定动作顺序,不访问数据库。 2026-10-13 已用 Microsoft OpenJDK 17.0.20.1 执行这份示例并通过自带断言;下方为当次输出。SQLite 查询计数或固定并发模型,不代表远程数据库、并发压测或线上耗时。
运行后对照结果
check written row only: [false, false]
check both read rows: committed, stale-read
retry with a fresh read: rule-rejected
remaining on duty: 1
same-row version conflict is still detected: true
Deterministic model only; no database or parallel threads.旧依据失效后,重新决定,而非只换版本号
乙遇到 stale-read 后,重新读取,发现仅剩自己在岗。再执行同一项业务判断,结果变成 rule-rejected:这次离岗请求不能成立。重试并不保证原愿望最终成功,它意味着重新执行读取、判断和必要写入;如果业务条件变了,正确结果可能就是拒绝。
程序最后还用同一行的重复保存检查原版本机制仍会识别冲突,帮助我们区分“版本检查完全失灵”和“检查范围太小”。如果将模型扩展到新人员加入、删除、不同班次或多个值班组,固定两行的比较就不够了;查询条件覆盖的集合变化也要纳入协议,不能只遍历过去见过的编号。
真实数据库要按实际版本与隔离级别验证
PostgreSQL 17 文档说明,其 Repeatable Read 可以保持稳定快照,但仍允许某些无法等价成逐个执行的事务结果;Serializable 会检测相关异常条件,并可能中止一个事务。应用因此需要准备重试整个事务。文档中的跨分组求和再插入例子是官方语义证据,本篇没有启动 PostgreSQL 去实测它。
我们的“把两条版本都核对一遍”只是便于观察的保守模型,不是 PostgreSQL Serializable 的内部算法,也不是复制几行 Java 就获得数据库级隔离。PostgreSQL 的普通 CHECK 还不能引用别行数据来保证这种持续的跨行规则。选择约束、锁或隔离级别时,应核对数据库真正支持的保证及失败形式。
记住这三点
- 不同记录上的成功写入,仍可能共同破坏跨行规则。
- 保护范围应覆盖决策依据,而不只是最终写入的那一行。
- 重试需要重读并重新判断;成功保存不是唯一正确结果。
别把例子用过头
- 把两行 Java 版本比较称作某数据库 Serializable 的实现。
- 用单进程对象锁推断多实例服务已经协调。
- 只重试最后一条 UPDATE,保留之前已失效的判断。
自己试一小步
改为三人值班且至少留下两人,怎样设计反例和检查方案,并说明增加人员记录会带来什么新问题?
- 自行排列两个离岗申请的读取与提交顺序,先预测每一步人数和返回结果。
- 修改模型的初始名单与业务阈值,分别测试只查待写行和你选择的保护范围。
- 增加一个“读取后名单加入或删除记录”的设想,写明当前模型缺少什么;无需把未实现部分包装成数据库方案。
做到这些,就算完成
- 反例里的两次写入针对不同人,且单独执行各自都符合原先看到的规则。
- 验收既检查返回值,也检查最终在岗人数,不靠随机线程碰运气。
- 清楚区分本机模型输出、官方数据库语义和尚未运行的真实数据库实验。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- PostgreSQL 17:事务隔离 (新标签页)↗
阅读 13.2.2、13.2.3:Repeatable Read 的快照和异常边界、Serializable 的求和插入例子及整个事务重试要求;不据此声称本机运行了
PostgreSQL。 - PostgreSQL 17:行锁 (新标签页)↗
阅读 13.3.2 Row-Level Locks 中 FOR UPDATE 的锁定对象、等待与事务结束释放;支持仅锁自己的行不等于覆盖完整决策范围。
- PostgreSQL 17:CHECK 的限制 (新标签页)↗
阅读 5.5.1 的 Note:CHECK 不支持引用当前新建或更新行以外的表数据来保证持续一致;本文不把普通 CHECK 当作跨行人数约束。
- Java 17 语言规范:同步 (新标签页)↗
阅读 17.1 Synchronization:对象监视器的互斥和 synchronized 的进入退出;仅支持本机对象内比较与修改的同步边界。