home

関係データベースにおける同時実行制御

作成: 2026-02-01 23:57

データベース工学という講義の課題2

このページでは、一般的な説明を行う一般モードと、具体的な説明を行う具体モードを切り替えて読むことができます。特に、本文背景色が変わっているところが、切り替えると別の文章が読める部分です。左上の一般モード/具体モードのボタンをクリックして切り替えてください。片方だけでは読めないこともないけど接続が変な部分もありますから、どちらも読むことをおすすめします。

概要

wikipediaから引用:

データベースへの読み書き(トランザクション処理)には処理時間が発生する。複数のトランザクション処理依頼がデータベース管理システムへ行われたとき、これらを処理する1つの方法は逐次的(Serial)に1つずつトランザクションを処理する方法である。トランザクション処理に処理時間がかかることから、後に行われた処理は先に行われた処理が完了するまでの間「待ち」の状態になってしまう。

待ちを最小化するためにはデータベース管理システムが複数のトランザクションを並行(Concurrent)に処理すればよい。しかしトランザクションは必ずしも安全に並列化できるとは限らない。そのためデータベース管理システムは各並行トランザクションが互いに影響を受けず分離された安全な範囲内でトランザクションを並行化する。あるいは、異常な振る舞い(anomalies)を起こしうる分離レベルが低い並行化を許容し、代わりに並行性を高めてトランザクション処理性能を上昇させる。この安全性・一貫性と性能のトレードオフを生む、並行性トランザクションの分離具合がトランザクション分離レベルである。

トランザクション分離レベルはシステム要件に合わせデータベースの設計者によって設定される。例えば一切の異常な振る舞いが許されない金融システムには高いトランザクション分離レベルが設定される(代わりに性能は低い)。またanomaliesの可能性がある分離レベルを選択してもanomaliesを起こしえないトランザクションのみを処理するように設計すれば一貫性を保ちながら高い性能を得ることが可能になる。

私たちが普段使っているWebサービスやアプリの裏側では、データベースが絶えずデータの読み書き(トランザクション処理)を行っています。この処理には必ず「時間」がかかります。

もし、数千人が同時にアクセスしてきたとき、システムはどう対応すべきでしょうか?

🛒 オンラインショッピングで考える「直列」と「並行」

人気のオンラインストアで「在庫残り1個」の限定商品を販売する状況を想像してみてください。

  1. 順番待ちの列を作る(逐次処理:Serial)
  2. 最も安全なのは、購入リクエストを完全に1列に並べ、1人ずつ順番に処理していく方法です。 Aさんの決済処理が終わるまで、BさんやCさんはデータベースに触れません。そのため「在庫1個なのに2人とも買えてしまう」といった事故は絶対に起きません。しかし、後続のユーザーはずっとロード画面のまま待たされることになり、全体の処理性能は最悪になります。

  3. 複数のレジで同時に処理する(並行処理:Concurrent)
  4. 待ち時間を最小化するためには、複数人のリクエストを同時に処理すればよいのです。 しかし、データベース上のデータには物理的な実体がありません。もしAさんとBさんが完全に同じタイミングで「在庫1」というデータを読み取り、それぞれが「在庫0」に更新してしまったらどうなるでしょうか。1個しかない商品が2人に売れてしまうという、致命的な破綻が発生します。データベースの世界では、こうした同時実行によるデータの矛盾をアノマリー(Anomalies、異常な振る舞い)と呼びます。

⚖️ 安全性と性能のトレードオフ:トランザクション分離レベル

この「アノマリー」を防ぎつつ、できるだけ「並行処理」のスピードも活かしたい。これ解決するためにデータベースが用意している設定値が「トランザクション分離レベル(隔離レベル)」です。

  • 分離レベルを高くすると、他の処理を厳しくブロックして完全に矛盾を防ぎますが、待ち時間が増えて性能は落ちます(例:1円どころか1銭のミスも許されない銀行のシステム)。
  • 分離レベルを低くすると、多少のアノマリーが起きるリスクを許容する代わりに、高速で処理を捌きます(例:SNSのいいね数の集計など)。

システム要件に合わせてこのレベルを設定するのが、データベース設計者の重要な役割です。 また、あえて性能の高い(低い)分離レベルを選択しつつも、アプリケーションのコードやSQLの書き方(明示的なロックなど)を工夫することで、アノマリーを完全に防ぎながら高い性能を両立させるという高度な設計も可能です。

ここでは、この「隔離レベル」の違いによって、実際にどのようなアノマリーが発生するのか、またそれをどう防ぐのかを、PostgreSQL 17 の Dockerコンテナを用いて実験・検証していきます。

実験環境の準備

事前にDocker Desktopをインストールして起動しておいてください。

まず、PostgreSQL 17(.6) の Dockerイメージをダウンロードします。

docker pull postgres:17.6

compose.ymlの作成

適当な作業ディレクトリを開いて、compose.yml(または、docker-compose.yml)という名前のファイルを作成し、以下の内容を貼り付けて保存してください。

services:
  db:
    image: postgres:17
    container_name: postgres-concurrency-lab
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: shop_db
    ports:
      - "5432:5432"

ファイルを作成して保存したら、ターミナルでそのディレクトリに移動し、以下のコマンドを実行してください。

docker compose up -d

これで、PostgreSQL 17 のコンテナが起動します。

ターミナルを2つ開いて接続する

並行処理(複数のユーザーが同時にアクセスする状態)を疑似体験するために、ターミナルのウィンドウを2つ開いてください。 これらを画面の左右に並べて配置することをおすすめします。

ここでは、左側のウィンドウをAさん、右側のウィンドウをBさんとして扱います。

両方のターミナルで、それぞれ以下のコマンドを実行し、PostgreSQLのコマンドラインツール(psql)に接続してください。

docker compose exec db psql -U postgres -d shop_db

プロンプトが

shop_db=#
に変われば接続成功です。これで、AさんとBさんが同じデータベースに同時にログインしている状態が作れました。

psql での基本操作と終了方法

psql 内での基本的なルールは以下の通りです。

  • SQLの実行
  • SQL文を入力し、Enterキーを押すと実行されます。結果が表示されます。文の最後には、セミコロン(;)をつける必要があります。セミコロンがないと、改行待ち扱いになりコマンドが実行されません。

  • テーブル一覧の確認
  • \dt と入力してEnterを押すと、作成されているテーブルの一覧を確認できます(これにはセミコロンは不要です)。

  • 終了
  • \q と入力してEnterを押すと、psqlから抜けることができます。あるいは、Ctrl + Dで終了することも可能です。

環境の削除

不要になったら、以下のコマンドを実行してコンテナを削除してください。

docker compose down -v

-v をつけることでデータベースの中身も綺麗に消去されます。

実験用テーブルの作成

実験で使用するテーブルを作成します。どちらか片方のターミナルで以下のSQL文を実行してください。

-- 既存のテーブルがあれば削除
DROP TABLE IF EXISTS inventory;

-- テーブルの作成
CREATE TABLE inventory (
    id SERIAL PRIMARY KEY,
    item_name VARCHAR(50) NOT NULL,
    category VARCHAR(50) NOT NULL,
    stock INT NOT NULL
);

-- 初期データの投入
INSERT INTO inventory (item_name, category, stock) VALUES
('限定スニーカー', 'shoes', 10),
('革靴', 'shoes', 5),
('限定Tシャツ', 'clothes', 10);

