《Java 编程实战》5.2 隔离级别与 MVCC:PostgreSQL 的真实行为

两个连接同时读写 loans 时,read committed 与 repeatable read 到底差在哪?本节用真实 PostgreSQL 输出拆开语句快照与事务快照,观察 xmin/xmax 与行锁等待,并给出 libraryd 报表查询与可重试事务的落地写法。

本节目标:把「隔离级别」从标准里的名词变成可观察的行为,知道 libraryd 的借阅、查询和报表各自该用哪一档,以及冲突时应用要做什么。
适用版本:JDK 25(Temurin 25.0.5)、PostgreSQL 17、PostgreSQL JDBC 42.7.14。

承接的问题

5.1 节让一次借阅的三条 SQL 落在同一个事务里。但事务只解决「一起提交或一起回滚」,没有回答另一个问题:两个事务同时操作同一行时,彼此能看到什么。

libraryd 里有两类典型场景:

  • 两个馆员同时处理同一本书的归还。一个事务已经把 returned_on 改成今天但还没提交,另一个事务的查询应该看到 null 还是今天?
  • 后台导出某个会员的在借清单。导出过程要读会员信息和借阅列表两条 SQL,如果两条 SQL 之间有人新增了借阅,导出的结果会不会自相矛盾?

很多资料会用「脏读、不可重复读、幻读」三个词解释隔离级别,但 PostgreSQL 的实现和标准表格并不完全一一对应。要写出正确的代码,需要看它实际怎么维护快照、怎么加锁、冲突时抛什么。

先确认默认值:

docker exec libraryd-pg-55434 psql -U libraryd -d libraryd \
  -c "show default_transaction_isolation;"

本机输出:

 default_transaction_isolation 
-------------------------------
 read committed
(1 row)

libraryd 没有改这个默认值,所以每个新连接、每个新事务默认都是 read committed。后续实验沿用 5.1 节的 open(),容器仍然是 libraryd-pg-55434。

核心机制一:PostgreSQL 的四档隔离级别

JDBC 的 Connection 定义了四个常量。PostgreSQL 接受四个名字,但行为并不是四份独立实现:

JDBC 常量PostgreSQL 名字实际行为
TRANSACTION_READ_UNCOMMITTEDread uncommitted按 read committed 执行,不会脏读
TRANSACTION_READ_COMMITTEDread committed每条语句取一次新快照
TRANSACTION_REPEATABLE_READrepeatable read整个事务共用第一次查询建立的快照
TRANSACTION_SERIALIZABLEserializable在 repeatable read 基础上加 SSI 冲突检测

第一行经常被忽略:PostgreSQL 不提供真正的脏读。你把 JDBC 级别设成 read uncommitted,驱动会把它降级成 read committed。想靠「读未提交」提升吞吐,在 PostgreSQL 上没有意义。

第二行和第三行的差别是本节的核心:

  • read committed 的快照粒度是语句。同一条 select 内部看到一致的数据,但下一条 select 会重新取快照。
  • repeatable read 的快照粒度是事务。第一次查询之后,后面所有查询都看同一份历史版本,即使别人已经提交。

第四行 serializable 不是「加更多锁」,而是 Serializable Snapshot Isolation:读写依赖会被跟踪,发现可能产生不可串行化的执行顺序时,主动 abort 一个事务,SQLState 为 40001。

核心机制二:MVCC 下的行版本

PostgreSQL 的 update 不是原地改字节,而是写一个新版本,把旧版本标记为「被哪个事务替换」。每一行有两个隐藏列:

  • xmin:创建这个版本的事务 id。
  • xmax:删除或更新这个版本的事务 id;0 表示还没有被删除。

ctid 是物理位置。下面这段实验先让一个 repeatable read 事务读一次,建立快照;然后另一个连接更新同一行并提交;最后原事务再读一次:

