事务与 PostgreSQL 隔离级别
1. 概述
当多个请求同时读写一张表时,结果可能是错误的,尽管每个 SQL 语句都是正确的。隔离级别决定了一个事务“看到”有多少其他事务的更改。
在本文中,我们将了解 PostgreSQL 如何实现隔离级别。你将能够重现丢失更新和写入偏差的错误。最后,你将了解如何选择合适的级别、在 Spring Boot 中配置它,以及正确地编写重试逻辑。
2. PostgreSQL 中的事务
一个事务将多个语句打包成一个单元。要么全部成功,要么都没有效果:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
owner TEXT NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO account VALUES (1, 'An', 1000), (2, 'Binh', 500);
BEGIN;
UPDATE account SET balance = balance - 300 WHERE id = 1;
UPDATE account SET balance = balance + 300 WHERE id = 2;
COMMIT;
如果第二条 UPDATE 语句失败,你调用 ROLLBACK,An 的余额将保持不变。没有 BEGIN 时,PostgreSQL 运行在自动提交模式:每个语句都是一个单独的事务。
PostgreSQL 使用 MVCC(多版本并发控制)。每条 UPDATE 语句创建一个行的新版本,而不是覆盖它。每个事务通过一个快照读取数据。因此,读取操作不会阻止写入操作,反之亦然。隔离级别决定了快照何时被获取:在每个语句的开始,还是整个事务一次。
3. 异常现象和四个隔离级别
SQL 标准定义了以下现象:
- 脏读:读取其他事务尚未提交的数据。
- 不可重复读:两次读取同一行但得到不同的值。
- 幻读:重新执行相同的条件查询,结果中出现或消失了行。
- 丢失更新:两个事务都读取然后写入同一行,其中一个写入被覆盖了。
- 写入偏差:两个事务读取共享数据,然后分别写入不同的行,破坏了业务约束。
下表描述了 PostgreSQL 如何处理每种现象:
级别 脏读 不可重复读 幻读 丢失更新 写入偏差
READ UNCOMMITTED 否 是 是 是 是
READ COMMITTED 否 是 是 是 是
REPEATABLE READ 否 否 否 否(*) 是
SERIALIZABLE 否 否 否 否(*) 否(*)
(*) 事务被取消,返回 SQLSTATE 40001,应用程序必须重试。
有两个与 SQL 标准的差异需要记住:
- PostgreSQL 中的
READ UNCOMMITTED的行为与READ COMMITTED相同。你永远不会遇到脏读。 REPEATABLE READ阻止幻读,因为它实际上是快照隔离。
默认级别是 READ COMMITTED。你可以用以下命令检查:
SHOW default_transaction_isolation;
default_transaction_isolation
-------------------------------
read committed
4. READ COMMITTED
在这个级别,每个语句都获取一个新的快照。打开两个 psql 会话并按顺序交错运行:
-- 会话 A
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 700.00
-- 会话 B
UPDATE account SET balance = 900 WHERE id = 1; -- 自动提交
-- 会话 A
SELECT balance FROM account WHERE id = 1; -- 900.00
COMMIT;
在同一个事务中,会话 A 看到了两个不同的值。这就是不可重复读。
4.1. 丢失更新
应用程序中最常见的错误是“在 Java 中读取、计算、然后写回”的模式:
BigDecimal balance = jdbc.queryForObject(
"SELECT balance FROM account WHERE id = ?", BigDecimal.class, 1L);
jdbc.update("UPDATE account SET balance = ? WHERE id = ?",
balance.subtract(new BigDecimal("100")), 1L);
假设两个线程同时运行这段代码,都读取到 1000。两者都会写入 900。结果是一次扣款被丢失了。
有两种方法可以修复这个问题,同时保持 READ COMMITTED。
方法 1:让数据库自己计算,写成一条原子语句。
UPDATE account SET balance = balance - 100 WHERE id = 1;
当遇到被其他事务更新的行时,PostgreSQL 会等待该事务提交。然后它读取该行的最新版本并应用减法操作。
方法 2:读取时立即用 SELECT ... FOR UPDATE 锁定行。
BEGIN;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- 应用程序检查业务规则、计算...
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;
第二个事务将在 SELECT ... FOR UPDATE 语句处被阻止,直到第一个事务提交。因此它总是读到最新的值。
5. REPEATABLE READ
在这个级别,快照在事务的第一个语句处获取一次。所有后续读取都看到相同的数据状态。
如果你尝试更新一行,而其他事务在获取快照后修改并提交了该行,PostgreSQL 会拒绝:
-- 会话 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1; -- 1000.00
-- 会话 B
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 提交
-- 会话 A
UPDATE account SET balance = balance - 200 WHERE id = 1;
ERROR: could not serialize access due to concurrent update
丢失更新不会再默默发生。相反,会话 A 必须 ROLLBACK 并重新开始。错误代码 40001(serialization_failure)是应用程序重试的信号。
6. SERIALIZABLE 和写入偏差
REPEATABLE READ 仍然可能让写入偏差漏掉。考虑以下经典示例:医院要求始终至少有一名医生值班。
CREATE TABLE doctor (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
on_call BOOLEAN NOT NULL
);
INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true);
两名医生各自在一个事务中请求下班:
-- A 和 B 同时运行:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctor WHERE on_call; -- 两者都看到 2
-- A
UPDATE doctor SET on_call = false WHERE id = 1;
-- B
UPDATE doctor SET on_call = false WHERE id = 2;
COMMIT; -- A: OK
COMMIT; -- B: OK -> 没人值班了!
两个事务写入不同的行,所以没有检测到写入冲突。如果改为 SERIALIZABLE,PostgreSQL 使用可序列化快照隔离(SSI)机制。SSI 跟踪事务之间的读-写依赖关系并取消其中一个:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
错误可能出现在 UPDATE 或 COMMIT 时。错误代码仍然是 40001。
注意:
SERIALIZABLE不会锁定额外的行。它只检测冲突并取消事务。使用 SSI,PostgreSQL 可能会误杀一些并非真正冲突的事务。因此,在高并发写入的情况下,重试率可能会显著增加。
7. 在 Spring Boot 中使用隔离级别
添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
最简单的方法是在 @Transactional 上声明隔离级别:
@Service
public class ReportService {
private final JdbcTemplate jdbc;
public ReportService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public BigDecimal totalBalance() {
BigDecimal total = jdbc.queryForObject(
"SELECT sum(balance) FROM account", BigDecimal.class);
Long count = jdbc.queryForObject(
"SELECT count(*) FROM account", Long.class);
// 两个查询使用同一个快照 -> 数据一致
return total;
}
}
Spring 在开始事务前调用 Connection.setTransactionIsolation(...)。PostgreSQL 驱动会将其转换为 SET TRANSACTION ISOLATION LEVEL ...。
7.1. 遇到序列化失败时重试
重试必须在事务外部进行。在事务内部重试是无意义的,因为事务已被标记为损坏。使用 TransactionTemplate 可以避免 @Transactional 的自调用陷阱:
@Service
public class OnCallService {
private static final int MAX_ATTEMPTS = 3;
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
public OnCallService(JdbcTemplate jdbc, PlatformTransactionManager tm) {
this.jdbc = jdbc;
this.tx = new TransactionTemplate(tm);
this.tx.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
}
public void goOffCall(long doctorId) {
for (int attempt = 1; ; attempt++) {
try {
tx.executeWithoutResult(status -> doGoOffCall(doctorId));
return;
} catch (ConcurrencyFailureException e) {
if (attempt >= MAX_ATTEMPTS) {
throw e;
}
}
}
}
private void doGoOffCall(long doctorId) {
Integer onCall = jdbc.queryForObject(
"SELECT count(*) FROM doctor WHERE on_call", Integer.class);
if (onCall == null || onCall < 2) {
throw new IllegalStateException("必须至少有一名医生值班");
}
jdbc.update("UPDATE doctor SET on_call = false WHERE id = ?", doctorId);
}
}
上述代码的工作流程如下:
setIsolationLevel(ISOLATION_SERIALIZABLE)应用于每次executeWithoutResult调用。- Spring 将 SQLSTATE
40001(和死锁40P01)转换为ConcurrencyFailureException的子类。因此你不必读取驱动程序的错误代码。 - 在重试时,新事务获取新的快照。它看到另一名医生已下班,所以抛出
IllegalStateException。业务约束得到正确保护。
注意: Spring Boot 3 自动配置
JdbcTransactionManager。这个类可以将提交时发生的错误转换为DataAccessException。如果你手动声明 beanDataSourceTransactionManager,提交错误将变成TransactionSystemException,上述重试循环将无法捕获。
8. 用 Testcontainers 测试写入偏差
为了精确重现交错顺序,我们在同一个线程中使用两个 JDBC Connection。这种方法不依赖于线程的运行时机。添加测试依赖:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
测试类:
@Testcontainers
class WriteSkewTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@BeforeEach
void setUp() throws SQLException {
try (Connection c = connect(); Statement st = c.createStatement()) {
st.execute("DROP TABLE IF EXISTS doctor");
st.execute("CREATE TABLE doctor (id BIGINT PRIMARY KEY, name TEXT, on_call BOOLEAN)");
st.execute("INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true)");
}
}
@Test
void repeatableReadAllowsWriteSkew() throws SQLException {
runConcurrently(Connection.TRANSACTION_REPEATABLE_READ);
assertThat(countOnCall()).isZero();
}
@Test
void serializableRejectsWriteSkew() throws SQLException {
assertThatThrownBy(() -> runConcurrently(Connection.TRANSACTION_SERIALIZABLE))
.isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState())
.isEqualTo("40001");
assertThat(countOnCall()).isEqualTo(1);
}
private void runConcurrently(int level) throws SQLException {
try (Connection a = begin(level); Connection b = begin(level)) {
assertThat(countOnCall(a)).isEqualTo(2);
assertThat(countOnCall(b)).isEqualTo(2);
offCall(a, 1);
a.commit();
offCall(b, 2);
b.commit();
}
}
private Connection begin(int level) throws SQLException {
Connection c = connect();
c.setAutoCommit(false);
c.setTransactionIsolation(level);
return c;
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
private void offCall(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(
"UPDATE doctor SET on_call = false WHERE id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
private int countOnCall(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT count(*) FROM doctor WHERE on_call")) {
rs.next();
return rs.getInt(1);
}
}
private int countOnCall() throws SQLException {
try (Connection c = connect()) {
return countOnCall(c);
}
}
}
两个测试的预期结果:
repeatableReadAllowsWriteSkew -> 通过(两者都提交,0 名医生值班)
serializableRejectsWriteSkew -> 通过(B 被取消,返回 40001,1 名医生值班)
第二个测试不确定错误是在 B 的 UPDATE 还是 COMMIT 时发生。它只检查错误代码 40001,因为 SSI 检测冲突的时机可能因 PostgreSQL 版本而异。
9. 常见错误和何时不应使用
- 提升级别但不重试。 在
REPEATABLE READ和SERIALIZABLE中,40001错误是正常行为,不是 bug。没有重试,用户会随机收到 500 错误。 - 在
@Transactional内部重试。 事务已破损必须被回滚,然后从头开始。将重试放在外层的调用方。 - 在可重试的事务中调用外部 API。 邮件或支付命令可能会被多次发送。在成功提交后执行副作用。
- 为整个系统设置
SERIALIZABLE“保险起见”。 这会增加 SSI 监控的成本和重试率。仅在跨多行的约束使用它。 - 在同一业务操作中混合级别。 SSI 只在所有相关事务都运行
SERIALIZABLE时才能保护。一个READ COMMITTED事务写入同一个表仍然可能破坏约束。 REPEATABLE READ事务运行太长。 旧快照会阻止VACUUM清理已删除的行版本,导致表膨胀。保持事务简短。
注意: 对于可以通过 schema 表示的约束,比如
CHECK、UNIQUE或排斥约束,优先使用约束。它比提升隔离级别更便宜更可靠。
10. 总结
PostgreSQL 默认运行 READ COMMITTED。这个级别足以满足大多数应用程序,只要你编写原子更新或使用 SELECT ... FOR UPDATE 来避免丢失更新。
REPEATABLE READ 提供整个事务的一致快照,适合报告。SERIALIZABLE 阻止写入偏差。这两个级别都要求应用程序在遇到 SQLSTATE 40001 时重试。
在 Spring Boot 中,通过 @Transactional(isolation = ...) 或 TransactionTemplate 声明级别,在事务外捕获 ConcurrencyFailureException,并用确定操作顺序的 Testcontainers 验证行为。