開発でのロックの重要性とORMでのロックの実現例 |楽観的ロックの紹介

こちらは 「アプリケーション開発におけるロックの重要性と ORM におけるロックの実現例〜楽観的ロックの紹介〜」からの転載です。


前回、「開発におけるロックの重要性と ORM でのロックの実現例」ではロックについて掘り下げ、トランザクションについてとその特徴を紹介し、その中で楽観的ロックの存在を挙げました。

そこで、今回は楽観的ロック及び悲観的ロックについて紹介したいと思います。

まずは、データの不整合が発生する状態とトランザクションの分離レベルについて詳細を紹介し、続いて楽観的ロックと悲観的ロックについて紹介していきます。

データの不整合

ロックによりデータの不整合が発生することを防ぐことができますが、不整合が発生するケースは様々です。

データの不整合を全て防ごうとすると可用性や性能が著しく低下するため、どの程度まで不整合を許容するかを考え、それに見合ったトランザクションの分離レベルを選択する必要があります。

データの不整合が発生した状態

整合性に問題が発生した状態として、ANSI/ISO 標準 SQL と論文「A critique of ansi sql isolation levels」から取り上げると次の項目が挙げられます。

  • ダーティライト(Dirty Write)

    • 複数のトランザクションが同じエンティティを更新した後、あるトランザクションがロールバックした場合に戻すべき値が不明となった状態である
  • ロストアップデート(Lost Update)

    • とあるトランザクションが書き込んだ値が、他のトランザクションにより上書きされた状態である
  • ダーティリード(Dirty Read)

    • とあるトランザクションが更新した値 A' を他のトランザクションが参照した後に、更新された値がロールバックされると、読み取った値 A' がロールバックされずにトランザクションに利用されることになってしまった状態である
  • ファジーリード(Fuzzy Read) / 非再現リード / ノンリピータブルリード(Non-repeatable read) ※

    • とあるトランザクションが読み込んだ値 A が、他のトランザクションにより A' に更新されてコミットされると、値 A が二度と呼び出せなくなってしまった状態である。

      つまり、とあるトランザクションは何度呼び出しても値 A を読み込めることを期待しているが、他のトランザクションにより更新されることで、二度と呼び出せなくなった状態である

  • ファントムリード(Phantom Read) ※

    • とあるトランザクションがテーブルを読み込んだ後に、他のトランザクションによりエンティティが挿入された場合、再度テーブルを読み込むと挿入されたエンティティが参照できてしまう状態である
  • リードスキュー(Read Skew) ※

    • とあるトランザクション A がエンティティの値 x を読み込んだ後に、他のトランザクションにより同一エンティティの値 x, y を書き込んだ上でコミットした後、トランザクション A が値 y を読み込むことで発生する、トランザクション A が持つ x は古く y は新しいという不整合である
  • ライトスキュー(Write Skew) ※

    • とあるトランザクション A がエンティティの値 x, y を読み込んだ後に、他のトランザクションにより同一エンティティの値 x, y の読み込みと新しい y を -x 等として書き込んだ上でコミットした後、トランザクション A が x を -y(この時点でy=-xなのでx) を書き込むことで発生する不整合である

「※」が付いた不整合状態は、コミット前の値は参照できない前提で考えると問題の本質が分かりやすいと思われます。(それでも防げない状態であるため)

リードスキューとライトスキューを除く各状態について、以下に例を用いて問題が発生するまでのシーケンスを図示します。

ダーティライト

ダーティライトとは「複数のトランザクションが同じエンティティを更新した後、あるトランザクションがロールバックした場合に戻すべき値が不明となった状態」です。

図のように、Transaction A が Michael のニックネームを Mick から Mike へ変更したことにより、Transaction B がロールバックする際に Mick に戻すべきか、Mike へ戻すべきか判断できない状態となります。

ロストアップデート(Lost Update)

ロストアップデートとは「とあるトランザクションが書き込んだ値が、他のトランザクションにより上書きされた状態」です。

図のように、Transaction A が Michael のニックネームを Mick から Mike へ変更し、その後に Transaction B が Michael のニックネームを Mick から Mickey へ変更する操作を行うと、Transaction A の更新が失われてしまいます。

ダーティリード

ダーティリードとは「とあるトランザクションが更新した値 A' を他のトランザクションが参照した後に、更新された値がロールバックされると、読み取った値 A' がロールバックされずにトランザクションに利用されることになってしまった状態」です。

図のように、Transaction A がコミットする前に Michael のニックネームを Mick から Mike へ変更した内容をTransaction B が読み取ってしまうと、Transaction A がロールバックしても Transaction B はニックネーム Mike を保持し続けてしまいます。

ファジーリード / 非再現リード / ノンリピータブルリード

ファジーリードとは「とあるトランザクションが読み込んだ値 A が、他のトランザクションにより A' に更新されてコミットされると、値 A が二度と呼び出せなくなってしまった状態」です。

ダーティリードを防ぐために、他のトランザクションがコミットする前のエンティティは参照できないようにしても発生します。

