楽観ロックと悲観ロックの違い:FOR UPDATEとversion列

red padlock on black metal railing SQL

在庫が残り1個なのに、2人の注文が両方とも通ってしまった。管理画面で2人が同じ商品を編集し、先に保存した方の変更が消えた。どちらも「同時更新」が原因で起きる事故です。この記事では、これを防ぐ2つの排他制御(同時に同じデータを触らせない仕組み)を比べます。読む前にロックを取る悲観ロックは SELECT ... FOR UPDATE で、書くときに衝突を検出する楽観ロックは version 列で実装します。MySQL 8.0 で動くSQLと、待たずに失敗させる NOWAIT・SKIP LOCKED の使い方、どちらを選ぶかの判断表まで1本で整理しました。

このシナリオで考える:在庫の引き当て

題材はECサイトの在庫テーブルです。注文が入ったら在庫数を確認し、足りていれば1つ減らします。この「確認してから減らす」の間に別の注文が割り込むと、在庫がマイナスになったり二重に売れたりします。

CREATE TABLE products (
  id      INT PRIMARY KEY,
  name    VARCHAR(50) NOT NULL,
  stock   INT NOT NULL,
  version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;

INSERT INTO products (id, name, stock) VALUES
  (1, 'ライブTシャツ', 1),
  (2, 'タオル', 30),
  (3, 'キーホルダー', 5);

何も対策しないと、次のような順番で事故が起きます。セッションA・Bは別々の注文処理です。

時刻 セッションA セッションB
1 SELECT stock → 1
2 SELECT stock → 1
3 在庫ありと判断して stock = 0 に更新
4 在庫ありと判断して stock = 0 に更新

1個しかない商品が2回売れています。この失敗は「更新の消失(ロストアップデート)」と呼ばれます。分離レベルを REPEATABLE READ にしても、普通の SELECT だけでは防げません。

悲観ロック:SELECT … FOR UPDATEで先に押さえる

悲観ロックは「きっと衝突する」と考えて、読む時点でロックを取る方法です。MySQL では SELECT ... FOR UPDATE を使います。ロックした行は、トランザクションが終わるまで他のセッションから更新できません。

START TRANSACTION;

-- 行ロックを取りながら読む
SELECT stock FROM products WHERE id = 1 FOR UPDATE;

-- アプリ側で stock > 0 を確認してから減らす
UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT;

このときセッションBが同じ行に FOR UPDATE を投げると、Aが COMMIT するまで待たされます。Aの COMMIT 後、Bは更新後の値を読みます。

-- セッションB(Aの COMMIT 後に返ってくる)
+-------+
| stock |
+-------+
|     0 |
+-------+

Bは在庫0を見て「売り切れ」と判断できます。二重販売は起きません。

FOR UPDATE と FOR SHARE の違い

ロック付きの読み取りには2種類あります。更新するつもりなら FOR UPDATE、読んだ値が変わらないことだけ保証したいなら FOR SHARE を選んでください。

構文 ロックの種類 他セッションの読み取り 他セッションの更新
SELECT … FOR UPDATE 排他ロック 普通のSELECTは可、FOR SHAREは待つ 待つ
SELECT … FOR SHARE 共有ロック 可(FOR SHAREどうしも可) 待つ
普通の SELECT ロックなし 可 可

普通の SELECT は MVCC(過去のバージョンを読む仕組み)でロックを無視して読みます。FOR SHARE は古い LOCK IN SHARE MODE の新しい書き方です。

待たずに失敗させる:NOWAIT と SKIP LOCKED

MySQL 8.0 からは、ロック待ちの振る舞いを変える2つのオプションが使えます。

-- ロック中ならすぐエラーにする
SELECT * FROM products WHERE id = 1 FOR UPDATE NOWAIT;
-- ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.

-- ロック中の行は飛ばして、取れる行だけ返す
SELECT * FROM jobs WHERE status = 'waiting'
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;
オプション 動き 向いている場面
なし(既定) ロックが外れるまで待つ 在庫引き当てなど、順番に処理したい
NOWAIT すぐエラーを返す 「他の人が編集中です」と即座に返したい画面
SKIP LOCKED ロック中の行を結果から除く 複数ワーカーでジョブキューを取り合う

SKIP LOCKED は結果が一貫しないため、集計や残高の確認には使えません。キュー型のテーブル専用と覚えてください。

楽観ロック:version列で衝突を見つける

楽観ロックは「めったに衝突しない」と考えて、読むときはロックを取らない方法です。代わりに更新時に、読んだときから行が変わっていないかを確かめます。目印に使うのが version 列です。

  1. 普通の SELECT で、データと version を読む
  2. アプリ側で画面表示や計算を行う
  3. UPDATE の WHERE に「読んだときの version」を入れ、同時に version を1増やす
  4. 更新件数(affected rows)が0なら、誰かが先に更新したと判断する
-- 1. 読む(ロックなし)
SELECT id, name, stock, version FROM products WHERE id = 2;
-- → stock = 30, version = 0

-- 3. 読んだ version を条件に入れて更新
UPDATE products
SET    stock = 29,
       version = version + 1
WHERE  id = 2
  AND  version = 0;

先に別のセッションが更新していると、version はすでに1になっています。条件に合う行がないため、UPDATE は何も変えずに終わります。

-- 先に更新された後の結果
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

アプリ側では、この0件を「衝突」として扱います。画面なら「ほかの人が更新しました。読み込み直してください」と表示するのが定番です。バッチなら、読み直してからやり直します。

version の代わりに使えるもの

  • updated_at 列:使えますが、同じ秒に2回更新されると見分けられません。DATETIME(6) などの精度が必要です
  • 更新したい列そのもの:WHERE stock = 30 のように元の値を条件にする方法です。列が少ないときは手軽です
  • 整数の version 列:一番確実で、多くのORM(Rails の lock_version、JPA の @Version など)もこの方式です

在庫だけなら条件付きUPDATE1本でも足りる

「在庫を1減らす」だけなら、ロックも version も使わずに済みます。判定と更新を1文にまとめれば、MySQL がその行を自動でロックするからです。

UPDATE products
SET    stock = stock - 1
WHERE  id = 1
  AND  stock > 0;
-- 0 rows affected なら売り切れ

読んだ値をアプリで加工してから書き戻す処理には、悲観・楽観どちらかのロックが必要になります。

どちらを選ぶか:判断の目安

衝突の頻度と、処理にかかる時間で選ぶのが基本です。

観点 悲観ロック(FOR UPDATE) 楽観ロック(version列)
ロックを取るタイミング 読むとき 取らない(更新時に確認)
衝突したときの動き 後の人が待つ 後の人の更新が0件になる
向いている衝突頻度 多い(人気商品・残高) 少ない(マスタ編集・プロフィール)
トランザクションの長さ 短くないと待ちが増える 画面を開いている間でもよい
アプリ側の実装 少ない 0件時のリトライや再表示が必要
デッドロックの心配 ある ほぼない

特に「編集画面を開いてから保存まで」のように人が操作する時間を含む処理は、楽観ロック一択です。数分もロックを持ち続けると、ほかの全員が待たされてしまいます。

よくある落とし穴

  • autocommit のまま FOR UPDATE を書く:START TRANSACTION がないと、SELECT が終わった瞬間にロックが外れます。悲観ロックは必ずトランザクションの中で使ってください
  • WHERE の列にインデックスがない:InnoDB は検索でたどった行にロックをかけます。インデックスがないと全件をなめるため、関係ない行まで大量にロックされます
  • ロックの順番がバラバラ:複数の行を FOR UPDATE するとき、セッションごとに順番が違うとデッドロックになります。ORDER BY id などで順番をそろえましょう
  • 楽観ロックで更新件数を見ていない:0件でもエラーにはなりません。affected rows を確認しないと、衝突に気づけません
  • サブクエリはロックされない:外側の FOR UPDATE は、サブクエリで読んだ行まではロックしません。必要ならサブクエリにも FOR UPDATE を付けます

デッドロックの詳しい回避策は「MySQLデッドロックの原因と回避策」、分離レベルとの関係は「MySQLのトランザクション分離レベル」で解説しています。

まとめ

同時更新の事故は、普通の SELECT と UPDATE だけでは防げません。衝突が多く処理が短いなら、SELECT ... FOR UPDATE の悲観ロックが向いています。衝突がまれで、人の操作をはさむなら version 列の楽観ロックです。待たせたくない場面では NOWAIT、キューの取り合いには SKIP LOCKED が使えます。在庫を減らすだけなら、条件付き UPDATE 1本で済むことも覚えておきましょう。

参考リンク

アイキャッチ画像: Photo by Christian Englmeier on Unsplash

タイトルとURLをコピーしました