事务与 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。如果你手动声明 bean DataSourceTransactionManager,提交错误将变成 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 验证行为。

postgresqltransactionisolation levelspring bootconcurrency