YASD-TECH
YASD TECH
# DB

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

投稿日:2026/6/24

更新日:2026/6/24

ttitleImage

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

はじめに

Webアプリでは、複数のSQLをまとめて成功・失敗させたい場面や、複数のリクエストが同じデータを同時に操作する場面があります。

このとき重要になるのが、次の2つです。

  • トランザクション: 複数の処理を1つのまとまりとして扱う
  • 分離レベル: 同時実行中のトランザクション同士を、どこまで互いに見えなくするか決める

トランザクションを使うだけで、すべての同時実行問題が解決するわけではありません。
どの分離レベルで何が起こり得るかを理解し、DB制約、原子的な更新、ロック、リトライと組み合わせる必要があります。


トランザクションとは

トランザクションは、複数のSQLをすべて成功させるか、すべて取り消すかの単位です。

たとえば、口座Aから口座Bへ1,000円を送金するとします。

BEGIN;

UPDATE accounts
SET balance = balance - 1000
WHERE account_id = 'A';

UPDATE accounts
SET balance = balance + 1000
WHERE account_id = 'B';

COMMIT;

途中で失敗した場合は、ROLLBACK します。

ROLLBACK;

これにより、「口座Aからは引かれたが、口座Bには入っていない」という中途半端な状態を防げます。

PostgreSQLでは、明示的に BEGIN しない単独のSQLも、内部的には1文ごとのトランザクションとして実行されます。


ACID

トランザクションの代表的な性質をまとめたものが ACID です。

Atomicity(原子性)

トランザクション内の処理は、すべて成功するか、すべて失敗します。

口座Aから減額: 成功
口座Bへ加算:   失敗
→ 全体をROLLBACK

一部だけがDBに反映されることを防ぎます。

Consistency(一貫性)

トランザクションの前後で、データが定められたルールを満たしている状態を維持します。

たとえば、次のようなルールです。

  • 残高は0以上
  • 注文には必ず存在するユーザーが紐づく
  • 同じユーザーが同じイベントへ重複申込できない

これらは、トランザクションだけで自動的に保証されるわけではありません。
アプリの処理に加えて、CHECKFOREIGN KEYUNIQUE などのDB制約で表現します。

Isolation(分離性)

複数のトランザクションが同時に動いても、互いの途中状態によって不正な結果が生じないようにします。

ただし、どこまで強く分離するかは分離レベルによって異なります。

Durability(永続性)

一度 COMMIT が成功したデータは、その後に障害が発生しても失われないようにDBが記録します。

PostgreSQLでは、WAL(Write-Ahead Logging)などの仕組みによって実現されています。


なぜ分離レベルが必要なのか

分離を強くすれば、同時実行による不整合を防ぎやすくなります。
一方で、競合によるトランザクションの失敗やリトライ、管理コストが増える場合があります。

そのため、DBでは用途に応じて分離レベルを選択できます。

PostgreSQLで主に使う分離レベルは次の3つです。

分離レベル 特徴
Read Committed SQL文ごとに、その時点でコミット済みのデータを見る。PostgreSQLのデフォルト
Repeatable Read トランザクション中、基本的に同じスナップショットを見続ける
Serializable 並行実行の結果を、何らかの順番で1件ずつ実行した結果と同等にする

SQL標準には Read Uncommitted もありますが、PostgreSQLでは Read Committed と同じ動作になります。
PostgreSQLでは未コミットデータを読むDirty Readは発生しません。


同時実行で起こる代表的な現象

Dirty Read

別のトランザクションが、まだコミットしていない変更を読んでしまう現象です。

トランザクションA: 残高を10,000円から0円へ変更(未コミット)
トランザクションB: 残高0円を読む
トランザクションA: ROLLBACK

Bは、最終的には存在しなかった値を利用したことになります。

PostgreSQLでは、どの分離レベルでもDirty Readは発生しません。

Non-repeatable Read

同じトランザクション内で同じ行を2回読むと、別のトランザクションの更新によって値が変わっている現象です。

トランザクションA: 商品価格を読む → 1,000円
トランザクションB: 商品価格を1,200円に更新してCOMMIT
トランザクションA: 商品価格を再度読む → 1,200円

Read Committedでは発生し得ます。

Phantom Read

同じ検索条件で再度検索すると、条件に該当する行の集合が増減している現象です。

トランザクションA: status = 'pending' を検索 → 10件
トランザクションB: pendingの行を1件追加してCOMMIT
トランザクションA: 同じ条件を検索 → 11件

SQL標準ではRepeatable Readでも発生し得るとされています。
ただし、PostgreSQLのRepeatable Readは標準より強く、Phantom Readも防ぎます。

Serialization Anomaly

各トランザクションから見たデータは一貫していても、全体としては「1件ずつ順番に実行した」と説明できない結果になる現象です。

代表例は Write Skew です。

ルール: 当直医は最低1人必要
現在: 医師Aと医師Bが当直中

トランザクションA:
  当直医が2人いることを確認
  医師Aを当直から外す

