51
48

More than 3 years have passed since last update.

アプリケーション開発において重要なロックを掘り下げ、ORM における楽観的ロックの実現例を紹介する

Last updated at Posted at 2018-09-18

導入

アプリケーション開発においてデータを扱う時にロックを行うことはデータ保全性の観点から重要です。

一般的な話としてアプリケーションの「ロック」について掘り下げ、ロック方法の種類や DB のトランザクションとの関連性を紹介します。

ロックとは

まず、ロックとはデータに対して読み書きの処理を同時に行うことを排除する仕組みです。

ロックを行わない場合、同じデータに対して複数の処理を実行すると、結果に整合性が取れなくなったりします。

例えば、商品カテゴリごとの売上の合計を計算する処理が実行されている間に、商品カテゴリが変更されると、商品カテゴリごとの売上の合計は総売り上げと違う値となってしまいます。

そこでロックを用いることで、一方の処理が完了するまでもう一方の処理の読み書きを禁止させることができ、結果の整合性を取ることが出来ます。

ロックを掘り下げる

システムの「どのレベル」でロックを行うかにより、考慮すべきポイントやロックできるデータの単位が異なります。レベル毎に掘り下げていきます。

DBレベルのロックとアプリケーションレベルのロック

DB には MySQL, PostgreSQL, Oracle, Microsoft SQL Server, MongoDB, Cassandra, Redis 等幾つか種類がありますが、ほぼ例外なくロック機能を有しています。

DB でロックを行う場合 DB によって詳細な挙動は異なりますが、読み書き(RW)を同時に排除するロック、書き込みのみを排除するロック等、「どの操作」に対してロックを行うかを決めることが出来たり、RDB におけるテーブルに対する操作を排除するロック、レコードに対する操作を排除するロック等、「どのデータ単位」に対してロックを行うかを決めることも出来ます。

一方で、アプリケーション内で行われる複数の処理において、それぞれ共通のルールを持たせることでロックを実現することが出来ます。(ルールを破る処理が無い前提)

アプリケーション全体で共有できるメモリ空間にロック有無を示す boolean 変数を用意し次のルールを設けることでロックが行えます。

  1. DB へ読み書きを行う際は必ずその変数を参照する
  2. ロック有無が false であれば変数を true にして DB へ読み書きを行い、
    終わったタイミングで変数を false に戻す
  3. ロック有無が true であれば変数が false となるまで待機する

トランザクションについて

トランザクションとは DB に対する一連の操作を一つの処理単位として扱う機能です。
処理の途中で失敗した場合は、操作を開始した時の状態にロールバックされます。
処理の完了はコミットと呼ばれます。

並列でトランザクションが実行される場合に、操作の一貫性を保てるようにするためにはロックが必要となります。
この時、どのような処理をロックするかをトランザクションの分離レベルと呼び、DB 毎に分離レベルがいくつか用意されています。

例えば、MySQL の分離レベルは次の種類があります。

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

それぞれ簡単に説明します。

分離レベルを READ UNCOMMITTED に設定すると、トランザクション中に変更された非コミットな値の読み込みが可能です。

トランザクションを2つ実行した際に、一方で値の変更を行ったもののロールバックされた場合、もう一方で変更後の値が読み込まれると結果の整合性がとれない状態となる可能性があります。

分離レベルを READ COMMITTED に設定すると、コミットされた値が読み込まれるようになります。

しかし、トランザクションを2つ実行した際に、一方で値の変更が行われ、もう一方で同じ値を参照していたとした場合、値を変更するトランザクションが完了次第、もう一方のトランザクションからは変更後の値が参照されることになります。

そのため、値の参照を行う側のトランザクションを考えると、トランザクションの途中から同じ値の呼び出しを行った結果が異なることとなります。

分離レベルを REPEATABLE READ に設定すると、トランザクション中に何度値を読み込んでも結果が変わらないことが保証されます。

最後に、分離レベルを SERIALIZABLE に設定すると、トランザクションは並列に行われず順次実行されるようになります。

トランザクションシステムの信頼性を高める特性

トランザクションを行うことができるシステムにおいて、その信頼性を高めるために実現するとよいとされる特性があります。

特性の種類としては、トランザクション自体についての特性を示す ACID 特性、システム自体の特性を示す BASE 特性があります。

