PostgreSQL のトランザクションと分離レベル

1. 概要

複数のリクエストが同じテーブルを読み書きするとき、各 SQL 文が正しくても結果は間違う可能性があります。分離レベルは、トランザクションが他のトランザクションの変更をどれだけ「見える」かを決定します。

このセクションでは、PostgreSQL がどのように分離レベルを実装しているかを見ていきます。ロストアップデートと Write Skew のエラーを再現できるようになります。記事の最後に、適切なレベルを選択する方法、Spring Boot で設定する方法、正しくリトライロジックを書く方法を学びます。

2. PostgreSQL のトランザクション

トランザクションは複数のステートメントを 1 つの単位にまとめたものです。すべてが成功するか、どのコマンドも実行されないかのいずれかです:

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;

2 番目の UPDATE ステートメントが失敗した場合、ROLLBACK を実行すると An の残高は変わりません。BEGIN がない場合、PostgreSQL はオートコミットモードで実行されます:各ステートメントは独立したトランザクションです。

PostgreSQL は MVCC (Multi-Version Concurrency Control) を使用します。UPDATE コマンドは行を上書きするのではなく、新しいバージョンを作成します。各トランザクションは snapshot を通じてデータを読み取ります。したがって、読み取り操作は書き込み操作をブロックしませんし、その逆も然りです。分離レベルは、snapshot がいつ取得されるかを決定します:各ステートメントの開始時か、トランザクション全体で 1 回だけか。

3. 異常現象と 4 つの分離レベル

SQL 標準は次の現象を定義しています:

  • Dirty read: コミットされていないデータを別のトランザクションから読み取ります。
  • Non-repeatable read: 同じ行を 2 回読み取っても、値が異なります。
  • Phantom read: 条件付きクエリを再実行すると、行が追加または削除されます。
  • Lost update: 2 つのトランザクションが同じ行を読んでから書き込むと、1 つの書き込みが上書きされます。
  • Write skew: 2 つのトランザクションが共通のデータを読んでから、それぞれ別の行に書き込み、ビジネス制約を破ります。

以下の表は、PostgreSQL が各現象をどのように処理するかを示しています:

Level              Dirty read  Non-repeatable  Phantom  Lost update  Write skew
READ UNCOMMITTED   なし        あり            あり     あり         あり
READ COMMITTED     なし        あり            あり     あり         あり
REPEATABLE READ    なし        なし            なし     なし (*)     あり
SERIALIZABLE       なし        なし            なし     なし (*)     なし (*)
(*) トランザクションは SQLSTATE 40001 で中止されます。アプリケーションはリトライする必要があります。

SQL 標準と異なる 2 つのポイントがあります:

  • PostgreSQL の READ UNCOMMITTED は READ COMMITTED と同じように機能します。Dirty read が発生することはありません。
  • REPEATABLE READ は phantom read もブロックします。実質的には snapshot isolation だからです。

デフォルトレベルは READ COMMITTED です。次のコマンドで確認できます:

SHOW default_transaction_isolation;
 default_transaction_isolation
-------------------------------
 read committed

4. READ COMMITTED

このレベルでは、各ステートメントが新しい snapshot を取得します。2 つの 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 は 2 つの異なる値を見ています。これが Non-repeatable read です。

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);

2 つのスレッドが同じコードを実行し、両方とも 1000 を読み込んだとしましょう。両方とも 900 を書き込むでしょう。結果として、1 回の引き出しが失われます。

READ COMMITTED を保ったまま修正する方法は 2 つあります。

方法 1: データベースに計算させ、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;

2 番目のトランザクションは SELECT ... FOR UPDATE でブロックされます。最初のトランザクションがコミットされるまで。したがって、常に新しい値を読み取ります。

5. REPEATABLE READ

このレベルでは、snapshot はトランザクションの最初のステートメントで 1 回だけ取得されます。その後のすべての読み取りは、同じデータ状態を見ます。

別のトランザクションが snapshot 取得後に行を変更してコミットした場合、その行を更新しようとすると、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 と Write Skew

REPEATABLE READ は Write Skew を許してしまいます。古典的な例を考えます:病院では常に 1 人以上の医師が当番でいる必要があります。

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);

2 人の医師が休みを求め、それぞれ別のトランザクションで実行します:

-- 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  -> 誰も当番していない!

2 つのトランザクションは異なる行に書き込むため、書き込み競合は検出されません。SERIALIZABLE に変更すると、PostgreSQL は Serializable Snapshot Isolation (SSI) メカニズムを使用します。SSI はトランザクション間の読み書き依存関係を追跡し、そのうちの 1 つを中止します:

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);
        // 両方のクエリが同じ snapshot を使う -> データは一貫している
        return total;
    }
}