実験

これからの実験では、2人のユーザー(AさんとBさん)が同時にデータベースにアクセスしている状況を想定して、トランザクション分離レベルの違いによってどのようなアノマリーが発生するのか、またそれをどう防ぐのかを確認していきます。Aさんは上に、Bさんは下に書いてあるコマンドを順番(その時点では同時に)に実行した、ということにします(空である場合もありますというか、ほとんど片方は空です)。

実験1: Dirty Read(ダーティリード)

トランザクションT1がデータ項目を変更し、その変更がコミットまたはロールバックされる前に、別のトランザクションT2がそのデータを読み取ってしまう現象。もしT1がロールバックした場合、T2は「存在しなかったはずのデータ」を読み取って処理を進めたことになり、不整合を引き起こす。

Aさんが「限定スニーカー」をカートに入れ、決済の確認画面で悩んでいます(まだ購入確定=コミットしていない)。この時、Bさんが在庫を確認すると、Aさんが買うかどうかわからないのに「在庫が減っている」ように見えてしまう状態です。もしAさんが「やっぱりやめた(ロールバック)」とした場合、Bさんは幻の在庫減少を見ていたことになります。

実は、PostgreSQLではこのDirty Readはシステム構造上、発生しません。それを確認してみましょう。

順番にしたがって、Aさんのターミナル、Bさんのターミナルでそれぞれコマンドを実行してみてください。

ステップ 1

Aさんがトランザクションを開始

BEGIN;

ステップ 2

Aさんが「限定スニーカー」の在庫を1減らす操作を実行(まだコミットしていない)

UPDATE inventory SET stock = stock - 1 WHERE item_name = '限定スニーカー';

ステップ 3

Bさんが「限定スニーカー」の在庫を確認

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

ステップ 4

Aさんがトランザクションをロールバック。Bさんは在庫が減っていないことを確認(Dirty Readは発生しない)

ROLLBACK;
SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

PostgreSQLは、他の人が「編集中」のデータは決して見せず、常に「確定済み」の安全なデータだけを見せてくれることがわかります。

実験2: Non-repeatable Read(ノンリピータブルリード)

トランザクションT1が特定の行を読み取る。その後、別のトランザクションT2がその行を更新(または削除)してコミットする。T1が再び同じ行を読み取ると、1回目と異なる値を取得してしまう現象。一つのトランザクション内で「読み取り結果の再現性(Repeatable)」が失われる。

Aさんがスニーカーの在庫を確認し「お、まだ10個あるな」と安心して、他のページを見て回っています。その隙に、Bさんがスニーカーを購入し決済を完了(コミット)しました。Aさんがもう一度同じスニーカーの在庫を見ると、自分が何もしていないのに突然「9個」に変わってしまっている現象です。「さっき見たデータと違う!」という状態ですね。

PostgreSQLのデフォルト設定(READ COMMITTED)では、この現象は発生します。

順番に行っている場合は、stockの値を確認して、必要であれば 実験環境の準備 節まで戻ってテーブルをリセットしてください。

ステップ 1

Aさんがトランザクションを開始

BEGIN;

ステップ 2

1回目の確認。結果は「10」

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

ステップ 3

Bさんがトランザクションを開始

BEGIN;

ステップ 4

Bさんが「限定スニーカー」の在庫を1減らす

UPDATE inventory SET stock = stock - 1 WHERE item_name = '限定スニーカー';

ステップ 5

Bさんがトランザクションを終了

COMMIT;

ステップ 6

Aさんが同じコマンドで2回目の確認。結果が「9」に変わっている!

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

ステップ 7

Aさんがトランザクションを終了

COMMIT;

Aさんから見ると、同じコマンドを2回実行したのに、結果が異なる(Non-repeatable Read)という現象が発生しています。

Non-repeatable Readは、Fuzzy Readとも呼ばれます。同じ行を読んだのに、トランザクション中に値が曖昧(ファジー)に変わってしまう現象を指します。

実験3: Phantom Read(ファントムリード)

トランザクションT1がある検索条件(WHERE句など)を満たす行の集合を読み取る。その後、別のトランザクションT2がその条件を満たす新しい行を挿入(INSERT)してコミットする。T1が再び同じ条件で検索を行うと、1回目には存在しなかった「幻(ファントム)の行」が結果に現れる現象。Non-repeatable Readが「既存行の更新」であるのに対し、Phantomは「新規行の出現」を指す。

Aさんが「靴(shoes)カテゴリの商品リスト」を検索して、現在2種類あることを確認しました。そのリストをもとに社内資料を作っている最中に、Bさんが裏で新しく「ブーツ」を靴カテゴリに追加(INSERT)しました。Aさんが資料の最終確認でもう一度検索すると、さっきまで無かったはずの商品がリストに増えている(幻影が現れる)現象です。

これもデフォルト設定で発生します。

順番に行っている場合は、stockの値を確認して、必要であれば 実験環境の準備 節まで戻ってテーブルをリセットしてください。

ステップ 1

Aさんがトランザクションを開始

BEGIN;

ステップ 2

1回目の確認。結果は「限定スニーカー」と「革靴」の2行

SELECT item_name FROM inventory WHERE category = 'shoes';

ステップ 3

Bさんがトランザクションを開始

BEGIN;

ステップ 4

Bさんが「ブーツ」を靴カテゴリに追加

INSERT INTO inventory (item_name, category, stock) VALUES ('ブーツ', 'shoes', 5);

ステップ 5

Bさんがトランザクションを終了

COMMIT;

ステップ 6

Aさんが同じコマンドで2回目の確認。結果が「限定スニーカー」「革靴」「ブーツ」の3行に増えている!

SELECT item_name FROM inventory WHERE category = 'shoes';

ステップ 7

Aさんがトランザクションを終了

COMMIT;

Aさんから見ると、同じ検索条件で2回実行したのに、結果の行数が増えている(Phantom Read)という現象が発生しています。

実験4: Dirty Write(ダーティライト)

トランザクションT1があるデータ項目を更新し、それがまだコミットまたはロールバックされていない状態で、別のトランザクションT2が同じデータ項目を上書きしてしまう現象。

Aさんが「限定スニーカー」の在庫を更新処理している最中(まだ確定していない)に、Bさんも同時に同じ在庫を強制的に上書きしてしまう状態です。もしAさんがエラーで処理を取り消そうとしても、Bさんが書き込んだデータまで巻き戻ってしまい、データがぐちゃぐちゃになります。

PostgreSQLでは、Dirty Writeも発生しません。他の人が編集中(未コミット)の行を更新しようとすると、どうなるか見てみましょう。

順番に行っている場合は、stockの値を確認して、必要であれば 実験環境の準備 節まで戻ってテーブルをリセットしてください。

ステップ 1

Aさんがトランザクションを開始

BEGIN;

ステップ 2

Aさんが在庫を変更(未コミット)

UPDATE inventory SET stock = 5 WHERE item_name = '限定スニーカー';

ステップ 3

Bさんがトランザクションを開始

BEGIN;

ステップ 4

Bさんも同じ行を更新しようとする。Bの画面がフリーズ(待機状態)になる!

UPDATE inventory SET stock = 10 WHERE item_name = '限定スニーカー';

