トランザクションと分離レベル — 同時に動く処理が互いをどこまで見てよいか
在庫が二重に引かれる、残高がずれる。同時実行の事故は、分離レベルの選択と設計の両方で決まる。
この記事の進み方
What — 分離レベルごとに何が起きうるか
分離レベルは「同時に動いている他のトランザクションの作業を、どこまで見てしまうか」の設定です。
両方とも未開始在庫は 1。誰もまだ何もしていない
A が読み取り済み処理AA は stock = 1 を見た
B も読み取り済み処理BB も stock = 1 を見た。ここが分岐点
A が更新処理AA が stock を 0 にした(未コミット)
A がコミット処理Astock = 0 が確定した
B が更新して在庫が -1処理BB は自分が読んだ 1 を信じて更新した
両方とも未開始—A: SELECT stock→A が読み取り済み — A が在庫を読む。stock = 1。ロックを取らない普通の SELECT。どこで何が見えるかが分離レベルで変わる。READ COMMITTED では他方の COMMIT 後の値が見え、REPEATABLE READ ではトランザクション開始時点の値が見え続ける。
4つの分離レベル
他のトランザクションがコミットしていない変更まで見える。実務ではまず使わない。
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つ動くと何が起きますか?
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]);
}
分離レベルを常に一番厳しくしておけば安全ですか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この処理を、同時実行でも正しく動くようにするにはどうしますか?
- ポイント交換機能。ユーザーが保有ポイントで商品と交換する
- 処理内容: ①ユーザーの保有ポイントを確認 ②商品の在庫を確認 ③ポイントを減算 ④在庫を減算 ⑤交換履歴を作成
- キャンペーン開始時に、同一ユーザーから連打されることがある(二重送信)
- 人気商品には、複数ユーザーから同時に交換リクエストが来る
- ポイント残高がマイナスになることも、在庫がマイナスになることも許されない
- DB は MySQL(InnoDB)、既定の REPEATABLE READ
- · ①②の確認と③④の更新の間に、どんな隙間があるか
- · 同一ユーザーの連打と、複数ユーザーの競合は、同じ対策で防げるか
- · ロックを取る順序が問題になる場面はあるか
「在庫がマイナスになった」という不具合を、原因からチームに説明してください
- 実装は SELECT で確認してから UPDATE で減らしている
- トランザクションでは囲まれている
- 再発防止の方針まで示したい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。