Spring はトランザクション開始前に Connection.setTransactionIsolation(...) を呼び出します。PostgreSQL ドライバはそれを SET TRANSACTION ISOLATION LEVEL ... に変換します。

7.1. Serialization Failure 時のリトライ

リトライはトランザクションの外にある必要があります。トランザクション内のリトライは無意味です。トランザクションは既に破損とマークされているからです。TransactionTemplate を使用すると、@Transactional の self-invocation の罠を回避できます:

@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("最低 1 人の医師は当番でいる必要があります");
        }
        jdbc.update("UPDATE doctor SET on_call = false WHERE id = ?", doctorId);
    }
}

上記のコードは以下のように機能します:

  • setIsolationLevel(ISOLATION_SERIALIZABLE) は executeWithoutResult へのすべての呼び出しに適用されます。
  • Spring は SQLSTATE 40001 (とデッドロック 40P01) を ConcurrencyFailureException のサブクラスに変換します。したがって、ドライバのエラーコードを読む必要がありません。
  • リトライ時、新しいトランザクションが新しい snapshot を取得します。もう 1 人の医師が休んでいることに気付き、IllegalStateException をスロー。ビジネス制約は正しく保たれます。

注意: Spring Boot 3 は JdbcTransactionManager を自動的に設定します。このクラスは commit 時に発生したエラーも DataAccessException に変換します。カスタム DataSourceTransactionManager bean を宣言した場合、commit エラーは TransactionSystemException になり、上記のリトライループはそれをキャッチできません。

8. Testcontainers で Write Skew をテストする

実際の交差順序を再現するには、同じスレッド内で 2 つの 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);
        }
    }
}

2 つのテストの期待される結果:

repeatableReadAllowsWriteSkew  -> PASSED (両方コミット、0 人の医師が当番)
serializableRejectsWriteSkew   -> PASSED (B が 40001 で中止、1 人の医師が当番)

2 番目のテストは、SSI が UPDATE と COMMIT の B のどちらでエラーを検出したかを主張しません。PostgreSQL のバージョン間でエラー検出のタイミングが変わる可能性があるため、40001 だけをチェックします。

9. よくあるエラーと使うべきでない場合

  • レベルを上げたがリトライなし。 REPEATABLE READ と SERIALIZABLE では、エラー 40001 はバグではなく正常な動作です。リトライなしでは、ユーザーはランダムに 500 エラーを受け取ります。
  • @Transactional 内でのリトライ。 トランザクションは既に破損とマークされており、ロールバックして最初からやり直す必要があります。リトライはトランザクションの外の呼び出し層に配置します。
  • リトライ付きトランザクション内での外部 API 呼び出し。 メールまたは支払いコマンドが複数回送信される可能性があります。コミット成功後に副作用を実行します。
  • 「安全性のため」全システムに SERIALIZABLE を設定。 この方法は SSI 追跡のコストと多くのリトライを引き起こします。複数行にまたがるビジネス制約のある部分にのみ使用します。
  • 同じビジネスロジック内でのレベル混在。 SSI はすべての関連トランザクションが SERIALIZABLE で実行されている場合にのみ保護します。READ COMMITTED トランザクションが同じテーブルに書き込む場合、制約は依然として破られる可能性があります。
  • REPEATABLE READ トランザクションが長時間実行。 古い snapshot は VACUUM が死んだ行バージョンをクリーンアップするのを防ぎ、テーブルが膨張します。トランザクションを短く保ちます。

注意: CHECK、UNIQUE、exclusion constraint などのスキーマで表現できる制約の場合、制約を優先します。分離レベルを上げるより安く、信頼性が高いです。

10. まとめ

PostgreSQL はデフォルトで READ COMMITTED で実行されます。このレベルはほとんどのアプリケーションに十分です。ロストアップデートを避けるために原子的な更新を書くか、SELECT ... FOR UPDATE を使用すればよいのです。

REPEATABLE READ はトランザクション全体で一貫した snapshot を提供し、レポートに適しています。SERIALIZABLE は Write Skew もブロックします。両方のレベルでは、SQLSTATE 40001 に遭遇したときにアプリケーションがリトライする必要があります。

Spring Boot では、@Transactional(isolation = ...) または TransactionTemplate を通じてレベルを宣言し、トランザクション外で ConcurrencyFailureException をキャッチし、決定的な操作順序を持つ Testcontainers でビジネスロジックを検証します。

postgresqltransactionisolation levelspring bootconcurrency