図のように、Transaction A が処理中に読み込んだエンティティは Michael の Age が 12 であった値が、Transaction B によって Age が 13 に変更された上でコミットされると、再度 Transaction A が読み取った値は Age 13 になります。

ファントムリード

ファントムリードとは「とあるトランザクションがテーブルを読み込んだ後に、他のトランザクションによりエンティティが挿入された場合、再度テーブルを読み込むと挿入されたエンティティが参照できてしまう状態」です。

ファジーリードを防ぐために、トランザクション処理中に読み取ったエンティティの値は常に同じとなるようにしても発生します。

図のように、Transaction B は Prize winners テーブルから読み取ったレコード数は 2 つであるため、David を当選者へ追加しようとしますが、Transaction A により Charlie が当選者に追加された上でコミットされると、Transaction B では新しい当選者 Charlie が追加されたテーブルが読み込まれるようになります。

トランザクションの分離レベル

データの不整合が発生するケースを紹介しましたが、極端な話ではトランザクションを並列で実行せずに直列(シリアル) に実行することで全てのケースを防ぐことが出来ます。

しかしそれでは非効率なので、次に示すトランザクションの分離レベルが ANSI/ISO 標準 SQL によって定義されています。(前回記事で紹介した通り、これらの分離レベルは MySQL 等多くの DBMS で実装されています)

  • READ UNCOMMITTED

    • コミットされていない値も読み取ることが出来るよう分離する
  • READ COMMITTED

    • 読み取った値は必ずコミットされた値となるよう分離する
  • REPEATABLE READ

    • とあるトランザクション処理の間、同じエンティティであればいつ読み取っても同じ値となるよう分離する
  • SERIALIZABLE

    • 複数のトランザクション処理結果が、シリアルに実行された場合と同じ結果になるよう分離する

それぞれの分離レベルにおいてデータ不整合発生可否をまとめると次のようになります。

分離レベル Dirty Write Dirty Read Non-Repeatable Read Phantom Read
READ UNCOMMITTED × × ×
READ COMMITTED × ×
REPEATABLE READ ×
SERIALIZABLE

<凡例>×…発生する、〇…発生しない

一方で、論文「A critique of ansi sql isolation levels」では上記の ANSI/ISO SQL 標準における分離レベルを再定義・拡張させた分離レベルが定義されています。
(参考: A critique of ansi sql isolation levels 解説公開用)

この定義の中では ANSI/ISO 標準 SQL のトランザクション分離レベルを明確に定義し、それらの分離レベルでは防ぐことのできないデータ不整合が発生する状態を追加し、それらに含むトランザクション分離レベル毎の発生可否を整理しています。

新たに追加されたトランザクション分離レベルは次の 2 つであり、Snapshot Isolation が Serialize に近い分離レベルを保つことができ、並列で実行することのできるレベルであると述べています。(Snapshot Isolation は InterBase, Firebird, Oracle, PostgreSQL, SQL Anywhere, MongoDB, Microsoft SQL Server (2005 and later) で実装されています。但し、Oracle では Snapshot Isolation を Serializable と呼ぶなど、DBMS によって分離レベル名が異なっていたりするようです。※参考)

  • Cursor Stability

    • SQL カーソルにおけるロック動作を踏まえた拡張により、READ COMMITTED では解決できない問題を防げるよう分離する
  • Snapshot Isolation

    • とある時点において取得したスナップショットに対してトランザクション操作を行うことで、ファントムリードを防げるよう分離する

分離レベルの再定義は割愛して、トランザクションの分離レベルとデータの不整合発生可否をまとめると次のようになります。

分離レベル Dirty Write Dirty Read Lost Update Non-Repeatable Read Phantom Read Read Skew Write Skew
READ UNCOMMITTED × × × × × ×
READ COMMITTED × × × × ×
Cursor Stability × ×
REPEATABLE READ ×
Snapshot Isolation ×
SERIALIZABLE

<凡例>×…発生する、〇…発生しない、△…一部発生する

分離レベルの選択

トランザクションの分離レベルの種類と、それに応じて防ぐことのできるデータ整合性について紹介しましたが、結論としてはどのような目的・環境においても最適となる分離レベルは存在せず、可用性と性能のトレードオフで選択することになります。

参考までに、いくつかの DBMS におけるデフォルトの分離レベルを紹介します。

DBMS名 デフォルトの分離レベル 参考情報

MySQL 8.0(5.6も同じ)
(InnoDB) | REPEATABLE READ | 8.0, 5.6
PostgreSQL 10(9.6も同じ) | READ COMMITTED | 9.6, 10
Oracle Database 18c(12cも同じ) | READ COMMITTED | 18c, 12c
Microsoft SQL Server 2017(2016も同じ) | READ COMMITTED | 2017, 2016
MongoDB 4.0 | READ UNCOMMITTED | 4.0
※v4.0からマルチドキュメントのトランザクションが対応された

ロックの有効期間と楽観的アプローチ

トランザクションの分離レベル以外の観点として、ロックの有効期間の違いによってもデータの不整合状態を防げる可能性の違いと性能の違いが生まれます。

