TOCTOUとは?排他制御とトランザクションの違いを検証
【要約】TOCTOUと排他制御・トランザクションの比較
| 項目 | 最初のイメージ | 実際に試して分かったこと |
|---|---|---|
| トランザクション | 使えば同時更新トラブルは全部解決しそう | 分割された処理の意図までは自動で保護してくれない |
| TOCTOU | よく分からない専門用語 | 「確認(Check)」と「利用(Use)」の隙間に状態が変わる問題 |
FOR UPDATE | 行を最後まで独占して勝てる | トランザクション終了までロックするが、待機中の後続処理はその後実行される |
| 楽観的排他 | 難しそう | バージョン番号などを条件にして古い状態からの更新を弾く |
| デッドロック | ロックすれば防げそう | ロック取得順序が循環すると発生し、DBが片方を強制終了する |
要するに、TOCTOU対策で大事なのは「チェックした時点の状態が、実際に使う時にも有効なのか」を保証すること。
そもそもTOCTOUって何者?
TOCTOUは「Time Of Check To Time Of Use」の略。
簡単に言ってしまうと、
「確認した時点」と「実際に使う時点」の間で、対象の状態が変わっちゃう問題
のこと。
この言葉を見かけたので気になって調べてみた。
最初は、
「複数ユーザーが同じデータを更新するなら、DBのトランザクションを囲っておけば解決するんじゃないの?」
と思ってた。
でも、実際にPostgreSQLで同時実行を試してみたら、単純なトランザクションだけじゃ説明がつかない動きがあった。
というわけで、psqlセッションを2つ立ち上げて色々検証してみた。
トランザクションだけじゃダメなの?(背景とハマりどころ)
そもそも「トランザクションでよくない?」と思った理由
例えば、在庫が10個あるとする。
処理Aと処理Bが同時に、
- 在庫を取得する(10個)
- 1個減らす計算をする(9個)
- DBを更新する
という処理を行うケース。
最初は「両方をトランザクション(BEGIN 〜 COMMIT)にすれば、DB側でうまく調整してくれるでしょ」と思ってた。
で、実際にこんな感じのSQLを試してみた。
BEGIN;
SELECT stock FROM products WHERE id = 1;
UPDATE products
SET stock = 9
WHERE id = 1;
COMMIT;処理Aと処理Bの両方が在庫10を読み取って、そのあとに「9」を書き込む流れ。
期待としては2回減って「8」になってほしいところだけど、結果は最終的な在庫が「9」になってしまった。
【実測結果】
PostgreSQL 18.6で試した結果がこれ:
- Aが在庫10を取得
- Bも在庫10を取得
- Aが9を書き込む
- Bが待機状態になる
- AがCOMMITする
- Bが待機解除されて9を書き込む
- 最終値が9になる(本来は8になってほしい)
ここで分かったのは、
トランザクションは「人間の処理の意図」まで勝手に汲み取って合成してくれるわけじゃない
ということ。
ちなみに、
UPDATE products
SET stock = stock - 1
WHERE id = 1;みたいにDB上で直接引き算(相対更新)をする書き方なら、同時実行しても順番に適用されて在庫はちゃんと「8」になった。
つまり、同じ「在庫を1減らす」処理でも、アプリ側で値を読み込んで固定値を書き戻すのか、DB上で引き算するのかで結果がガラッと変わる。
TOCTOUの正体
TOCTOUの構造を理解するために、もっとシンプルなケースを試してみた。
BEGIN;
-- Check(確認)
SELECT stock FROM products WHERE id = 1;ここで処理Aが「在庫10」を確認する。
その直後に、処理Bが割り込んできて、
BEGIN;
UPDATE products
SET stock = 0
WHERE id = 1;
COMMIT;を実行して在庫を0にしちゃう。
そのあとに処理Aが、
-- Use(利用)
UPDATE products
SET stock = 9
WHERE id = 1;
COMMIT;を実行。
結果として、在庫は「9」になっちゃう。
【実測結果】
処理Aが最初に確認した時点では在庫10だった。
でも、処理Aが更新する前に処理Bが在庫を0に変えてしまっている。
それなのに処理Aは「さっき在庫10だったからOK!」という古い前提のまま更新をかけちゃったわけ。
これがTOCTOUの基本的な構造。
sequenceDiagram
autonumber
participant A as 処理A
participant DB as PostgreSQL
participant B as 処理B (割り込み)
A->>DB: 【Check】SELECT (在庫10を確認)
Note over A: 「在庫10あるからOK」と判定
B->>DB: UPDATE (在庫を0に変更&COMMIT)
Note over DB: 在庫が 0 に変動!
A->>DB: 【Use】UPDATE (在庫を9に変更)
Note over DB: 在庫0なのに「さっき10だった」前提で9に上書きポイントなのは、TOCTOU自体は「排他制御の手法」を指す言葉じゃないということ。
「Check」と「Use」の間に状態が変わってしまい、古い確認結果のまま処理を進めてしまう現象そのものがTOCTOUってわけ。
SELECT FOR UPDATE で囲めば完璧なの?
そこで登場するのが、
SELECT ... FOR UPDATEという書き方。
PostgreSQLでは FOR UPDATE を付けて読み込むと、対象の行がロックされて、他のトランザクションからの更新を待たせることができる。ロックはトランザクションが終了する(COMMITかROLLBACKする)まで維持される。
実際、
BEGIN;
SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;を処理Aで実行したあとに、処理Bから同じ行を更新してみた。
【実測結果】
処理BのUPDATEはすぐには終わらず、処理AがCOMMITするまでしっかり待たされた。
動きとしてはこんなイメージ:
sequenceDiagram
autonumber
participant A as 処理A
participant DB as PostgreSQL
participant B as 処理B
A->>DB: BEGIN
A->>DB: SELECT ... FOR UPDATE
Note over DB: id=1 の行をロック取得!
B->>DB: BEGIN
B->>DB: UPDATE (id=1)
Note over B,DB: 処理Aがロック中のため待機...
A->>DB: UPDATE (stock = 9)
A->>DB: COMMIT
Note over DB: ロック解除!
B->>DB: 待機解除 → UPDATE 実行 & COMMITただ、ここで一つ「おや?」と思う結果に。
処理Aがロック中に在庫を9に変更してCOMMITしたあと、待たされていた処理BのUPDATEが実行されて、結局Bの更新内容が最終値になった。
PostgreSQLの仕様的にも、SELECT FOR UPDATE は他のトランザクションによる更新を「一時的にブロック」するけど、ロックが解除されたあとは待機していた処理がそのまま続行される。
つまり、
FOR UPDATEは「自分だけが永久に勝つための魔法」ではない
ということ。
チェックから利用までの間に他人が割り込んでくるのを防ぐためのガード、と考えると納得がいった。
楽観的排他(バージョン管理)ってどうなの?
もう一つの対策として、更新の条件に「自分が確認した時の状態」をそのまま含める方法も試してみた。
例えばこんな感じ:
UPDATE products
SET stock = 9
WHERE id = 1
AND stock = 10; -- 自分が確認した時の値を条件に入れるもし他の処理によって在庫が10から0に変わっていたら、このUPDATEを実行しても、
UPDATE 0となって、更新件数は0件になる。
【実測結果】
実際に試すと、処理AのUPDATE結果は UPDATE 0 になり、在庫0を「9」で上書きしてしまう事故を防げた。
UPDATE 0 はSQLのエラーではなく、「条件に合う行がなかったから0件更新したよ」という正常なレスポンス。
でも、
AND stock = 10みたいに実際のデータ値を直接条件にすると、値が元に戻った時(10 → 5 → 10みたいなケース)に誤検知するリスクがあって扱いづらいこともある。
そこでよく使われるのが**バージョン番号(version)**を入れる手法。
テーブルに version カラムを用意しておく:
id | stock | version
---+-------+--------
1 | 10 | 1処理Aが version = 1 を読み取ったあと、処理Bが先に更新すると:
id | stock | version
---+-------+--------
1 | 0 | 2になる。
そのあと処理Aが、
UPDATE products
SET stock = 9,
version = version + 1
WHERE id = 1
AND version = 1; -- さっき取得したversion=1を条件にするを実行しても、今のversionは2になっているので更新条件にマッチせず、UPDATE 0 になる。
これが**楽観的排他(楽観的ロック)**の考え方。
最初に行をロックして相手を待たせるのではなく、
更新する時に「自分が読み取った状態から変わっていないか?」をチェックして、変わっていたら失敗(競合)とする
というアプローチ。
PostgreSQL 18.6で楽観的排他を試してみた
検証環境
- OS:Windows 11
- DB:PostgreSQL 18.6(ZIP版を手動起動)
- クライアント:
psql(セッション2つ)
バージョン方式の同時更新テスト
処理Aと処理Bの両方で、
SELECT stock, version
FROM products
WHERE id = 1;を実行して、どちらも stock = 10, version = 1 を取得。
次に処理Aが、
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 1;を実行。処理Aは無事に UPDATE 1 になった。
続けて処理Bがまったく同じUPDATEを実行すると、一瞬待機したあとに UPDATE 0 となった。
最終的なデータを確認すると:
stock = 9
version = 2【実測結果】
2つの処理が同時に version = 1 を読み取っていても、更新に成功するのは最初にコミットした1つだけ。
これによって、古い状態を元にした更新が勝手に上書きされるのを防げる。
デッドロックという別の罠
排他制御(ロック)を使えば全部解決!かというと、そう甘くもない。
2行のデータを使って、こんな状態を作ってみた。
flowchart TD
subgraph A["処理 A"]
A1["id=1 のロック取得"] --> A2["id=2 のロック取得を試みる"]
end
subgraph B["処理 B"]
B1["id=2 のロック取得"] --> B2["id=1 のロック取得を試みる"]
end
A2 -- "待ち (ブロック)" --> B1
B2 -- "待ち (ブロック)" --> A1お互いが「相手がロックを解除してくれる」のを待つ状態になって、完全にフリーズする。これがデッドロック。
PostgreSQLはこの状態を自動で検知して、どちらか片方のトランザクションを強制終了(エラー)にしてくれる。
【実測結果】
実際に試したら、こんなエラーが出た:
ERROR: デッドロックを検出しましたpsqlのプロンプトもエラー状態を示す postgres=!# に変わった。
この後、
ROLLBACK;を実行して元のプロンプト(postgres=#)に復帰。
データを確認すると:
id | name | stock | version
---+-------+-------+--------
1 | 商品A | 9 | 2
2 | 商品B | 10 | 1片方の処理だけが適用されていた。
公式ドキュメントの対策
PostgreSQLの公式ドキュメントによると、デッドロックを防ぐ基本は**「複数の行をロックするときは、どの処理でも必ず同じ順番でロックを取得すること」**。
それでも防げない場合は、エラーが起きた時にトランザクション全体をリトライ(再試行)する設計にしておく必要がある。
ハマりやすいポイント・注意点
今回検証してみて、「トランザクションを使っていれば勝手に全部守られる」わけじゃないのが一番の学びだった。
PostgreSQLデフォルトの Read Committed アイソレーションレベルでは、トランザクション内であってもSELECTするたびにその時点の最新コミットデータを見る。
だから、
SELECT(確認)
↓
アプリ側で判断
↓
UPDATE(利用)という流れを単に BEGIN と COMMIT で囲んだだけじゃ、TOCTOUを防ぐことはできない。
また、SELECT FOR UPDATE を使えば万事解決というわけでもなく、複数行を扱うならデッドロック対策が必要になる。
逆に、毎回行を強力にロック(悲観的ロック)しなくても、失敗したらやり直す前提なら version カラムを使った楽観的排他で十分なケースも多い。
まとめ(最初の疑問への回答)
最初は、
「同じデータを同時更新するなら、DBのトランザクションでよくない?」
と思ってた。
でも実際に試してみて分かったのは、トランザクションを使うことと、TOCTOUを防ぐことは別物だということ。
TOCTOUで一番重要なのは、
flowchart LR
Check["1. Check (状態確認)"] --> Gap["割り込みで状態が変化"] --> Use["2. Use (古い状態のまま利用)"]
style Gap fill:#ffefbf,stroke:#ed6c02,color:#bf5600という隙間をどうやって埋めるか。
対策としては、
SELECT FOR UPDATEで確認〜利用まで行をがっつりロックする- UPDATEの条件に「確認時の状態」を入れて弾く
version番号を使って古い状態からの更新を検知する(楽観的排他)- ロックの順序を揃えてデッドロックを防ぐ
- エラーが起きたらトランザクション単位で再試行する
といった方法がある。
「排他制御の難しい話」として覚えるより、「確認した時点の状態が、使う時にも本当に正しいかをどう保証するか?」という問題として捉えるとすごくしっくりきた。
参考資料
- PostgreSQL 18「Transaction Isolation」:
Read Committed、Repeatable Read、Serializableの仕様 PostgreSQL 18 Transaction Isolation - PostgreSQL 18「Explicit Locking」:
FOR UPDATE、行ロック、デッドロックの仕様 PostgreSQL 18 Explicit Locking - PostgreSQL 18「Data Consistency Checks at the Application Level」:
SELECT FOR UPDATEによる整合性確保 PostgreSQL 18 Data Consistency Checks