try (Connection snap = open()) {
    snap.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
    snap.setAutoCommit(false);
    try (Statement s = snap.createStatement();
         ResultSet rs = s.executeQuery(
                 "select xmin::text, xmax::text, " +
                 "coalesce(returned_on::text, 'NULL'), ctid::text " +
                 "from loans where id = 1")) {
        rs.next();
        System.out.println("[MVCC before] xmin=" + rs.getString(1)
                + " xmax=" + rs.getString(2)
                + " returned_on=" + rs.getString(3)
                + " ctid=" + rs.getString(4));
    }
    try (Connection upd = open(); Statement s = upd.createStatement();
         ResultSet rs = s.executeQuery(
                 "update loans set returned_on = date '2026-10-22' " +
                 "where id = 1 returning xmin::text, xmax::text, ctid::text")) {
        rs.next();
        System.out.println("[MVCC update] new version xmin=" + rs.getString(1)
                + " xmax=" + rs.getString(2)
                + " ctid=" + rs.getString(3));
    }
    try (Statement s = snap.createStatement();
         ResultSet rs = s.executeQuery(
                 "select xmin::text, xmax::text, " +
                 "coalesce(returned_on::text, 'NULL'), ctid::text " +
                 "from loans where id = 1")) {
        rs.next();
        System.out.println("[MVCC after ] xmin=" + rs.getString(1)
                + " xmax=" + rs.getString(2)
                + " returned_on=" + rs.getString(3)
                + " ctid=" + rs.getString(4));
    }
    snap.commit();
}

本机输出:

[MVCC before] xmin=770 xmax=0 returned_on=NULL ctid=(0,5)
[MVCC update] new version xmin=771 xmax=0 ctid=(0,6)
[MVCC after ] xmin=770 xmax=771 returned_on=NULL ctid=(0,5)

三个关键事实:

  • 更新写出了一个新版本 ctid=(0,6),xmin=771。
  • 旧版本 ctid=(0,5) 没有被抹掉,它的 xmax 变成了 771,表示「被 771 号事务替换」。
  • repeatable read 事务再读时仍然看到旧版本:returned_on 还是 NULL,xmax=771。这就是 MVCC 的可见性规则:快照决定看哪个版本,而不是看物理行当前是什么。

事务 id 每次重建容器都会变,数字本身不重要,重要的是模式:旧版本保留、xmax 指向替换它的事务、旧快照继续读旧版本。

这也解释了为什么长事务会拖住清理。只要还有一个快照可能看到旧版本,vacuum 就不能回收它。pg_stat_activity.backend_xmin 记录的就是这个「最老仍可能被需要的 xmin」。

核心机制三:read committed 与 repeatable read 的可见性差异

把上面的机制落到一个更贴近借阅的例子里。连接 reader 先读一次 returned_on,连接 writer 更新并提交,reader 再读一次:

reader.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
reader.setAutoCommit(false);
System.out.println("[RC] first read  = " + returnedOn(reader));
try (Statement s = writer.createStatement()) {
    s.executeUpdate("update loans set returned_on = date '2026-10-20' where id = 1");
}
System.out.println("[RC] second read = " + returnedOn(reader));
reader.commit();

本机输出:

[RC] first read  = NULL
[RC] second read = 2026-10-20

第二次读看到了新值,因为 read committed 每条语句重新取快照。把同一段代码的隔离级别换成 repeatable read,本机输出:

[RR] first read  = NULL
[RR] second read = NULL

第二次读仍然是 NULL。事务的快照在第一次查询时就固定了,writer 的提交对它不可见。

这两种行为没有绝对的好坏,取决于业务:

  • 借阅前的「这本书有没有活动借阅」检查,用 read committed 就够了,因为最终约束靠唯一索引,不靠这条查询。
  • 导出在借清单时,希望会员信息和借阅列表来自同一时刻,就应该用 repeatable read,避免两条 SQL 之间数据变化。

核心机制四:行锁、锁等待与写冲突

MVCC 让读不阻塞写,但写与写之间仍然要排队。两个事务更新同一行时,后到的事务会等待前一个事务提交或回滚。这个等待可以在 pg_locks 和 pg_stat_activity 里看到。

