Skip to main content

ロックと分離レベル — 同時実行の制御

在庫が 1 個の商品を 2 人が同時に買った。両方とも「購入成功」と表示され、在庫は -1 になった。

こうした事故は、読んでから書くまでの間に他の誰かが割り込むことで起きます。防ぐ手段がロックと分離レベルです。どちらも「同時に触らせない」ための道具ですが、効き方と代償が違います。

この章で学ぶこと

  • 共有ロックと排他ロックの違い、保持される期間
  • 4 つの分離レベルと、それぞれが防ぐ読み取り異常
  • 分離レベルを上げてもロック無しでは防げないもの
  • 楽観ロックと悲観ロックの使い分け
  • デッドロックの原因と、回避の原則
前提知識

トランザクション の基本操作と ACID を前提にします。

この章で扱わないこと

観点参照先
リトライの回数・間隔の設計と、競合が集中する場合の判断リトライと冪等性
フレームワークでの実装、SQLite の落とし穴Laravel API 開発ガイド — トランザクションと並行性
楽観ロックの版数カラム設計と例外設計Laravel × DDD ガイド — トランザクション管理
複製先を読んだときに乗る遅れ (分離レベルとは別の層)データの分散
複数の表を結合して集計するクエリの書き方結合と集計
行をまたいで数える書き方 (サブクエリ・CTE・ウィンドウ関数)サブクエリとウィンドウ関数

2 種類のロック

共有ロック (S)排他ロック (X)
別名読み取りロック書き込みロック
取る場面読むとき更新・削除するとき
他の共有ロック共存できる待たせる
他の排他ロック待たせる待たせる

同時に読むだけなら互いに邪魔しません。しかし誰かが書いている最中は、読むことも書くことも待たされます。

**ロックはトランザクションが終わるまで保持されます。**排他ロックを取った行は、そのトランザクションが COMMIT するか ROLLBACK するまで、他のトランザクションから更新できません。

BEGIN;
SELECT * FROM items WHERE id = 1 FOR UPDATE; -- 排他ロックを取る
-- ここから COMMIT / ROLLBACK まで、他は id=1 を更新できない
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT; -- ここでロックが外れる

だからトランザクションを短く保つ必要があります。長く開けば、その間ずっと他を待たせます。

3 つの読み取り異常

並行実行で起きる問題は、代表的な 3 つに分類されます。

異常何が起きるか
ダーティリード他のトランザクションの未コミットの変更を読んでしまう
ノンリピータブルリード同じ行を 2 回読むと値が変わっている
ファントムリード同じ条件で 2 回検索すると行数が変わっている

後ろ 2 つの違いは対象です。ノンリピータブルリードは既存の行の値が変わること、ファントムリードは条件に合う行が増減することを指します。

ノンリピータブルリード
1 回目: SELECT price FROM items WHERE id = 1 → 1000
(他のトランザクションが更新してコミット)
2 回目: SELECT price FROM items WHERE id = 1 → 1200 ← 値が変わった

ファントムリード
1 回目: SELECT * FROM items WHERE price < 2000 → 3 件
(他のトランザクションが挿入してコミット)
2 回目: SELECT * FROM items WHERE price < 2000 → 4 件 ← 件数が変わった

4 つの分離レベル

SQL 標準は、どの異常を許すかで 4 段階を定めています。

分離レベルダーティリードノンリピータブルリードファントムリード
READ UNCOMMITTED起こりうる起こりうる起こりうる
READ COMMITTED防ぐ起こりうる起こりうる
REPEATABLE READ防ぐ防ぐ起こりうる
SERIALIZABLE防ぐ防ぐ防ぐ

下へ行くほど安全ですが、待ちが増えて同時実行性が落ちます。どこまでの異常を許容できるかで選びます。多くのデータベースの既定値は READ COMMITTED です。

実装は標準と一致しない

ここが実務で効く注意点です。標準が定めるのは「これ以上の異常を起こしてはならない」という下限で、実装がより強い保証を与えることは許されています。

