同じトランザクション内の SELECT が違う値を返す理由:PostgreSQL の READ COMMITTED とスナップショット

PostgreSQL のデフォルトの分離レベル READ COMMITTED で、1つのトランザクション内の SELECT が2回で違う結果を返す理由を公式ドキュメントで整理しました。文ごとのスナップショット、UPDATE の再評価、REPEATABLE READ と再試行も扱います。

開発データベースSQL

同じトランザクションの中で同じ SELECT を2回実行して結果が違うとバグに見えますが、PostgreSQL のデフォルトの分離レベルである READ COMMITTED では、ドキュメントに書かれたとおりの動作です。2026年10月7日時点の最新の正式版である PostgreSQL 18 のドキュメントをもとにしており、同じ説明は 14 のドキュメントとベータ段階の 19 のドキュメントにもあります。

2つのセッションで見る例

口座1の残高が500のとき、2つのセッションが次の順で実行されたとします。

-- セッション A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 500

-- セッション B(BEGIN なしで実行)
UPDATE accounts SET balance = 400 WHERE id = 1;

-- セッション A
SELECT balance FROM accounts WHERE id = 1;  -- 400
COMMIT;

BEGIN なしで実行した文はオートコミットモードでそれ自体が1つのトランザクションになり、成功すれば文の終わりにコミットされます。セッション B の変更は A の2つの SELECT の間にコミットされたので、A の2回目の SELECT は400を返します。

スナップショットは文ごとに取り直される

PostgreSQL は多版型同時実行制御(MVCC)によって、各 SQL 文にある時点のスナップショットを見せます。READ COMMITTED では、FOR UPDATE/FOR SHARE のない SELECT は、そのクエリが始まる前にコミットされたデータだけを見ます。コミットされていないデータも、実行中に他のトランザクションがコミットした変更も見ません。自分のトランザクションで先に行った変更は、コミット前でも見えます。

基準がトランザクションではなくクエリの開始時点だという点が要です。そのためドキュメントの表でも、READ COMMITTED は反復不能読み取り(nonrepeatable read)と、条件に合う行の集合が変わるファントム読み取り(phantom read)が起こりうるレベルです。上の例は反復不能読み取りです。

標準と PostgreSQL

PostgreSQL のドキュメントによると、SQL 標準は SERIALIZABLE 以外の3つのレベルを「起きてはならない現象」で定義し、最低限の保護だけを定めているので、より強く実装してもかまいません。標準のデフォルトは SERIALIZABLE、PostgreSQL のデフォルトは READ COMMITTED です。READ UNCOMMITTED を指定しても READ COMMITTED と同じように動くため、ダーティリードは起きません。同じ名前のレベルでも他のデータベースは別の実装をとりうるので、この記事の説明は PostgreSQL に限ります。

UPDATE は最新の行で確かめ直す

UPDATE、DELETE、SELECT FOR UPDATE/FOR SHARE も、コマンド開始時点でコミット済みの行だけを対象として探します。見つけた行を他のトランザクションが更新中なら終わるまで待ちます。相手がコミットした場合、削除された行は飛ばし、更新された行には WHERE 条件を評価し直して、まだ合うときだけ処理します。

この UPDATE が同じ行を更新中の別トランザクションを待ち、そのトランザクションがコミットした場合、SET balance = balance - 100 は新しくコミットされた行バージョンの値をもとに計算されます。一方、アプリが先に読んだ500から100を引いて SET balance = 400 のように定数を書き込むと、WHERE 条件がまだ合う限り、新しくコミットされた値をその定数で上書きしてしまうことがあります。ドキュメントは、行を同時更新から守るには SELECT FOR UPDATE、SELECT FOR SHARE または適切なモードの LOCK TABLE を使うよう案内しています。ただし SELECT FOR UPDATE が他のトランザクションを止めておけるのは、ロックを持つトランザクションがコミットかロールバックするまでです。

評価し直しが意外な結果を生むこともあります。ドキュメントの例では、website テーブルの hits が9と10の2行に対して、あるトランザクションが UPDATE website SET hits = hits + 1 を実行し、まだコミットしていない間に別のセッションが DELETE FROM website WHERE hits = 10 を実行すると、何も削除されません。9だった行は最初の検索で条件に合わず、10だった行はロックを取れた時点で11になっていたからです。

トランザクションの間ずっと同じデータが必要なら

REPEATABLE READ は、トランザクションで最初に実行する、トランザクション制御文ではない文の開始時点でスナップショットを取り、最後まで使います。そのため SELECT を繰り返しても同じデータが見えます。分離レベルは最初のクエリやデータ変更文より前に決める必要があります。

BEGIN ISOLATION LEVEL REPEATABLE READ;

その代わり、再試行の用意が要ります。更新またはロックしようとする行を、トランザクション開始後に他のトランザクションが更新か削除してコミットしていた場合、could not serialize access due to concurrent update エラーでロールバックされます。シリアライゼーションエラーはメッセージが違っても SQLSTATE は常に 40001 なので、コードで判定するほうが確実です。ドキュメントは、どの SQL をどの値で送るかを決めるロジックも含めてトランザクション全体をやり直すよう求めており、そのため自動再試行の機能はありません。読み取りだけのトランザクションではこの衝突は起きません。

並行実行とは関係なく、1つのクエリの結果が直感と違ってしまう例は NOT IN と NULL で扱いました。

参考にしたドキュメント

リンクと内容は2026年10月7日に確認しました。

ブログへ