qinghuan
← 回到知识探索

后端现场 / 动手实验

两人没改同一行,为什么值班表还是空了?

用两份旧快照重放一次写偏斜,分清单行版本检查、整个业务规则与真实数据库隔离的边界。

展开本文目录
  1. 下载完整练习包
  2. 先把必须始终成立的规则写出来
  3. 四个时刻就能形成反例
  4. 先接受两次提交,再扩大检查范围
  5. 旧依据失效后,重新决定,而非只换版本号
  6. 真实数据库要按实际版本与隔离级别验证
  7. 让所有修改路径守住同一条规则
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 理解事务读写与版本号检查的基本作用。
  • 能运行 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 对象。

展开收起代码 · 小实验java · 64 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
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 还不能引用别行数据来保证这种持续的跨行规则。选择约束、锁或隔离级别时,应核对数据库真正支持的保证及失败形式。

让所有修改路径守住同一条规则

一种设计方向是让同一班次的离岗操作先取得共同的协调点,再读取当前人数并决定能否修改;另一种是采用适合业务的 Serializable 事务并处理重试。无论选择什么,都要覆盖所有会改变在岗人数的入口,包括后台脚本、管理员操作和补偿流程。只让网页入口遵守规则,仍可能被其他写入绕过。

共同协调点会带来等待,较广的验证会增加冲突,重试还可能重复非事务内的外部动作。这些都需要另做设计和实测。可交付的证明应包含初始状态、交错顺序、每次返回结果和最终规则检查;单独展示“两条 UPDATE 都没报错”,只能说明语句执行结果,不能证明值班规则成立。

记住这三点

  • 不同记录上的成功写入,仍可能共同破坏跨行规则。
  • 保护范围应覆盖决策依据,而不只是最终写入的那一行。
  • 重试需要重读并重新判断;成功保存不是唯一正确结果。

别把例子用过头

  • 把两行 Java 版本比较称作某数据库 Serializable 的实现。
  • 用单进程对象锁推断多实例服务已经协调。
  • 只重试最后一条 UPDATE,保留之前已失效的判断。

自己试一小步

改为三人值班且至少留下两人,怎样设计反例和检查方案,并说明增加人员记录会带来什么新问题?

  1. 自行排列两个离岗申请的读取与提交顺序,先预测每一步人数和返回结果。
  2. 修改模型的初始名单与业务阈值,分别测试只查待写行和你选择的保护范围。
  3. 增加一个“读取后名单加入或删除记录”的设想,写明当前模型缺少什么;无需把未实现部分包装成数据库方案。

做到这些,就算完成

  • 反例里的两次写入针对不同人,且单独执行各自都符合原先看到的规则。
  • 验收既检查返回值,也检查最终在岗人数,不靠随机线程碰运气。
  • 清楚区分本机模型输出、官方数据库语义和尚未运行的真实数据库实验。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • PostgreSQL 17:事务隔离 (新标签页)↗PostgreSQL Global Development Group · 英文

    阅读 13.2.2、13.2.3:Repeatable Read 的快照和异常边界、Serializable 的求和插入例子及整个事务重试要求;不据此声称本机运行了 PostgreSQL。

    公开英文官方资料;本文只采用列出的阅读范围。

  • PostgreSQL 17:行锁 (新标签页)↗PostgreSQL Global Development Group · 英文

    阅读 13.3.2 Row-Level Locks 中 FOR UPDATE 的锁定对象、等待与事务结束释放;支持仅锁自己的行不等于覆盖完整决策范围。

    公开英文官方资料;本文只采用列出的阅读范围。

  • PostgreSQL 17:CHECK 的限制 (新标签页)↗PostgreSQL Global Development Group · 英文

    阅读 5.5.1 的 Note:CHECK 不支持引用当前新建或更新行以外的表数据来保证持续一致;本文不把普通 CHECK 当作跨行人数约束。

    公开英文官方资料;本文只采用列出的阅读范围。

  • Java 17 语言规范:同步 (新标签页)↗Oracle · 英文

    阅读 17.1 Synchronization:对象监视器的互斥和 synchronized 的进入退出;仅支持本机对象内比较与修改的同步边界。

    公开英文官方资料;本文只采用列出的阅读范围。