🪨

ロックをかけずにDBの整合性を保つ方法

2024/05/01に公開

データベースの整合性は、多くのアプリケーションにとって重要な要素です。

しかし、ロックをかける手法では仕様面で考慮しなければいけないことが莫大に増えるため、銀行システムほどの大規模な開発ではない限り避けたいケースが多くあるかと思います。
私もIOSアプリ(ブラックジャンケン)のオンライン対戦を開発する際、この問題に直面しました。

本記事では、DBロックを使用せずに安全にデータを管理する方法を解説します。
また、トランザクションを利用する場合でも書き込み順序によってはデッドロックを引き起こす可能性もあるため、トランザクションも利用しない方針(最後の章では例外)で解説したいと思います。

システム要件

私の作成したIOSアプリで求めた要件は以下です。

  1. 1対戦ルームにつき、特定テーブルの1行で管理する
  2. 2人が同時に更新リクエストを出した場合でも、データの整合性は保てるようにする
    (両方が一気に反映された状態、もしくは片方ずつリクエストされた状態となるようにする)
  3. 対戦におけるデータ不整合によるバグは許容しないが、極低確率での対戦不可な状況は許容する
    (極低確率でも許容しない場合は進行不能バグも回避するまで読んでください)

整合性を保つフロー (先行鍵取得方式)

同様の手法で整合性を保っている記事が見当たらなかったため、私はこのフローを先行鍵取得方式と命名しました。

整合性をどのようにして保つか、ここではトイレに行きたい2人のイラストで例えたいと思います。

  1. 2人が同時にトイレに行きたくなりました。トイレは一つしかありません。トイレには鍵がかかっており、テーブルの上に無数に置かれています。

  2. 先にトイレに辿り着いた人は鍵を開けます。

  3. トイレに入っている間に他の人が鍵を開けて入ることを防ぐため、先に着いた人はトイレの鍵を壊して中に入ります。

  4. 後に着いた人は仕方なくその場から立ち去ります。先にトイレに着いた人は自由を謳歌できます。

  5. 用を済ませたらトイレから出て鍵を直し、別の形の鍵をテーブルに並べ直します。

もし内鍵をかけて入る式のトイレの場合、2人が全く同じタイミングでトイレに入ってしまうリスクがありますが、もし「先に着いた人はトイレの鍵を壊さないと中に入れない」という制約を課した場合、トイレの鍵を壊す行為ができるのは1人だけになります。
こうすることで、2人同時にトイレに入ってしまうリスクを無くすことができます。

DB制御フロー

実際のDB上での失敗パターン・成功パターンの制御フローを考えていきます。
更新対象のデータは以下です。

テーブル \ カラム toilet_id (主キー) toilet_paper_count
toilet_rooms 1 5

前提として、以下条件を付け加えます。
・toilet_paper_countは今のトイレットペーパーの枚数で、トイレに入ってくる人に応じて消費する枚数が違います。
・A,Bさんがトイレに入った時、Aさんはトイレットペーパーを1枚、Bさんはトイレットペーパーを2枚消費します。

失敗パターン (整合性が保てないパターン)

  1. AさんとBさん2人同時にトイレに行きたくなりました。Aさん・Bさん共に、今のトイレットペーパーの枚数(5)を取得します。

  2. Aさんはトイレットペーパーを1枚消費するので、toilet_paper_countから1を引いた(4)に更新します。

    テーブル \ カラム toilet_id (主キー) toilet_paper_count
    toilet_rooms 1 4
  3. Bさんはトイレットペーパーを2枚消費するので、toilet_paper_countから2を引いた(3)に更新します。

    テーブル \ カラム toilet_id (主キー) toilet_paper_count
    toilet_rooms 1 3

これでは本来3枚消費したはずなのに、Aさんの消費したトイレットペーパー1枚の消費が反映されない状態となってしまいます。

成功パターン (整合性が保てるパターン)

更新対象テーブルに、今の鍵の形を記憶するtoilet_keyカラムを追加し、使用した鍵を管理するused_toilet_keysテーブルを追加します。

テーブル \ カラム toilet_id (主キー) toilet_paper_count toilet_key
toilet_rooms 1 5 def
テーブル \ カラム toilet_id (複合主キー) toilet_key (複合主キー)
used_toilet_keys 1 abc
  1. AさんとBさん2人同時にトイレに行きたくなりました。Aさん・Bさん共に、今のトイレットペーパーの枚数(5)と今の鍵の形(def)を取得します。

  2. Aさんが先にトイレに到達したので、鍵を壊し中に入ります。
    (used_toilet_keysテーブルに、「toilet_id: 1, toilet_key: def」を追加します。)

    テーブル \ カラム toilet_id (複合主キー) toilet_key (複合主キー)
    used_toilet_keys 1 abc
    used_toilet_keys 1 def
  3. Bさんが後に到着し、同じく鍵を壊そうとしますが、既に壊されていたため仕方なく帰っていきます。
    (used_toilet_keysテーブルに、「toilet_id: 1, toilet_key: def」を追加しようとしましたが、既に同じ複合主キーのデータが存在するため、エラーが返ってきます。)

  4. Aさんはトイレットペーパーを1枚消費するので、toilet_paper_countから1を引いた(4)に、toilet_keyを(ghe)に更新します。

    テーブル \ カラム toilet_id (主キー) toilet_paper_count toilet_key
    toilet_rooms 1 5 ghe

こうすることで、トイレットペーパーの消費量を整合性を保った上で更新することができます。

対象テーブルが1テーブルだけの場合、楽観的排他制御を踏襲すればこのような回りくどいことをしなくても済みますが、複数テーブルを更新したい時にこの手法を利用すると、全て更新し終わった後に鍵を入れ替えることで、複数テーブルデータの更新において整合性を保つことが可能になります。

進行不能バグも回避する

ここまでで紹介した先行鍵取得方式のフローでは、DBの整合性は保つことができますがtoilet_roomsの特定行の更新が一切できなくなってしまうパターンがあります。
トイレの例で例えると、トイレ利用権を獲得した人が新たな鍵を設置する前に中で死んでしまうパターンです。

サーバのプロセスが途中で切れることは早々ないですが有り得なくはありません。
オンラインゲーム対戦だと、1戦が流れるだけですのでこのリスクは許容する前提として進めましたが、絶対に進行不能バグを起こしてはならないシステムだとこのパターンも回避する必要があります。

その場合は「READ UNCOMMITTED」レベルでのトランザクションを開くことで解決できます。
もしトイレの中で人が死んでしまったとしても死んだことを検知して元の鍵を自動で修復し、他の人が入ってこれる状況を作ることが可能です。

ダーティリードの問題が起こり得ますので、正常に更新が完了したかどうかをクライアント側で把握できるような仕組みも必要になります。
鍵を保持する対象テーブルの鍵が更新されたかどうかで、一連の更新処理が正常に行われたかがわかるかと思います。

終わりに

本記事では、データベースの整合性を保つためにロックを使用せずに済む方法をご紹介しました。
同様の課題に直面している開発者の方々にとって、有益な参考になれば幸いです。

先行鍵取得方式を利用して作ったブラックジャンケンも是非遊んでいただけると嬉しいです。

Discussion