实验用一个线程持有行锁,另一个线程更新同一行,主线程查询锁视图:

try (Connection c = open(); Statement s = c.createStatement();
     ResultSet rs = s.executeQuery("""
             select a.pid, a.state, a.wait_event_type, a.wait_event,
                    l.locktype, l.mode
             from pg_stat_activity a
             join pg_locks l on l.pid = a.pid
             where a.datname = 'libraryd' and not l.granted
             """)) {
    while (rs.next()) {
        System.out.println("[LOCK] pid=" + rs.getInt(1)
                + " state=" + rs.getString(2)
                + " wait=" + rs.getString(3) + "/" + rs.getString(4)
                + " lock=" + rs.getString(5) + " mode=" + rs.getString(6));
    }
}

本机输出:

[LOCK] pid=125 state=active wait=Lock/transactionid lock=transactionid mode=ShareLock
[LOCK] waiter updated rows = 1

等待事件是 Lock/transactionid,锁类型是 transactionid,模式是 ShareLock。这不是表锁,而是「等待某个事务 id 结束」的行级冲突。前一个事务提交后,等待者继续执行并更新了 1 行。

在 libraryd 里排查「接口变慢」时,这张查询比看应用日志更快:如果大量会话停在 Lock/transactionid,说明有长事务持有行锁;如果停在 IO,则是磁盘或检查点问题。

repeatable read 下的写冲突

repeatable read 不阻塞读,但两个事务更新同一行时,后提交者可能拿到 40001。实验让两个事务都先读同一行,然后先后更新,本机输出:

[RR-CONFLICT] tx B committed
[RR-CONFLICT] tx A: 40001 - ERROR: could not serialize access due to concurrent update

实际调度取决于线程启动顺序,本机这次是 B 先提交、A 失败;反过来也一样,总有一个事务被 abort。错误原文是:

ERROR: could not serialize access due to concurrent update

这不是 bug,而是 repeatable read 的规则:事务基于旧快照修改一行,而该行已经被别的事务改过,继续覆盖会丢失更新,所以 PostgreSQL 让后到者失败。

应用的处理方式只有一种:回滚整个事务,然后重试。不能只重试那条 update,因为事务已经 aborted,后续语句会返回 25P02。

在 libraryd 里落地

5.1 的 JdbcTransactionRunner 已经把隔离级别做成显式参数:Isolation 是 domain 枚举,JdbcTransactionRunner 负责映射到 JDBC 常量。这里只补两件事:冲突重试和只读报表。

重试必须包住整个事务,而不是单条语句。JdbcTransactionRunner 在 inTransaction 外层套一个有限次循环:

@Override
public <T> T inTransaction(Isolation isolation, TransactionWork<T> work) {
    return inTransaction(isolation, false, work);
}

@Override
public <T> T inTransaction(Isolation isolation, boolean readOnly, TransactionWork<T> work) {
    for (int attempt = 1; ; attempt++) {
        try {
            return inTransactionOnce(isolation, readOnly, work);
        } catch (LibraryException e) {
            if (attempt >= 3 || !isRetryable(e)) {
                throw e;
            }
        }
    }
}

private static boolean isRetryable(LibraryException e) {
    if (!(e.getCause() instanceof SQLException sql)) {
        return false;
    }
    String state = sql.getSQLState();
    return "40001".equals(state) || "40P01".equals(state);
}

inTransactionOnce 就是 5.1 节那段「取连接、设 autoCommit、设隔离级别、提交/回滚」的代码。40001 是序列化/快照冲突,40P01 是死锁,两者都属于「重试可能成功」的类别;25P02 不在重试范围,它说明事务已经中止,应该回滚后重新开始。

重试有一个前提:事务里的操作必须可以重复执行。借阅插入如果第一次其实已经提交、只是客户端没收到响应,重试会撞唯一索引,变成 BOOK_ALREADY_BORROWED;这时需要靠幂等键或查询现有状态来区分。5.3 节会把这个边界补上。

