本节目标:把「隔离级别」从标准里的名词变成可观察的行为,知道
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_UNCOMMITTED | read uncommitted | 按 read committed 执行,不会脏读 |
TRANSACTION_READ_COMMITTED | read committed | 每条语句取一次新快照 |
TRANSACTION_REPEATABLE_READ | repeatable read | 整个事务共用第一次查询建立的快照 |
TRANSACTION_SERIALIZABLE | serializable | 在 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 并发控制:唯一索引、条件更新与幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。