ステップ 5

Aさんがトランザクションを確定。その瞬間、Bのフリーズが解けて更新が実行される。

COMMIT;

ステップ 6

Bさんがトランザクションを終了

COMMIT;

PostgreSQLは、同時に同じ行を書き換えようとした場合、自動的に「行ロック(Row-level lock)」をかけて、後から来た人を待たせる仕組みを持っています。

実験5: Lost Update(更新の喪失)

トランザクションT1とT2が同じデータを読み取る(Read)。その後、T1がそのデータをもとに計算して書き込み(Write)、T2も同様に書き込む。結果として、T2の書き込みがT1の書き込みを上書きしてしまい、T1の更新内容が完全に失われる(Lost)現象。

AさんとBさんが同時にスニーカーの在庫(残り10個)を確認しました。「よし、買えるぞ」と、Aさんが「10 - 1 = 9」に更新しました。同時にBさんも、自分が最初に見た「10個」という数字をもとに「10 - 1 = 9」に更新しました。 2個売れたのだから本当は「8個」にならなければいけないのに、結果は「9個」になり、1個分の売上が消滅(ロスト)してしまいます。

実験5: Lost Update(更新の喪失)

トランザクションT1とT2が同じデータを読み取る(Read)。その後、T1がそのデータをもとに計算して書き込み(Write)、T2も同様に書き込む。結果として、T2の書き込みがT1の書き込みを上書きしてしまい、T1の更新内容が完全に失われる(Lost)現象。

AさんとBさんが同時にスニーカーの在庫(残り10個)を確認しました。「よし、買えるぞ」と、Aさんが「10 - 1 = 9」に更新しました。同時にBさんも、自分が最初に見た「10個」という数字をもとに「10 - 1 = 9」に更新しました。 2個売れたのだから本当は「8個」にならなければいけないのに、結果は「9個」になり、1個分の売上が消滅(ロスト)してしまいます。

ここで言う"アプリ"とは、データベースの操作を制御する(=このデータベースを利用して実行されるプログラム)アプリケーションソフトウェアのことです。

PostgreSQLのデフォルト設定では、Lost Updateも発生します。

順番に行っている場合は、stockの値を確認して、必要であれば 実験環境の準備 節まで戻ってテーブルをリセットしてください。

ステップ 1

Aさんがトランザクションを開始

BEGIN;

ステップ 2

Bさんがトランザクションを開始

BEGIN;

ステップ 3

Aさんが在庫を確認。結果は「10」

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

ステップ 4

Bさんも在庫を確認。結果は「10」

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

ステップ 5

Aさんがアプリ上で計算した「9」を直接書き込む(未コミット)

UPDATE inventory SET stock = 9 WHERE item_name = '限定スニーカー';

ステップ 6

Bさんもアプリ上で計算した「9」を直接書き込む(未コミット)。フリーズして待つ

UPDATE inventory SET stock = 9 WHERE item_name = '限定スニーカー';

ステップ 7

Aさんが確定。Bのフリーズが解ける

COMMIT;

ステップ 8

Bさんが確定。(Aさんが書いた9の上に、Bさんがさらに9を上書きしてしまう)

COMMIT;

ステップ 9

結果は「9」! Aさんが買った事実が完全に消滅(Lost)した!

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

同じような現象として、Cursor Lost Update(カーソルによる更新の喪失)があります。プログラムからデータベースを操作する際、「カーソル(Cursor)」という1行ずつデータを処理する仕組みを使った時に発生する Lost Update のことです。本質的な発生メカニズムは通常の Lost Update と同じです。

実験6: Read Skew

データxとyに一貫性の制約(例:xとyの合計が常に一定など)があるとする。トランザクションT1がxを読み取る。次にT2がxとyの両方を更新し、コミットする。その後、T1がyを読み取る。結果として、T1は「更新前のx」と「更新後のy」を読んでしまい、一貫性のない状態(Skew:偏り・歪み)を観測してしまう現象。

「Tシャツ(10着)」と「スニーカー(10足)」の在庫があり、店長が「現在庫の合計数」を集計しようとしています。店長(Aさん)がまずTシャツの在庫を「10」とメモしました。その直後、BさんがTシャツとスニーカーを1つずつセットで購入(Tシャツ9、スニーカー9に変更)しました。その後、店長がスニーカーの在庫を見ると「9」になっています。 店長は「10 + 9 = 19個」と集計してしまい、本来存在しないはずの合計数(偏ったデータ)を観測してしまいます。

Read Skewは、特に複数のデータ項目間に一貫性の制約がある場合に問題になります。例えば、銀行口座の残高と取引履歴のようなケースです。

順番に行っている場合は、stockの値を確認して、必要であれば 実験環境の準備 節まで戻ってテーブルをリセットしてください。

ステップ 1

店長(Aさん)が集計処理を開始

BEGIN;

ステップ 2

店長がTシャツの在庫を確認。結果は「10」(メモ帳に10と記録する)

SELECT stock FROM inventory WHERE item_name = '限定Tシャツ';

ステップ 3

客(Bさん)が購入処理を開始

BEGIN;

ステップ 4

客がセット購入のため、Tシャツの在庫を1減らす(9に更新)

UPDATE inventory SET stock = stock - 1 WHERE item_name = '限定Tシャツ';

ステップ 5

客がスニーカーの在庫も1減らす(9に更新)

UPDATE inventory SET stock = stock - 1 WHERE item_name = '限定スニーカー';

ステップ 6

客の購入が確定(DB上は正しく両方とも9になる)

COMMIT;

ステップ 7

店長が次にスニーカーの在庫を確認。結果は「9」! 店長は「Tシャツ10 + スニーカー9 = 19個」という、絶対にあり得ない合計数を観測してしまう。

SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

ステップ 8

店長が集計処理を終了

COMMIT;

実験7: Write Skew

データxとyに「x + y > 0(どちらかは必ず正)」などの制約があるとする。T1とT2が同時にxとyを読む。T1はyが正であることを見てxを0に更新する。同時にT2はxが正であることを見てyを0に更新する。お互いの更新は競合しない(別の行を更新している)ため、両方ともコミットに成功してしまうが、結果として「x=0かつy=0」となり、制約が破綻する現象。

オンラインストアのルールで「Tシャツかスニーカー、どちらか1種類は必ず在庫を残して見栄えを保つ(両方ゼロにはしない)」という制約があるとします(現在どちらも10個)。 Aさんがスニーカーを0個にする処理を走らせ、同時にBさんがTシャツを0個にする処理を走らせました。お互い、処理の前に「相手の商品の在庫が残っているか」を確認します。同時に確認したため、どちらも「相手の在庫があるから、自分はゼロにしてもルール違反にならない!」と判断し、別々の商品の在庫をゼロに更新してしまいます。 結果、見事に両方の在庫がゼロになり、ルールが崩壊します。

ここで、「データベース側に最初から(例えば)『スニーカーの在庫 + Tシャツの在庫 > 0』という制約をつけておけばいいのでは?」と疑問に思うかもしれません。

実は、PostgreSQLなどのリレーショナルデータベースにおける CHECK 制約は、基本的に「1つの行の中(単一レコード)」でしか判定できません(例:CHECK (stock >= 0) など)。今回のような複数行にまたがる制約をテーブルの機能だけで厳密に守らせることは、通常の機能では非常に困難です(複雑なトリガーなどを駆使する必要があります)。