例えばトランザクションが開始されてから終了するまでの間ずっとロックを行うことで不整合を防げる可能性は高まりますが、ロックが解放されるまでの待ち時間が増えることになります。

一方で、読込・更新操作の間だけロックをかけることで待ち時間は少なくなりますが、今度は不整合が発生する可能性が高まります。

そこで、不整合が発生するような更新が同時に行われる頻度によるアプローチの違いを紹介します。

悲観的ロック(Pessimistic Locking)

概要

悲観的ロックは更新が同時に行われる頻度が高いことを想定しており、読込・更新処理が開始された時点で他の処理を排除するロック方式です。

アプリケーションレベルでも悲観的ロックを行うことは可能ですが、一般的に DB レベルで行なわれます。

書き込み操作が主に行われる用途に対して適したロック方式です。

デメリット

悲観的ロックはロックが解除されるまでトランザクションの待ち時間が長く発生する可能性があること、明示的な開放が必要となることから、読み取り操作が主であり、ステートレスな通信である HTTP 等との相性が悪い(ロックかけっぱなしが発生しうる)とされています。

楽観的ロック(Optimistic Locking)

概要

楽観的ロックはレコードに対する書き込みを禁止するためのロック方式の 1 つです。

更新が同時に行われる頻度は低いだろうという楽観的な考えに基づくロック方式です。
ロックと言いつつも、データに対してのロックは行わずに競合の検証のみを行います。

悲観的ロックの対となる方式です。

ロックの仕組み

楽観的ロックは ActiveRecord や GORM 等、O/R マッパーによりアプリケーションレベルで実装されています。

ロックをかける実装の単位としては、エンティティ単位(RDB におけるテーブルの 1 レコード単位) であることが一般的のようです。
(単位は O/R マッパーの実装に依存するとは思いますが、そもそも同時更新が行われる頻度が低い前提なので、カラム単位で設定するメリットが少ないのだと思います)

ロックの仕組みは次のとおり単純なものです。

  1. エンティティを更新する前にエンティティ毎に設定したバージョンを読み取る

  2. エンティティの更新処理が完了したらバージョンが読み取った時から変わっていないか検証する

  3. 【バージョン変化なしの場合】

    競合がなかったと判断して、バージョンをカウントアップしてエンティティの更新処理を行う

  4. 【バージョン変化ありの場合】

    競合が発生したと判断して、トランザクション処理を失敗させる

ここで、上記説明の中で事前の定義なく「バージョン(を示すカラム)」と記載しましたが、楽観ロックを使うためにはテーブルのレコードにバージョンを示すカラムを用意する必要があります。

このカラムは DB のテーブル作成/マイグレーション時に必要であり、O/R マッパーの使い方によって具体的な方法は変わりますが、例えば Ruby on Rails の ActiveRecord ではマイグレーションファイルでモデルに lock_version カラムを追加するマイグレーションファイルを作成してマイグレーションを実行することになります。

ロック動作のシーケンス

下記にロック未使用時と楽観的ロック使用時のシーケンス図を示します。

ロック未使用時


ロストアップデート状態となる

楽観的ロック使用時


更新処理が競合したことを検出してトランザクション B が失敗する

図のように、楽観的ロック使用時には更新処理が競合したことが検出され、トランザクション B は失敗して Optimistic Lock Exception(OLE) が発生します。

このように、楽観的ロックはエンティティに対するロックは行わず、更新処理の開始時点とコミット時点のバージョンを比較することで更新処理が競合したことを検証します。

防ぐことのできるデータ不整合

楽観的ロックではダーティリードの発生を防ぐことが出来ますが、ファジーリード、ファントムリードは防ぐことができません。

メリット

悲観的ロックに比べてロック待ちが発生しない分、トランザクション完了までの時間は短くなります。
また、ロックによるブロックと解放(Two phase locking) を行う必要がありません。

従って、高速な応答が求められ、ステートレスである HTTP と相性が良いとされています。

デメリット

潜在的に、ロック待ちが発生しない分、不整合が発生する可能性が高まるデメリットが存在します。

但し、前提として更新が同時に行われる頻度が少ない環境を想定していることから、このデメリットは無視できます。

また、楽観的ロックは DB レベルで提供される機能ではないため、アプリケーション側でバージョンフィールドを用意して、バージョン比較による検証や、バージョン書き込み、OLE の発生などを実装する必要があります。

但し、先に述べたように O/R マッパーにより楽観的ロック機能が提供されるため、O/R マッパーを使う限りはこのデメリットも無視できます。

まとめ

データの不整合が発生した状態は多く存在し、トランザクションの分離レベルを可用性と効率とのトレードオフで選択する必要があることを紹介しました。

また、楽観的な考えに基づくアプローチである楽観的ロックについて紹介し、高速な読み取り処理が必要とされるステートレスな処理である HTTP において有効であると言えることを紹介しました。

次回は楽観的ロックを実装したフレームワークや O/R マッパーについて具体例を紹介していきたいと思います。

関連記事

開発におけるロックの重要性と ORM でのロックの実現例

開発でのロックの重要性とORMのロック実現例〜楽観的ロックの利用例|フレームワーク O/R マッパー