楽観ロックが効いていない — 予約が二重に入る4つの実装ミス | GH Media
URLがコピーされました

楽観ロックが効いていない — 予約が二重に入る4つの実装ミス

URLがコピーされました
楽観ロックが効いていない — 予約が二重に入る4つの実装ミス

「同じ時間枠に予約が2件入っています」という連絡が現場から来る。ログを確認すると、2つのリクエストはどちらも正常終了していて、例外は1件も記録されていない。データベースを見ると、確かに2件ある。

在庫でも同じことが起きます。残り1個の商品に2人が同時に注文を入れ、在庫数が -1 になる。どちらの注文も決済まで通っていて、システム上はエラーゼロ。

この種の不具合が厄介なのは、再現しないことです。手元で順番に操作しても起きません。同時に起きたときだけ、しかも数百ミリ秒のずれの中でだけ発生します。そして排他制御を「入れてある」システムでも普通に起きます。入れ方が惜しいと、入れていないのとほぼ同じ結果になるからです。

消えているのは、片方の更新そのもの

失われた更新(lost update)は、次の並びで起きます。

  1. リクエストAが予約テーブルの該当行を読む。空きあり
  2. リクエストBが同じ行を読む。こちらも空きあり
  3. Aが「空きがあるので予約可能」と判断して書き込む
  4. Bも「空きがあるので予約可能」と判断して書き込む

Bが読んだのは、Aが書き込む前の状態です。Bにとって世界は正しく、判断も正しい。間違っているのは、判断してから書き込むまでの間に世界が変わったことに気づく手段がないことです。

排他制御は、この「気づく手段」を用意する話です。手段は大きく2つに分かれます。悲観ロックは、読む時点で他人を待たせます。競合が起きないので確実ですが、待ちが発生します。楽観ロックは、読む時点では何もせず、書き込む瞬間に「自分が読んだあと誰も触っていないか」を確かめます。競合が滅多に起きない前提なら、こちらのほうが待ちがない分だけ速くなります。

問題は、この「書き込む瞬間に確かめる」を実装で正しく表現できているかどうかです。

比較と更新が分かれた瞬間に、楽観ロックは無効になる

楽観ロックの標準的な作りは、対象の行に version カラムを持たせ、更新のたびに1つ増やす方式です。ここまでは、たいていの実装で共通しています。

分かれるのは、その先です。次のコードは、動いているように見えて何も守っていません。

// 読む
Reservation r = repo.findById(id);

// 比較する
if (r.getVersion() != requestVersion) {
    throw new ConflictException();
}

// 更新する
r.setStatus("BOOKED");
r.setVersion(r.getVersion() + 1);
repo.save(r);

AもBも、比較した時点では version が一致しているので、どちらもチェックを通過します。そのあと両方が UPDATE を投げ、後に投げたほうが勝ちます。守りたかったはずの「失われた更新」が、楽観ロックを入れたうえで起きます。

原因は、比較と更新が別々の処理になっていることです。この2つの間には隙間があり、隙間には他のトランザクションが入れます。防ぐには、比較と更新をデータベース側の1文にまとめる必要があります。

UPDATE reservations
   SET status  = 'BOOKED',
       version = version + 1
 WHERE id      = :id
   AND version = :expected_version;

この形なら、条件の評価と更新が同じ文の中で起きるので、隙間がありません。あとから来たほうは version が一致せず、更新行数が 0 で返ってきます。

更新行数を見ていないと、静かに壊れる

ここが2つめの落とし穴です。上の SQL を書いても、返ってきた更新行数を確認していなければ意味がありません。0行でも SQL としては成功なので、例外は飛びません。

int updated = jdbc.update(SQL, params);
if (updated == 0) {
    throw new OptimisticLockException();  // ここが無いと、失敗が成功として通る
}

ORM を使っている場合、@Version に相当する仕組みが更新行数を見て例外を投げてくれることが多いのですが、設定次第では黙って通る経路が残ります。バルク更新、ネイティブクエリ、INSERT ... ON CONFLICT を使った書き方などは、フレームワークのバージョン管理の外に出ます。「ORM に任せてあるので大丈夫」で片付けず、その経路が本当にバージョンを見ているかは確認する価値があります。

読み取り・比較・更新が分かれた実装と、UPDATE の WHERE 句に条件をまとめた実装で、同時実行時に何が変わるかを示した図

数量の管理に、楽観ロックは向かない

3つめは、そもそも道具の選択を間違えているパターンです。