そのため、実際のシステム開発では、このような複雑なビジネスルールはアプリケーション側でチェックします。 ここで言う「アプリ」とは、データベースを利用して処理を実行するプログラム(Web開発などで書くバックエンドのAPIサーバーなど)のことです。

今回のシナリオでは、アプリの内部に以下のような処理(IF文)が書かれていると想像してください。

// アプリ側の処理イメージ
// 1. まず現在の在庫を確認 (実験手順の SELECT に相当)
const sneaker = db.query("SELECT stock FROM inventory WHERE item_name = '限定スニーカー'");
const tshirt = db.query("SELECT stock FROM inventory WHERE item_name = '限定Tシャツ'");

// 2. 制約(ルール)を満たしているかチェック
if (sneaker.stock + tshirt.stock > 0) {
    // ルールクリア!在庫を0に更新する (実験手順の UPDATE に相当)
    db.query("UPDATE ..."); 
} else {
    throw new Error("両方をゼロにすることはできません!");
}
Write Skew は、AさんとBさんの処理が同時にこの「アプリ側のIF文」を通過してしまうことに問題があります。データベース側はただ別々の行(スニーカーとTシャツ)が更新されただけなので競合とみなさず、結果としてアプリのルールが崩れてしまうのです。

順番に行っている場合は、stockの値を確認して、必要であれば 実験環境の準備 節まで戻ってテーブルをリセットしてください。

ステップ 1

Aさんが少し厳しいレベルでトランザクションを開始

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

ステップ 2

BEGIN;

ステップ 3

Bさんも同じレベルでトランザクションを開始

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

ステップ 4

BEGIN;

ステップ 5

Aさんが両方の在庫を確認。「Tシャツが残っているから、スニーカーを0にしてもルール違反にならない」とアプリが判断

SELECT stock FROM inventory WHERE item_name IN ('限定スニーカー', '限定Tシャツ');

ステップ 6

Bさんも両方の在庫を確認。「スニーカーが残っているから、Tシャツを0にしてもルール違反にならない」とアプリが判断

SELECT stock FROM inventory WHERE item_name IN ('限定スニーカー', '限定Tシャツ');

ステップ 7

Aさんがスニーカーを0に更新(まだコミットしていない)

UPDATE inventory SET stock = 0 WHERE item_name = '限定スニーカー';

ステップ 8

BさんがTシャツを0に更新(まだコミットしていない)。更新する行が違うのでフリーズせず通る!

UPDATE inventory SET stock = 0 WHERE item_name = '限定Tシャツ';

ステップ 9

Aさんがトランザクションを確定

COMMIT;

ステップ 10

Bさんもトランザクションを確定。ルール違反の状態が完成!

COMMIT;

4つの分離レベル

ここまで数々の異常を発生させてきました。では、データベース設計者はこれらをどうやって防げばよいのでしょうか?

その答えが、ANSI SQL標準で定義された4つの分離レベルです。

各分離レベルとPostgreSQLの挙動

  1. READ UNCOMMITTED(未コミット読取)
  2. 最も制限が緩く、他人のコミット前のデータ(Dirty Read)が見えてしまう危険なレベル。

    実はPostgreSQLでは、このレベルを指定しても内部的には②の READ COMMITTED と全く同じ挙動になります(後述の表における※1)。MVCC(多版型同時実行制御)という仕組みを使っているため、構造上Dirty Readを起こすことができないのです。

  3. READ COMMITTED(コミット済読取)
  4. コミットされたデータしか読まない。

    PostgreSQLのデフォルト設定です。Dirty Readは防げますが、「実験5」で体験した Lost Update(売上の消滅) などの致命的なバグはスルーしてしまいます。

  5. REPEATABLE READ(反復可能読取)
  6. トランザクション中に同じ行を何度読んでも、必ず同じ値が返ってくることを保証する。

    トランザクションを開始した瞬間のスナップショットを見続ける仕組みです。

  7. SERIALIZABLE(直列化可能)
  8. 複数の並行処理を、完全に「1列に並んで順番に処理(直列実行)」したのと同じ結果になることを保証する最高レベル。

    「実験7」で猛威を振るった Write Skew(書き込みの偏り) を防げる唯一のレベルです。PostgreSQLは背後でデータの依存関係(読み書きのグラフ)を監視しており、矛盾が起きそうになるとコミット時にエラーを出してシステムを守ります。

PostgreSQLの内部実装はこのような標準規格が「発生してもよい」としているバグすら防いでいます(実際Dirty Readなどは起こりませんでしたね)。

PostgreSQLにおいて各分離レベルが、どれくらいの防御力があるのかについて表にまとめたものが以下です。

異常\分離レベル READ UNCOMMITTED (未コミット読取) READ COMMITTED (コミット済読取) REPEATABLE READ (反復可能読取) SERIALIZABLE (直列化可能)
Dirty Write 🛡️ 防げる 🛡️ 防げる 🛡️ 防げる 🛡️ 防げる
Dirty Read 🛡️ 防げる(※1) 🛡️ 防げる 🛡️ 防げる 🛡️ 防げる
Non-repeatable Read ❌ 発生する ❌ 発生する 🛡️ 防げる 🛡️ 防げる
Phantom ❌ 発生する ❌ 発生する 🛡️ 防げる(※2) 🛡️ 防げる
Read Skew ❌ 発生する ❌ 発生する 🛡️ 防げる 🛡️ 防げる
Lost Update ❌ 発生する ❌ 発生する ⚠️ エラーで防ぐ(※3) ⚠️ エラーで防ぐ
Write Skew ❌ 発生する ❌ 発生する ❌ 発生する ⚠️ エラーで防ぐ

※2の凄さ: ANSI規格では「Phantom(幻の行の追加)は発生してもよい」とされていますが、PostgreSQLのスナップショットは優秀なのでPhantomまで完全に防ぎます。

※3の挙動: Lost Updateを起こそうとすると、後から更新しようとしたトランザクションに対して ERROR: could not serialize access due to concurrent update というエラーを発生させ、強制終了(ロールバック)させることでデータを守ります。

分離レベルの選択

すべての不整合(アノマリー)を防ぐために、常にデータベース全体を最高レベルの SERIALIZABLE に設定すれば良いかというと、実運用上はそうではありません。SERIALIZABLEREPEATABLE READ は、競合を検知するとトランザクション自体をアボート(強制終了)させて整合性を守るため、システム全体の並行処理性能が大きく低下する原因となります。

では、Lost Update などのバグを防ぐために、常にデータベース全体を SERIALIZABLE にしておけば良いのでしょうか?
そんなことをしていると、少しでも競合が起きるたびにPostgreSQLがバンバンとエラー(could not serialize access...)を出して処理を止めてしまいます。結果として、Webアプリ側で「エラーが出たら最初からやり直す(リトライ)」という複雑なプログラムを書く必要があり、システムの動作も重くなってしまいます。

システムの要件によっては、厳密な整合性よりもスループット(単位時間あたりの処理量)が最優先され、低水準の分離レベルによるアノマリーが許容される場合があります。