トランザクションB:
  当直医が2人いることを確認
  医師Bを当直から外す

両方が同じスナップショットを見てコミットすると、当直医が0人になります。

これはRepeatable Readでも起こり得ます。
Serializableでは依存関係を検出し、どちらかのトランザクションを失敗させます。


PostgreSQLにおける分離レベルの違い

分離レベル Dirty Read Non-repeatable Read Phantom Read Serialization Anomaly
Read Committed 防ぐ 起こり得る 起こり得る 起こり得る
Repeatable Read 防ぐ 防ぐ 防ぐ 起こり得る
Serializable 防ぐ 防ぐ 防ぐ 防ぐ

この表はPostgreSQLでの動作です。
同じ分離レベル名でも、DB製品によって実装や挙動が異なる場合があります。


Read Committed

PostgreSQLのデフォルトです。

BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 処理

COMMIT;

各SQL文は、そのSQL文が開始した時点のコミット済みデータを参照します。
そのため、同じトランザクション内でも、SQL文が変われば新しくコミットされたデータが見えます。

トランザクションA: SELECT → 100
トランザクションB: UPDATEして200に変更、COMMIT
トランザクションA: SELECT → 200

多くの一般的なCRUDでは十分ですが、「最初に読んだ状態がトランザクション終了まで変わらない」ことを前提にしてはいけません。

Read Committedでの基本方針

  • 可能なら、確認と更新を1クエリにまとめる
  • 不変条件はDB制約で守る
  • 読んだ行を後から更新するなら SELECT ... FOR UPDATE を検討する
UPDATE products
SET stock = stock - 1
WHERE product_id = $1
  AND stock > 0;

単純な在庫更新なら、分離レベルを上げるより、このような原子的UPDATEの方が意図が明確です。


Repeatable Read

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 処理

COMMIT;

トランザクション内のクエリは、原則として最初の実データ参照時点のスナップショットを共有します。

そのため、途中で別のトランザクションがコミットしても、同じトランザクション内ではその変更が見えません。

トランザクションA: SELECT → 100
トランザクションB: UPDATEして200に変更、COMMIT
トランザクションA: SELECT → 100

集計処理や、複数のクエリで同じ時点のデータを読みたい場合に向いています。

ただし、スナップショット取得後に他のトランザクションが同じ行を更新していた場合などは、次のエラーで失敗することがあります。

could not serialize access due to concurrent update

この場合は、トランザクション全体を最初からリトライします。

また、Repeatable Readは同じスナップショットを保証しますが、Write SkewのようなSerialization Anomalyまでは防ぎません。


Serializable

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 処理

COMMIT;

最も強い分離レベルです。

並行して実行されたトランザクションについて、コミットに成功した結果が、何らかの順番で1件ずつ実行した結果と同等になるようにします。

PostgreSQLは、すべての処理を実際に直列化するわけではありません。
並行実行しながら読み書きの依存関係を監視し、安全な直列順序を作れない場合に一部を失敗させます。

代表的なエラーは次のとおりです。

SQLSTATE: 40001
could not serialize access due to read/write dependencies among transactions

Serializableを使う場合、40001 は異常事態ではなく、競合を安全に解消するための通常の結果として扱います。

向いている処理

  • 複数行を読んで、全体条件をもとに更新する
  • 集計結果をもとに別のデータを更新する
  • 行ロックだけでは守りにくい業務ルールがある
  • Write Skewを防ぐ必要がある

注意点

  • トランザクション全体のリトライが必須
  • 競合が多いと失敗率と処理時間が増える
  • 長いトランザクションを避ける
  • トランザクション中に外部APIやメール送信を行わない
  • すべての不正データを防ぐ機能ではないため、DB制約も必要

Serializableのリトライ設計

必ずトランザクション全体をやり直す

失敗したSQLだけを再実行してはいけません。

読み取り結果を含め、トランザクション開始時点の前提が無効になっているためです。

悪い例:
BEGIN
SELECTで状態確認
UPDATEで40001
UPDATEだけ再実行

良い例:
ROLLBACK
BEGINからSELECT、判定、UPDATEをすべて再実行

SQLSTATEで判定する

エラーメッセージの文字列ではなく、PostgreSQLのSQLSTATE 40001 で判定します。

デッドロックのSQLSTATE 40P01 も、一般にはトランザクション全体のリトライ対象として設計できます。ただし、デッドロックが頻発する場合は更新順序などの設計も修正します。

回数制限とバックオフを入れる

無限リトライは避けます。

const MAX_ATTEMPTS = 3;

for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
  try {
    return await runSerializableTransaction();
  } catch (error) {
    const retryable =
      error.sqlState === "40001" || // serialization_failure
      error.sqlState === "40P01";   // deadlock_detected

    if (!retryable || attempt === MAX_ATTEMPTS) {
      throw error;
    }

    const baseDelayMs = 50 * 2 ** (attempt - 1);
    const jitterMs = Math.random() * 50;
    await sleep(baseDelayMs + jitterMs);
  }
}