在庫数のように「複数人が同時に減らすことが日常的に起きる」データに楽観ロックを当てると、競合が頻発します。楽観ロックは競合が稀であることを前提にした設計なので、頻発する場所ではリトライが積み上がり、混雑時ほど遅くなり、混雑時ほど失敗します。いちばん守りたい瞬間にいちばん弱くなるという、ありがたくない性質が出ます。

数量については、条件を SQL 側に持たせた原子的な更新のほうが素直です。

UPDATE inventories
   SET stock = stock - :qty
 WHERE sku   = :sku
   AND stock >= :qty;

現在の値を読んでからアプリ側で引き算するのをやめ、「引ける場合だけ引く」を1文で表現します。更新行数が 0 なら在庫不足です。バージョンカラムは要りません。

使い分けの目安は、次のあたりに落ち着きます。

データの性質向いている方式
同じ行を同時に触ることが稀(顧客情報、設定、記事)楽観ロック(version + WHERE 句)
数量の増減(在庫、ポイント、残高)条件付きの原子的更新
同時アクセスが常態で、順序も保証したい(座席指定、採番)悲観ロック(SELECT ... FOR UPDATE)またはキュー化

予約システムのように、枠の確保と在庫の減算と決済が一連で走るものは、この3つが1つのフローに混在します。フロー全体を1つの方式で統一しようとすると、どこかに無理が出ます。予約まわりを自作するか既製サービスに寄せるかの判断軸は予約システムは作るか、SaaSに乗るかで整理しています。

残りのつまずきどころ

**updated_at をバージョン代わりにするのは避けたほうが無難です。**時刻の精度がミリ秒までしかない環境では、同一ミリ秒内の2つの更新が同じ値になります。また、アプリケーションサーバーが複数台ある構成では、サーバー間の時刻ずれが競合判定に混ざります。整数のカウンタを使うほうが、考えることが減ります。

リトライは、必ず入れるものではありません。競合を検出したあと自動でリトライすると、ユーザーが見ていた画面の内容と、実際に書き込まれる内容がずれます。「他の人が先に更新しました。最新の内容を確認してください」と返して人に判断させるほうが正しい場面のほうが、業務システムでは多くなります。リトライしてよいのは、リトライしても結果が変わらない処理だけです。

トランザクションの境界が実装とずれていることがあります。楽観ロックのチェックはトランザクションの中にありますが、外部APIの呼び出しやファイル書き込みがその外に出ていると、ロールバックしても副作用だけが残ります。テーブルの状態遷移をどう持つかはステータス管理と論理削除の設計で扱った考え方が近く、状態を持つ場所を整理しておくと境界の議論がしやすくなります。

壊れていないことを、どう確かめるか

同時実行の不具合は、通常のテストでは通ってしまいます。確かめたいなら、同時に叩くテストを書くしかありません。

やることは単純です。同じ対象に対する更新リクエストを N 本、同じタイミングで投げる。そのうえで、成功が1件、残りが競合エラーになっていることと、データベースの最終状態が矛盾していないことの両方を検査します。片方だけでは足りません。「エラーが返っている」ことと「データが正しい」ことは別なので、両方を見て初めてテストになります。

CountDownLatchPromise.all で足並みを揃えるだけでも、上に挙げた実装ミスは十分に露出します。CI で毎回回すには重いなら、日次のジョブに寄せる判断でも構いません。一度も同時に叩いたことがない状態でリリースするより、はるかにましです。

次にやること

自社のシステムで、予約や在庫のように競合が起きうるテーブルを1つ選び、そこを更新している SQL を実際に見てください。確認するのは1点だけです。UPDATE の WHERE 句に、バージョンまたは数量の条件が入っているかどうか。入っていなければ、その処理は同時実行に対して無防備です。

入っていた場合は、次に更新行数を見て分岐しているかを確認します。この2つが揃っていない箇所は、まだ発生していないだけの不具合として残っています。

既存システムの排他制御の見直しや、同時実行を含めた検証の組み立てについては、グリームハブの開発・AI・自動化のご相談で承っています。業務フローによって取るべき方式が変わるため、個別にご相談ください。お問い合わせからどうぞ。

Sources

URLがコピーされました

グリームハブ株式会社は、変化の激しい時代において、アイデアを形にし、人がもっと自由に、もっと創造的に生きられる世界を目指しています。

記事を書いた人

鈴木 翔

鈴木 翔

技術の可能性に魅了され、学生時代からプログラミングとデジタルアートの分野に深い関心を持つ

関連記事

「TECH」の記事一覧を見る