大体、別に許容できるエラー(不整合)というのもあります。
例えば、YouTubeの再生回数やSNSの「いいね」の数です。1万人が一斉にボタンを押したとき、Lost Updateが発生して実際の「いいね」が9998回になってしまったとしても、誰も大きな問題にはしません。それよりも「いちいちエラー画面を出さずに、とにかく最高速度で処理を受け付ける」ことの方が圧倒的に重要だからです。

そのため、実際のシステム開発では、アプリケーションの要件や許容できるリスクに応じて、適切な分離レベルを選択し、SQLの書き方を工夫することが重要になります。実務でよく使われる2つのアプローチを紹介します。

楽観的並行性制御(楽観的ロック)

「トランザクション同士の競合はめったに起きないだろう」という楽観的な前提に基づき、事前のロックをかけずに処理を進めます。そして、コミット時に競合を検知した場合は処理をロールバックし、アプリケーション側でリトライを行います。一時的に分離レベルを引き上げる手法などがこれに該当します。

基本はデフォルト(READ COMMITTED)のままにしておき、集計処理や在庫の最終更新など「絶対に途中でデータが変わってほしくない時」だけ、一時的に分離レベルを REPEATABLE READSERIALIZABLE に引き上げる方法です。
(※Web開発の現場では version という数値を管理する専用カラムを作ってアプリ側で弾く手法も人気ですが、ここでは分離レベルによる防衛策を紹介します)

具体例
-- 処理の最初で、このトランザクションだけ分離レベルを引き上げる
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 現在の在庫を確認
SELECT stock FROM inventory WHERE item_name = '限定スニーカー';

-- アプリ上で計算後、更新を実行
UPDATE inventory SET stock = 9 WHERE item_name = '限定スニーカー';

-- もしこの間に他人の更新と衝突していれば、COMMIT時にPostgreSQLがエラーを返す
-- ERROR: could not serialize access due to concurrent update
COMMIT;

※PostgreSQLがエラーを出して「データの破壊」からは守ってくれますが、処理自体は失敗しています。そのため、アプリ側(Node.jsやPythonなど)に「この特定のエラーが出たら、もう一度最初のSELECTから自動でやり直す(リトライする)」というコードを書く必要があります。

悲観的並行性制御(悲観的ロック / 排他ロック)

「トランザクション同士の競合は頻繁に起きるだろう」という悲観的な前提に基づき、データを読み取る時点で明示的に排他ロック(Exclusive Lock)を取得します。他のトランザクションは、ロックが解放されるまで待機(ブロック)されます。

分離レベルはデフォルトのまま、SQLの構文を使って「今からこの行を更新するから、私が終わるまで他の人は触らないで待ってて!」とデータベースに直接宣言する方法です。
Lost Update(売上の消滅)を防ぐための実務テクニックとして、最もよく使われる強力な手法です。具体的には、SELECT 文の末尾に FOR UPDATE をつけます。

具体例(Aさんが購入、Bさんが同時アクセスした場合)
-- === Aさんの処理 ===
BEGIN;
-- 単なる SELECT ではなく FOR UPDATE をつけることで「行ロック」を獲得!
SELECT stock FROM inventory WHERE item_name = '限定スニーカー' FOR UPDATE;
-- === Bさんの処理(Aさんがコミットするまで進めなくなる) ===
BEGIN;
-- Bさんも在庫を見ようとしますが、Aさんが FOR UPDATE で掴んでいるため、
-- ここで画面がピタッとフリーズして「待ち(待機状態)」になります。
SELECT stock FROM inventory WHERE item_name = '限定スニーカー' FOR UPDATE;
-- === Aさんの処理 ===
-- Aさんは安全に計算し、更新できる
UPDATE inventory SET stock = 9 WHERE item_name = '限定スニーカー';
-- Aさんがコミットした瞬間、ロックが解除される!
COMMIT; 
-- === Bさんの処理 ===
-- Aさんのコミットによりフリーズが解け、Bさんの SELECT がようやく実行されます。
-- ★超重要:このときBさんが取得する在庫数は、Aさんが更新した後の「9」になります!
-- そのため、Bさんは正しく「9 - 1 = 8」の計算を行うことができ、バグを防げます。
UPDATE inventory SET stock = 8 WHERE item_name = '限定スニーカー';
COMMIT;

問題

ここまで学んだ「同時実行制御」と「隔離レベル」に関する知識を確認しましょう。理論的な定義から、実務でのPostgreSQLの挙動まで、全10問を出題します。答えを頭の中に思い浮かべてから、「解答を見る」をクリックしてください。

Q1. 実務において、Lost Update(更新の喪失)を確実に防ぐために「この行を今から更新するから、私が終わるまで他の人は触らないこと」と排他ロック(悲観的ロック)をかけるSQL構文はどれですか?
解答を見る

A. SELECT ... FOR UPDATE;

解説:この構文を使うことで、指定した行に対して明示的に行ロックを取得できます。他のトランザクションが同じ行を更新(またはFOR UPDATEで読み取り)しようとした場合、現在のトランザクションがCOMMITまたはROLLBACKされるまで待機させることができます。

Q2. トランザクション処理において「直列化可能性(Serializability)」が保証されている状態とは、どのような状態を指しますか?
解答を見る

A. 複数のトランザクションを並行して実行した結果が、それらを何らかの順序で1つずつ順番に(直列に)実行した結果と「等価」になる状態。

解説:これがデータベースの並行制御における「正しさ」の究極の定義です。これさえ満たされていれば、並行処理によるデータの矛盾は一切起きていないと証明できます。

Q3. PostgreSQLのデフォルトのトランザクション分離レベルは何ですか?
解答を見る

A. READ COMMITTED (コミット済読取)

解説:PostgreSQLに限らず、多くの商用リレーショナルデータベースでこのレベルがデフォルトに採用されています。Dirty Readは防げますが、Lost Updateなどはアプリケーション側で対処する必要があります。

Q4. 他のトランザクションが更新処理中であり、まだ「確定(コミット)」していないデータを読み取ってしまう異常現象を何と呼びますか?
解答を見る

A. Dirty Read(ダーティリード)

解説:もし更新中のトランザクションがエラーなどでロールバックされた場合、「決して存在しなかったはずの幻のデータ」を読み取って処理を進めてしまったことになり、深刻なバグを引き起こします。

Q5. すべてのトランザクションを最高レベルの「SERIALIZABLE」に設定すれば、データの整合性は完全に守られます。しかし、実務のWebアプリケーション等ではあまり推奨されません。その最大の理由はなぜですか?
解答を見る

A. 競合を検知するたびにエラーを出して処理を強制終了させるため、アプリ側での「リトライ処理」が頻発し、システム全体の処理性能(スループット)が著しく低下するため。

解説:整合性と性能は常にトレードオフです。SERIALIZABLEは「待ち」ではなく「強制終了(アボート)」によって不整合を防ぐため、ユーザーから見ると「エラーで弾かれやすい重いシステム」になってしまいます。

Q6. 「データXとYの合計は常に正である」というルールがある時、トランザクションAがXを0にし、同時にトランザクションBがYを0にしてコミットすることで、お互い別の行を更新したのにも関わらずルールが破綻してしまう異常現象を何と呼びますか?
解答を見る

A. Write Skew(書き込みの偏り)

解説:REPEATABLE READレベル(スナップショット分離)でも防ぐことができない、非常に高度で厄介なアノマリーです。

