投稿日:2026/6/24
更新日:2026/6/24

Webアプリでは、複数のSQLをまとめて成功・失敗させたい場面や、複数のリクエストが同じデータを同時に操作する場面があります。
このとき重要になるのが、次の2つです。
トランザクションを使うだけで、すべての同時実行問題が解決するわけではありません。
どの分離レベルで何が起こり得るかを理解し、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 です。
トランザクション内の処理は、すべて成功するか、すべて失敗します。
口座Aから減額: 成功
口座Bへ加算: 失敗
→ 全体をROLLBACK
一部だけがDBに反映されることを防ぎます。
トランザクションの前後で、データが定められたルールを満たしている状態を維持します。
たとえば、次のようなルールです。
これらは、トランザクションだけで自動的に保証されるわけではありません。
アプリの処理に加えて、CHECK、FOREIGN KEY、UNIQUE などのDB制約で表現します。
複数のトランザクションが同時に動いても、互いの途中状態によって不正な結果が生じないようにします。
ただし、どこまで強く分離するかは分離レベルによって異なります。
一度 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は発生しません。
別のトランザクションが、まだコミットしていない変更を読んでしまう現象です。
トランザクションA: 残高を10,000円から0円へ変更(未コミット)
トランザクションB: 残高0円を読む
トランザクションA: ROLLBACK
Bは、最終的には存在しなかった値を利用したことになります。
PostgreSQLでは、どの分離レベルでもDirty Readは発生しません。
同じトランザクション内で同じ行を2回読むと、別のトランザクションの更新によって値が変わっている現象です。
トランザクションA: 商品価格を読む → 1,000円
トランザクションB: 商品価格を1,200円に更新してCOMMIT
トランザクションA: 商品価格を再度読む → 1,200円
Read Committedでは発生し得ます。
同じ検索条件で再度検索すると、条件に該当する行の集合が増減している現象です。
トランザクションA: status = 'pending' を検索 → 10件
トランザクションB: pendingの行を1件追加してCOMMIT
トランザクションA: 同じ条件を検索 → 11件
SQL標準ではRepeatable Readでも発生し得るとされています。
ただし、PostgreSQLのRepeatable Readは標準より強く、Phantom Readも防ぎます。
各トランザクションから見たデータは一貫していても、全体としては「1件ずつ順番に実行した」と説明できない結果になる現象です。
代表例は Write Skew です。
ルール: 当直医は最低1人必要
現在: 医師Aと医師Bが当直中
トランザクションA:
当直医が2人いることを確認
医師Aを当直から外す
トランザクションB:
当直医が2人いることを確認
医師Bを当直から外す
両方が同じスナップショットを見てコミットすると、当直医が0人になります。
これはRepeatable Readでも起こり得ます。
Serializableでは依存関係を検出し、どちらかのトランザクションを失敗させます。
| 分離レベル | Dirty Read | Non-repeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| Read Committed | 防ぐ | 起こり得る | 起こり得る | 起こり得る |
| Repeatable Read | 防ぐ | 防ぐ | 防ぐ | 起こり得る |
| Serializable | 防ぐ | 防ぐ | 防ぐ | 防ぐ |
この表はPostgreSQLでの動作です。
同じ分離レベル名でも、DB製品によって実装や挙動が異なる場合があります。
PostgreSQLのデフォルトです。
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 処理
COMMIT;
各SQL文は、そのSQL文が開始した時点のコミット済みデータを参照します。
そのため、同じトランザクション内でも、SQL文が変われば新しくコミットされたデータが見えます。
トランザクションA: SELECT → 100
トランザクションB: UPDATEして200に変更、COMMIT
トランザクションA: SELECT → 200
多くの一般的なCRUDでは十分ですが、「最初に読んだ状態がトランザクション終了まで変わらない」ことを前提にしてはいけません。
SELECT ... FOR UPDATE を検討するUPDATE products
SET stock = stock - 1
WHERE product_id = $1
AND stock > 0;
単純な在庫更新なら、分離レベルを上げるより、このような原子的UPDATEの方が意図が明確です。
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までは防ぎません。
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 処理
COMMIT;
最も強い分離レベルです。
並行して実行されたトランザクションについて、コミットに成功した結果が、何らかの順番で1件ずつ実行した結果と同等になるようにします。
PostgreSQLは、すべての処理を実際に直列化するわけではありません。
並行実行しながら読み書きの依存関係を監視し、安全な直列順序を作れない場合に一部を失敗させます。
代表的なエラーは次のとおりです。
SQLSTATE: 40001
could not serialize access due to read/write dependencies among transactions
Serializableを使う場合、40001 は異常事態ではなく、競合を安全に解消するための通常の結果として扱います。
失敗したSQLだけを再実行してはいけません。
読み取り結果を含め、トランザクション開始時点の前提が無効になっているためです。
悪い例:
BEGIN
SELECTで状態確認
UPDATEで40001
UPDATEだけ再実行
良い例:
ROLLBACK
BEGINからSELECT、判定、UPDATEをすべて再実行
エラーメッセージの文字列ではなく、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
リトライ
メールを再送信
この設計では、メールや決済などが重複する可能性があります。
基本方針は次のとおりです。
リトライ時は、ユーザー入力などの元データは再利用して構いません。
ただし、最初のトランザクション内で読んだDBの値や判定結果は使い回さず、再度DBから取得します。
分離レベルと他の仕組みは、守る対象が異なります。
| 守りたいこと | 主な手段 |
|---|---|
| 複数SQLをまとめて成功・失敗させる | トランザクション |
| 値を0以上に保つ | CHECK 制約 |
| 重複を防ぐ | UNIQUE 制約 |
| 存在しない関連を防ぐ | FOREIGN KEY |
| 1行を条件付きで安全に更新する | 原子的な UPDATE |
| 読んだ行への同時更新を待たせる | SELECT ... FOR UPDATE |
| 複数行にまたがる並行処理の矛盾を防ぐ | Serializableまたは適切なロック |
| バッチの二重起動を防ぐ | advisory lock |
まず制約や原子的更新で単純に守れないかを考え、必要に応じてロックや分離レベルを選びます。
分離レベルは、アプリ全体で一律に上げるのではなく、整合性要件が強い処理単位で選ぶのが基本です。
Read Committedでは、同じトランザクション内でも別のSQL文が異なる状態を見ることがあります。
トランザクションだけでなく、更新方法、制約、ロック、分離レベルまで考える必要があります。
同じスナップショットは見られますが、Write SkewなどのSerialization Anomalyは発生し得ます。
逆です。安全な結果を保証するため、競合したトランザクションを意図的に 40001 で失敗させます。
Serializableはリトライ処理とセットです。
強い保証が不要な処理までSerializableにすると、競合時のリトライや監視コストが増えます。
必要な整合性を明確にして選択します。
40001 はトランザクション全体を最初から再実行する