システム自体の特性として ACID 特性を持たせようとすると、可用性や性能が低下するトレードオフとなることから、可用性や性能を重視した BASE 特性が Eric Brewer により提唱されました。(参考情報 http://yohei-y.blogspot.com/2009/03/cap-base.html)

ACID 特性と BASE 特性は共存可能なので、DB は ACID 特性を保ち、システムは BASE 特性に従うといった構成をとることができます。

ACID 特性

ACID 特性とは、次の4つの特性を指します。

  • 原子性(Atomicity)
  • 一貫性(Consistency)
  • 独立性(Isolation)
  • 永続性(Durability)

それぞれ簡単に説明すると、次のとおりです。

  • 原子性とはトランザクション処理が一連の処理「全てが実行された」か、あるいは「全てが実行されなかった」(実行前の状態に戻す) ことを保証する特性のこと
  • 一貫性とはトランザクション処理があらかじめ決められたルールに従って整合性が保たれていることを保証する特性のこと
  • 独立性とはトランザクション処理の途中経過が隠蔽できる特性のこと
  • 永続性とはトランザクション処理が完了したことをユーザに通知した時点で、その操作が永続化されたことを保証する特性のこと

詳細については Wikipedia 等を確認してみてください。

BASE 特性

BASE 特性とは、次の特性を指します。

  • Basically Available
  • Soft-State
  • Eventual Consistency

それぞれ簡単に説明すると、次のとおりです。

  • Basically Available とは可用性が基本である特性のこと
    • ロックにより処理が待機されることがないことを指します (楽観的ロック)
  • Soft-State とは緩いステート管理を行う特性のこと
  • Eventual Consistency とは途中で整合性が保たれていなくても、結果的に整合性が保たれていればよしとする特性のこと

楽観的ロックについて

ここまで、ロックについて掘り下げ、トランザクションについてとその特徴を紹介し、その中で楽観的ロックの存在を挙げました。

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

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

データの不整合

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

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

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

整合性に問題が発生した状態として、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) を書き込むことで発生する不整合である

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

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

ダーティライト

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

case_dirty_write.png

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

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

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

case_non_locking.png

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

ダーティリード

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

case_dirty_read.png

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

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

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

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

case_fazzy_read_non_repeatable_read.png

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

ファントムリード

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

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

case_phantom_read.png

図のように、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 カラムを追加するマイグレーションファイルを作成してマイグレーションを実行することになります。

ロック動作のシーケンス

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

ロック未使用時

case_non_locking.png

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

楽観的ロック使用時

case_using_optimistic_lock.png

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

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

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

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

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

メリット

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

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

デメリット

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

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

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

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

楽観的ロックの利用例(フレームワーク/O/R マッパー)

導入

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

以下は楽観的ロックを利用する方法についてフレームワークや O/R マッパーの具体例を用いて紹介していきます。

楽観的ロックの実現例

Ruby on Rails の場合

Ruby on Rails の場合、O/R マッパーとして ActiveRecord が使われています。
(ActiveRecord とは O/R マッパーの実装パターンの名前であり、同じ名前が使われています。)

次のマイグレーションファイルのようにモデルに lock_version カラムを integer 型で作成します。

# マイグレーションファイル
$ cat db/migrate/20180906171026_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|

      t.string :name, default: ''
      t.timestamps
      t.integer :lock_version
    end
  end
end

すると、ActiveRecord は update メソッドでデータを更新する際にカラムの lock_version をカウントアップして、楽観的ロックを実現します。

更新が競合した場合は、 ActiveRecord::StaleObjectError が発生します。

