5.4. 排他制御¶
5.4.1. Overview¶
排他制御とは、複数のトランザクションから同じデータに対して、同時に更新処理が行われる際に、データの整合性を保つために行う処理のことである。
複数のトランザクションから同じデータに対して、同時に更新処理が行われる可能性がある場合は、基本的に排他制御を行う必要がある。 ここで言うトランザクションとは、かならずしもデータベースとのトランザクションとは限らず、ロングトランザクションも含まれる。
Note
ロングトランザクションとは
データの取得とデータの更新を、別々のデータベーストランザクションとして行う際に発生するトランザクションのことである。
具体例としては、取得したデータを編集画面に表示し、画面で編集した値をデータベースに更新するようなアプリケーションで発生する。
5.4.1.1. 排他制御の必要性¶
まず、排他制御の必要性を理解してもらうために、排他制御を行わなかった際に発生する問題について、具体例を3つ挙げて説明する。
5.4.1.1.2. Problem2¶
ここでは、ショッピングサイトでTeaの在庫数を管理するスタッフが、Teaの在庫数を表示し、仕入れたTeaの数をクライアントで計算して、Teaの在庫数を更新する場合の例を示す。
5.4.1.1.3. Problem3¶
ここでは、バッチ処理によってロックされているデータに対して、オンライン処理で更新する例を示す。
項番 UserA Batch 説明
- 〇 Batchがテーブルの更新対象の該当行(ここでは仮に全ての行とする。)をロックし、他の処理で更新できないようにする。
〇 - User Aが更新情報を検索する。この時点でBatchはコミットされていないため、Batch更新前の情報が取得できる。
〇 - User Aが更新要求をするが、Batchにロックされているため、更新が待たされる。
- 〇 Batchが処理を終えてロックを解放する。
〇 - User Aの待たされていた更新処理が、実行可能となり更新処理を実行する。 User AはBatch終了を待たされた後に、更新処理を実行する。しかし、User Aの取得した元のデータは、Batchの更新前のデータであり、Batchで更新した情報を上書く可能性がある。また、Batch時間はオンライン処理と比べると長いものが多く、ユーザが待たされる時間が長くなる。
5.4.1.2. トランザクションの分離レベルによる排他制御¶
ANSI/ISO SQL標準では、トランザクションの分離レベル(各トランザクションがそれぞれどの程度互いに影響を及ぼし合うか)を表す指標を定義している。 以下に、トランザクションの分離レベルを4つ示す。併せて、各分離レベルで起こりうる現象について説明する。
項番
|
分離レベル
|
ダーティ・リード
DRITY READ
|
再読込不可能読取
NON-REPEATABLE READ
|
ファントム・リード
PHANTOM READ
|
---|---|---|---|---|
1.
|
未コミット読込
READ UNCOMMITTED
|
有 | 有 | 有 |
2.
|
コミット済読込
READ COMMITTED
|
無 | 有 | 有 |
3.
|
再読込可能読取
REPEATABLE READ
|
無 | 無 | 有 |
4.
|
直列化
SERIALIZABLE
|
無 | 無 | 無 |
Tip
ダーティ・リード(DRITY READ)
まだコミットされていないトランザクションが書き込んだデータを、別のトランザクションが読み込む現象のことである。
Tip
再読込不可能読取(NON-REPEATABLE READ)
同一トランザクション内で同じレコードを2度読み込むような場合、1度目と2度目の読み込みの間に他トランザクションがコミットすると、1度目に読み込んだ内容と2度目に読み込んだ内容が異なる可能性がある。 複数回の読み込みの結果が、他のトランザクションのコミットのタイミングによって変わることである。
Tip
ファントム・リード(PHANTOM READ)
同一トランザクション内で、同じレコードを2回読み込む間に、他のトランザクションがレコードを追加、または削除することにより、2回目の読み込みで1回目と取得レコード数(内容)が異なることである。
項番 データベース READ UNCOMMITTED READ COMMITTED REPEATABLE READ SERIALIZABLE 1. Oracle × 〇(default) × 〇 2. PostgreSQL × 〇(default) × 〇 3. DB2 〇 〇(default) 〇 〇 4. MySQL InnoDB 〇 〇 〇(default) 〇
データの整合性を保ちつつ、分離性と同時実行性のバランスをとる場合、データベースのロック機能を使用して排他制御を行う必要がある。以下に、データベースのロック機能について説明する。
5.4.1.3. データベースのロック機能による排他制御¶
- データベース上で管理されているデータの整合性を保つため
- 更新処理が競合しないようにするため
¶ 項番 ロック種類 適用ケース 特徴
RDBMSによる自動的なロック
- データの更新条件として、データの整合性を保証するために必要な条件を指定できる場合。
- 同一データに対する同時実行数が少なく、更新処理も短い時間でおわる場合。
- チェックと更新処理を一つのSQLで実行するため、効率的である。
- 楽観ロックに比べ、データの整合性を保証するための条件を個別に検討する必要がある。
楽観ロック
- 事前に取得したデータが他のトランザクションによって更新されていた場合に、更新内容を確認させる必要がある場合。
- 同一データに対する同時実行数が少なく、更新処理も短い時間でおわる場合。
- 取得したデータに対して、他のトランザクションからの更新が行われていないことが保証される。
- テーブルにVersionを管理するためのカラムを定義する必要がある。
悲観ロック
- 長い時間ロックされる可能性があるデータに対して更新する場合。
- 楽観ロックが使用できない(Versionを管理するためのカラムが定義できない)ため、処理としてデータの整合性チェックを行う必要がある場合。
- 同一データに対する同時実行数が多く、更新処理も長い時間実行される可能性がある場合。
- 他のトランザクションの処理結果によって処理が失敗する可能性がなくなる。
- 悲観ロックを取得するためのselect文を発行する必要があるので、その分コストがかかる。
Note
ロックの種類の採用基準について
どの手法を採用するかについて、アーキテクトが、機能要件および性能要件を考慮して決定すること。
- 画面にデータを戻し、画面上でデータを変更するような、データベースとのトランザクションが切れて、次のトランザクションでデータが変わっていないことを保証するためには、楽観ロックが必要となる。
- 1トランザクション内でロックをかける必要がある場合は、悲観ロックと楽観ロックの両方で実現できるが、悲観ロックを使用した場合、データベース内のロック制御処理が行われるため、データベース内の処理コストが高くなる可能性がある。特に問題がない場合は、楽観ロックの方がよい。
- 更新頻度が高い処理で、1トランザクション内で多くのテーブルを更新する場合は、楽観ロックを使用すると、ロックを取得するための待ち時間は最小限に抑えることが出できるが、途中で排他エラーとなる可能性があるため、エラーが発生するポイントが増える。 悲観ロックを使用すると、ロックを取得するまでの待ち時間が長くなる可能性はあるが、ロックを取得した後の処理で排他エラーが発生することはないため、エラーが発生するポイントが減る。
Tip
業務トランザクションについて
実際のアプリケーション開発では、業務フローレベルのトランザクションに対して、排他制御が必要になる場合もある。 業務フローレベルのトランザクションとなる代表例としては、旅行代理店のカウンタで、お客様と話をしながら予約作業を進めていく際に使用するアプリケーションがあげられる。
旅行予約を行う場合、鉄道、宿泊施設、さらに追加プランなどを話しながら決めていくことになる。 その際に、予約することに決めた宿泊施設や追加プランが、他の利用者に予約されないようにする仕組みが必要になる。 このような場合は、テーブルにステータスを持たせ、仮予約 -> 予約 のように更新し、仮予約中の場合も、他の利用者から更新されないようにする必要がある。
業務トランザクションに対する排他制御については、業務設計や機能設計として検討・設計すべき箇所になるので、本節の説明範囲からは省いている。
5.4.1.3.1. データベースの行ロック機能による排他制御¶
項番 データベース 確認Version デフォルト設定時のロック 備考
Oracle 11 行ロック ロック分メモリ使用量が増大する。
PostgreSQL 9 行ロック メモリ上に変更された行の情報を記憶しないので、同時にロックできる行数に、上限はない。ただし、テーブルに書き込むため、定期的にVACUUMしなければならない。
DB2 9 行ロック ロック分メモリ使用量が増大する。
MySQL InnoDB 5 行ロック ロック分メモリ使用量が増大する。
以下に、具体例を示す。シナリオは、以下の通りである。
- ショッピングサイトでUser A,User Bともに同じ商品の購入画面を同時に表示する。 その際に、Stock Tableから取得した在庫数も表示されている。
- 買いたい商品を5個ずつ同時に購入したが、少しUserAの方が早く購入ボタンを押下したため、User Aが先に購入し、User Bが次に購入する。
項番 UserA UserB 説明
〇 - User Aが、商品の購入画面を表示する。在庫数が100個で画面に表示されている。
select quantity from Stock where ItemId = '01'
- 〇 User Bが、商品の購入画面を表示する。在庫数が100個で画面に表示されている。
select quantity from Stock where ItemId = '01'
〇 - User Aが、ItemId=01の商品を5個購入する。Stock Tableから個数を-5する。
Update from Stock set quantity = quantity - 5 where ItemId='01' and quantity >= 5
- 〇 User Bが、ItemId=01の商品を5個購入する。Stock Tableから個数を-5しようとするが、User Aのトランザクションが終了していないので、User Bの購入処理が待たされる。
〇 - User Aのトランザクションをコミットする。
- 〇 User Aのトランザクションがコミットされたため、4で待たされていたUserBの購入処理が再開する。この時、在庫は画面で見ると、個数は100ではなく、95になっているが、購入数(上記例では、5個)以上の在庫が残っているため、Stock Tableから個数を-5する。Update from Stock set quantity = quantity - 5 where ItemId='01' and quantity >= 5
- 〇 User Bのトランザクションをコミットする。 Note
ポイント
SQL内で減算(
"quantity - 5"
)と、更新条件("and quantity >= 5"
)の指定を行うことが、ポイントとなる。
quantity >= 5
を満たさないので、更新件数が0件となる。この方法でロックする場合、参照した情報が変わっていても条件次第で処理を進めることができ、かつ、データベースの機能によってデータの整合性を保証することができる。
5.4.1.3.2. 楽観ロックによる排他制御¶
Note
Versionカラムとは
レコードの更新回数を管理するためのカラムで、レコード挿入時に0を設定し、更新成功時にインクリメントしていく楽観ロック用のカラムである。 Versionカラムは、数値以外に最終更新タイムスタンプで代用することもできる。 しかし、タイムスタンプを用いると、同時に処理が実行された際の、一意性が保証されない。 そこで、確実な一意性を求める場合、Versionカラムは、数値を使用する必要がある。
Warning
楽観ロックを行う場合、IDとVersion以外の条件を加えて更新・削除するのは適切でない。 なぜなら更新できなかった場合に、Versionが一致しないことが理由なのか、別の条件に一致しないのが理由なのか、判断できないためである。 更新条件として別の条件がある場合は、事前の処理として条件を満たしているか、チェックを行う必要がある。
具体例を、以下に示す。シナリオは、以下の通りである。
- ショッピングサイトの在庫数を管理するスタッフ(Staff A, Staff B)が、それぞれ商品を仕入れる。Staff Aが5個、Staff Bが15個仕入れたものとする。
- 仕入れた商品を、在庫管理システムに反映するために、在庫管理画面を表示する。その際、在庫管理システムで管理されている在庫数が表示される。
- それぞれ表示された在庫数に対して、仕入れた数を加算した値を更新フォームに入力し、更新を行う。
項番 Staff A Staff B 説明
〇 - Staff Aが、商品の在庫管理画面を表示する。在庫数は10個と画面に表示されている。参照したデータのVersionは 1
である。
- 〇 Staff Bが、商品の在庫管理画面を表示する。在庫数は10個と画面に表示されている。参照したデータのVersionは 1
である。
〇 - Staff Aが、画面に表示されていた在庫数10に対して、仕入れた5個を加算し、変更後の在庫数を15個で更新する。更新条件として、参照したデータのVersionを含める。
UPDATE Stock SET quantity = 15, version = version + 1 WHERE itemId = '01' and version = 1
- 〇 Staff Bが、画面に表示されていた在庫数10に対して仕入れた15個を加算し、変更後の在庫数を25個で更新しようとするが、Staff Aのトランザクションが終了していないので待たされる。更新条件として、参照したデータのVersionを含める。
〇 - Staff Aのトランザクションをコミットする。 この時点で、Versionは 2 になる。
〇 - Staff Aのトランザクションがコミットされたため、4で待たされていたStaff Bの更新処理が再開する。この時、Stock TableのデータのVersionが
2
になっているため、更新結果が0件となる。更新結果が0件の場合は排他エラーとする。UPDATE Stock SET quantity = 25, version = version + 1 WHERE itemId = '01' and version = 1
〇 - Staff Bのトランザクションをロールバックする。
Note
ポイント
SQL内でVersionのインクリメント( "version + 1"
)と、更新条件( "and version = 1"
)の指定を行うことが、ポイントとなる。
5.4.1.3.3. 悲観ロックによる排他制御¶
項番 | データベース | 悲観ロック方法 |
---|---|---|
Oracle | FOR UPDATE | |
PostgreSQL | FOR UPDATE | |
DB2 | FOR UPDATE WITH | |
MySQL | FOR UPDATE |
Note
悲観ロックのタイムアウトについて
悲観ロックには、悲観ロック取得時に他のトランザクションによってロックが取得されていた場合に、どのような動作にするかをオプションとして指定することがある。 Oracleの場合は、
- デフォルトでは、
select for update [wait]
となり、ロックが解除されるまで待つ。 select for update nowait
とすると、他にロックされている場合は、即時にリソースビジーのエラーとなる。select for update wait 5
とすると5秒待ち、5秒間ロックが解除されない場合は、リソースビジーのエラーが返却される。
DBにより機能に差はあるが、悲観ロックを使用する際は、どの手法を採用するか検討が必要である。
Note
JPA(Hibernate)を使用する場合
悲観ロックの取得方法はデータベースによって異なるが、その差分はJPA(Hibernate)によって吸収される。 HibernateのサポートしているRDBMSについては、 Hibernate Developer Guide を参照されたい。
悲観ロックによる排他制御は、以下3ケースのいずれかに当てはまる場合に使用する。
- 更新対象のデータが複数のテーブルに分かれて管理されている。更新対象のテーブルが複数のテーブルに分かれている場合、各テーブルに対して更新が終わるまでの間に、他のトランザクションから更新がされないことを保証するために、必要となる。
- 更新処理を行う前に取得したデータの状態をチェックする必要がある。チェック処理が終わった後に、他のトランザクションから更新がされていないことを保証するために、必要となる。
- バッチ実行中にオンラインの処理が実行されることがある。バッチ処理では、実行途中に排他エラーが発生しないようにするために、更新対象となるデータのロックを一括で取得することがある。一括で取得されたロックが取得された場合、オンラインの処理が待たされる時間が長くなる可能性がある。その場合、タイムアウト時間を指定して、悲観ロックを使用するのが妥当である。
具体例を以下に示す。シナリオは、以下の通りである。
- バッチ処理が既に実行済みで、オンラインで更新するデータを悲観ロックしている。
- オンライン処理は10秒のタイムアウト時間を指定して、更新対象のデータのロックを取得する。
- バッチ処理は5秒後(タイムアウト前)に終了する。
項番 Online Batch 説明
- 〇 バッチ処理が、オンライン処理で更新するデータの悲観ロックを取得する。
〇 - オンライン処理が、更新対象のデータの悲観ロックを行うが、バッチ処理のトランザクションによって悲観ロックされているので待たされる。
SELECT * FROM Stock WHERE quantity < 5 FOR UPDATE WAIT 10
- 〇 バッチ処理が、データを更新する。
- 〇 バッチ処理のトランザクションをコミットする。
〇 - バッチ処理のトランザクションがコミットされたため、オンライン処理の処理が再開する。取得されるデータはバッチ処理の更新結果が反映されているので、データ不整合が発生することはない。
〇 - オンライン処理が、データを更新する。
〇 - オンライン処理のトランザクションをコミットする。
バッチ処理とオンライン処理が競合する可能性があり、かつバッチ処理の処理時間が長くなる場合は、悲観排他のタイムアウト時間を指定することを推奨する。 タイムアウト時間については、オンライン処理の処理要件に応じて決めること。
5.4.1.4. デッドロックの予防¶
5.4.1.4.1. テーブル内でのデッドロック¶
以下(1)~(5)の流れで、複数のトランザクションから、同一テーブルのレコードに対してロックを行うと、デッドロックとなる。
項番 Program A Program B 説明 (1)〇 - Program Aは、Record X に対するロックを取得する。 (2)〇 - Program Bは、Record Y に対するロックを取得する。 (3)〇 - Program Aは、Program BのトランザクションによってロックされているRecord Y に対してロックの取得を試みるが、(2)のロック状態が解放されていないので、解放待ちの状態となる。 (4)- 〇 Program Bは、Program AのトランザクションによってロックされているRecord X に対してロックの取得を試みるが、(1)のロック状態が解放されていないので、解放待ちの状態となる。 (5)- - Program AとProgram Bが、お互いが保持しているロックの解放待ちの状態となるため、デッドロックとなる。デッドロックが発生した場合、データベースによって検知されエラーとなる。 Note
デッドロックの解決方法について
タイムアウトやリトライ実施での解消する方法もあるが、同一テーブル上でのレコードの更新順序にルールを決めることが重要である。 1行ずつ更新する場合は、PK(PRIMARY KEY)順の若い順に更新するなどのルールを定めること。
仮にProgram AもProgram BもRecord Xから更新するというルールに準じていれば、上記テーブル内でのデッドロックの図のようなデッドロックは発生しなくなる。
5.4.1.4.2. テーブル間でのデッドロック¶
項番 Program A Program B 説明 (1)〇 - Program Aは、Table A の Record X に対するロックを取得する。 (2)〇 - Program Bは、Table B の Record Y に対するロックを取得する。 (3)〇 - Program Aは、Program BのトランザクションによってロックされているTable B の Record Y に対してロックの取得を試みるが、(2)のロック状態が解放されていないので、解放待ちの状態となる。 (4)- 〇 Program Bは、Program AのトランザクションによってロックされているTable A の Record X に対してロックの取得を試みるが、(1)のロック状態が解放されていないので、解放待ちの状態となる。 (5)- - Program AとProgram Bが、お互いが保持しているロックの解放待ちの状態となるため、デッドロックとなる。デッドロックが発生した場合、データベースによって検知されエラーとなる。
Note
デッドロックの解決方法について
タイムアウトやリトライ実施での解消する方法もあるが、テーブルを跨った際も、更新順序をルール化しておくことが重要である。
仮にProgram AもProgram BもTable Aから更新するというルールに準じていれば、上記テーブル間でのデッドロックの図のような、デッドロックは発生しなくなる。
Warning
注意としては、どの方法を採用したとしても、レコードをロックする順序により、デッドロックが発生する可能性がある。 テーブル、レコードのロック順序については、ルールを決めること。
5.4.2. How to use¶
5.4.2.1. JPA(Spring Data JPA)使用時の実装方法¶
5.4.2.1.1. RDBMSの行ロック機能¶
- Repositoryインタフェース
public interface StockRepository extends JpaRepository<Stock, String> { @Modifying @Query("UPDATE Stock s" + " SET s.quantity = s.quantity - :quantity" + " WHERE s.itemCode = :itemCode" + " AND :quantity <= s.quantity") // (1) public int decrementQuantity(@Param("itemCode") String itemCode, @Param("quantity") int quantity); }
項番 説明 (1) Queryメソッドに、在庫数が注文数以上ある場合に、在庫数を減らすJPQLを指定する。更新件数をチェックする必要があるので、Queryメソッドの返り値は、int
を指定する。
- Service
String itemCodeOfOrder = "ITM0000001"; int quantityOfOrder = 31; int updateCount = stockRepository.decrementQuantity(itemCodeOfOrder, quantityOfOrder); // (2) if (updateCount == 0) { // (3) ResultMessages message = ResultMessages.error(); message.add(ResultMessage .fromText("Not enough stock. Please, change quantity.")); throw new BusinessException(message); // (4) }update m_stock set quantity=quantity-31 where item_code='ITM0000001' and 31<=quantity -- (5)
項番 説明 (2)Queryメソッドを呼び出す。 (3)Queryメソッドの呼び出し結果を判定する。 0
の場合、更新条件を満たしていないので、在庫数が不足していることになる。 (4) 在庫がない、または不足している旨のメッセージを格納し、業務エラーを発生させる。発生させたエラーは、Controllerで要件に応じて適切にハンドリングすること。上記例では、ビジネスルールのチェックを排他制御しながら行っているだけなので、更新条件を満たさない場合は、排他エラーではなく業務エラーとしている。エラーのハンドリング方法については、コーディングポイント(Controller編)を参照されたい。 (5)Queryメソッド呼び出し時に実行されるSQL。
5.4.2.1.2. 楽観ロック¶
JPAでは、バージョン管理用のプロパティに、@javax.persistence.Version
アノテーションを指定することで、楽観ロックを行うことができる。
- Entity
@Entity @Table(name = "m_stock") public class Stock implements Serializable { @Id @Column(name = "item_code") private String itemCode; private int quantity; @Version // (1) private long version; // ... }
項番 説明 (1)バージョン管理用のプロパティに、 @Version
アノテーションを指定する。
- Service
String itemCode = "ITM0000001"; int newQuantity = 30; Stock stock = stockRepository.findOne(itemCode); // (2) if (stock == null) { ResultMessages messages = ResultMessages.error().add(ResultMessage .fromText("Stock not found. itemCode : " + itemCode)); throw new ResourceNotFoundException(messages); } stock.setQuantity(newQuantity); // (3) stockRepository.flush(); // (4)update m_stock set quantity=30, version=7 where item_code='ITM0000001' and version=6 -- ( 5)
項番 説明 (2)RepositoryインタフェースのfindOneメソッドを呼び出し、Entityを取得する。 (3)(2)で取得したEntityに対して、更新する値を指定する。 (4) (3)の変更内容を永続層(DB)に反映する。この処理は説明のために行っている処理のため、通常は不要である。通常は、トランザクションコミット時に自動で反映される。上記例だと、(2)で取得したEntityがもつバージョンと永続層(DB)で保持しているバージョンが一致しない場合に、楽観ロックエラー(org.springframework.dao.OptimisticLockingFailureException
) が発生する。 (5)(4)の永続層(DB)に反映する際に実行されるSQL。
ロングトランザクションに対する楽観ロックを行う場合は、以下の点に注意すること。
Warning
ロングトランザクションに対する楽観ロックについては、@Version
アノテーションを付与するだけでは不十分である。
ロングトランザクションに対して楽観ロックを行う場合は、JPAの機能で行われる更新時のチェックに加えて、更新対象のデータを取得する際にも、バージョンのチェックを行うこと。
以下に、実装例を示す。
- Service
long version = 12; String itemCode = "ITM0000001"; int newQuantity = 30; Stock stock = stockRepository.findOne(itemCode); // (1) if (stock == null || stock.getVersion() != version) { // (2) throw new ObjectOptimisticLockingFailureException(Stock.class, itemCode); // (3) } stock.setQuantity(newQuantity); stockRepository.flush();
項番 説明 (1)永続層(DB)からEntityを取得する。 (2) 事前に別のデータベーストランザクションで取得されたEntityのバージョンと、(1)で取得した永続層(DB)の最新のバージョンを比較する。バージョンが一致する場合は、以降の処理で@Version
アノテーションを使った楽観ロックの仕組みが有効となる。 (3)バージョンが異なる場合は、楽観ロックエラー( org.springframework.dao.ObjectOptimisticLockingFailureException
)を発生させる。
Warning
Version管理用のプロパティへの値の設定について
Repositoryインタフェースを使って取得したEntityは、「管理状態のEntity」と呼ばれる。
「管理状態のEntity」に対して、処理でVersion管理用のプロパティの値を設定することはできないので、注意すること。
以下のような処理をしても、「管理状態のEntity」に設定したバージョンの値は反映されないため、楽観ロックを取得する際に使用されることはない。楽観ロックで使用されるのは、findOneメソッドで取得した時点のバージョンとなる。
long version = 12; String itemCode = "ITM0000001"; int newQuantity = 30; Stock stock = stockRepository.findOne(itemCode); if (stock == null) { ResultMessages messages = ResultMessages.error().add(ResultMessage .fromText("Stock not found. itemCode : " + itemCode)); throw new ResourceNotFoundException(messages); } stock.setVersion(version); // ★ Invalid Processing stock.setQuantity(newQuantity); stockRepository.flush();
例えば、画面から送られてきたバージョンの値を上書きしても、Entityには反映されないため、排他制御が正しく行われなくなってしまう。
Note
ロングトランザクションに対する楽観ロック処理の共通化について
複数の処理でロングトランザクションに対して楽観ロックが必要になる場合は、上記の(1)~(3)の処理を共通的なメソッドにすることを検討した方がよい。 共通化の方法については、カスタムメソッドの追加方法を参照されたい。
RDBMSの行ロック機能と、楽観ロック機能を両方使用する場合は、以下の点に注意すること。
Warning
同じデータに対して、RDBMSの行ロック機能を利用して排他制御を行う処理と、 楽観ロック機能を利用して排他制御を行う処理が共存するアプリケーションの場合は、 RDBMSの行ロック機能を使うQueryメソッドにて、Versionの更新を必ず行う必要がある。
RDBMSの行ロック機能を使って、排他制御を行うQueryメソッドでVersionを更新しない場合、 Queryメソッドで更新した内容が、別のトランザクションの処理で上書きされる可能性があるため、正しく排他制御が行われない。
以下に、実装例を示す。
- Repositoryインタフェース
public interface StockRepository extends JpaRepository<Stock, String> { @Modifying @Query("UPDATE Stock s SET s.quantity = s.quantity - :quantity" + ", s.version = s.version + 1" // (1) + " WHERE s.itemCode = :itemCode" + " AND :quantity <= s.quantity") public int decrementQuantity(@Param("itemCode") String itemCode, @Param("quantity") int quantity); }
項番 説明 (1) Versionの更新(s.version = s.version + 1
)を行う必要がある。
5.4.2.1.3. 悲観ロック¶
Spring Data JPAでは、@org.springframework.data.jpa.repository.Lock
アノテーションを指定することで、悲観ロックを行うことができる。
- Repositoryインタフェース
public interface StockRepository extends JpaRepository<Stock, String> { @Lock(LockModeType.PESSIMISTIC_WRITE) // (1) @Query("SELECT s FROM Stock s WHERE s.itemCode = :itemCode") Stock findOneForUpdate(@Param("itemCode") String itemCode); }-- (2) SELECT stock0_.item_code AS item1_5_ ,stock0_.quantity AS quantity2_5_ ,stock0_.version AS version3_5_ FROM m_stock stock0_ WHERE stock0_.item_code = 'ITM0000001' FOR UPDATE;
項番 説明 (1)Queryメソッドに、 @Lock
アノテーションを指定する。 (2)実行されるSQL。上記例ではPostgreSQLを使用した場合に実行されるSQLとなる。
@Lock
アノテーションで指定することができる悲観ロックの種類は、以下の通りである。
項番 LockModeType 説明 発行されるSQL
PESSIMISTIC_READ 参照用の悲観ロックが取得される。データベースによっては、排他ロックではなく共有ロックとなる。コミットまたはロールバック時に、ロック解放される select … for update / select … for share
PESSIMISTIC_WRITE 更新用の悲観ロックが取得され、排他ロックがかかる。排他ロックの場合、既にロックがかかっていた場合には、ロックが解放されるまで待機してからエンティティが取得される。コミットまたはロールバック時に、ロック解放される select … for update
PESSIMISTIC_FORCE_INCREMENT エンティティを取得した時点から、対象データに対して排他ロックがかかる。取得直後に強制的にバージョンの更新も行われる。コミットまたはロールバック時に、ロック解放される select … for update + updateNote
ロックタイムアウト時間について
JPA(
EntityManager
)の設定またはQueryヒントとして、"javax.persistence.lock.timeout"
を指定することで、タイムアウト時間を指定することができる。
ロックのタイムアウト時間の指定は、全体に適用する方法と、Query毎に適用する2つの方法が用意されている。
全体に適用する方法は、以下の通りである。
xxx-infra.xml
<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean"> <property name="packagesToScan" value="xxxxxx.yyyyyy.zzzzzz.domain.model" /> <property name="dataSource" ref="dataSource" /> <property name="jpaVendorAdapter" ref="jpaVendorAdapter" /> <property name="jpaPropertyMap"> <util:map> <!-- ... --> <entry key="javax.persistence.lock.timeout" value="1000" /> <!-- (1) --> </util:map> </property> </bean>
項番 説明 (1)タイムアウトをミリ秒で指定する。 1000
を指定すると、1秒となる。Note
nowaitのサポート
OracleとPostgreSQLについては、
0
を指定した場合、nowait
が付加され、他のトランザクションによってロックされていた場合に、ロックの解放待ちを行わずに排他エラーとなる。Warning
PostgreSQLの制約
PostgreSQLではnowaitの指定はできるが、wait時間の指定ができない。 そのため、Queryのタイムアウトを別途設けておくなどの対策を行う必要がある。
Query毎に適応する方法は、以下の通りである。
- Repositoryインタフェース
@Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "2000")) // (1) @Query("SELECT s FROM Stock s WHERE s.itemCode = :itemCode") Stock findOneForUpdate(@Param("itemCode") String itemCode);
項番 説明 (1) タイムアウトをミリ秒で指定する。2000
を指定すると、2秒となる。全体に指定した値は、上書きされる。
5.4.2.2. Mybatis使用時の実装方法¶
5.4.2.2.1. RDBMSの行ロック機能¶
RDBMSの行ロック機能を使って排他制御を行う場合は、sqlmapファイルに、SQL定義を追加して実現する。
xxx-sqlmap.xml
<update id="decrementQuantity" parameterClass="OrderItem"> UPDATE m_stock SET quantity = quantity - #quantity# WHERE item_code = #itemCode# AND #quantity# <![CDATA[ <= ]]> quantity <!-- (1) --> </update>
項番 説明 (1)sqlmapファイルに、在庫数が注文数以上ある場合に、在庫数を減らすSQLを指定する。
- Repository(RepositoryImpl)
public interface StockRepository { int decrementQuantity(String itemCode, int quantity); }public class StockRepositoryImpl implements StockRepository { public int decrementQuantity(String itemCode, int quantity) { // (2) OrderItem orderItem = new OrderItem(); orderItem.setItemCode(itemCode); orderItem.setQuantity(quantity); return updateDAO.execute("stock.decrementQuantity", orderItem); // (3) } }
項番 説明 (2)Repositoryにメソッドを追加する。 (3)SQL実行に必要なパラメータを生成し、SQLを呼び出す。 Note
Repositoryを作成しない場合
Repositoryを作成しない場合は、上記処理はServiceで実装することになる。
- Service
String itemCodeOfOrder = "ITM0000001"; int quantityOfOrder = 31; int updateCount = stockRepository.decrementQuantity(itemCodeOfOrder, quantityOfOrder); // (4) if (updateCount == 0) { // (5) ResultMessages message = ResultMessages.error(); message.add(ResultMessage .fromText("Not enough stock. Please, change quantity.")); throw new BusinessException(message); // (6) }
項番 説明 (4)Queryメソッドを呼び出す。 (5)Queryメソッドの呼び出し結果を判定する。 0
の場合、更新条件を満たしていないので、在庫数が不足していることになる。 (6) 在庫がないまたは不足している旨のメッセージを格納し、業務エラーを発生させる。発生させたエラーは、Controllerで要件に応じて適切にハンドリングすること。上記例では、ビジネスルールのチェックを排他制御しながら行っているだけなので、更新条件を満たさない場合は、排他エラーではなく業務エラーとしている。エラーのハンドリング方法については、コーディングポイント(Controller編)を参照されたい。
5.4.2.2.2. 楽観ロック¶
- Entity
public class Stock implements Serializable { private String itemCode; private int quantity; private long version; // (1) // ... }
項番 説明 (1)バージョン管理用のプロパティを用意する。
xxx-sqlmap.xml
<resultMap id="stockResultMap" class="Stock"> <result property="itemCode" column="item_code" /> <result property="quantity" column="quantity" /> <result property="version" column="version" /> <!-- (2) --> </resultMap> <select id="findOne" parameterClass="java.lang.String" resultMap="stockResultMap"> SELECT * FROM m_stock WHERE item_code = #itemCode# </select> <update id="update" parameterClass="Stock"> UPDATE m_stock SET quantity = quantity ,version = version + 1 <!-- (3) --> WHERE item_code = #itemCode# AND version = #version# <!-- (4) --> </update>
項番 説明 (2) 更新対象のデータを取得するSQLにて、バージョン管理用のカラムに設定されている値を取得する。 (3) 更新する際は、バージョンをインクリメントする。 (4) 更新条件として、バージョンが一致することを加える。
- Repository(RepositoryImpl)
public interface StockRepository { Stock findOne(String itemCode); Stock save(Stock stock); }public class StockRepositoryImpl implements StockRepository { public Stock findOne(String itemCode) { return queryDAO.executeForObject("stock.findOne", itemCode, Stock.class); } public Stock save(Stock stock) { if(exists(stock.getItemCode())){ int updateCount = updateDAO.execute("stock.update", stock); if(updateCount == 0){ throw new ObjectOptimisticLockingFailureException(Stock.class, itemCode); // (5) } } else { updateDAO.execute("stock.insert", stock); } return stock; } }
項番 説明 (5)更新結果が 0
件の場合、他のトランザクションによって更新されたことになるので、楽観ロックエラー(org.springframework.orm.ObjectOptimisticLockingFailureException
)を発生させる。Note
Repositoryを作成しない場合
Repositoryを作成しない場合は、上記処理はServiceで実装することになる。
- Service
String itemCode = "ITM0000001"; int newQuantity = 30; Stock stock = stockRepository.findOne(itemCode); // (2) if (stock == null) { ResultMessages messages = ResultMessages.error().add(ResultMessage .fromText("Stock not found. itemCode : " + itemCode)); throw new ResourceNotFoundException(messages); } stock.setQuantity(newQuantity); // (3) stock = stockRepository.save(stock); // (4)
項番 説明 (2)RepositoryインタフェースのfindOneメソッドを呼び出し、Entityを取得する。 (3)(2)で取得したEntityに対して、更新する値を指定する。 (4) (3)の変更内容を永続層(DB)に反映する。
ロングトランザクションに対する楽観ロックを行う場合は、以下の点に注意すること。
Warning
ロングトランザクションに対して楽観ロックを行う場合は、更新時のチェックに加えて、データ取得時にもバージョンのチェックを行うこと。
以下に、実装例を示す。
- Service
long version = 12; String itemCode = "ITM0000001"; int newQuantity = 30; Stock stock = stockRepository.findOne(itemCode); // (1) if (stock == null || stock.getVersion() != version) { // (2) throw new ObjectOptimisticLockingFailureException(Stock.class, itemCode); // (3) } stock.setQuantity(newQuantity); stock = stockRepository.save(stock);
項番 説明 (1) 永続層(DB)からEntityを取得する。 (2) 事前に、別のデータベーストランザクションで取得されたEntityのバージョンと、(1)で取得した永続層(DB)の最新のバージョンを比較する。 (3) バージョンが異なる場合は、楽観ロックエラー(org.springframework.dao.ObjectOptimisticLockingFailureException
)を発生させる。
RDBMSの行ロック機能と楽観ロック機能を両方使用する場合は、以下の点に注意すること。
Warning
同じデータに対して、RDBMSの行ロック機能を利用して排他制御を行う処理と、楽観ロック機能を利用して排他制御を行う処理が共存するアプリケーションの場合は、 RDBMSの行ロック機能を使うSQLにて、Versionの更新を必ず行う必要がある。
RDBMSの行ロック機能を使って排他制御を行SQLでVersionを更新しない場合、 Queryメソッドで更新した内容が別のトランザクションの処理で上書きされる可能性があるため、正しく排他制御が行われない。
以下に、実装例を示す。
xxx-sqlmap.xml
<update id="decrementQuantity" parameterClass="OrderItem"> UPDATE m_stock SET quantity = quantity - #quantity# ,version = version + 1 <!-- (1) --> WHERE item_code = #itemCode# AND #quantity# <![CDATA[ <= ]]> quantity </update>
項番 説明 (1) Versionの更新(version = version + 1
)を行う必要がある。
5.4.2.2.3. 悲観ロック¶
xxx-sqlmap.xml
<select id="findOneForUpdate" parameterClass="java.lang.String" resultMap="stockResultMap"> SELECT * FROM m_stock WHERE item_code = #itemCode# FOR UPDATE <!-- (1) --> </select>
項番 説明 (1) 悲観ロックが必要なSQLに対して、悲観ロックを取得するためのキーワードを指定する。キーワードは、データベースによって異なる。
5.4.2.3. 排他エラーのハンドリング方法¶
5.4.2.3.1. 楽観ロックの失敗時のエラーハンドリング¶
楽観ロックの失敗時には、org.springframework.dao.OptimisticLockingFailureException
が発生するため、
Controllerで適切にハンドリングする必要がある。
リクエスト単位に動作を変える必要がない場合は、@ExceptionHandler
アノテーションを使用してハンドリングする。
@ExceptionHandler(OptimisticLockingFailureException.class) // (1) public String handleOptimisticLockingFailureException( OptimisticLockingFailureException e) { // (2) ExtendedModelMap modelMap = new ExtendedModelMap(); ResultMessages resultMessages = ResultMessages.warn(); resultMessages.add(ResultMessage.fromText("Other user updated!!")); modelMap.addAttribute(setUpForm()); String viewName = top(modelMap); return new ModelAndView(viewName, modelMap); }
項番 説明 (1)@ExceptionHandler
アノテーションのvalue属性に、OptimisticLockingFailureException.class
を指定する。 (2) エラーハンドリングの処理を実装する。エラーを通知するためのメッセージ、画面表示に必要な情報(フォームやその他のモデル)を生成し、遷移先を指定したModelAndView
を返却する。エラーハンドリングの詳細については、ユースケース単位で例外をハンドリングする方法を参照されたい。
リクエスト単位に動作を変える必要がある場合は、Controllerの処理メソッドの中で、try - catch
を使用してハンドリングする。
@RequestMapping(value = "{itemId}/update", method = RequestMethod.POST) public String update(StockForm form, Model model, RedirectAttributes attributes){ // ... try { stockService.update(...); } catch (OptimisticLockingFailureException e) { // (1) // (2) ResultMessages resultMessages = ResultMessages.warn(); resultMessages.add(ResultMessage.fromText("Other user updated!!")); model.addAttribute(resultMessages); return updateRedo(modelMap); } // ... }
項番 説明 (1)OptimisticLockingFailureException
をcatchする。 (2) エラーハンドリングの処理を実装する。エラーを通知するためのメッセージ、画面表示に必要な情報(フォームやその他のモデル)を生成し、遷移先のview名を返却する。エラーハンドリングの詳細については、リクエスト単位で例外をハンドリングする方法を参照されたい。
5.4.2.3.2. 悲観ロックの失敗時のエラーハンドリング¶
悲観ロックの失敗時には、org.springframework.dao.PessimisticLockingFailureException
が発生するため、 Controllerで適切にハンドリングする必要がある。
ハンドリング方法は、悲観ロックエラーが発生した時のアプリケーションの動作仕様によって異なる。
リクエスト単に動作を変える必要がない場合は、@ExceptionHandler
アノテーションを使用してハンドリングする。
@ExceptionHandler(PessimisticLockingFailureException.class) // (1) public String handlePessimisticLockingFailureException( PessimisticLockingFailureException e) { // (2) ExtendedModelMap modelMap = new ExtendedModelMap(); ResultMessages resultMessages = ResultMessages.warn(); resultMessages.add(ResultMessage.fromText("Other user updated!!")); modelMap.addAttribute(setUpForm()); String viewName = top(modelMap); return new ModelAndView(viewName, modelMap); }
項番 説明 (1)@ExceptionHandler
アノテーションのvalue属性に、PessimisticLockingFailureException.class
を指定する。 (2) エラーハンドリングの処理を実装する。エラーを通知するためのメッセージ、画面表示に必要な情報(フォームやその他のモデル)を生成し、遷移先を指定したModelAndView
を返却する。エラーハンドリングの詳細については、ユースケース単位で例外をハンドリングする方法を参照されたい。
リクエスト単位に動作を変える必要がある場合は、Controllerの処理メソッドの中で、try - catch
を使用してハンドリングする。
@RequestMapping(value = "{itemId}/update", method = RequestMethod.POST) public String update(StockForm form, Model model, RedirectAttributes attributes){ // ... try { stockService.update(...); } catch (PessimisticLockingFailureException e) { // (1) // (2) ResultMessages resultMessages = ResultMessages.warn(); resultMessages.add(ResultMessage.fromText("Other user updated!!")); model.addAttribute(resultMessages); return updateRedo(modelMap); } // ... }
項番 説明 (1)PessimisticLockingFailureException
をcatchする。 (2) エラーハンドリングの処理を実装する。エラーを通知するためのメッセージ、画面表示に必要な情報(フォームやその他のモデル)を生成し、遷移先のview名を返却する。エラーハンドリングの詳細については、リクエスト単位で例外をハンドリングする方法を参照されたい。
Todo
JPA(Hibernate)を使用すると、現状意図しないエラーとなることが発覚している。
- 悲観ロックに失敗した場合、
PessimisticLockingFailureException
ではなく、org.springframework.dao.UncategorizedDataAccessException
の子クラスが発生する。
悲観エラー時に発生するUncategorizedDataAccessException
は、システムエラーに分類される例外なので、
アプリケーションでハンドリングすることは推奨されないが、最悪ハンドリングを行う必要があるかもしれない。
原因例外には、悲観ロックエラーが発生したことを通知する例外が格納されているので、ハンドリングができる。
⇒継続調査。
現状以下の動作となる。
- PostgreSQL + for update nowait
- org.springframework.orm.hibernate3.HibernateJdbcException
- Caused by: org.hibernate.PessimisticLockException
- Oracle + for update
- org.springframework.orm.hibernate3.HibernateSystemException
- Caused by: Caused by: org.hibernate.dialect.lock.PessimisticEntityLockException
- Caused by: org.hibernate.exception.LockTimeoutException