PostgreSQL のドキュメントは 2 点を明記しています。

  • READ UNCOMMITTED は READ COMMITTED と同じ動作をする。4 段階を要求できるが内部的には 3 段階しか実装していない
  • REPEATABLE READ はファントムリードを許さない。標準より強い保証を与えている

MySQL の InnoDB も REPEATABLE READ が既定で、こちらもファントムリードを一定の範囲で防ぎます。ただし防ぎ方の仕組みが PostgreSQL とは異なります。

**「REPEATABLE READ ならファントムリードが起きる」は SQL 標準の話であって、目の前のデータベースの挙動とは限りません。**使っている製品のドキュメントで確認してください。

分離レベルを上げてもロックは要る

**分離レベルを上げても、ロックが要らなくなるわけではありません。**分離レベルに向けられる期待は、2 つの点で実際とずれています。

**1 つ目。REPEATABLE READ は「最新を読む」レベルではありません。**逆です。トランザクションの開始時点のスナップショットを読み続けるので、他が更新してコミットしても、自分には見えません。同じ値を繰り返し読めることを保証するために、あえて古い値を見せています。「常に最新」なのはむしろ READ COMMITTED のほうで、こちらは文ごとに最新のコミット済みデータを読みます。

**2 つ目。そして、こちらがロックを要する理由です。分離レベルが制御するのは読み取りに現れる異常だけで、更新の競合は扱いません。**在庫の例で見ます。

どちらのトランザクションも REPEATABLE READ、ロックなし

Tx1: SELECT stock FROM items WHERE id=1 → 1
Tx2: SELECT stock FROM items WHERE id=1 → 1
Tx1: UPDATE items SET stock = 0 WHERE id=1
Tx2: UPDATE items SET stock = 0 WHERE id=1

2 人とも「在庫 1」を読んで、2 人とも売ってしまいます。これはロストアップデートと呼ばれる更新の競合で、分離レベルだけでは防げません。同じ形の競合は 1 つのプロセスの中でも起きます (プロセスとスレッド)。防ぐには次のどちらかが要ります。

  • ロックを取る (SELECT ... FOR UPDATE)
  • 版数で衝突を検出する (楽観ロック)

PostgreSQL の REPEATABLE READ ではこの状況で片方が直列化失敗のエラーになりますが、それはエラーを受けてリトライする実装が前提です。放っておいて自動的に正しくなるわけではありません。

楽観ロックと悲観ロック

同じ「ロック」という語が付きますが、思想が正反対です。

悲観ロック楽観ロック
前提衝突は頻繁に起きる衝突はめったに起きない
やること先にロックを取って他を待たせる**ロックを取らない。**更新時に版数を照合する
衝突したらそもそも起きない (待たされる)更新が 0 件になる。検出してリトライか通知
向く場面競合が多い、待たせてよい競合が少ない、待たせたくない
コスト待ち時間、デッドロックの危険衝突時のやり直し

名前に「ロック」と付きますが、楽観ロックはロックを取りません。「楽観」は「たいてい衝突しないだろう」という見込みを指していて、衝突したときだけ検出して対処する、という組み立てになっています。

-- 悲観ロック: 先に押さえる
BEGIN;
SELECT stock FROM items WHERE id = 1 FOR UPDATE; -- 他を待たせる
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- 楽観ロック: 押さえずに、更新時へ条件を付ける
SELECT stock, version FROM items WHERE id = 1; -- version = 3 を得た
UPDATE items SET stock = 0, version = 4
WHERE id = 1 AND version = 3; -- 更新件数が 0 なら衝突

楽観ロックの肝は WHERE に版数の条件を付けることです。読んでから書くまでに誰かが更新していれば版数が進んでいるので、更新件数が 0 件になり衝突を検出できます。