実際のエラーオブジェクトからSQLSTATEを取得する方法は、使用するORMやDBドライバによって異なります。

外部副作用をトランザクション内に置かない

トランザクションがリトライされると、コードも複数回実行されます。

トランザクション開始
注文を作成
メール送信
COMMIT時に40001
リトライ
メールを再送信

この設計では、メールや決済などが重複する可能性があります。

基本方針は次のとおりです。

  • 外部処理はコミット成功後に実行する
  • 確実な連携が必要ならOutboxパターンを使う
  • 外部APIにはidempotency keyを使う

リトライ可能な入力を保持する

リトライ時は、ユーザー入力などの元データは再利用して構いません。
ただし、最初のトランザクション内で読んだDBの値や判定結果は使い回さず、再度DBから取得します。


分離レベルを上げればすべて解決するわけではない

分離レベルと他の仕組みは、守る対象が異なります。

守りたいこと 主な手段
複数SQLをまとめて成功・失敗させる トランザクション
値を0以上に保つ CHECK 制約
重複を防ぐ UNIQUE 制約
存在しない関連を防ぐ FOREIGN KEY
1行を条件付きで安全に更新する 原子的な UPDATE
読んだ行への同時更新を待たせる SELECT ... FOR UPDATE
複数行にまたがる並行処理の矛盾を防ぐ Serializableまたは適切なロック
バッチの二重起動を防ぐ advisory lock

まず制約や原子的更新で単純に守れないかを考え、必要に応じてロックや分離レベルを選びます。


実務での選び方

Read Committedを基本にするケース

  • 一般的なCRUD
  • 1行単位の更新が中心
  • 原子的UPDATEやDB制約で整合性を守れる
  • 必要な箇所だけ行ロックを使える

Repeatable Readを検討するケース

  • 複数クエリで同じ時点のデータを参照したい
  • 長めの集計中に参照結果が変化してほしくない
  • Write Skewは別の手段で防げる

Serializableを検討するケース

  • 読み取り結果をもとに複数行を更新する
  • 複数トランザクション間の依存関係が複雑
  • 明示的なロック設計より、直列実行相当の保証が適している
  • アプリにトランザクション単位のリトライ機構を実装できる

分離レベルは、アプリ全体で一律に上げるのではなく、整合性要件が強い処理単位で選ぶのが基本です。


よくある誤解

トランザクションを使えばレースコンディションは起きない

Read Committedでは、同じトランザクション内でも別のSQL文が異なる状態を見ることがあります。
トランザクションだけでなく、更新方法、制約、ロック、分離レベルまで考える必要があります。

Repeatable Readなら完全に安全

同じスナップショットは見られますが、Write SkewなどのSerialization Anomalyは発生し得ます。

Serializableならエラーにならない

逆です。安全な結果を保証するため、競合したトランザクションを意図的に 40001 で失敗させます。
Serializableはリトライ処理とセットです。

分離レベルは高いほどよい

強い保証が不要な処理までSerializableにすると、競合時のリトライや監視コストが増えます。
必要な整合性を明確にして選択します。


まとめ

  • トランザクションは複数の処理をすべて成功または失敗させる
  • ACIDは原子性、一貫性、分離性、永続性を表す
  • PostgreSQLのデフォルトはRead Committed
  • Read CommittedではSQL文ごとに新しいスナップショットを見る
  • Repeatable Readでは同じスナップショットを維持するが、Write Skewは防げない
  • Serializableは直列実行と同等の結果を保証する
  • SerializableとRepeatable Readでは、競合時にトランザクション全体のリトライが必要
  • 40001 はトランザクション全体を最初から再実行する
  • 外部API、決済、メール送信などはリトライによる重複に注意する
  • DB制約、原子的更新、行ロック、advisory lockと適切に使い分ける

学習リソース

Index

  • トランザクションと分離レベル
  • はじめに
  • トランザクションとは
  • ACID
  • Atomicity(原子性)
  • Consistency(一貫性)
  • Isolation(分離性)
  • Durability(永続性)
  • なぜ分離レベルが必要なのか
  • 同時実行で起こる代表的な現象
  • Dirty Read
  • Non-repeatable Read
  • Phantom Read
  • Serialization Anomaly
  • PostgreSQLにおける分離レベルの違い
  • Read Committed
  • Read Committedでの基本方針
  • Repeatable Read
  • Serializable
  • 向いている処理
  • 注意点
  • Serializableのリトライ設計
  • 必ずトランザクション全体をやり直す
  • SQLSTATEで判定する
  • 回数制限とバックオフを入れる
  • 外部副作用をトランザクション内に置かない
  • リトライ可能な入力を保持する
  • 分離レベルを上げればすべて解決するわけではない
  • 実務での選び方
  • Read Committedを基本にするケース
  • Repeatable Readを検討するケース
  • Serializableを検討するケース
  • よくある誤解
  • トランザクションを使えばレースコンディションは起きない
  • Repeatable Readなら完全に安全
  • Serializableならエラーにならない
  • 分離レベルは高いほどよい
  • まとめ
  • 学習リソース