Q7. 本記事の実験において、Write Skew(スニーカーとTシャツの両方がゼロになるバグ)が発生してしまった理由は、「在庫の合計がゼロ以上」という制約をどこで判定していたからですか?
解答を見る

A. データベースの機能(CHECK制約など)ではなく、アプリケーション(プログラム)側のIF文で判定していたから。

解説:DBのテーブル定義で行をまたぐ制約をかけるのは難しいため、実務ではアプリ側でチェックしますが、同時にチェックをすり抜けられるとDBはそのまま更新を受け入れてしまいます。

Q8. PostgreSQLなどが採用している、データを直接上書きせずに「バージョン(版)」を管理することで、読み取り処理が書き込み処理をブロックしない(待たせない)ようにする同時実行制御の仕組みを何と呼びますか?(アルファベット4文字)
解答を見る

A. MVCC (Multi-Version Concurrency Control / 多版型同時実行制御)

解説:これにより、高い整合性を保ちながらも、読み取り専用の処理が高速に実行できるようになっています。

Q9. PostgreSQLにおいて、分離レベルを一番低い「READ UNCOMMITTED」に設定してトランザクションを開始しました。この時、Dirty Read(他人の未確定データの読み取り)は発生しますか?
解答を見る

A. 発生しない。(PostgreSQLでは仕様上、READ COMMITTEDと同じ挙動になるため)

解説:PostgreSQLは前述のMVCCアーキテクチャを採用しているため、構造上、物理的に「確定していないデータ」を見せることができません。

Q10. ANSI SQL標準規格において、分離レベル「REPEATABLE READ」では『発生する可能性がある』とされているものの、PostgreSQLの実装においては独自に『防ぐことができる』とされているアノマリー(異常)はどれですか?
解答を見る

A. Phantom(ファントム / 幻の行の出現)

解説:標準規格では防げなくてもよいとされていますが、PostgreSQLのREPEATABLE READは優秀な「スナップショット分離」を実現しているため、他の人がINSERTした新しい行(ファントム)が見えてしまう現象も完全にブロックします。

PostgreSQLのロック機構を解剖する

ここまで、分離レベルや FOR UPDATE を使ってアノマリーを防ぐ方法を体験してきました。では、PostgreSQLは内部でどのように「待て」の指示(ロック)を管理しているのでしょうか?

PostgreSQLは、高い並行性(たくさんの人が同時にアクセスできる性能)を維持するために、「テーブル」「行」「インデックス」という3つの異なる階層で、非常に緻密なロック制御を行っています。

テーブルレベルロック(Table-level Locks)

PostgreSQLは、テーブルに対するアクセス制御のために8種類のロックモードを備えています。文(ステートメント)の実行時に自動的に獲得され、トランザクション終了時まで維持されます。重要なのは、各モードによって「何とコンフリクト(競合・ブロック)するか」が厳密に定義されている点です。

  • AccessShareLock: SELECT 時に獲得。AccessExclusiveLock とのみ競合。
  • RowShareLock: SELECT FOR UPDATE 時に獲得。ExclusiveLock, AccessExclusiveLock と競合。
  • RowExclusiveLock: UPDATE, DELETE, INSERT 時に獲得。共有系のロックなどと競合。
  • ShareUpdateExclusiveLock: VACUUM (FULLなし) などで獲得。
  • ShareLock: CREATE INDEX などで獲得。
  • ShareRowExclusiveLock: 明示的な LOCK TABLE 時に獲得。
  • ExclusiveLock: 明示的な LOCK TABLE 時に獲得。
  • AccessExclusiveLock: ALTER TABLE, DROP TABLE, VACUUM FULL 時に獲得。すべてのロックモードと競合する最強のロック。(これだけが普通の SELECT をブロックします)
具体的なイメージ:オンラインストアでの挙動
これらの難しいモード名が意味しているのは、「読み取りと書き込みはお互いを邪魔しない」というPostgreSQLの優れた設計です。
  • Aさんが SELECT で商品一覧を見ている時(AccessShareLock)。
  • Bさんが UPDATE で在庫を更新している時(RowExclusiveLock)。
この2つのロックはコンフリクトしない設定になっているため、Aさんの画面読み込みがBさんの購入処理でフリーズすることはありません。
しかし、深夜のメンテナンスで店長がテーブルの構造を変更する ALTER TABLE(AccessExclusiveLock)を実行すると、これは全てのロックと競合するため、Aさんの SELECT すらもブロックされ「メンテナンス中」の状態になります。

行レベルロック(Row-level Locks)

行レベルロックは、特定の行が更新・削除される時、または SELECT FOR UPDATE が実行された時に獲得されます。行レベルロックはデータの「問い合わせ(読み取り)」には一切影響を与えず、同じ行への「書き込み(または明示的ロック)」のみを阻止します。
また、PostgreSQLの特筆すべきアーキテクチャとして、ロックされた行の情報をメモリ上に記憶しません。代わりに、実際のディスク上のデータ(タプル)に直接「ロックの印」を書き込みます。そのため、同時に何百万行をロックしてもメモリが枯渇(ロックエスカレーション)することはありませんが、ディスクへの書き込み(I/O)が発生するという特徴があります。

具体的なイメージ:ピンポイントでの排他制御
前回の「Lost Update」の実験でBさんの画面がフリーズしましたね。あれが行レベルロックです。
Aさんが「限定スニーカー」の行だけをガッチリとロックしたため、Bさんはスニーカーを買おうとして待たされました。しかし、この時Cさんが「Tシャツ」を買おうとしたらどうなるでしょうか?
答えは「全く待たされずに即座に買える」です。ロックされているのはスニーカーの「行」だけであり、テーブル全体ではないからです。これが、多くの人が同時に買い物できる(高い並行性を持つ)理由です。
また、PostgreSQLはロックの情報を「メモリ」ではなく「商品タグそのもの(ディスク)」にメモするため、「お店の全商品を一気にロックしても、管理用のメモリがパンクしない」という非常にタフな性質を持っています。

ロックとインデックス(Indexes)

データベースの検索を高速化する「インデックス」に対しても、読み書きの際にロックが発生します。PostgreSQLでは、インデックスの種類(アクセスメソッド)によってロックの戦略が異なります。

  • GiST / R-Tree インデックス: インデックス全体のレベルで共有/排他ロックを使います(文の完了後に解放)。
  • ハッシュ (Hash) インデックス: ページレベル(データのかたまり毎)でロックを使います。インデックスレベルより並行性に優れますが、デッドロック(お互いにロック待ちになって動けなくなる現象)の対象となるリスクがあります。
  • B-tree インデックス: 読み書きアクセスに「短期の」ページレベルロックを使います。インデックスへのタプルの挿入/取り出しが終わるとただちに解放されます。
具体的なイメージ:なぜB-treeが推奨されるのか
大量のアクセスが来るWebアプリにおいて、インデックスのロックが長く続くと、システム全体の致命的なボトルネック(渋滞)になります。
例えば WHERE item_name = '限定スニーカー' と検索する際、裏側ではインデックス(目次)が使われています。
PostgreSQLのデフォルトであるB-treeインデックスは、この「目次を見る・書き込む」瞬間のロックを、処理が終わった瞬間に秒速で手放します。さらに、構造上デッドロックが発生しません。
そのため、オンラインショッピングのような「同時実行性(多くの人が同時にアクセスすること)」が求められるアプリケーションでは、迷わず B-tree インデックスを使用することが強く推奨されています。(特別な理由がない限り、PostgreSQLで普通にインデックスを作ると自動的にB-treeになります)。

