エラーの内容
トランザクションが SERIALIZABLE 分離レベルで実行中に、突然以下のエラーが発生します:
ERROR: could not serialize access due to concurrent update
SQLSTATE: 40001
PostgreSQL はトランザクションを即座にロールバックします。部分的な状態も、警告も残りません。すべてが消えます。
このエラーは、2つ以上の同時実行トランザクションが同じ行を読み書きするパターンで発生しますが、そのパターンが逐次実行では再現できない場合に起こります。PostgreSQL は競合を検出し、意図的にそのうちの1つをアボートします。これはバグではありません。あなたが要求した保証をデータベースが強制しているのです。
なぜこのエラーが発生するのか
内部では、PostgreSQL の SERIALIZABLE レベルは Serializable Snapshot Isolation(SSI)を使用しています。SSI は実行中のトランザクション間の読み取り/書き込み依存関係を継続的に追跡します。依存関係のサイクル、つまりどの逐次順序でも再現できないパターンを検出すると、それを解消するために1つのトランザクションを犠牲にします。
危険なサイクルの典型的な例:
- トランザクション A が行 1〜5 を読み取り、行 6 を書き込む
- トランザクション B が行 6 を読み取り、行 1〜5 を書き込む
- 各トランザクションの書き込みが相手の読み取りに依存している — PostgreSQL はいずれかをアボートする
REPEATABLE READ の場合、トリガーはより単純です:ある行を読み取り、別のトランザクションがその行に変更をコミットし、その後あなたがその行を書き込もうとした場合、PostgreSQL は両方の書き込みを許可しません。
手順ごとの修正方法
ステップ 1:リトライロジックを追加する(必須)
SERIALIZABLE トランザクションを使用するすべてのアプリケーションは、40001 に対してリトライを実装する必要があります。これは絶対条件です。いつか対処すべきエッジケースではなく、この分離レベルを使用する上での基本的な契約です。
Python(psycopg2 使用):
import psycopg2
from psycopg2 import OperationalError
import time
import random
def run_serializable_transaction(conn, operation, max_retries=5):
for attempt in range(max_retries):
try:
with conn.cursor() as cur:
cur.execute("BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE")
operation(cur)
conn.commit()
return # 成功
except OperationalError as e:
conn.rollback()
if e.pgcode == '40001': # serialization_failure
if attempt setTimeout(resolve, delay));
continue;
}
throw err;
} finally {
client.release();
}
}
}
ステップ 2:競合の発生範囲を減らす
リトライによってアプリケーションの正確性は保たれますが、高負荷時にはコストがかかります。そもそも競合が発生する頻度を減らしましょう。
トランザクションを短く保つ。 50人の同時ユーザーがいる状況で2秒間オープンなトランザクションは、衝突が起きるのを待っているようなものです。計算や検証をトランザクションを開く前にアプリケーションコード側で行うようにしましょう:
-- 悪い例:トランザクションウィンドウ内で重い処理を行う
BEGIN;
SELECT pg_sleep(2); -- 処理の遅延をシミュレート
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 良い例:先に値を計算してから素早く書き込む
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 1100 WHERE id = 2;
COMMIT;
一貫した順序で行にアクセスする。 複数のトランザクションが同じ行を操作する場合、常に同じ順序で更新しましょう。主キーが小さい順に更新するのが効果的です。これだけで、書き込みが多いワークロードにおけるシリアライズ失敗を 30〜50% 削減できます:
-- 常に低い ID から先に更新する
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- IDが小さい方を先に
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
SELECT FOR UPDATE で早めにロックする。 行を書き込む予定があるなら、読み取り時にロックしましょう。これにより、アボートになりうる状況をウェイトに変えられます。他のトランザクションはクラッシュするのではなく、キューに並んで待機します:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 行 1 を書き込む他のトランザクションはここで待機する
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
ステップ 3:SERIALIZABLE が本当に必要か見直す
40001 が頻繁に発生する場合は、本当にこの分離レベルが必要かどうかを考える価値があります。
-- 現在のトランザクション分離レベルを確認
SHOW transaction_isolation;
-- データベースのデフォルトを確認
SHOW default_transaction_isolation;
-- セッションのデフォルトを下げる
SET default_transaction_isolation = 'read committed';
多くのアプリケーションは、PostgreSQL のデフォルトである READ COMMITTED と、行レベルの保護が必要な箇所での SELECT FOR UPDATE の組み合わせで十分に動作します。SERIALIZABLE は、複数行にまたがる読み取り・変更・書き込みのサイクルで、かつ結合結果がアトミックに見える必要がある場合に限定して使用しましょう。単純な残高移動やカウンターの更新であれば、READ COMMITTED + 明示的ロックの方がコストが低く、競合も発生しません。
ステップ 4:本番環境でシリアライズ失敗を監視する
チューニングを始める前に、ベースラインの数値を把握しましょう:
-- データベースごとのロールバック数と競合数
SELECT datname,
xact_commit,
xact_rollback,
conflicts
FROM pg_stat_database
WHERE datname = current_database();
-- デッドタプル数が多いテーブル(更新が頻繁なサインです)
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
xact_commit に対して xact_rollback が多い場合、シリアライズの負荷が高いことを直接示しています。アプリケーション側でリトライ試行もログに記録しましょう — リトライログのスパイクは、監視システムが検知するよりも早くホットな行を発見する手がかりになります。
修正が機能しているか確認する
開発環境でエラーを意図的に再現してみましょう。2つのターミナルを開きます:
-- ターミナル 1:開始するがまだコミットしない
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM accounts WHERE id = 1;
-- このまま開いておき、ターミナル 2 に切り替える
-- ターミナル 2:競合する書き込みをコミットする
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM accounts WHERE id = 1;
UPDATE accounts SET balance = 999 WHERE id = 1;
COMMIT; -- 成功する
-- ターミナル 1:同じ行に書き込みを試みる
UPDATE accounts SET balance = 888 WHERE id = 1;
COMMIT; -- 40001 で失敗する
リトライロジックが正しく機能していれば、アプリケーションは次の試行でサイレントに成功するはずです。ログを確認しましょう:リトライ試行がログに記録され、その後コミット成功が記録されること、そして 40001 がユーザーに返されていないことを確認してください。
クイックリファレンス
- SQLSTATE 40001 = serialization_failure — 常にリトライ可能
- SQLSTATE 40P01 = deadlock_detected — こちらもリトライ可能、原因は異なる
- リトライ前に必ずロールバックする — 失敗したままのトランザクションでリトライしてはいけない
- 指数バックオフ + ジッターを使用する(例:100ms、200ms、400ms + ランダムオフセット)ことで、高負荷時のリトライストームを防ぐ
- リトライのたびにログを記録し、競合のホットスポットがインシデントになる前に発見できるようにする