报表查询明确使用 repeatable read,并且只读:

public List<Loan> exportMemberLoans(String memberId) {
    return transactions.inTransaction(Isolation.REPEATABLE_READ, true, repositories -> {
        repositories.members().findById(memberId).orElseThrow(() ->
                new LibraryException(ErrorCode.MEMBER_NOT_FOUND, "会员不存在: " + memberId));
        return repositories.loans().findActiveByMember(memberId);
    });
}

members().findById 和 loans().findActiveByMember 是两条 SQL,但在同一个 repeatable read 事务里,它们看到同一份快照。导出结果不会出现「会员存在但借阅列表属于另一时刻」的错位。服务层返回领域对象 Loan,web 层再用 DtoMapper 转成 LoanDto,避免 domain 反向依赖 web。readOnly=true 会让 JDBC 驱动把事务标记为只读,PostgreSQL 拒绝写语句,避免报表代码误写。

借阅服务保持 read committed:

public Loan borrow(String isbn, String memberId, LocalDate today) {
    return transactions.inTransaction(Isolation.READ_COMMITTED, repositories -> {
        repositories.books().findByIsbn(isbn).orElseThrow(() -> new LibraryException(
                ErrorCode.BOOK_NOT_FOUND, "图书不存在: " + isbn));
        repositories.members().saveIfAbsent(new Member(memberId, memberId));
        return repositories.loans().insert(Loan.open(isbn, memberId, today));
    });
}

它不需要 repeatable read,因为「同一本书不能有两条活动借阅」由部分唯一索引兜底,而不是靠「先查再插」。把隔离级别调高不能替代约束,只会增加冲突和重试。

常见错误与边界

错误一:以为 repeatable read 能防止所有并发异常。 repeatable read 防的是「同一行被并发更新」和「同一查询结果变化」,防不住写偏斜(write skew):两个事务读同一组行、各自更新不同的行,最终组合出的状态不满足业务约束。要防写偏斜,需要 serializable 加 SSI,而不是继续调高锁。

错误二:捕获 40001 后只重试失败的语句。 事务已经 aborted,正确的做法是从头执行整个事务。上面的模板用循环包住 inTransactionOnce,每次重试都是新连接、新事务。

错误三:在 repeatable read 事务里做长时间计算。 快照会一直持有,旧行版本无法被 vacuum 回收。报表导出应尽量只读、尽快结束;不要在这个事务里调用外部服务或等待用户输入。

边界:setTransactionIsolation 的调用时机。 JDBC 规范说在事务进行中修改隔离级别是实现相关的。稳妥做法是在 setAutoCommit(false) 之后、执行第一条 SQL 之前设置,本机用 PostgreSQL JDBC 42.7.14 验证过两种顺序都能生效,但不要依赖「事务中途切换」。

边界:serializable 的成本。 serializable 会增加 SSI 的读写依赖跟踪,冲突多时重试率上升。libraryd 的借阅主链路不启用它;只有确实需要跨行不变量的业务才值得付出这个代价。

小结

  • PostgreSQL 的 read uncommitted 等同 read committed,不提供脏读。
  • read committed 每条语句取新快照;repeatable read 整个事务共用第一份快照。
  • MVCC 更新会写新版本,旧版本的 xmax 指向替换它的事务;旧快照仍然读旧版本。
  • 行锁等待可以在 pg_locks 与 pg_stat_activity 里观察,等待事件通常是 Lock/transactionid。
  • repeatable read 下并发更新同一行会得到 40001,应用必须回滚整个事务并重试。
  • libraryd 借阅用 read committed 加唯一索引,报表导出用 repeatable read 只读事务。

理解了隔离级别,接下来要回答一个更具体的问题:两个借阅请求在同一毫秒到达时,数据库靠什么做到只有一条活动借阅?下一节看唯一索引、条件更新和幂等归还。

阅读导航:上一节:5.1 JDBC 事务:提交、回滚与事务边界 · 下一节:5.3 并发控制:唯一索引、条件更新与幂等 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发