**悲観ロックには待ち時間とデッドロックという代償があります。**とりあえずロックしておけば安全だと考えると、競合がめったに起きない場面でも他のトランザクションを待たせ続けることになります。競合の頻度で選び分けます。

なお、UI で「レスポンスを待たずに画面を先に更新する」楽観的更新とは別の概念です。名前が似ているだけで、層も目的も違います。

デッドロック

2 つのトランザクションが互いの持つロックを待ち合って、どちらも進めなくなる状態です。

多くのデータベースはデッドロックを検出して、片方を強制的にロールバックします。アプリケーション側はそのエラーを受けてリトライすることになります。

回避の原則は「順序を固定する」

デッドロックを防ぐには、ロックを取る順序を全トランザクションで揃えます。

// 悪い例: 引数の順にロックする → 呼び出し方によって順序が入れ替わる
lockRow(fromId);
lockRow(toId);

// 良い例: ID の昇順に固定する → どの呼び出しでも同じ順序になる
const [first, second] = [fromId, toId].sort((a, b) => a - b);
lockRow(first);
lockRow(second);

先の図で、Tx2 も「行 1 → 行 2」の順に取っていれば、Tx2 は行 1 の取得で待たされるだけで、行 2 を掴んだまま止まることがありません。待ちが一方向になるので循環しません。

ここで順序をばらつかせるのは逆効果です。タイミングをずらして衝突を避ける発想でランダム化すると、かえって順序が揃わず、循環する組み合わせが生まれやすくなります。効くのは順序の固定です。

その他の緩和策もあります。

手段効果
ロック順序の固定根本的な回避
トランザクションを短くする待ち合いの窓が狭くなる
ロックの粒度を細かくする競合する範囲が減る
リトライを実装する起きたときに復帰できる
分離レベルを下げるロックの範囲が狭くなる場合がある

デッドロックは完全には避けきれないので、順序の固定で発生確率を下げつつ、リトライで受け止めるのが実務的な構えです。リトライの回数と間隔をどう決めるかはリトライと冪等性で扱います。

よくある誤解

「行の排他ロックはクエリが終われば解放される」 — トランザクションが COMMITROLLBACK するまで保持されます。だからトランザクションは短く保ちます。

「分離レベルを上げれば、ロックは要らなくなる」 — 分離レベルが規定するのは読み取りに現れる異常です。読んだ値をもとに書き戻す処理の取りこぼし (ロストアップデート) は、製品とレベルの組み合わせによっては検出されないまま通ります。検出される場合も直列化失敗というエラーになるので、リトライを実装しなければ処理が落ちます。

「REPEATABLE READ は、いつ読んでも最新の値が返るレベル」 — 逆です。トランザクションの開始時点のスナップショットを読み続けるので、他がコミットした更新は見えません。同じ値を繰り返し読めることを保証するために、あえて古い値を見せています。

「楽観ロックもロックの一種」 — 名前に反してロックを取りません。更新時に版数を照合して衝突を検出する手法です。「楽観」は衝突がめったに起きないという見込みを指します。

「迷ったら排他ロックを取っておけば安全」 — 待ち時間とデッドロックという代償があります。競合の頻度で選び分けます。

「ロックの順序をばらつかせれば衝突を避けられる」 — 逆効果です。順序を固定するのが回避策です。

「REPEATABLE READ ではファントムリードが必ず起きる」 — SQL 標準が定めているのは「起こりうる」までで、起きることを要求してはいません。実装がより強い保証を与えるのは許されており、PostgreSQL は実際に防ぎます。製品のドキュメントで確認してください。

確認問題

問 1. 「分離レベルを SERIALIZABLE に上げたので、もうロックは要らない」と提案されました。妥当ですか。

答え: 「ロックが要らない」という理解が誤りです。

指摘すべき点が 2 つあります。

**1. 分離レベルを上げても、内部ではロックや衝突検出が働いています。**利用者が SELECT ... FOR UPDATE を書かなくなるだけで、待ちや失敗が消えるわけではありません。SERIALIZABLE では直列化に失敗したトランザクションがエラーになるので、リトライを実装しないと処理が落ちます。