# update実行時のSQLで例外発生動作
## [スレッド1] ユーザ(user1) を作成
[1] pry(main)> user1 = User.create!(name: 'test')
   (0.0ms)  SAVEPOINT active_record_1
  SQL (0.6ms)  INSERT INTO "users" ("name", "created_at", "updated_at", "lock_version") VALUES (?, ?, ?, ?)  [["name", "test"], ["created_at", "2018-09-06 17:23:40.408881"], ["updated_at", "2018-09-06 17:23:40.408881"], ["lock_version", 0]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> #<User:0x00007fb2786676f0
 id: 1,
 name: "test",
 created_at: Thu, 06 Sep 2018 17:23:40 UTC +00:00,
 updated_at: Thu, 06 Sep 2018 17:23:40 UTC +00:00,
 lock_version: 0>

## [スレッド2] ユーザ(user1) を参照
[2] pry(main)> user2 = User.find(1)
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User:0x00005605bcfecaf0
 id: 1,
 name: "test",
 created_at: Thu, 06 Sep 2018 17:23:40 UTC +00:00,
 updated_at: Thu, 06 Sep 2018 17:23:40 UTC +00:00,
 lock_version: 0>

## [スレッド1] ユーザ(user1) の name を test2 に更新
[3] pry(main)> user1.update(name: 'test2')
   (0.1ms)  SAVEPOINT active_record_1
  SQL (0.2ms)  UPDATE "users" SET "name" = ?, "updated_at" = ?, "lock_version" = ? WHERE "users"."id" = ? AND "users"."lock_version" = ?  [["name", "test2"], ["updated_at", "2018-09-06 17:24:07.820275"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

## [スレッド2] ユーザ(user1) の namae を test3 に更新(★例外発生)
[4] pry(main)> user2.update(name: 'test3')
   (0.1ms)  SAVEPOINT active_record_1
  SQL (0.2ms)  UPDATE "users" SET "name" = ?, "updated_at" = ?, "lock_version" = ? WHERE "users"."id" = ? AND "users"."lock_version" = ?  [["name", "test3"], ["updated_at", "2018-09-06 17:24:15.752651"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
   (0.1ms)  ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::StaleObjectError: Attempted to update a stale object: User.
from /usr/local/src/rails-sample/vendor/bundle/ruby/2.5.0/gems/activerecord-5.1.6/lib/active_record/locking/optimistic.rb:95:in `_update_row'

Grails の場合

Grails では O/R マッパーとして GORM を使います。
GORM は SQL DB へアクセスする際に Hibernate を利用します。

デフォルトでドメインクラスに version プロパティが追加され、楽観的ロックのバージョンとして利用されます。

# Userドメイン
package grails.sample.app

class User {

  String name

  static constraints = {
  }
}

上記のように、version フィールドをドメインクラスに明示的に指定する必要はありません。デフォルトで追加されます。(逆に楽観的ロックを使いたくない場合は version false を mapping に指定する必要があります)

// Userドメインの name を test2 に変更
def user = User.get(1)
println 'name: ' + user.name + ', version: ' + user.version
user.name = 'test2'
user.save(flush: true)
println 'name: ' + user.name + ', version: ' + user.version

// 実行結果
name: test, version: 40
name: test2, version: 41

例外が発生すると org.springframework.dao.OptimisticLockingFailureException エラーが発生します。

SQLAlchemy の場合

SQLAlchemy は O/R マッパーを含む Python 用の SQL Toolkit です。

次のファイルのようにモデルに数値を保存する任意のフィールド(BigInteger 型等) で作成し、 __mapper_args__ の定義内で version_id_col として設定します。

from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String

class User(Base):
  __tablename__ = 'users'

  id = Column('id', Integer, primary_key = True)
  name = Column('name', String(200))
  version_id = Column(BigInteger, nullable=False)

  __mapper_args__ = {
    'version_id_col': version_id
  }

すると、SQLAlchemy はセッションが commit されてデータを更新する際にカラムの version_id をカウントアップして、楽観的ロックを実現します。

更新が競合した場合は、 sqlalchemy.orm.exc.StaleDataError が発生します。

# スレッド1
## モデルファイルと DB Connector の読み込み
>>> from setting import session
>>> from user import *

## User の name を test として作成
>>> user = User()
>>> user.name = 'test'
>>> session.add(user)
>>> session.commit()

## User の name を test6 へ変更
>>> user.name = 'test6'
>>> session.commit()
2018-09-11 03:34:45,280 INFO sqlalchemy.engine.base.Engine UPDATE users SET name=%s, version_id=%s WHERE users.id = %s AND users.version_id = %s
2018-09-11 03:34:45,280 INFO sqlalchemy.engine.base.Engine ('test6', 4, 1, 3)
2018-09-11 03:34:45,281 INFO sqlalchemy.engine.base.Engine COMMIT
>>> 
# スレッド2
## モデルファイルと DB Connector の読み込み
>>> from setting import session
>>> from user import *

## User を取得
>>> user = session.query(User).filter(User.id==1).first()
2018-09-11 03:33:34,195 INFO sqlalchemy.engine.base.Engine SHOW VARIABLES LIKE 'sql_mode'
2018-09-11 03:33:34,196 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine SELECT DATABASE()
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine show collation where `Charset` = 'utf8mb4' and `Collation` = 'utf8mb4_bin'
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,200 INFO sqlalchemy.engine.base.Engine SELECT CAST('test plain returns' AS CHAR(60)) AS anon_1
2018-09-11 03:33:34,200 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,201 INFO sqlalchemy.engine.base.Engine SELECT CAST('test unicode returns' AS CHAR(60)) AS anon_1
2018-09-11 03:33:34,201 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,202 INFO sqlalchemy.engine.base.Engine SELECT CAST('test collated returns' AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_bin AS anon_1
2018-09-11 03:33:34,202 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,202 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
2018-09-11 03:33:34,203 INFO sqlalchemy.engine.base.Engine SELECT users.id AS users_id, users.name AS users_name, users.version_id AS users_version_id 
FROM users 
WHERE users.id = %s 
 LIMIT %s
2018-09-11 03:33:34,203 INFO sqlalchemy.engine.base.Engine (1, 1)

## User の name を test5 に変更する(★例外発生)
>>> user.name = 'test5'
>>> session.add(user)
>>> session.commit()
2018-09-11 03:35:00,504 INFO sqlalchemy.engine.base.Engine UPDATE users SET name=%s, version_id=%s WHERE users.id = %s AND users.version_id = %s
2018-09-11 03:35:00,504 INFO sqlalchemy.engine.base.Engine ('test5', 4, 1, 3)
2018-09-11 03:35:00,504 INFO sqlalchemy.engine.base.Engine ROLLBACK
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 943, in commit
    self.transaction.commit()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 467, in commit
    self._prepare_impl()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 447, in _prepare_impl
    self.session.flush()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 2254, in flush
    self._flush(objects)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 2380, in _flush
    transaction.rollback(_capture_exception=True)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/util/langhelpers.py", line 66, in __exit__
    compat.reraise(exc_type, exc_value, exc_tb)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/util/compat.py", line 249, in reraise
    raise value
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 2344, in _flush
    flush_context.execute()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/unitofwork.py", line 391, in execute
    rec.execute(self)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/unitofwork.py", line 556, in execute
    uow
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/persistence.py", line 177, in save_obj
    mapper, table, update)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/persistence.py", line 791, in _emit_update_statements
    (table.description, len(records), rows))
sqlalchemy.orm.exc.StaleDataError: UPDATE statement on table 'users' expected to update 1 row(s); 0 were matched.

O/R マッパーにおける楽観的ロックの実装有無

ここまでで、幾つかの O/R マッパーを使って楽観的ロックを実装するための具体例を見てきました。

他の O/R マッパーを含めて、楽観的ロックの実装があるかどうか以下に纏めます。

ORM名 楽観的ロックの実装有無 発生する例外 参考情報
GORM org.springframework.dao.OptimisticLockingFailureException GORM for Hibernate > Pessimistic and Optimistic Locking
ActiveRecord ActiveRecord::StaleObjectError Ruby on Rails API > ActiveRecord::Locking::Optimistic
SQLAlchemy sqlalchemy.orm.exc.StaleDataError SQLAlchemy > Class Mapping API
Hibernate org.hibernate.StaleObjectStateException Hibernate/楽観的ロックを実装する
JPA javax.persistence.OptimisticLockException Java7 API - Class OptimisticLockException
Eclipse Link(Toplink Essentials) javax.persistence.OptimisticLockException ※Eclipse Link は JPA の実装である。stack overflow - what-is-the-difference-between-toplink-essentials-eclipselink
MyBATIS(旧iBATIS) × - MyBatisで行ロック(悲観ロック)を行うには?
Django の ORM × -
Sequelize OptimisticLockError Sequelize > Model definition > Optimistic Locking

NoSQL データベースにおける楽観的ロック実装について

トランザクション処理が存在しない NoSQL では楽観的ロックが多く使われている。

(Memcached の CAS(Check and Set) が楽観的ロックを指す)

※参考: Slide Share - db-tech-showcase-mongodbP.44-50 付近

O/R マッパーにおける楽観的ロックの実装有無

ORM名 楽観的ロックの実装有無 発生する例外 参考情報
Mongoose VersionError mongoose > versionKey

まとめ

本記事では、冒頭でロックについて掘り下げ、トランザクションについてとその特徴を紹介しました。

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

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

最後にいくつか具体的な OR マッパーを例にして楽観的ロックを利用する方法を紹介し、OR マッパーにおいて楽観的ロックを実装しているかどうかをまとめて紹介しました。

ソフトウェアを扱う上で重要なロックについて、皆さんが理解する・復習するための一助となりましたら幸いです。

51
48
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
48