仕組みから学ぶ Web
発展読了目安 16#トランザクション#ACID#分離レベル#ロック

トランザクションと分離レベル — 同時に動く処理が互いをどこまで見てよいか

在庫が二重に引かれる、残高がずれる。同時実行の事故は、分離レベルの選択と設計の両方で決まる。

この記事の進み方

What — 分離レベルごとに何が起きうるか

分離レベルは「同時に動いている他のトランザクションの作業を、どこまで見てしまうか」の設定です。

Figure在庫処理を2つ同時に実行したとき
両方とも未開始

在庫は 1。誰もまだ何もしていない

A が読み取り済み処理A

A は stock = 1 を見た

B も読み取り済み処理B

B も stock = 1 を見た。ここが分岐点

A が更新処理A

A が stock を 0 にした(未コミット)

A がコミット処理A

stock = 0 が確定した

B が更新して在庫が -1処理B

B は自分が読んだ 1 を信じて更新した

1/5
両方とも未開始A: SELECT stockA が読み取り済みA が在庫を読む。stock = 1。ロックを取らない普通の SELECT。

どこで何が見えるかが分離レベルで変わる。READ COMMITTED では他方の COMMIT 後の値が見え、REPEATABLE READ ではトランザクション開始時点の値が見え続ける。

4つの分離レベル

Compare分離レベルと、防げる異常

他のトランザクションがコミットしていない変更まで見える。実務ではまず使わない。

A: UPDATE stock = 0    (まだ COMMIT していない)
B: SELECT stock  →  0    ← 見えてしまう
A: ROLLBACK              (なかったことに)

B は存在しなかった値を読んだ = ダーティリード
ダーティリード
起きる
ノンリピータブルリード
起きる
ファントムリード
起きる
同時実行性能
最高
  • ロールバックされた値を読む可能性があるため、正しさの保証がほぼない。

上から下へ厳しくなり、同時実行性能は下がる。多くの DB の既定は READ COMMITTED か REPEATABLE READ。既定で何が防げないかを知っておくことが重要。

ダーティリード
コミットされていない変更を読むこと。ロールバックされれば、存在しなかった値を読んだことになる。
ノンリピータブルリード
同じ行を2回読んだら値が変わっていること。間に他のトランザクションがコミットした。
ファントムリード
同じ条件で2回検索したら、行数が変わっていること。他のトランザクションが行を追加・削除した。
ロストアップデート
2つの更新のうち片方が上書きされて消えること。冒頭の事故はこの一種。
楽観ロック
バージョン番号などで、読んでから書くまでに変更されていないことを確認する方式。衝突が稀な場合に向く。
悲観ロック
読む時点で行をロックし、他の処理を待たせる方式(SELECT ... FOR UPDATE)。衝突が頻繁な場合に向く。
確認 — ここまで読めたか

「在庫を SELECT して、1以上なら UPDATE で減らす」。同時に2つ動くと何が起きますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜ分離レベルという選択肢があるのか

理想を言えば、全部 SERIALIZABLE にすればよいはずです。正しさが最も強いのだから。

しかし SERIALIZABLE は、同時実行の多くを直列化するためスループットが大きく落ちます。秒間1万件の処理が必要なシステムで、全トランザクションを順番待ちさせるわけにはいきません。

そこで「どの異常なら許容できるか」という選択肢が用意されました。

  • 集計レポートの数字が、実行中に1件増減しても構わない → READ COMMITTED で十分
  • 在庫を引く処理で、二重に引かれるのは絶対に困る → その処理だけ厳しくする

重要なのは、分離レベルが「トランザクション単位で設定できる」ことです。 システム全体を厳しくする必要はなく、危険な処理だけを守ればよい

分離レベルを上げなくても防げる

冒頭の事故は、分離レベルを上げなくても2つの方法で防げます。

方法1:読む時点でロックする(悲観ロック)

BEGIN;
SELECT stock FROM products WHERE id = ? FOR UPDATE;  -- ← 他の処理を待たせる
-- ここでチェック
UPDATE products SET stock = stock - 1 WHERE id = ?;
COMMIT;

FOR UPDATE を付けると、この行に対する他の FOR UPDATE は待たされます。2つの処理が同時に同じ行を読むこと自体を防ぐわけです。

方法2:更新条件にチェックを含める

UPDATE products SET stock = stock - 1
WHERE id = ? AND stock >= 1;     -- ← 条件をここに入れる

-- 影響行数が 0 なら在庫切れ

UPDATE は行ロックを取りながら現在値に対して実行されるため、同時実行でも条件が正しく評価されます。SELECT と UPDATE の間の隙間が存在しません。

デッドロックはなぜ起きるか

2つのトランザクションが、互いに相手が持っているロックを待つ状態です。

A: 商品1 をロック → 商品2 のロックを待つ
B: 商品2 をロック → 商品1 のロックを待つ
→ 永遠に解けない

DB はこれを検出して片方を強制終了します。アプリ側はエラーを受け取り、リトライする必要があります。

予防策は ロックを取る順序を揃えること。常に ID の昇順でロックすれば、循環が発生しません。

// ID 順に並べてからロックする
const ids = [productB, productA].sort((a, b) => a - b);
for (const id of ids) {
  await db.query('SELECT * FROM products WHERE id = ? FOR UPDATE', [id]);
}
確認 — ここまで読めたか

分離レベルを常に一番厳しくしておけば安全ですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この処理を、同時実行でも正しく動くようにするにはどうしますか?

与えられた条件
  • ポイント交換機能。ユーザーが保有ポイントで商品と交換する
  • 処理内容: ①ユーザーの保有ポイントを確認 ②商品の在庫を確認 ③ポイントを減算 ④在庫を減算 ⑤交換履歴を作成
  • キャンペーン開始時に、同一ユーザーから連打されることがある(二重送信)
  • 人気商品には、複数ユーザーから同時に交換リクエストが来る
  • ポイント残高がマイナスになることも、在庫がマイナスになることも許されない
  • DB は MySQL(InnoDB)、既定の REPEATABLE READ
この軸で考える
  • · ①②の確認と③④の更新の間に、どんな隙間があるか
  • · 同一ユーザーの連打と、複数ユーザーの競合は、同じ対策で防げるか
  • · ロックを取る順序が問題になる場面はあるか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「在庫がマイナスになった」という不具合を、原因からチームに説明してください

与えられた条件
  • 実装は SELECT で確認してから UPDATE で減らしている
  • トランザクションでは囲まれている
  • 再発防止の方針まで示したい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この知識を使う実践ケース

この記事を前提にしている記事