多版型同時実行制御

ここまで、PostgreSQLが「READ UNCOMMITTEDでもDirty Readを起こさない」「REPEATABLE READでPhantomまで防ぐ」という規格外の優秀さを見せることを確認しました。その魔法の正体が、MVCC (Multi-Version Concurrency Control / 多版型同時実行制御) というアーキテクチャです。

仕組み
通常、データを更新(UPDATE)すると、古いデータは物理的に上書きされて消滅します。しかし、MVCCを採用しているデータベースでは、データを直接上書きしません。代わりに、更新前の古いデータ(旧バージョン)を残したまま、新しく更新されたデータ(新バージョン)を追記します。
各トランザクションは、自分が開始された時点での「正しいバージョンのデータ」だけをフィルタリングして読み取ります。これにより、「読み取り処理」が「書き込み処理」をロックして待たせる必要がなくなり、高い整合性とパフォーマンスを両立しています。

イメージ
イメージとしては、ソースコード管理の「Git」や、鉛筆と消しゴムを使わない「ボールペンの帳簿」に似ています。 在庫を「10」から「9」に書き換えるとき、PostgreSQLは前の「10」を消しゴムで消しません。代わりに「10」に横線を引いて(無効化フラグを立てて)、下の行に新しく「9」と書き足します。 もし別の人が「更新される前のデータ」を見たい場合は、横線が引かれた古い「10」を読ませてあげればよいのです。だからこそ、誰かが編集中でも、別の人は安全に過去のデータを読むことができます。

実際にPostgreSQLが持っているバージョンの記録(システムカラム)を覗き見てみましょう。