**2. 同時実行性が大きく落ちます。**SERIALIZABLE は最も強い保証を与える代わりに、待ちと失敗が増えます。全体を上げるより、競合が起きる箇所だけを狙って制御するほうが実用的です。

判断の順序は次のようになります。

やりたいこと手段
特定の行を確実に押さえたいSELECT ... FOR UPDATE (悲観ロック)
競合はまれで、起きたときだけ気づきたい版数の照合 (楽観ロック)
読み取りの一貫性を広く保ちたい分離レベルを上げる

**分離レベルと明示的なロックは代替関係ではなく、目的が違います。**分離レベルは読み取りの異常を制御し、ロックは特定の行への更新競合を制御します。

問 2. 予約システムで座席を確保します。人気公演で同じ座席への同時アクセスが多発します。楽観ロックと悲観ロックのどちらを選びますか。

答え: 悲観ロック

判断軸は衝突の頻度です。

楽観ロック悲観ロック
衝突が少ない有利。待ちが無い不要な待ちが生じる
衝突が多い**不利。**やり直しが頻発する有利。順番に確実に処理される

人気公演の座席は競合が集中するので、楽観ロックだと大半のリクエストが衝突してリトライになります。リトライしてもまた衝突するので、ユーザーから見ると「何度やっても取れない」状態になります。しかもリトライのたびにサーバー負荷がかかります。

悲観ロック (SELECT ... FOR UPDATE) なら、待たされはしますが順番に確実に処理されます。待つほうがやり直しより体験がよい場面です。

逆に、ユーザープロフィールの編集のように同じレコードを同時に触ることがまれな場合は、楽観ロックが適切です。

問 3. 送金処理でデッドロックが頻発しています。原因と対策を答えてください。
async function transfer(fromId: number, toId: number, amount: number) {
await lockAccount(fromId);
await lockAccount(toId);
// 残高を移動する
}

答え: ロックの順序が呼び出しごとに入れ替わるためです。ID の順に固定します。

transfer(1, 2)transfer(2, 1) が同時に走ると、次のようになります。

Tx1 (1→2): 口座 1 をロック → 口座 2 を待つ
Tx2 (2→1): 口座 2 をロック → 口座 1 を待つ

互いに待ち合って進みません。

対策は、業務上の順序 (送金元 → 送金先) ではなく、ID などの安定した基準で順序を固定することです。

async function transfer(fromId: number, toId: number, amount: number) {
const [first, second] = [fromId, toId].sort((a, b) => a - b);
await lockAccount(first);
await lockAccount(second);
// 残高を移動する
}

これで transfer(1, 2)transfer(2, 1) も「口座 1 → 口座 2」の順にロックします。待ちが一方向になるので循環しません。

加えて、それでも起きうるデッドロックに備えてリトライを実装しておきます。順序の固定で発生率を下げ、リトライで受け止めるのが実務的な構えです。

まとめ

  • 共有ロックは同士で共存でき、排他ロックはすべてを待たせます
  • ロックはトランザクションの終了まで保持されます。だから短く保ちます
  • 分離レベルは 4 段階で、ダーティリード・ノンリピータブルリード・ファントムリードのどれを許すかが変わります
  • **実装は標準と一致しません。**PostgreSQL の READ UNCOMMITTED は READ COMMITTED と同じで、REPEATABLE READ はファントムリードを防ぎます
  • **REPEATABLE READ は「常に最新」ではありません。**開始時点のスナップショットを読み続けます
  • **分離レベルだけではロストアップデートを防げません。**ロックか版数による衝突検出が要ります
  • 楽観ロックはロックを取らず、版数で衝突を検出する手法です。名前に反してロックの一種ではありません
  • デッドロックの回避策はロック順序の固定です。ランダム化は逆効果です
関連リファレンス

次に読む