実験:隠しカラム`xmin`とxmax` を覗き見る

PostgreSQLのすべてのテーブルには、通常の SELECT * では表示されない隠しカラムが存在します。代表的なのが以下の2つです。

  • xmin: この行を「作成(INSERT/UPDATE)」したトランザクションのID。
  • xmax: この行を「削除(DELETE/更新前の古い行)」したトランザクションのID(まだ生きていれば0)。

ターミナルで、以下のSQLを実行して隠しカラムを表示させてみましょう。

順番 ターミナル(1つでOK) 説明
1 SELECT xmin, xmax, item_name, stock FROM inventory; 現在の状態を確認します。xminには数字が入り、xmaxは「0(まだ有効)」になっているはずです。
2 UPDATE inventory SET stock = 99 WHERE item_name = '限定スニーカー'; スニーカーの在庫を99に変更します。
3 SELECT xmin, xmax, item_name, stock FROM inventory; 👉 注目! xmin の数字が新しくなり、まるで別の行として新しく作られたかのように振る舞っていることがわかります。古い行は裏で xmax に値が入り、見えなくなっています。

隔離レベル(Isolation Level)とは、実のところ「この無数に存在するバージョンのうち、どの xmin/xmax の行を自分に見せるべきか?」というフィルタリングのルール(可視性チェック)の設定に過ぎないのです。

演習

実際に手を動かしてロック制御のSQLを書く演習(5問)と、本記事全体の内容を問う総合問題(4問)を出題します。

まずは、演習用の少し複雑なテーブル(コンサートの座席予約システムと、銀行口座システム)を作成しましょう。以下のSQLを実行して準備してください。

-- 演習用テーブルの準備
DROP TABLE IF EXISTS concert_seats;
DROP TABLE IF EXISTS bank_accounts;

-- ① コンサート座席テーブル
CREATE TABLE concert_seats (
    id SERIAL PRIMARY KEY,
    seat_num VARCHAR(10) UNIQUE NOT NULL,
    status VARCHAR(20) NOT NULL -- 'available' (空席) または 'reserved' (予約済)
);
INSERT INTO concert_seats (seat_num, status) VALUES
('A-1', 'available'), ('A-2', 'available'), ('A-3', 'available');

-- ② 銀行口座テーブル
CREATE TABLE bank_accounts (
    id SERIAL PRIMARY KEY,
    user_name VARCHAR(50) NOT NULL,
    balance INT NOT NULL
);
INSERT INTO bank_accounts (user_name, balance) VALUES
('Alice', 10000), ('Bob', 10000);

-- ③ メタバースホテル予約システム用テーブル
CREATE TABLE users ( user_id SERIAL PRIMARY KEY, coins INT NOT NULL );
CREATE TABLE rooms ( room_id INT PRIMARY KEY, status VARCHAR(20), price INT );
CREATE TABLE bookings ( id SERIAL PRIMARY KEY, user_id INT, room_id INT );

INSERT INTO users (user_id, coins) VALUES (1, 10000);
INSERT INTO rooms (room_id, status, price) VALUES (101, 'available', 8000);

準備ができたら、以下の問題に挑戦してください。解答は「解答を見る」をクリックすると開きます。

FOR UPDATE 構文

Q1. 【基本の行ロック】
あなたは「A-1」の座席を予約する処理をプログラムに実装しています。他のユーザーが同時に同じ席を予約(Lost Update)してしまうのを防ぐため、A-1の座席情報を取得すると同時に排他ロック(FOR UPDATE)をかけるSELECT文を書いてください。
(※トランザクションは既に BEGIN; で開始されているものとします)
解答を見る
SELECT * FROM concert_seats WHERE seat_num = 'A-1' FOR UPDATE;

解説:このクエリを実行した瞬間から、あなたが COMMIT; するまでの間、他の人がA-1の席を UPDATE したり FOR UPDATE で取得しようとすると、その人の画面はフリーズして待機状態になります。これが悲観的ロックの基本です。

Q2. 【ロックのスキップ(応用)】
人気のコンサートで、空いている席(status = 'available')を「どれでもいいから1席(LIMIT 1)」確保したいです。
しかし、普通の FOR UPDATE を使うと、誰かがロック中の席を引き当ててしまった場合にフリーズして待たされてしまいます。
「空席を1つ取得してロックするが、もし他の人が既にロックしている席があれば、待たずに『スキップ』して次の空席を即座にロックする」というFOR UPDATE構文を書いてください。
解答を見る
SELECT * FROM concert_seats WHERE status = 'available' LIMIT 1 FOR UPDATE SKIP LOCKED;

解説:SKIP LOCKED はチケット予約やキュー処理(ジョブワーカー)などで必須となるテクニックです。 これをつけることで、競合による「待ち」を完全に無くし、並行処理を進めることができます。

Q3. 【複数行の同時ロック】
銀行口座テーブルで、Alice(id=1)から Bob(id=2)へお金を振り込みます。この時、どちらの口座の残高も同時に安全に更新するため、AliceとBobの両方の行を一度にロックするSELECT文を書いてください。
ヒント:IN 句を使うと簡潔に書けます。
解答を見る
SELECT * FROM bank_accounts WHERE id IN (1, 2) FOR UPDATE;

解説:複数行をロックする際、PostgreSQLは内部的にID順などにソートしてロックを取得してくれます。別々のクエリで1行ずつロックする(Aliceをロックした後にBobをロックする)と、デッドロック(Q6参照)の原因になるため、このように1つのクエリでまとめてロックするのが安全な設計です。

Q4. 【複数テーブルにまたがる予約トランザクション】
メタバース空間上のホテル予約システムを開発しています。ユーザー(user_id = 1)が、空室の部屋(room_id = 101)を予約するバックエンドの処理をSQLで記述してください。
この処理では、他の予約処理との競合(Lost Updateや残高の二重引き落とし)を防ぐため、以下の要件を満たす一連のトランザクション(BEGIN から COMMIT まで)を完全に記述してください。
  • 部屋の情報とユーザーの所持コイン情報の両方を排他ロック(FOR UPDATE)して取得する。
  • 部屋のステータスを 'booked' に更新する。
  • ユーザーの所持コインから部屋の価格(8000)を減算する。
  • 予約履歴(bookingsテーブル)に新しいレコードをINSERTする。
解答を見る
BEGIN;

-- 1. 競合を防ぐため、関係する行を全てロックして最新の状態を取得
SELECT * FROM rooms WHERE room_id = 101 FOR UPDATE;
SELECT * FROM users WHERE user_id = 1 FOR UPDATE;

-- (ここでアプリケーション側が「空室か?」「残高は足りているか?」をIF文でチェックします)

-- 2. 部屋を予約済みに更新
UPDATE rooms SET status = 'booked' WHERE room_id = 101;

-- 3. ユーザーの残高を減らす
UPDATE users SET coins = coins - 8000 WHERE user_id = 1;

-- 4. 予約履歴を作成
INSERT INTO bookings (user_id, room_id) VALUES (1, 101);

COMMIT;

解説:実際のWebアプリケーション(API)では、必ずこのように BEGINCOMMIT で複数のクエリを囲み、最初に FOR UPDATE で必要なリソースを全てロックしてから更新処理を行います。これにより、同時に同じ部屋を予約しようとした他のユーザーの処理は最初のSELECT文で待機させられ、安全に処理を完了できます。

Q5. 【デッドロックを回避するロック順序】
ゲーム内のギルドシステムで、3人のプレイヤー(ID: 10, 20, 30)が同時にアイテムを交換し合う複雑なトレード処理を実行します。
システムはトランザクションの中で、これら3人のプレイヤーのデータ(playersテーブル)を FOR UPDATE でロックする必要があります。
もし別のトランザクションが同時に「30, 20, 10」の順でロックを取得しようとした場合、タイミングによってはデッドロックが発生してしまいます。
デッドロックをアーキテクチャレベルで確実に回避するために、この3人の行を安全にまとめてロックする1つのSELECTクエリを記述してください。
解答を見る
SELECT * FROM players 
WHERE id IN (10, 20, 30) 
ORDER BY id 
FOR UPDATE;

解説:デッドロックを防ぐ鉄則は「すべてのトランザクションが必ず同じ順番でロックを取得する」ことです。複数行をロックする際、IN句を使うだけでもPostgreSQLが内部で良しなに処理してくれる場合が多いですが、明示的に ORDER BY id (主キーなどの一意な値でソート)をつけてから FOR UPDATE を実行することで、どのような複雑なトレード処理でも確実に一方向からのロックとなり、デッドロックを理論上完全に防ぐことができます。


同時実行制御とロックの問題

Q6. 【分離レベルの選択】
あなたは月末に「今月の全ユーザーの売上集計レポート」を出力する重い処理(数分かかる)を実行します。
集計中に他のユーザーが新しく売上データ(INSERT)を追加しても、集計結果が途中で変わらない(Phantomが発生しない)ようにしたいです。しかし、他のユーザーの業務(INSERT)をロックしてブロックすることは許されません。
このレポート集計トランザクションにおいて、最も適した分離レベルは次のうちどれですか?
(A) READ COMMITTED
(B) REPEATABLE READ
(C) SERIALIZABLE
解答を見る

正解:(B) REPEATABLE READ

解説:PostgreSQLの REPEATABLE READ は「スナップショット分離」を採用しているため、トランザクション開始時点の過去のデータを参照し続けます。これにより、他のトランザクションの書き込みを一切ブロックすることなく、一貫したデータ(Phantomのない状態)を安全に集計できます。

Q7. 【テーブルロックの仕様】
データベースの運用中、あるテーブルから不要なカラムを削除するために ALTER TABLE コマンドを実行しました。この時、テーブルには最強のロックである「AccessExclusiveLock」がかかります。
この ALTER TABLE の処理が終わるまでの間、一般のユーザーがそのテーブルに対して単なる SELECT(読み取り)を実行しようとすると、どのような挙動になりますか?
解答を見る

正解:SELECTの処理もブロックされ、ALTER TABLEが終わるまでフリーズして待たされる。

解説:通常、読み取り(AccessShareLock)は書き込み(RowExclusiveLock等)をブロックしませんが、テーブル構造そのものを変更する ALTER TABLEDROP TABLE などが取得する「AccessExclusiveLock」だけは例外です。あらゆる操作とコンフリクトするため、稼働中の本番環境で実行する際は細心の注意が必要です。

Q8. 【デッドロックのメカニズム】
トランザクションAが「Alice」の行をロックし、トランザクションBが「Bob」の行をロックしました。
その後、Aが「Bob」の行をロックしようとして待ち状態になり、同時にBが「Alice」の行をロックしようとして待ち状態になりました。
お互いがお互いのロック解放を永遠に待ち続けるこの現象をデッドロック(Deadlock)と呼びます。PostgreSQLはこの状態に陥った時、システムを停止させないためにどのような自動復旧処理を行いますか?
解答を見る

正解:一定時間(デフォルトで1秒)経過後にデッドロックを検知し、どちらか一方のトランザクションをエラー(強制ロールバック)にして、もう一方の処理を進める。

解説:PostgreSQLにはデッドロックを監視する機能があります。エラー(ERROR: deadlock detected)を出された方のアプリケーションは、処理を最初からやり直す(リトライする)必要があります。これを防ぐには「常に同じ順番(例:IDの昇順)でロックを取得する」という設計ルールを設けるのが鉄則です。

Q9. 【理論と実装の統合:なぜFOR UPDATEでLost Updateが防げるのか?】
Lost Update(更新の喪失)の実験で、Bさんがフリーズして待たされた後、AさんがコミットするとBさんの処理が再開しました。もしBさんがアプリ側で「在庫を9にする」という固定のUPDATE文(SET stock = 9)を投げていればLost Updateが発生しますが、FOR UPDATEでロックを取得した後に再取得(SELECT)を行えば防ぐことができます。
なぜ FOR UPDATE の待機明けに再度 SELECT をすると、正しい計算ができるのでしょうか?(PostgreSQLのロック解除後の挙動の観点から答えてください)
解答を見る

正解:待機が解除された直後の SELECT では、Aさんがコミットした後の「最新の確定データ」を読み取ることができるから。

解説:Bさんが FOR UPDATE を実行した瞬間はAさんが編集中なので待ちます。Aさんが確定(コミット)してロックを手放すと、Bさんは「Aさんが書き換えた後の最新の行」を取得してプログラムに返します。アプリはその最新の数字(例:9)をもとに「9 - 1 = 8」を計算するため、データの喪失(Lost)が起きないのです。

本コンテンツの作成時間:約20時間

最終更新: 2026-02-23 22:40